diff --git a/Assets/_Project/Prefabs/CycleDirector.prefab b/Assets/_Project/Prefabs/CycleDirector.prefab index aaaf9c8cc..69859d857 100644 --- a/Assets/_Project/Prefabs/CycleDirector.prefab +++ b/Assets/_Project/Prefabs/CycleDirector.prefab @@ -89,6 +89,6 @@ MonoBehaviour: SiegeSizeBase: 5 SiegeSizePerResource: 0 SiegeTimeoutTicks: 3600 - ScheduleEnabled: 1 + ScheduleEnabled: 0 ScheduleIntervalTicks: 2700 ScheduleSizePerWave: 1 diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index 547e6c247..61e61ed0a 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -30,9 +30,9 @@ namespace ProjectM.Authoring [Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")] public uint SiegeTimeoutTicks = 3600; - [Header("Threat — scheduled base sieges")] - [Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")] - public bool ScheduleEnabled = true; + [Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")] + [Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")] + public bool ScheduleEnabled = false; [Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")] public uint ScheduleIntervalTicks = 2700; @@ -59,7 +59,7 @@ namespace ProjectM.Authoring }); AddComponent(entity); AddBuffer(entity); - AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th) + AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // DR-042: 4 expedition clears -> the climactic final siege // END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full; // CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue. AddComponent(entity, new CoreIntegrity diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index 440d51d5f..76e556ea5 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -159,18 +159,12 @@ namespace ProjectM.Server if (SystemAPI.HasComponent(cycleEntity)) SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); } - else if (SystemAPI.HasComponent(cycleEntity)) - { - // Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the - // increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem - // only READS this edge to arm the final siege. - var goal = SystemAPI.GetComponent(cycleEntity); - goal.Charge = math.min(goal.Charge + 1, goal.Target); - SystemAPI.SetComponent(cycleEntity, goal); - // Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag). - if (SystemAPI.HasComponent(cycleEntity)) - SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); - } + // DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win + // path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win- + // driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by + // ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward + // (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch + // above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target. } } diff --git a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs index 5da5c48d3..fcd31221a 100644 --- a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs @@ -83,19 +83,35 @@ namespace ProjectM.Server SystemAPI.SetComponent(threatEntity, threat); } - // Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was - // actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op - // returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2). - if (SystemAPI.HasSingleton() - && SystemAPI.TryGetSingleton(out var zoneDir) - && SystemAPI.HasSingleton()) + // Once-per-epoch zone-clear reward: a returner BANKS flat Ore to the shared ledger AND advances the + // long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver: + // CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge). + // Resolved ONCE here (not per-returner) so two same-tick co-op returns pay exactly once (DR-040 BLOCKER 4) + // and gate re-entry before a clear can't farm (MINOR 2). Ore + Charge share the SAME LastRewardedEpoch + // latch so they always share fate (never one without the other). The Charge credit is guarded + // independently of the ledger so it still lands in ledger-less worlds. + if (SystemAPI.HasSingleton()) { var cycleEntity = SystemAPI.GetSingletonEntity(); var runtime = SystemAPI.GetComponent(cycleEntity); if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) { - var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); - StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre); + if (SystemAPI.TryGetSingleton(out var zoneDir) + && SystemAPI.HasSingleton()) + { + var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); + StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre); + } + if (SystemAPI.HasComponent(cycleEntity)) + { + // +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer). + var goal = SystemAPI.GetComponent(cycleEntity); + goal.Charge = math.min(goal.Charge + 1, goal.Target); + SystemAPI.SetComponent(cycleEntity, goal); + } + // Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem). + if (SystemAPI.HasComponent(cycleEntity)) + SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); runtime.LastRewardedEpoch = runtime.ExpeditionEpoch; SystemAPI.SetComponent(cycleEntity, runtime); } diff --git a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs b/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs index 19db3452f..b7a435723 100644 --- a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs +++ b/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs @@ -6,8 +6,10 @@ namespace ProjectM.Simulation /// /// 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. SINGLE writer: CyclePhaseSystem increments on each completed - /// cycle (Build -> Expedition). The HUD observes it for a progress bar. + /// of region. Sole PRODUCTION writer (DR-042): ExpeditionGateSystem increments by + /// one per cleared EXPEDITION (on the player's return). GoalReachedSystem only READS the Charge==Target + /// edge to arm the climactic final siege. (DebugCommandReceiveSystem is a manual dev-op writer.) The HUD + /// observes it for a progress bar. /// public struct GoalProgress : IComponentData { diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs index 658a79dc9..52d393220 100644 --- a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs @@ -13,7 +13,7 @@ namespace ProjectM.Tests /// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase /// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so /// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds - /// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once; + /// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm WITHOUT charging the goal (DR-042: expedition clears drive the win); /// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math. /// public class CyclePhaseSystemTests @@ -99,7 +99,7 @@ namespace ProjectM.Tests } [Test] - public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once() + public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal() { var (world, group) = MakeWorld("SiegeClears", serverTick: 200); using (world) @@ -114,8 +114,8 @@ namespace ProjectM.Tests Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, "A cleared siege returns to Calm."); - Assert.AreEqual(1, em.GetComponentData(cycle).Charge, - "One goal charge accrues per siege survived (single writer)."); + Assert.AreEqual(0, em.GetComponentData(cycle).Charge, + "DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed)."); } } diff --git a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs index ee12d0a76..a5c7bb669 100644 --- a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs +++ b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs @@ -13,7 +13,7 @@ namespace ProjectM.Tests /// SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a /// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/ /// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger - /// final siege EXACTLY once and CyclePhaseSystem enters it once; Charge clamps to Target; a survived final siege + /// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege /// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1 /// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft /// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the @@ -127,21 +127,25 @@ namespace ProjectM.Tests } [Test] - public void Charge_Clamps_To_Target_On_Survived_Siege() + public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final() { var (world, group) = MakeWorld("End2Clamp", serverTick: 200); using (world) { var em = world.EntityManager; - // Charge already AT Target, a NORMAL (non-final) siege is survived -> Charge must not exceed Target. - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, + // DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final. + var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared group.Update(); - Assert.AreEqual(4, em.GetComponentData(dir).Charge, - "Charge clamps at Target on a survived siege (min(Charge+1, Target)); it never runs away."); + Assert.AreEqual(3, em.GetComponentData(dir).Charge, + "a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress)."); + Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData(dir).Value, + "the final siege is NOT armed by a survived siege near the cap."); + Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, + "nothing is armed (the cap is only crossed by an expedition clear)."); } } @@ -314,13 +318,13 @@ namespace ProjectM.Tests public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler() { // M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the - // Charge edge (3 -> 4 via a survived siege), then prove a DUE scheduled source can't stomp the armed final + // Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final // siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave. var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200); using (world) { var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4, + var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100; em.SetComponentData(dir, cfg); @@ -328,9 +332,10 @@ namespace ProjectM.Tests var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27 - // Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Charge 3->4, Calm) -> GoalReached (arm). + // Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm). group.Update(); - Assert.AreEqual(4, em.GetComponentData(dir).Charge, "the survived siege reaches the cap."); + Assert.AreEqual(4, em.GetComponentData(dir).Charge, + "Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042)."); Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData(dir).Value, "GoalReached flips FinalDefense the same tick the Charge edge is crossed."); Assert.AreEqual(expected, em.GetComponentData(dir).PendingSiegeSize, diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs index bce8ef522..258ca549d 100644 --- a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs +++ b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs @@ -10,9 +10,11 @@ namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into - /// (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger - /// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same - /// epoch (the co-op same-tick / gate-re-entry de-dup). + /// (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the + /// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not + /// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet + /// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge + /// share the one LastRewardedEpoch latch so they always share fate). /// public class ExpeditionGateRewardTests { @@ -26,13 +28,15 @@ namespace ProjectM.Tests world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; - // CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state. - var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState)); + // CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter. + var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), + typeof(ThreatState), typeof(GoalProgress)); em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm }); em.SetComponentData(cyc, new CycleRuntime { ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch, }); + em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 }); em.AddBuffer(cyc); // Zone-enemy director singleton (only RewardOre matters to the reward fold). @@ -68,7 +72,7 @@ namespace ProjectM.Tests } [Test] - public void Cleared_Return_Banks_Ore_Once() + public void Cleared_Return_Banks_Ore_And_Charge_Once() { var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); using (world) @@ -79,6 +83,8 @@ namespace ProjectM.Tests group.Update(); // player walks the gate back to base -> reward Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger"); + Assert.AreEqual(1, em.GetComponentData(cyc).Charge, + "DR-042: a cleared return also advances the win meter by one (the new win-driver)."); Assert.AreEqual(1, em.GetComponentData(cyc).LastRewardedEpoch, "the epoch is marked rewarded"); // Force a second same-epoch return (the player is back in the expedition at the gate). @@ -88,6 +94,26 @@ namespace ProjectM.Tests group.Update(); // returns again, but the epoch was already rewarded Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)"); + Assert.AreEqual(1, em.GetComponentData(cyc).Charge, + "the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch)."); + } + } + + [Test] + public void Cleared_Return_Clamps_Charge_At_Target() + { + // DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots. + var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); + using (world) + { + var em = world.EntityManager; + em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap + MakeExpeditionPlayerAtGate(em); + + group.Update(); + + Assert.AreEqual(4, em.GetComponentData(cyc).Charge, + "a cleared return at the cap clamps at Target (never overshoots)."); } } @@ -103,6 +129,8 @@ namespace ProjectM.Tests group.Update(); Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)"); + Assert.AreEqual(0, em.GetComponentData(cyc).Charge, + "an uncleared return advances neither Ore nor the win meter."); } } } diff --git a/CLAUDE.md b/CLAUDE.md index 7857ef55c..10e68e69e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A - `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. - **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above). -- **Core loop is base-local ★ (DR-031):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner` singleton; `SetComponent`-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via `ThreatDirectorSystem`'s reserved **Schedule** source (`ScheduleEnabled`/`Interval`/`SizePerWave` on `CycleDirectorAuthoring`) need NO expedition trip; `ExpeditionFieldSystem` teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at `base+(1000,0,0)`, hidden per-connection via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]]. +- **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]]. ## DOTS / ECS conventions (authoritative summary) diff --git a/Docs/Vault/06_Roadmap/Backlog.md b/Docs/Vault/06_Roadmap/Backlog.md index 7ae24d695..47001b9fa 100644 --- a/Docs/Vault/06_Roadmap/Backlog.md +++ b/Docs/Vault/06_Roadmap/Backlog.md @@ -29,7 +29,7 @@ Last decluttered 2026-06-08 (removed all shipped `[x]` items; their context is p A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order: -- **A — Coherence core (first):** move the win-driver from *base sieges* → *expedition clears* (`GoalProgress.Charge` +1 on a cleared sortie); **kill the AFK win** (disable the blind scheduled siege as a progression source); final beat reached through the spine. *Netcode-touching → design-review first.* +- **A — Coherence core — ✅ BUILT + validated (2026-06-25):** win-driver moved *base sieges → expedition clears* — `ExpeditionGateSystem` is now the sole production writer of `GoalProgress.Charge` (+1/clear, credited **on RETURN** — the review overturned credit-on-clear, which would arm the undefended final siege → uncontestable Loss; credit-on-return keeps the player home). **AFK win killed** (`ScheduleEnabled` baked OFF, retaliation-only; survived sieges no longer credit). Final beat = the END-2 final **base** siege (operator-locked). 389/389 EditMode + clean Play smoke (no sort-cycle, `ScheduleEnabled=0`). Design-review-gated (`wf_ebef4e81-dba`). Fun-gate playtest pending. *Next: **C**.* - **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.* - **C — Legibility fixes:** walls actually block (structures on the enemy collision filter); Aether-upgrade HUD button + cost; Biomass sink (or cut); cold-start ledger seed; hide dead Harvester/Conveyor/Pylon; expedition objective UI + gate prompt; reward scales with depth. - **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above). diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-042_Loop_Reshape_Expedition_Driven.md b/Docs/Vault/07_Sessions/_Decisions/DR-042_Loop_Reshape_Expedition_Driven.md index 0e22f37f6..7bcb042b8 100644 --- a/Docs/Vault/07_Sessions/_Decisions/DR-042_Loop_Reshape_Expedition_Driven.md +++ b/Docs/Vault/07_Sessions/_Decisions/DR-042_Loop_Reshape_Expedition_Driven.md @@ -75,10 +75,22 @@ The game has **two disconnected win-models that fight each other**: - **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (A–C) comes first; the meta/SaveData-v6 (D) is the final phase. - **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect. -## Open forks (operator, lock before/at the design review) -- **Final beat:** a **final expedition** (the climax IS a deep sortie — purest expression of "expedition = spine") vs a **final base siege** (a defense climax, reuses END-2's final-siege machinery). *Recommend present both at the A-phase review.* -- **Do base sieges stay in v1 at all,** or does retaliation become a later layer (B) so the first coherent build is purely sortie→reward→escalate? *(The recommendation keeps a light retaliation siege so the build pillar isn't orphaned.)* -- Expedition reward shape + depth scaling; whether a soft-loss should cost `GoalProgress` (give the run real downside). +## Open forks — RESOLVED (operator, 2026-06-25) +- **Final beat:** **final base siege** (defense climax, reuses END-2's final-siege machinery) — *operator-chosen*. With credit-on-RETURN the capping return arms the climactic siege while the player is freshly home, so the build pillar pays off at the finish. (A *final expedition* was the alternative; deferred.) +- **Base sieges stay in v1** as **post-expedition retaliation only** (the build pillar isn't orphaned). The blind scheduled source is disabled. +- Expedition reward shape + depth scaling, and whether a soft-loss costs `GoalProgress` — deferred to phase C/tuning. ## Status -Accepted + locked (direction). Build pending — phase A first, design-review-gated. Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]]. +**Phase A — BUILT + validated (2026-06-25).** Design-review-gated (`wf_ebef4e81-dba`, GREEN-WITH-CHANGES). Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]]. + +### Phase A build record — what shipped (and the design correction) +The review overturned the literal A1 sketch. **The win credit fires on the player's RETURN, not at the expedition-clear edge.** Crediting at the clear edge (while the player is still out in the expedition) would arm the climactic final *base* siege with nobody home — and `SiegeTimeout` is disabled in the final — so the undefended Core would breach into an **uncontestable terminal Loss**. Credit-on-return guarantees the player is teleported home the same tick the final arms. + +The implementation **relocates** the single writer of `GoalProgress.Charge` (it does not add a second writer): +- **`ExpeditionGateSystem`** is now the sole *production* writer of `GoalProgress.Charge`: folded a clamped `+1` (`math.min(Charge+1, Target)`) + a `SaveRequest` checkpoint into the **existing** once-per-epoch reward block, reusing the same `LastRewardedEpoch` latch that gates the Ore reward (Ore + Charge share fate; co-op/re-entry de-dup is free). No new latch field, **no new `[GhostField]`**, no ghost re-hash. The credit is guarded independently of the ledger so it still lands in ledger-less worlds. +- **`CyclePhaseSystem`** no longer credits Charge on a survived base siege (the AFK win path deleted); the final-siege Victory latch is unchanged. `GoalReachedSystem` still arms the climactic final siege at `Charge>=Target`. +- **`ScheduleEnabled` baked OFF** (both the `CycleDirectorAuthoring` code default *and* the serialized `CycleDirector.prefab` value — the code default alone does not flip an already-serialized prefab field; Play-verified `ScheduleEnabled=0` at runtime). The schedule code path is kept as a config-inert reserved hook; `ScheduleSizePerWave` left non-zero (the final-siege size formula still reads it). Retaliation (`PostExpeditionEnabled`) stays on. +- **No system-ordering change** → no sort-cycle risk (Gate is already `[UpdateBefore(CyclePhaseSystem)]`, so the credit lands before `GoalReachedSystem` reads the edge same-tick). SaveData stays **v5**; legacy v5 saves load as-is (their old siege-era Charge reads as clears — accepted for single-slot dev saves). +- **Validation:** 389/389 EditMode (re-pointed `CyclePhaseSystemTests` + `EndgameWinLoseTests` survived-siege assertions; extended `ExpeditionGateRewardTests` with the +1, no-double-credit, and clamp cases) + a clean netcode Play smoke (world boots, no sort-cycle, `ScheduleEnabled=0`, no exceptions). **Fun-gate playtest still pending** (the base reads as inert until you walk to the gate — the gate-prompt UI is phase C, accepted caveat). + +### Next phases (unchanged order): C (legibility) → B (retaliation polish) → D (Slice 4 meta, SaveData v6).