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
@@ -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.");
}
}
}