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
@@ -25,17 +25,17 @@ namespace ProjectM.Simulation
/// <summary>Opcodes for <see cref="DebugCommandRequest.Op"/> (bytes — never an enum on the wire).</summary>
public static class DebugOp
{
/// <summary>Arm + immediately fire a siege of ArgA Husks (ArgA = size).</summary>
/// <summary>Force the NEXT wave to start this tick (re-meant from the old arm-a-siege op; args unused).</summary>
public const byte SpawnWave = 0;
/// <summary>Collapse the current siege (cull Husks, stop spawning, clear pending) -> back to Calm.</summary>
/// <summary>Quiet the arena: cull Husks + push the next wave ~1 h out (re-meant from the old end-siege op).</summary>
public const byte EndSiege = 1;
/// <summary>Cull every living Husk now (leaves the phase alone).</summary>
/// <summary>Cull every living Husk now (leaves wave state alone).</summary>
public const byte ClearEnemies = 2;
/// <summary>Hard-reset the run-state to Calm (clears any siege/pending).</summary>
public const byte SetCalm = 3;
// RETIRED (LANTERN purge — keep the byte values reserved, never renumber a wire byte):
// 3 = SetCalm · 10 = AdvanceGoal · 11 = SetHeat
/// <summary>Deposit ArgB of resource ArgA (a <see cref="ResourceId"/>) into the shared ledger.</summary>
public const byte GrantResource = 4;
@@ -55,11 +55,6 @@ namespace ProjectM.Simulation
/// <summary>Kill the sender (Health -> 0; the normal death/respawn loop takes over).</summary>
public const byte KillPlayer = 9;
/// <summary>Add ArgA to the long-arc goal charge.</summary>
public const byte AdvanceGoal = 10;
/// <summary>Set ThreatState.Heat to ArgA (inert until the Heat source ships).</summary>
public const byte SetHeat = 11;
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
public const byte SetTuning = 12;
@@ -50,16 +50,6 @@ namespace ProjectM.Simulation
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
public float StructureAggroWeight;
// END-1 Engine Core (live feel knobs; read server-side by CoreDamageSystem/CoreRestoreSystem/CyclePhaseSystem).
// CoreDamagePerHusk + CoreOverrunDrainPct are value knobs (>=0); CoreRegenIntervalTicks is a tick knob (>=1).
public float CoreDamagePerHusk; // integrity drained by one breaching Husk (~5 unintercepted = serious dent)
public float CoreRegenIntervalTicks; // ticks between +1 regen in Calm (18 -> +1/0.3s -> ~full over one short Calm)
public float CoreOverrunDrainPct; // fraction (0..1) of the shared ledger lost on a breach (soft-loss penalty)
// END-2 final siege: the would-be-next normal siege size is multiplied by this for the FINAL siege so the
// climax reads visibly larger. Floors at 1 (the default ClampKnob bucket) — a final siege is never smaller
// than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site.
public float FinalSiegeMultiplier;
// Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel
// (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap.
@@ -89,10 +79,6 @@ namespace ProjectM.Simulation
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
MeleeComboLength = 3f, // light, light, finisher
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
CoreDamagePerHusk = 10f, // END-1: 10 breaching Husks = full loss; ~5 = a serious dent
CoreRegenIntervalTicks = 18f, // END-1: +1 integrity / 0.3s in Calm (~30s to refill 100 from 0)
CoreOverrunDrainPct = 0.5f, // END-1: a breach costs half the shared ledger (soft-loss penalty)
FinalSiegeMultiplier = 2.5f, // END-2: the final siege is ~2.5x the would-be-next normal siege
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
};
@@ -114,8 +100,6 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeKnockbackSpeed:
case TuningKnob.MeleeFinisherMult:
case TuningKnob.StructureAggroWeight:
case TuningKnob.CoreDamagePerHusk:
case TuningKnob.CoreOverrunDrainPct:
case TuningKnob.StaggerKnockbackSpeed:
case TuningKnob.SeparationMaxSpeed:
return math.max(0f, value);
@@ -152,10 +136,6 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break;
case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break;
case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break;
case TuningKnob.CoreDamagePerHusk: c.CoreDamagePerHusk = value; break;
case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break;
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = value; break;
case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break;
case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break;
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
@@ -187,10 +167,6 @@ namespace ProjectM.Simulation
case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult;
case TuningKnob.MeleeComboLength: return c.MeleeComboLength;
case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight;
case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk;
case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks;
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier;
case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed;
case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed;
default: return 0f;
@@ -220,10 +196,6 @@ namespace ProjectM.Simulation
MeleeFinisherMult = c.MeleeFinisherMult,
MeleeComboLength = c.MeleeComboLength,
StructureAggroWeight = c.StructureAggroWeight,
CoreDamagePerHusk = c.CoreDamagePerHusk,
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
FinalSiegeMultiplier = c.FinalSiegeMultiplier,
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
SeparationMaxSpeed = c.SeparationMaxSpeed,
};
@@ -251,10 +223,6 @@ namespace ProjectM.Simulation
MeleeFinisherMult = r.MeleeFinisherMult,
MeleeComboLength = r.MeleeComboLength,
StructureAggroWeight = r.StructureAggroWeight,
CoreDamagePerHusk = r.CoreDamagePerHusk,
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
FinalSiegeMultiplier = r.FinalSiegeMultiplier,
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
SeparationMaxSpeed = r.SeparationMaxSpeed,
};
@@ -283,10 +251,8 @@ namespace ProjectM.Simulation
public const byte MeleeFinisherMult = 17;
public const byte MeleeComboLength = 18;
public const byte StructureAggroWeight = 19;
public const byte CoreDamagePerHusk = 20;
public const byte CoreRegenIntervalTicks = 21;
public const byte CoreOverrunDrainPct = 22;
public const byte FinalSiegeMultiplier = 23;
// RETIRED knob ids (LANTERN purge — keep 20-23 reserved, never renumber):
// 20 = CoreDamagePerHusk · 21 = CoreRegenIntervalTicks · 22 = CoreOverrunDrainPct · 23 = FinalSiegeMultiplier
public const byte StaggerKnockbackSpeed = 24;
public const byte SeparationMaxSpeed = 25;
@@ -322,10 +288,6 @@ namespace ProjectM.Simulation
public float MeleeFinisherMult;
public float MeleeComboLength;
public float StructureAggroWeight;
public float CoreDamagePerHusk;
public float CoreRegenIntervalTicks;
public float CoreOverrunDrainPct;
public float FinalSiegeMultiplier;
public float StaggerKnockbackSpeed;
public float SeparationMaxSpeed;
}
@@ -76,24 +76,7 @@ namespace ProjectM.Simulation
/// <summary>END-1 soft-loss penalty: remove a FRACTION (0..1) of EVERY row, floored per row, dropping any
/// row that hits zero. Pure/deterministic (no RNG, no wall-clock), Burst-safe; iterates back-to-front so a
/// dropped row never skips its successor. No-op for fraction &lt;= 0.</summary>
public static void DrainFraction(DynamicBuffer<StorageEntry> buffer, float fraction)
{
fraction = math.clamp(fraction, 0f, 1f);
if (fraction <= 0f)
return;
for (int i = buffer.Length - 1; i >= 0; i--)
{
var entry = buffer[i];
int drop = (int)math.floor(entry.Count * fraction);
if (drop <= 0)
continue;
entry.Count -= drop;
if (entry.Count <= 0)
buffer.RemoveAt(i);
else
buffer[i] = entry;
}
}
}
}
@@ -16,6 +16,9 @@ namespace ProjectM.Simulation
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
}
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
@@ -24,10 +27,6 @@ namespace ProjectM.Simulation
Type = s.Type,
CellX = s.CellX,
CellZ = s.CellZ,
Direction = s.Direction,
RemainingTicks = s.RemainingTicks,
ConveyorResId = s.ConveyorResId,
ConveyorCount = s.ConveyorCount,
HP = s.HP,
};
}
@@ -5,23 +5,13 @@ namespace ProjectM.Simulation
/// <summary>
/// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
/// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
/// serializes a default <see cref="GoalProgress"/> / empty ledger to clients (no replication flicker). The
/// menu creates exactly one of these (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly
/// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it.
/// Unmanaged so the Bursted spawn system reads it without a managed bridge.
/// serializes an empty ledger to clients (no replication flicker). The menu creates exactly one of these
/// (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly created ServerWorld BEFORE the
/// gameplay subscene streams in; the spawn system consumes + destroys it. Unmanaged so the Bursted spawn
/// system reads it without a managed bridge.
/// </summary>
public struct PendingSave : IComponentData
{
public int GoalCharge;
public int GoalTarget;
/// <summary>END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max).</summary>
public int CoreCurrent;
/// <summary>END-2: terminal run outcome to restore (0 = InProgress / pre-v5 save / New Game; 1 = Victory,
/// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn.</summary>
public byte RunOutcome;
/// <summary>v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror.</summary>
public int RunsCompleted;
public int MaxDepthReached;
@@ -40,14 +30,15 @@ namespace ProjectM.Simulation
/// <summary>One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's
/// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at
/// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system
/// GetBuffers it inside the HasData block and a missing buffer would throw on any v&lt;=5 Continue.</summary>
/// GetBuffers it inside the HasData block and a missing buffer would throw on any Continue.</summary>
public struct PendingMetaRow : IBufferElementData
{
public byte ClassId;
public byte UpgradeId;
public byte Tier;
}
/// <summary>One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
/// <summary>One staged player-built structure row for a Continue session; BaseRestoreSystem replays it
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
public struct PendingStructure : IBufferElementData
@@ -55,28 +46,13 @@ namespace ProjectM.Simulation
public byte Type;
public int CellX;
public int CellZ;
public byte Direction;
public uint RemainingTicks;
public byte ConveyorResId;
public int ConveyorCount;
public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max)
}
/// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index.
/// Slot 0 = MachineInput, 1 = MachineOutput.</summary>
public struct PendingStructureIo : IBufferElementData
{
public int StructureIndex;
public byte Slot;
public byte ResourceId;
public int Count;
}
/// <summary>
/// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem
/// sets <see cref="Pending"/>=1 on the Siege-&gt;Calm checkpoint; the managed SaveWriteSystem reads it, writes
/// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
/// Host-only autosave request flag on the director entity (added at spawn). RunDirectorSystem sets
/// <see cref="Pending"/>=1 on the terminal bank; the managed SaveWriteSystem reads it, writes the JSON save,
/// and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
/// </summary>
public struct SaveRequest : IComponentData
{
@@ -21,67 +21,38 @@ namespace ProjectM.Simulation
public byte Tier;
}
/// <summary>
/// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
/// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
/// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers
/// live in the flat <see cref="SaveData.StructureIo"/> table keyed by index.
/// </summary>
/// <summary>One serialized player-built structure. Flat scalars (JsonUtility has no int2).</summary>
[Serializable]
public struct StructureSave
{
public byte Type;
public int CellX;
public int CellZ;
public byte Direction; // conveyor facing (0 for non-conveyors)
public uint RemainingTicks; // production/cooldown ticks left at save time
public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
public int ConveyorCount;
public float HP; // EB-1: hit points at save time (0 from a pre-v3 save -> restored to baked Max)
public float HP; // EB-1: hit points at save time (0 -> restored to baked Max)
}
/// <summary>
/// One serialized machine I/O buffer row, joined to <see cref="SaveData.Structures"/> by
/// <see cref="StructureIndex"/>. A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 =
/// MachineInput, Slot 1 = MachineOutput.
/// </summary>
[Serializable]
public struct StructureIoRow
{
public int StructureIndex;
public byte Slot;
public byte ResourceId;
public int Count;
}
/// <summary>
/// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared
/// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
/// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without
/// breaking old saves, gated by <see cref="Version"/> migration.
/// Versioned, host-authoritative save slice: the shared resource ledger + player-built structures + the
/// permanent meta. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
/// The schema is ADDITIVE going forward, gated by <see cref="Version"/> migration.
/// </summary>
[Serializable]
public class SaveData
{
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcome; v4 added CoreCurrent
public const int CurrentVersion = 7; // v7: LANTERN fresh epoch — goal/core/outcome + conveyor/machine-I/O fields dropped
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
public const int MinLoadableVersion = 2;
/// <summary>Oldest save schema the loader accepts. v7 is a FRESH EPOCH (operator-approved): older saves are ignored.</summary>
public const int MinLoadableVersion = 7;
public int Version = CurrentVersion;
public int GoalCharge;
public int GoalTarget;
public int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max)
public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished
// v6 — permanent meta-progression (0/empty-defaults on any v<=5 save):
// Permanent meta-progression:
public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore)
public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned)
public MetaUpgradeSave[] MetaUpgrades = Array.Empty<MetaUpgradeSave>(); // sparse per-class tiers
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
public StructureSave[] Structures = Array.Empty<StructureSave>();
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
public long SavedAtMs;
}
}
@@ -44,13 +44,7 @@ namespace ProjectM.Simulation
/// placed structures survive untouched. Without this, Continue/PLAY AGAIN after a win re-latches the dead
/// outcome banner on the first replicated snapshot. Pure + idempotent; no-op for in-progress saves.
/// </summary>
public static void RollTerminalCampaignForward(SaveData data)
{
if (data == null || data.RunOutcome == 0) return;
data.RunOutcome = 0;
data.GoalCharge = 0;
data.CoreCurrent = 0;
}
public static void Save(SaveData data)
@@ -13,7 +13,7 @@ namespace ProjectM.Simulation
/// </summary>
public static class SaveStructureScan
{
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures)
{
var structs = new List<StructureSave>();
@@ -38,7 +38,6 @@ namespace ProjectM.Simulation
}
structures = structs.ToArray();
io = System.Array.Empty<StructureIoRow>(); // machine I/O retired with the automation chain (row type dies at save v7)
}
}
}
@@ -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