// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace TD.Gameplay.Waves
{
///
/// The whole run: how many waves make a cycle, how many cycles make a phase, and the
/// per-phase pools everything is drawn from. One asset assigned to RunState in the
/// match scene; replaces WaveManager's old flat, hand-ordered wave array.
///
///
/// Stable wave ids. Waves are replicated by index into a flat list this asset builds
/// by walking its phases, groups, and entries in declaration order (see
/// ). Because every peer loads the identical asset, the same walk
/// produces the same ids everywhere — the same trick the tower catalog and
/// DraftPool already use, and the reason the server can say "wave slot 2 is wave #7"
/// in a single int.
///
/// The index is frozen for the match. RunState builds it once on spawn
/// and never rebuilds, so editing this asset mid-play-session cannot renumber ids out from
/// under in-flight replication. Edit-time changes invalidate the cache normally.
///
[CreateAssetMenu(fileName = "RunDefinition", menuName = "TD/Run/Run Definition", order = 12)]
public class RunDefinition : ScriptableObject
{
[Header("Structure")]
[Tooltip("Waves in one cycle. The phase draws this many distinct-enemy-type waves and " +
"replays them every cycle.")]
[Min(1)]
public int WavesPerCycle = 5;
[Tooltip("Cycles before the phase's boss appears. The same drawn waves repeat each " +
"cycle, carrying whatever upgrades players voted onto them.")]
[Min(1)]
public int CyclesPerPhase = 3;
[Tooltip("Phases in order. Beating the last phase's boss wins the run. The MVP ships " +
"one phase; the full design is three.")]
public PhaseDefinition[] Phases;
// ----- Flat wave index (network identity) --------------------------
// Built lazily and cached. Null means "not built yet". Rebuilt only on explicit request
// or on edit-time validation — never spontaneously during play, since ids in flight
// would be invalidated.
private List waveIndex;
private Dictionary waveIds;
/// Number of distinct waves reachable anywhere in this run.
public int WaveCount
{
get { EnsureIndex(); return waveIndex.Count; }
}
/// Resolves a replicated wave id back to its asset, or null if out of range.
public WaveDefinition GetWave(int waveId)
{
EnsureIndex();
return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null;
}
/// Resolves a wave asset to its replicated id.
public bool TryGetWaveId(WaveDefinition wave, out int waveId)
{
EnsureIndex();
if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true;
waveId = -1;
return false;
}
///
/// Rebuilds the flat wave index from the current asset contents. Called once by
/// RunState on every peer at match start so ids agree, and by the editor when
/// the asset changes.
///
public void RebuildIndex()
{
waveIndex ??= new List();
waveIds ??= new Dictionary();
waveIndex.Clear();
waveIds.Clear();
if (Phases == null) return;
// Declaration-order walk. Every peer runs this over the same asset, so the
// resulting ids match without any negotiation. Zero-weight entries are indexed
// too: they can't be drawn, but indexing them keeps ids stable if a designer
// re-weights between sessions.
foreach (var phase in Phases)
{
if (phase == null) continue;
IndexGroups(phase.WaveGroups);
IndexGroups(phase.BossGroups);
}
}
private void IndexGroups(WaveGroup[] groups)
{
if (groups == null) return;
foreach (var group in groups)
{
if (group?.Waves == null) continue;
foreach (var entry in group.Waves)
{
if (entry?.Wave == null) continue;
if (waveIds.ContainsKey(entry.Wave)) continue; // first occurrence wins
waveIds[entry.Wave] = waveIndex.Count;
waveIndex.Add(entry.Wave);
}
}
}
private void EnsureIndex()
{
if (waveIndex == null || waveIds == null) RebuildIndex();
}
// ----- Validation --------------------------------------------------
///
/// Checks that this run can actually be played: at least one phase, every phase able to
/// fill slots with distinct enemy types, and every phase able
/// to produce a boss. Returns true when the run is playable;
/// carries the problems either way (it may hold warnings even
/// on success).
///
///
/// The distinct-enemy-type requirement is the one that bites in practice: a phase can
/// hold twenty waves and still be unplayable if they all point at the same three enemies.
/// Checking it up front turns that into a startup error instead of a draw that silently
/// runs short a wave.
///
public bool Validate(out string report)
{
var sb = new StringBuilder();
bool ok = true;
if (Phases == null || Phases.Length == 0)
{
sb.AppendLine("• No phases assigned — the run has nothing to play.");
report = sb.ToString();
return false;
}
var candidates = new List();
for (int i = 0; i < Phases.Length; i++)
{
var phase = Phases[i];
string label = phase != null ? phase.Label : $"Phase {i + 1}";
if (phase == null)
{
sb.AppendLine($"• {label}: slot is empty.");
ok = false;
continue;
}
int distinctTypes = phase.CountDistinctEnemyTypes();
if (distinctTypes < WavesPerCycle)
{
sb.AppendLine($"• {label}: needs {WavesPerCycle} distinct enemy types to fill " +
$"a cycle but only {distinctTypes} are reachable. Add waves " +
$"using other enemies, or lower Waves Per Cycle.");
ok = false;
}
phase.CollectBossCandidates(candidates);
if (candidates.Count == 0)
{
sb.AppendLine($"• {label}: no boss candidates (every boss group is empty, " +
$"unassigned, or entirely zero-weight).");
ok = false;
}
// Warnings — these don't block a run, they just play oddly.
phase.CollectWaveCandidates(candidates);
foreach (var entry in candidates)
{
if (entry.Wave.PrimaryEnemyType == null)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' has no usable " +
$"enemy entries and can never be drawn meaningfully.");
else if (!entry.Wave.HasSingleEnemyType)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' mixes enemy " +
$"types. Upgrades and HUD labels will use " +
$"'{entry.Wave.PrimaryEnemyType.name}' only.");
}
}
report = sb.Length > 0 ? sb.ToString() : "Run structure OK.";
return ok;
}
#if UNITY_EDITOR
private void OnValidate()
{
// Only invalidate outside play mode. Clearing the cache mid-match would renumber
// wave ids while replicated slot references are still pointing at the old numbering.
if (!Application.isPlaying)
{
waveIndex = null;
waveIds = null;
}
}
#endif
}
}