LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7

Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:27:12 -07:00
parent 835dace213
commit b34945c2d2
74 changed files with 233 additions and 3955 deletions
@@ -1,34 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// END-1 — the losable Engine Core. An aggregate base-integrity meter that rides the GLOBAL CycleDirector
/// ghost (the untagged ghost already carrying <see cref="CycleState"/>/<see cref="GoalProgress"/>/the resource
/// ledger), so it is visible to every player regardless of region with NO new ghost and NO relevancy work — it
/// must NEVER be region-tagged (the shared-global-state rule; <c>SetIsIrrelevant</c> would hide it cross-region).
/// <para>
/// A Husk that breaches to the Core radius drains <see cref="Current"/> and despawns (server-only
/// <c>CoreDamageSystem</c>); in Calm the Core regenerates toward <see cref="Max"/> (<c>CoreRestoreSystem</c>) so a
/// chipped-but-survived base reads as "we got hurt but we're okay." When <see cref="Current"/> reaches 0 during a
/// Siege the SOFT-loss edge fires once in <c>CyclePhaseSystem</c> (the sole Phase writer): the siege ends, the
/// shared ledger is drained, the base persists wounded (no rollback — the locked DR-029 fork). <see cref="Max"/> is
/// baked from <c>CycleDirectorAuthoring</c>; <see cref="Current"/> is born-correct at spawn (full, or the persisted
/// wounded value from a Continue save).
/// </para>
/// </summary>
public struct CoreIntegrity : IComponentData
{
/// <summary>Current integrity (0 = breached/overrun). Server-authoritative; replicated for the HUD bar.</summary>
[GhostField] public int Current;
/// <summary>Integrity ceiling (baked from authoring; not persisted — a restored Core caps at the baked Max).</summary>
[GhostField] public int Max;
/// <summary>Server tick of the most recent overrun breach (0 = never). A TRANSIENT pulse the client HUD
/// edge-detects to flash a "BASE OVERRUN" banner — a SOFT loss is non-terminal ("keep playing"), so this is
/// the right shape, not a latching run-outcome (the terminal Victory latch is END-2's job).</summary>
[GhostField] public uint OverrunTick;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a933c59d7c550d844b615e3672b333f6
@@ -3,75 +3,15 @@ using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Macro-loop state for "The Aether Cycle": which phase the run is in, the cycle number, and the server
/// tick the current (timed) phase ends. Server-authoritative, maintained by CyclePhaseSystem. Currently a
/// server-side singleton; the [GhostField]s below are inert until it is moved onto the runtime-spawned
/// CycleDirector ghost (when the client HUD is wired), at which point the same struct replicates unchanged.
/// The Defend phase is NOT timed — it ends when the base-defense wave is cleared — so PhaseEndTick is only
/// meaningful in Expedition/Build (0 during Defend).
/// </summary>
public struct CycleState : IComponentData
{
/// <summary>Current phase (see <see cref="CyclePhase"/>).</summary>
[GhostField] public byte Phase;
// NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED.
// ExpeditionObjective below is the surviving replicated room-objective readout (live consumers:
// RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it).
/// <summary>1-based cycle counter (increments when a new Expedition begins).</summary>
[GhostField] public int CycleNumber;
/// <summary>Server tick the current timed phase ends (Expedition/Build only; 0 in Defend).</summary>
[GhostField] public uint PhaseEndTick;
/// <summary>Live Husk wave number during Defend, synced from the server-only WaveState by CyclePhaseSystem so the replicated-state-only HUD can show it (holds the last wave number outside Defend; the HUD gates the display to the Defend phase).</summary>
[GhostField] public int WaveNumber;
}
/// <summary>Phase constants for <see cref="CycleState.Phase"/> — the GLOBAL shared posture (a byte, not an enum, for trivial Burst/serialization). Being out on an expedition is per-player presence (server-only RegionTag), NOT a global phase.</summary>
public static class CyclePhase
{
// Re-meaned IN PLACE — the byte VALUES are unchanged from the old Expedition/Defend/Build, so the
// [GhostField] serializer layout is identical (the const re-mean alone forces no re-bake). slot 0 (was
// Expedition) -> Calm; slot 1 (was Defend) -> Siege; slot 2 (was Build) -> retired.
/// <summary>The persistent, unhurried home base — the DEFAULT posture. No countdown; build/prep at your pace.</summary>
public const byte Calm = 0;
/// <summary>The base is under assault by a Husk wave (event-triggered; ends when the wave is cleared).</summary>
public const byte Siege = 1;
}
/// <summary>
/// Server-only bookkeeping for the run-state machine that must NOT replicate (kept separate from the
/// replicated <see cref="CycleState"/>). Records the wave number captured when the current Siege began plus
/// the procedural-expedition-field session epoch (bumped when the expedition region goes empty-&gt;occupied so
/// the field reseeds per sortie).
/// </summary>
public struct CycleRuntime : IComponentData
{
/// <summary>WaveState.WaveNumber captured the moment the current Siege started (DefendCleared tests &gt; this).</summary>
public int DefendStartWave;
/// <summary>Monotonic expedition-field session counter; bumped on the expedition region's empty-&gt;occupied edge so each sortie reseeds. RNG seed (never tick math; never 0, via max(1, ...)).</summary>
public int ExpeditionEpoch;
/// <summary>The <see cref="ExpeditionEpoch"/> the field was last seeded for (compared by int equality).</summary>
public int LastSpawnedEpoch;
/// <summary>Previous-tick expedition occupancy (1 = at least one player out), for the empty&lt;-&gt;occupied edge.</summary>
public byte PrevExpeditionOccupied;
/// <summary>The <see cref="ExpeditionEpoch"/> a zone-clear Ore reward was last banked for — gates the once-per-epoch reward so two same-tick co-op returners pay once and gate re-entry can't farm (int equality, never tick math).</summary>
public int LastRewardedEpoch;
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty-&gt;occupied epoch bump. The reward fires only on a REAL clear.</summary>
public byte ClearedThisEpoch;
}
/// <summary>
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost
/// (alongside <see cref="CycleState"/> / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so
/// GhostRelevancy.SetIsIrrelevant never hides it
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
@@ -1,22 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
/// of region. Sole PRODUCTION writer (DR-042): <c>ExpeditionGateSystem</c> increments <see cref="Charge"/> by
/// one per cleared EXPEDITION (on the player's return). <c>GoalReachedSystem</c> only READS the Charge==Target
/// edge to arm the climactic final siege. (<c>DebugCommandReceiveSystem</c> is a manual dev-op writer.) The HUD
/// observes it for a progress bar.
/// </summary>
public struct GoalProgress : IComponentData
{
/// <summary>Accumulated progress.</summary>
[GhostField] public int Charge;
/// <summary>Charge required to reach the goal.</summary>
[GhostField] public int Target;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e1f60b3396850074ca0e44b831b5980c
@@ -1,61 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// END-2 — server-only marker of which run-phase the macro loop is in. Lives on the GLOBAL CycleDirector
/// entity beside <see cref="CycleState"/>/<see cref="CycleRuntime"/>/<see cref="ThreatState"/>; NOT replicated
/// (the client never needs to distinguish "the final siege is armed" — the larger wave + telegraph already read
/// as escalation; the client shows the terminal banner from the replicated <see cref="RunOutcome"/> instead).
/// SINGLE writer: <c>GoalReachedSystem</c> flips <see cref="RunPhaseId.Normal"/> -&gt;
/// <see cref="RunPhaseId.FinalDefense"/> exactly once when <see cref="GoalProgress.Charge"/> reaches Target.
/// Added at spawn by <c>CycleDirectorSpawnSystem</c> (like CycleRuntime/ThreatState), so it is server-world-only
/// and never on the ghost serializer (no re-hash). A <c>byte</c> (never an enum) so a Bursted reader can't trip
/// the cross-assembly-enum Burst ICE.
/// </summary>
public struct RunPhase : IComponentData
{
public byte Value;
}
/// <summary>Phase constants for <see cref="RunPhase.Value"/> (bytes — never an enum on a Bursted path).</summary>
public static class RunPhaseId
{
/// <summary>Normal play: scheduled / post-expedition sieges arm; the goal meter climbs +1 per survived siege.</summary>
public const byte Normal = 0;
/// <summary>The goal cap was reached: the larger FINAL siege is armed/running. No further sieges arm.</summary>
public const byte FinalDefense = 1;
}
/// <summary>
/// END-2 — the terminal run outcome: the LATCHING win/lose state, REPLICATED so the client HUD shows the
/// victory/loss banner by simply observing it (no fragile client-side reconstruction). Rides the GLOBAL
/// untagged CycleDirector ghost (relevant to every connection in every region — it must NEVER be region-tagged;
/// the shared-global-state rule), one <c>[GhostField] byte</c> alongside <see cref="CoreIntegrity"/>/
/// <see cref="GoalProgress"/>. SINGLE writer: <c>CyclePhaseSystem</c> latches <see cref="RunOutcomeId.Victory"/>
/// (final siege cleared) or <see cref="RunOutcomeId.Loss"/> (Core breached during the final siege). Once it is
/// non-<see cref="RunOutcomeId.InProgress"/> the run HALTS (GoalReachedSystem + ThreatDirectorSystem stop arming;
/// CoreRestoreSystem stops regen). Baked onto the prefab so it is part of the ghost (adding this <c>[GhostField]</c>
/// re-hashes the CycleDirector ghost -&gt; one re-bake); born-correct at spawn (InProgress for a New Game, or the
/// persisted value on Continue — SaveData v5).
/// </summary>
public struct RunOutcome : IComponentData
{
[GhostField] public byte Value;
}
/// <summary>Outcome constants for <see cref="RunOutcome.Value"/> (bytes — never an enum on a Bursted/serialized path).</summary>
public static class RunOutcomeId
{
/// <summary>The run is live (no terminal result yet).</summary>
public const byte InProgress = 0;
/// <summary>The final siege was survived — the Engine holds. Terminal; the run halts.</summary>
public const byte Victory = 1;
/// <summary>The Core was breached during the final siege — overrun. Terminal; the run halts.</summary>
public const byte Loss = 2;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8ce481dc9a135834fa6d59882895b0f5
@@ -1,88 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked, server-only tuning for the composite ThreatDirector (the data-driven base-attack scheduler). Lives
/// on the global CycleDirector entity but is NOT a [GhostField] — the server alone decides when a siege
/// fires; clients learn about it only through the replicated <see cref="CycleState.Phase"/> flip to Siege.
/// Flat scalar fields (never an enum/array — dodges the MCP authoring-drop gotcha + stays Burst-trivial). This
/// slice wires only the POST-EXPEDITION source; the Heat/Schedule fields are baked-but-inert so those sources
/// drop in later additively with NO re-bake (server-only layout, not a ghost serializer change).
/// </summary>
public struct ThreatConfig : IComponentData
{
// ---- Post-expedition retaliation (the only source wired this slice) ----
/// <summary>1 = a completed expedition (a player returning to base) can draw a retaliation siege.</summary>
public byte PostExpeditionEnabled;
/// <summary>Telegraph/arming delay (server ticks) between the trigger and the siege actually spawning.</summary>
public uint PostExpeditionDelayTicks;
/// <summary>Siege size floor (Husk count) for a post-expedition retaliation.</summary>
public int SizeBase;
/// <summary>Extra Husks per unit of resources hauled back this run (0 = a flat <see cref="SizeBase"/> siege).</summary>
public int SizePerExpeditionResource;
/// <summary>How a pending siege starts (see <see cref="ThreatStartCondition"/>).</summary>
public byte StartCondition;
/// <summary>Max server ticks a Siege may run before it auto-collapses (remaining Husks culled) so an unattended/empty-base siege can never soft-lock. 0 = no cap.</summary>
public uint SiegeTimeoutTicks;
// ---- Reserved, present-but-inert this slice (additive later, no re-bake) ----
public byte HeatEnabled;
public float HeatPerTickAtBase;
public float HeatPerHarvest;
public float HeatThreshold;
public byte ScheduleEnabled;
public uint ScheduleIntervalTicks;
/// <summary>Extra Husks per surviving wave for a SCHEDULED base siege (size = SizeBase + this*WaveNumber). 0 = flat SizeBase.</summary>
public int ScheduleSizePerWave;
}
/// <summary>Start-condition constants for <see cref="ThreatConfig.StartCondition"/> (bytes — never an enum, never in an RPC).</summary>
public static class ThreatStartCondition
{
/// <summary>DEFAULT: arm via the telegraph countdown (<see cref="ThreatState.ArmTick"/>) then fire — even at an empty base.</summary>
public const byte Immediate = 0;
/// <summary>Hold the pending siege until ≥1 player is in the base region OR the arm tick + a grace window elapses (bounded — never a soft-lock).</summary>
public const byte RequirePlayerAtBase = 1;
}
/// <summary>
/// Server-only runtime state of the ThreatDirector, on the global CycleDirector entity beside
/// <see cref="CycleRuntime"/>. NOT replicated. <see cref="PendingSiegeSize"/> is the single documented entry
/// point: any source (post-expedition, dev tools, later Heat/Schedule) sets it; <c>CyclePhaseSystem</c>
/// consumes it on the Calm→Siege edge and zeroes it. All stored ticks are wrap-safe (TickUtil.NonZero +
/// NetworkTick compares), never raw uint.
/// </summary>
public struct ThreatState : IComponentData
{
/// <summary>Husk count of the armed siege; 0 = none pending. Consumed (zeroed) by CyclePhaseSystem at Siege entry.</summary>
public int PendingSiegeSize;
/// <summary>Server tick the pending siege fires (telegraph). 0 = fire as soon as seen. Routed through TickUtil.NonZero.</summary>
public uint ArmTick;
/// <summary>Server tick the current Siege began (0 = not under siege). The bounded-resolution timeout measures from here (TickUtil.NonZero) so an unattended/empty-base siege can never soft-lock.</summary>
public uint SiegeStartTick;
/// <summary>Count of expeditions completed (a player returned to base). Drives the post-expedition source + stats.</summary>
public int ExpeditionsCompleted;
/// <summary>Return events the gate has signalled but the director has not yet consumed (the gate teleports the player out of its radius, so one increment per return — natural de-dup).</summary>
public int PendingReturns;
/// <summary>Accumulated heat (inert this slice; the Heat source reads/writes it later).</summary>
public float Heat;
/// <summary>Next scheduled-siege tick (inert this slice; the Schedule source uses it later). TickUtil.NonZero when used.</summary>
public uint NextScheduledTick;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2e66b1e7c715ceb418459c9323853271