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:
@@ -89,6 +89,6 @@ MonoBehaviour:
|
||||
SiegeSizeBase: 5
|
||||
SiegeSizePerResource: 0
|
||||
SiegeTimeoutTicks: 3600
|
||||
ScheduleEnabled: 1
|
||||
ScheduleEnabled: 0
|
||||
ScheduleIntervalTicks: 2700
|
||||
ScheduleSizePerWave: 1
|
||||
|
||||
@@ -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<ResourceLedger>(entity);
|
||||
AddBuffer<StorageEntry>(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
|
||||
|
||||
@@ -159,18 +159,12 @@ namespace ProjectM.Server
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
else if (SystemAPI.HasComponent<GoalProgress>(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<GoalProgress>(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<SaveRequest>(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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CycleState>()
|
||||
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||
// 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<CycleState>())
|
||||
{
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||
if (SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||
}
|
||||
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
// +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer).
|
||||
var goal = SystemAPI.GetComponent<GoalProgress>(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<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ 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. SINGLE writer: <c>CyclePhaseSystem</c> increments <see cref="Charge"/> on each completed
|
||||
/// cycle (Build -> Expedition). The HUD observes it for a progress bar.
|
||||
/// 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
|
||||
{
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user