// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; namespace TD.Gameplay.Waves { /// /// Outcome of advancing the run by one encounter. Returned by /// so WaveManager can react without /// re-deriving the progression maths. /// public enum RunAdvance : byte { /// Moved to the next wave inside the current cycle. NextWave = 0, /// Moved to the first wave of a new cycle — the same waves come round again, /// now carrying whatever upgrades players voted onto them. NextCycle = 1, /// Every cycle in the phase is done; the boss is up next. BossReady = 2, /// The boss died and a new phase was drawn. Upgrades were wiped. NextPhase = 3, /// The final phase's boss died — the run is won. RunComplete = 4, } /// /// Server-authoritative owner of run progression: which phase and cycle we're in, which waves /// this phase drew, and which upgrades the players have voted onto each wave slot. /// /// /// Split of duties with WaveManager. This type is the run's state — /// counters, draws, upgrade sets — and knows nothing about spawning. WaveManager is the /// driver: it runs an encounter, and when the field is clear it calls /// and acts on the returned . Keeping them /// apart is what stops WaveManager (already a large class) from also owning pool draws and /// upgrade bookkeeping. /// /// Linear encounter index. Rather than replicate cycle and wave separately and /// keep them in sync, one counter runs /// 0 .. CyclesPerPhase*WavesPerCycle inclusive, with the final value meaning "boss". /// Cycle and wave slot are derived from it, so they cannot disagree. /// /// Upgrades are keyed by wave slot, not enemy type. The phase draw guarantees the /// slots hold distinct enemy types, which makes slot and type interchangeable as a key — and /// slot is the one that survives a phase change cleanly, since the next phase reassigns every /// slot to a fresh enemy anyway. /// public class RunState : NetworkBehaviour { // ----- Singleton -------------------------------------------------- public static RunState Instance { get; private set; } // ----- Inspector -------------------------------------------------- [Tooltip("The run this match plays: phase pools, waves per cycle, cycles per phase. " + "Required — without it no waves can be drawn.")] [SerializeField] private RunDefinition runDefinition; // ----- Networked state -------------------------------------------- private readonly NetworkVariable phaseIndex = new NetworkVariable( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss". private readonly NetworkVariable encounterInPhase = new NetworkVariable( 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // Wave ids (indices into RunDefinition's flat index) drawn for this phase's slots. // Exactly WavesPerCycle entries once a phase has been drawn. private NetworkList drawnWaveIds; private readonly NetworkVariable bossWaveId = new NetworkVariable( -1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // Upgrades voted onto wave slots, packed as (slot << SlotShift) | optionId. A flat // append-only list rather than a per-slot structure because NGO has no nested-collection // support and the counts here are tiny (a handful per slot, per phase). private NetworkList waveUpgrades; private const int SlotShift = 16; private const int OptionMask = (1 << SlotShift) - 1; // Per-slot count of buffs to apply automatically, skipping the next vote for that slot. // Indexed by upgrade slot (0..WavesPerCycle, the last being the boss). Zero means a normal // vote. Written by the vote-meta cards, consumed by the inter-wave step. private NetworkList autoApplyCounts; /// /// Fired on every peer whenever run progression or the drawn/upgraded wave set changes. /// The HUD subscribes to relabel without polling. /// public event System.Action OnRunChanged; // ----- Lifecycle --------------------------------------------------- private void Awake() { drawnWaveIds = new NetworkList(); waveUpgrades = new NetworkList(); autoApplyCounts = new NetworkList(); } public override void OnNetworkSpawn() { if (Instance != null && Instance != this) { Debug.LogError("[RunState] Duplicate RunState detected. Only one may exist per scene."); return; } Instance = this; // Freeze the wave index on EVERY peer (not just the server) before any id crosses // the wire. Same asset, same declaration-order walk, same numbering. runDefinition?.RebuildIndex(); // One auto-apply counter per upgrade slot, plus one for the boss slot. if (IsServer) { for (int i = 0; i <= WavesPerCycle; i++) autoApplyCounts.Add(0); } phaseIndex.OnValueChanged += HandleIntChanged; encounterInPhase.OnValueChanged += HandleIntChanged; bossWaveId.OnValueChanged += HandleIntChanged; drawnWaveIds.OnListChanged += HandleListChanged; waveUpgrades.OnListChanged += HandleListChanged; } public override void OnNetworkDespawn() { phaseIndex.OnValueChanged -= HandleIntChanged; encounterInPhase.OnValueChanged -= HandleIntChanged; bossWaveId.OnValueChanged -= HandleIntChanged; drawnWaveIds.OnListChanged -= HandleListChanged; waveUpgrades.OnListChanged -= HandleListChanged; if (Instance == this) Instance = null; } private void HandleIntChanged(int _, int __) => OnRunChanged?.Invoke(); private void HandleListChanged(NetworkListEvent _) => OnRunChanged?.Invoke(); // ----- Structure accessors ----------------------------------------- /// The run asset driving this match, or null if unassigned (designer error). public RunDefinition Definition => runDefinition; public int WavesPerCycle => runDefinition != null ? Mathf.Max(1, runDefinition.WavesPerCycle) : 5; public int CyclesPerPhase => runDefinition != null ? Mathf.Max(1, runDefinition.CyclesPerPhase) : 3; public int PhaseCount => runDefinition?.Phases?.Length ?? 0; /// Encounters in one phase, counting its boss. public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1; // ----- Progression accessors --------------------------------------- /// Zero-based phase index. public int PhaseIndex => phaseIndex.Value; /// /// The phase currently being played, or null if the run isn't set up. Read by the vote /// generator to find which enemy-buff cards this phase allows. /// public PhaseDefinition CurrentPhase { get { var phases = runDefinition?.Phases; if (phases == null) return null; int i = phaseIndex.Value; return (i >= 0 && i < phases.Length) ? phases[i] : null; } } /// Zero-based cycle index within the current phase. Reads as the last cycle /// while the boss is up (the boss belongs to the phase, not to a fourth cycle). public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1); /// Zero-based wave slot within the current cycle. Meaningless while /// is true. public int WaveSlot => encounterInPhase.Value % WavesPerCycle; /// True when the next/current encounter is this phase's boss. public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle; /// /// 1-based count of encounters since the run began, including bosses. This is the key /// GoldConfig.Encounters is indexed by, so payouts scale monotonically across the /// whole run rather than resetting every cycle. /// public int GlobalEncounterNumber => phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1; /// Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5". public string ProgressLabel => IsBossStage ? $"Phase {phaseIndex.Value + 1} · BOSS" : $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " + $"Wave {WaveSlot + 1}/{WavesPerCycle}"; // ----- Wave accessors ---------------------------------------------- /// The wave asset drawn for , or null if not drawn yet. public WaveDefinition GetSlotWave(int slot) { if (runDefinition == null) return null; if (slot < 0 || slot >= drawnWaveIds.Count) return null; return runDefinition.GetWave(drawnWaveIds[slot]); } /// This phase's boss wave, or null if not drawn yet. public WaveDefinition BossWave => runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null; /// The wave the run is currently pointing at — boss or cycle wave. public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot); /// /// The slot key upgrades attach to for the current encounter. The boss occupies the slot /// one past the last cycle wave, so boss upgrades (if ever voted) don't collide with it. /// public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot; // ----- Upgrade accessors ------------------------------------------- /// /// Collects the upgrade option ids voted onto , in the order they /// were applied. Safe on any peer. /// public void GetUpgradesForSlot(int slot, List into) { into.Clear(); for (int i = 0; i < waveUpgrades.Count; i++) { int packed = waveUpgrades[i]; if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask); } } /// How many upgrades are stacked on . public int CountUpgradesForSlot(int slot) { int n = 0; for (int i = 0; i < waveUpgrades.Count; i++) if ((waveUpgrades[i] >> SlotShift) == slot) n++; return n; } /// True if already carries . /// Used to keep the vote from offering a buff a wave already has. public bool SlotHasUpgrade(int slot, int optionId) { int packed = (slot << SlotShift) | (optionId & OptionMask); for (int i = 0; i < waveUpgrades.Count; i++) if (waveUpgrades[i] == packed) return true; return false; } /// Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates. public void ServerAddUpgrade(int slot, int optionId) { if (!IsServer) return; if (slot < 0 || optionId < 0) return; if (SlotHasUpgrade(slot, optionId)) return; waveUpgrades.Add((slot << SlotShift) | (optionId & OptionMask)); } // ----- Auto-apply (vote-meta cards) -------------------------------- /// /// How many buffs will take automatically instead of holding its /// next vote. Zero means a normal vote. /// public int GetAutoApplyCount(int slot) => (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0; /// /// Server-only: arm to auto-take buffs in /// place of its next vote. Takes the larger value if already armed, so two vote-meta cards /// on one slot don't cancel each other down. /// public void ServerSetAutoApply(int slot, int count) { if (!IsServer) return; if (slot < 0 || slot >= autoApplyCounts.Count || count <= 0) return; autoApplyCounts[slot] = Mathf.Max(autoApplyCounts[slot], count); } /// Server-only: disarm after its auto-apply has fired. public void ServerConsumeAutoApply(int slot) { if (!IsServer) return; if (slot < 0 || slot >= autoApplyCounts.Count) return; autoApplyCounts[slot] = 0; } private void ServerClearAutoApply() { for (int i = 0; i < autoApplyCounts.Count; i++) autoApplyCounts[i] = 0; } // ----- Server: run control ------------------------------------------ /// /// Server-only: start the run at phase 0 and draw its waves. Returns false if the run /// definition is missing or unplayable — the caller should treat that as fatal rather /// than limping along with an empty schedule. /// public bool ServerBeginRun() { if (!IsServer) return false; if (runDefinition == null) { Debug.LogError("[RunState] No RunDefinition assigned — cannot start a run."); return false; } if (!runDefinition.Validate(out string report)) { Debug.LogError($"[RunState] RunDefinition '{runDefinition.name}' is not playable:\n{report}"); return false; } phaseIndex.Value = 0; encounterInPhase.Value = 0; waveUpgrades.Clear(); ServerClearAutoApply(); return ServerDrawPhase(0); } /// /// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the /// boss falls. See for what the caller should do with each result. /// public RunAdvance ServerAdvance() { if (!IsServer) return RunAdvance.NextWave; // Boss just died: the phase is over. Either draw the next one or win the run. if (IsBossStage) { int next = phaseIndex.Value + 1; if (next >= PhaseCount) return RunAdvance.RunComplete; phaseIndex.Value = next; encounterInPhase.Value = 0; // A new phase means five brand-new waves, so every upgrade voted onto the old // slots is meaningless — wiped by design, not by omission. Armed auto-applies go // with them: they were promises made about waves that no longer exist. waveUpgrades.Clear(); ServerClearAutoApply(); ServerDrawPhase(next); return RunAdvance.NextPhase; } int previousCycle = CycleIndex; encounterInPhase.Value++; if (IsBossStage) return RunAdvance.BossReady; if (CycleIndex != previousCycle) return RunAdvance.NextCycle; return RunAdvance.NextWave; } // ----- Server: phase draw ------------------------------------------- // Scratch list reused across draws; server is single-threaded so sharing is safe. private readonly List candidateScratch = new List(); /// /// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative /// weight without replacement and with distinct enemy types; the boss is a plain /// weighted draw. /// private bool ServerDrawPhase(int index) { if (!IsServer) return false; var phases = runDefinition?.Phases; if (phases == null || index < 0 || index >= phases.Length || phases[index] == null) { Debug.LogError($"[RunState] Phase {index + 1} is missing — cannot draw waves."); return false; } var phase = phases[index]; int needed = WavesPerCycle; phase.CollectWaveCandidates(candidateScratch); drawnWaveIds.Clear(); for (int drawn = 0; drawn < needed; drawn++) { var pick = DrawWeighted(candidateScratch); if (pick == null) { Debug.LogError( $"[RunState] {phase.Label}: ran out of candidates after {drawn} of " + $"{needed} wave(s). The phase needs {needed} waves with DISTINCT enemy " + $"types — check the RunDefinition inspector's phase-capacity readout."); return false; } if (!runDefinition.TryGetWaveId(pick.Wave, out int waveId)) { Debug.LogError($"[RunState] Wave '{pick.Wave.name}' is not in the run's wave " + $"index. This should be impossible — the index is built from " + $"the same pools. Skipping."); candidateScratch.Remove(pick); drawn--; continue; } drawnWaveIds.Add(waveId); // Retire every remaining candidate sharing this enemy type, so distinctness is // enforced by shrinking the pool rather than by reject-and-retry. var type = pick.Wave.PrimaryEnemyType; candidateScratch.RemoveAll( e => e.Wave == pick.Wave || (type != null && e.Wave.PrimaryEnemyType == type)); } // Boss draw — independent pool, no distinctness constraint. phase.CollectBossCandidates(candidateScratch); var boss = DrawWeighted(candidateScratch); if (boss == null || !runDefinition.TryGetWaveId(boss.Wave, out int bossId)) { Debug.LogError($"[RunState] {phase.Label}: no boss could be drawn."); bossWaveId.Value = -1; return false; } bossWaveId.Value = bossId; Debug.Log($"[RunState] Drew {phase.Label}: {needed} wave(s) + boss " + $"'{boss.Wave.name}'."); return true; } // Relative-weight draw. Weights are normalized against the candidate set's total, so a // pool of three 1.0 entries is uniform and a 0.5 entry is half as likely as a 1.0 one. // Zero-weight entries never reach here — PhaseDefinition filters them during collection. private static WavePoolEntry DrawWeighted(List candidates) { if (candidates == null || candidates.Count == 0) return null; float total = 0f; for (int i = 0; i < candidates.Count; i++) total += Mathf.Max(0f, candidates[i].Weight); if (total <= 0f) return null; float roll = Random.value * total; for (int i = 0; i < candidates.Count; i++) { roll -= Mathf.Max(0f, candidates[i].Weight); if (roll <= 0f) return candidates[i]; } return candidates[candidates.Count - 1]; // float drift fallback } } }