DR-042 Phase A: expedition-driven win — move win-driver off base-siege survival, kill the AFK path

Design-review-gated (wf_ebef4e81-dba, GREEN-WITH-CHANGES). The win-driver moves
from "survive N base sieges" to "clear N expeditions". The review overturned the
literal plan: credit on RETURN, not at the clear edge (clear-edge crediting arms
the undefended final base siege -> uncontestable terminal Loss).

- ExpeditionGateSystem: now the sole production writer of GoalProgress.Charge —
  a clamped +1 per cleared expedition folded into the existing once-per-epoch
  reward block, reusing the LastRewardedEpoch latch (Ore + Charge share fate) +
  a SaveRequest checkpoint. No new latch, no new GhostField, no ordering change.
- CyclePhaseSystem: deleted the survived-siege +1 (the AFK win path). Victory
  latch unchanged; GoalReached still arms the final base siege at cap.
- CycleDirectorAuthoring + CycleDirector.prefab: ScheduleEnabled baked OFF
  (retaliation-only). A serialized prefab bool ignores the C# field initializer,
  so the value is flipped in the prefab, not just the code default.
- Tests: re-pointed CyclePhaseSystemTests + EndgameWinLoseTests survived-siege
  assertions; extended ExpeditionGateRewardTests (+1, no-double-credit, clamp).
  389/389 EditMode green; clean netcode Play smoke (no sort-cycle, Schedule=0).

SaveData stays v5. Docs: DR-042 build record + forks resolved, CLAUDE.md
base-loop line, Backlog (A done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 20:49:27 -07:00
parent 03f778085b
commit ed65770cc9
11 changed files with 111 additions and 54 deletions
@@ -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.
/// </summary>
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<CycleState>(cycle).Phase,
"A cleared siege returns to Calm.");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
"One goal charge accrues per siege survived (single writer).");
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
"DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed).");
}
}
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
/// <see cref="ThreatDirectorSystem"/> 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<GoalProgress>(dir).Charge,
"Charge clamps at Target on a survived siege (min(Charge+1, Target)); it never runs away.");
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(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<RunPhase>(dir).Value,
"the final siege is NOT armed by a survived siege near the cap.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(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<GoalProgress>(dir).Charge, "the survived siege reaches the cap.");
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(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<RunPhase>(dir).Value,
"GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
@@ -10,9 +10,11 @@ namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
/// <see cref="ExpeditionGateSystem"/> (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).
/// <see cref="ExpeditionGateSystem"/> (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).
/// </summary>
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<StorageEntry>(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<GoalProgress>(cyc).Charge,
"DR-042: a cleared return also advances the win meter by one (the new win-driver).");
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(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<GoalProgress>(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<GoalProgress>(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<GoalProgress>(cyc).Charge,
"an uncleared return advances neither Ore nor the win meter.");
}
}
}