ed65770cc9
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>
138 lines
6.5 KiB
C#
138 lines
6.5 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Server;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.Transforms;
|
|
|
|
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 + 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
|
|
{
|
|
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name,
|
|
int epoch, byte clearedThisEpoch, int lastRewardedEpoch)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
|
|
group.SortSystems();
|
|
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 + 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).
|
|
var dir = em.CreateEntity(typeof(ZoneEnemyDirector));
|
|
em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 });
|
|
|
|
// A gate Expedition->Base sitting at the expedition origin.
|
|
var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform));
|
|
em.SetComponentData(gate, new ExpeditionGate
|
|
{
|
|
FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0),
|
|
});
|
|
em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
|
|
return (world, group, cyc);
|
|
}
|
|
|
|
static Entity MakeExpeditionPlayerAtGate(EntityManager em)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
|
|
em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
em.AddComponent<PlayerTag>(e);
|
|
return e;
|
|
}
|
|
|
|
static int OreInLedger(EntityManager em, Entity cyc)
|
|
{
|
|
var buf = em.GetBuffer<StorageEntry>(cyc);
|
|
for (int i = 0; i < buf.Length; i++)
|
|
if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count;
|
|
return 0;
|
|
}
|
|
|
|
[Test]
|
|
public void Cleared_Return_Banks_Ore_And_Charge_Once()
|
|
{
|
|
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var player = MakeExpeditionPlayerAtGate(em);
|
|
|
|
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).
|
|
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
|
|
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
|
|
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).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Uncleared_Return_Banks_Nothing()
|
|
{
|
|
var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
MakeExpeditionPlayerAtGate(em);
|
|
|
|
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|