using System; using System.Collections.Generic; using Cysharp.Threading.Tasks; using YachtDice.Categories; using YachtDice.Dice; using YachtDice.Economy; using YachtDice.Scoring; namespace YachtDice.Run { public sealed class RunLoopService { private readonly RunBalanceConfigSO _config; private readonly ScoringSystem _scoringSystem; private readonly CurrencyBank _currencyBank; private readonly StoredRollBank _storedRollBank; private readonly RunState _state = new(); public RunLoopService( RunBalanceConfigSO config, ScoringSystem scoringSystem, CurrencyBank currencyBank, StoredRollBank storedRollBank) { _config = config != null ? config : RunBalanceConfigSO.CreateDefault(); _scoringSystem = scoringSystem; _currencyBank = currencyBank; _storedRollBank = storedRollBank; if (_storedRollBank != null) _storedRollBank.OnChanged += HandleStoredRollsChanged; if (_currencyBank != null) _currencyBank.OnBalanceChanged += HandleCurrencyChanged; } public RunState State => _state; public int CurrentCurrency => _currencyBank != null ? _currencyBank.Balance : 0; public event Action OnRunStarted; public event Action OnBetStarted; public event Action OnShopOpened; public event Action OnStageStarted; public event Action OnStageCleared; public event Action OnStageFailed; public event Action OnStoredRollsChanged; public event Action OnCurrencyChanged; public event Action OnCycleCompleted; public event Action OnQuotaChanged; public event Action OnRunFailed; public event Action OnPhaseChanged; public event Action OnCategoriesRefreshed; public void StartNewRun() { _scoringSystem.ResetScorecard(); _storedRollBank.Reset(); _state.BaseQuota = _config.StartingBaseQuota; _state.BetIndex = 0; _state.StageIndex = 0; _state.CurrentRoll = 0; _state.CurrentStageRollBudget = _config.BaseRollsPerStage; _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, 0); _state.StoredRolls = _storedRollBank.Value; _state.IsActive = true; _state.IsFailed = false; SetPhase(RunPhase.None); OnRunStarted?.Invoke(_state); StartNextBet(); } public void CompleteShop() { if (_state.Phase != RunPhase.Shop || !_state.IsActive) return; StartStage(0); } public bool CanRoll() { if (!_state.IsActive || _state.IsFailed) return false; return _state.Phase == RunPhase.StageStart || _state.Phase == RunPhase.CategorySelection || _state.Phase == RunPhase.Rolling; } public bool TryBeginRoll() { if (!CanRoll()) return false; if (_state.CurrentRoll >= _state.CurrentStageRollBudget) { if (!_storedRollBank.TrySpend(1)) return false; _state.CurrentStageRollBudget += 1; _state.StoredRolls = _storedRollBank.Value; } _state.CurrentRoll += 1; SetPhase(RunPhase.Rolling); return true; } public void NotifyRollResolved(IReadOnlyList dice) { if (!_state.IsActive || _state.IsFailed || _state.Phase != RunPhase.Rolling) return; SetPhase(RunPhase.CategorySelection); if (!HasRemainingRollCapacity() && !HasAnyScorableCategory(dice)) FailCurrentStage(); } public bool CanScoreCategory(IReadOnlyList dice, CategoryDefinition category) { if (!_state.IsActive || _state.IsFailed || _state.Phase != RunPhase.CategorySelection) return false; if (category == null || _scoringSystem.IsCategoryUsed(category)) return false; if (_state.CurrentRoll <= 0) return false; var preview = PreviewScore(dice, category); return preview.FinalScore >= _state.CurrentStageTarget; } public ScoreResult PreviewScore(IReadOnlyList dice, CategoryDefinition category) { return _scoringSystem.PreviewScore( dice, category, _state.CurrentRoll, GetProgressOrdinal(), CurrentCurrency); } public bool HasAnyScorableCategory(IReadOnlyList dice) { var catalog = _scoringSystem.Catalog; if (catalog == null) return false; var categories = catalog.All; for (var i = 0; i < categories.Count; i++) { if (CanScoreCategory(dice, categories[i])) return true; } return false; } public bool HasRemainingRollCapacity() { return _state.CurrentRoll < _state.CurrentStageRollBudget || _storedRollBank.Value > 0; } public async UniTask TryScoreCategoryAsync(IReadOnlyList dice, CategoryDefinition category) { if (!CanScoreCategory(dice, category)) return false; var clearedStage = GetCurrentStageState(); var result = await _scoringSystem.ScoreCategoryAsync( dice, category, _state.CurrentRoll, GetProgressOrdinal(), CurrentCurrency); var remainingRolls = Math.Max(0, _state.CurrentStageRollBudget - _state.CurrentRoll); if (remainingRolls > 0) _storedRollBank.Add(remainingRolls); if (_config.StageClearReward > 0 && _currencyBank != null) _currencyBank.Add(_config.StageClearReward); SetPhase(RunPhase.StageResolved); OnStageCleared?.Invoke(clearedStage, category, result); if (_state.StageIndex >= _config.StageCount - 1) { ResolveBet(); } else { StartStage(_state.StageIndex + 1); } return true; } public void FailCurrentStage() { if (!_state.IsActive || _state.IsFailed) return; var failedStage = GetCurrentStageState(); _state.IsActive = false; _state.IsFailed = true; SetPhase(RunPhase.RunFailed); OnStageFailed?.Invoke(failedStage); OnRunFailed?.Invoke(_state); } private void StartNextBet() { _state.BetIndex += 1; _state.StageIndex = 0; _state.CurrentRoll = 0; _state.CurrentStageRollBudget = _config.BaseRollsPerStage; _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, 0); SetPhase(RunPhase.StartBet); OnCategoriesRefreshed?.Invoke(); OnBetStarted?.Invoke(_state); SetPhase(RunPhase.Shop); OnShopOpened?.Invoke(_state); } private void StartStage(int stageIndex) { _state.StageIndex = stageIndex; _state.CurrentRoll = 0; _state.CurrentStageRollBudget = _config.BaseRollsPerStage; _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, stageIndex); SetPhase(RunPhase.StageStart); OnStageStarted?.Invoke(GetCurrentStageState()); } private void ResolveBet() { SetPhase(RunPhase.BetResolved); if (_scoringSystem.IsComplete) { var bonus = _storedRollBank.Value * _config.CycleStoredRollBonusMultiplier; if (bonus > 0 && _currencyBank != null) _currencyBank.Add(bonus); OnCycleCompleted?.Invoke(bonus, _storedRollBank.Value); _scoringSystem.ResetScorecard(); var nextQuota = _config.GetNextQuota(_state.BaseQuota); _state.BaseQuota = nextQuota; OnQuotaChanged?.Invoke(nextQuota); SetPhase(RunPhase.CycleResolved); } StartNextBet(); } private RunStageState GetCurrentStageState() { return new RunStageState(_state.StageIndex, (RunStageType)Math.Min(_state.StageIndex, 2), _state.CurrentStageTarget); } private int GetProgressOrdinal() { return ((_state.BetIndex - 1) * Math.Max(1, _config.StageCount)) + _state.StageIndex + 1; } private void SetPhase(RunPhase phase) { _state.Phase = phase; OnPhaseChanged?.Invoke(phase); } private void HandleStoredRollsChanged(int value) { _state.StoredRolls = value; OnStoredRollsChanged?.Invoke(value); } private void HandleCurrencyChanged(int value) { OnCurrencyChanged?.Invoke(value); } } }