// 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
}
}
}