Tests: END-2 win/lose + final-siege arming + SaveData v5 (342/342 EditMode)
EndgameWinLoseTests: arms-once+enter, Charge clamp, Victory/Loss edges, the END-1 soft-loss regression (normal overrun stays soft), restored-Victory-no-rearm, SiegeTimeout-not-culling-final, the full ThreatDirector->CyclePhase->GoalReached pipeline (arm-not-stomped-by-scheduler), and FinalSiegeMultiplier override + sub-1 floor. SavePersistenceTests: RunOutcome v5 round-trip + pre-v5 default-to-InProgress. TuningConfigTests: FinalSiegeMultiplier default pin. See DR-036. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,394 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Core;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// END-2 (SL-3) — plain-Entities EditMode tests for the final-siege win/lose spine: <see cref="GoalReachedSystem"/>
|
||||||
|
/// arming + <see cref="CyclePhaseSystem"/>'s FinalDefense-gated Victory/Loss latches + the
|
||||||
|
/// <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
|
||||||
|
/// 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
|
||||||
|
/// final siege so a timeout can't fake a Victory. All timing is wrap-safe NetworkTick math.
|
||||||
|
/// </summary>
|
||||||
|
public class EndgameWinLoseTests
|
||||||
|
{
|
||||||
|
// ---- harness ----
|
||||||
|
|
||||||
|
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||||
|
{
|
||||||
|
var world = new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
// CyclePhaseSystem then GoalReachedSystem ([UpdateAfter(CyclePhaseSystem)] is honored by SortSystems).
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<GoalReachedSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
static (World world, SimulationSystemGroup group) MakeThreatWorld(string name, uint serverTick)
|
||||||
|
{
|
||||||
|
var world = new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SizeBase 5 / ScheduleSizePerWave 1 / immediate (delay 0) arm / no timeout — the END-2 arming math is
|
||||||
|
// (5 + 1*wave) * FinalSiegeMultiplier.
|
||||||
|
static ThreatConfig Cfg() => new ThreatConfig
|
||||||
|
{
|
||||||
|
PostExpeditionEnabled = 0,
|
||||||
|
ScheduleEnabled = 0,
|
||||||
|
PostExpeditionDelayTicks = 0,
|
||||||
|
SizeBase = 5,
|
||||||
|
ScheduleSizePerWave = 1,
|
||||||
|
StartCondition = ThreatStartCondition.Immediate,
|
||||||
|
SiegeTimeoutTicks = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
static Entity MakeDirector(EntityManager em, byte phase, int defendStartWave, int charge, int target,
|
||||||
|
int core, byte runPhase, byte runOutcome)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
|
||||||
|
em.AddComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
||||||
|
em.AddComponentData(e, new ThreatState());
|
||||||
|
em.AddComponentData(e, Cfg());
|
||||||
|
em.AddComponentData(e, new GoalProgress { Charge = charge, Target = target });
|
||||||
|
em.AddComponentData(e, new CoreIntegrity { Current = core, Max = 100, OverrunTick = 0u });
|
||||||
|
em.AddComponentData(e, new RunPhase { Value = runPhase });
|
||||||
|
em.AddComponentData(e, new RunOutcome { Value = runOutcome });
|
||||||
|
em.AddComponentData(e, new SaveRequest { Pending = 0 });
|
||||||
|
em.AddBuffer<StorageEntry>(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity MakeWave(EntityManager em, int waveNumber, byte phase, int remaining)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(WaveState));
|
||||||
|
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remaining });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ExpectedFinalSize(int sizeBase, int perWave, int wave)
|
||||||
|
=> (int)((sizeBase + perWave * wave) * TuningConfig.Defaults().FinalSiegeMultiplier);
|
||||||
|
|
||||||
|
// ---- tests ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GoalReached_Arms_Final_Siege_Then_CyclePhase_Enters_It_Once()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("End2Arm", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
||||||
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
|
var wave = MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
||||||
|
int expected = ExpectedFinalSize(5, 1, 4); // (5 + 4) * 2.5 = 22
|
||||||
|
|
||||||
|
// Tick 1: CyclePhase Calm (nothing pending) -> GoalReached arms the FINAL siege + flips FinalDefense.
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"final siege armed at (SizeBase + perWave*wave) * FinalSiegeMultiplier (visibly bigger than a normal siege).");
|
||||||
|
Assert.Greater(expected, 5 + 1 * 4, "the final siege is strictly larger than the would-be normal siege.");
|
||||||
|
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
||||||
|
"RunPhase flips to FinalDefense exactly when the goal cap is reached.");
|
||||||
|
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase,
|
||||||
|
"still Calm on the arm tick (CyclePhase consumes the pending siege the next tick).");
|
||||||
|
|
||||||
|
// Tick 2: CyclePhase Calm consumes the armed siege -> Siege; GoalReached no-ops (RunPhase != Normal).
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(dir).Phase,
|
||||||
|
"the final siege starts.");
|
||||||
|
Assert.AreEqual(expected, em.GetComponentData<WaveState>(wave).RemainingToSpawn,
|
||||||
|
"WaveState is seeded with the EXACT multiplied final-siege size.");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"the final siege is consumed exactly once (no re-arm by GoalReached while in FinalDefense).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Charge_Clamps_To_Target_On_Survived_Siege()
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Victory_Latches_Once_On_Final_DefendCleared()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("End2Victory", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
||||||
|
core: 100, RunPhaseId.FinalDefense, RunOutcomeId.InProgress);
|
||||||
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // cleared, no husks alive
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value,
|
||||||
|
"surviving the final siege latches Victory.");
|
||||||
|
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the run ends in Calm.");
|
||||||
|
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||||
|
"a Victory does NOT increment the already-capped goal.");
|
||||||
|
|
||||||
|
// A second tick must not change the latched outcome (GoalReached + the branch are inert once decided).
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value,
|
||||||
|
"Victory is latched (stable across ticks).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Loss_Latches_On_Final_Core_Breach_Without_Soft_Side_Effects()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("End2Loss", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
||||||
|
core: 0, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); // Core breached during the final siege
|
||||||
|
var ledger = em.GetBuffer<StorageEntry>(dir);
|
||||||
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
||||||
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
||||||
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3);
|
||||||
|
em.CreateEntity(typeof(EnemyTag));
|
||||||
|
em.CreateEntity(typeof(EnemyTag));
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(RunOutcomeId.Loss, em.GetComponentData<RunOutcome>(dir).Value,
|
||||||
|
"a Core breach during the FINAL siege latches a terminal Loss.");
|
||||||
|
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the run ends.");
|
||||||
|
var l = em.GetBuffer<StorageEntry>(dir);
|
||||||
|
Assert.AreEqual(100, l[0].Count, "terminal Loss does NOT drain the ledger (unlike the soft overrun).");
|
||||||
|
Assert.AreEqual(40, l[1].Count, "terminal Loss does NOT drain the ledger.");
|
||||||
|
Assert.AreEqual(0u, em.GetComponentData<CoreIntegrity>(dir).OverrunTick,
|
||||||
|
"terminal Loss does NOT stamp OverrunTick (the dedicated Loss banner shows instead of the soft flash).");
|
||||||
|
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
||||||
|
Assert.AreEqual(0, huskQ.CalculateEntityCount(), "the siege disperses (remaining husks despawned).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Normal_Overrun_Stays_Soft_When_RunPhase_Normal()
|
||||||
|
{
|
||||||
|
// REGRESSION: END-2 must not change END-1's soft-loss for a NORMAL (non-final) siege overrun.
|
||||||
|
var (world, group) = MakeWorld("End2NormalSoft", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 10,
|
||||||
|
core: 0, RunPhaseId.Normal, RunOutcomeId.InProgress); // breached, but NOT the final siege
|
||||||
|
var ledger = em.GetBuffer<StorageEntry>(dir);
|
||||||
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
||||||
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
||||||
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0);
|
||||||
|
em.CreateEntity(typeof(EnemyTag));
|
||||||
|
em.CreateEntity(typeof(EnemyTag));
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the soft loss ends the siege -> Calm.");
|
||||||
|
Assert.AreEqual(RunOutcomeId.InProgress, em.GetComponentData<RunOutcome>(dir).Value,
|
||||||
|
"a NORMAL overrun must NOT latch a terminal outcome (END-1 soft-loss preserved).");
|
||||||
|
var l = em.GetBuffer<StorageEntry>(dir);
|
||||||
|
Assert.AreEqual(50, l[0].Count, "the soft loss drains the ledger 50% (END-1 behaviour, unchanged).");
|
||||||
|
Assert.AreEqual(20, l[1].Count, "the soft loss drains the ledger 50%.");
|
||||||
|
Assert.AreNotEqual(0u, em.GetComponentData<CoreIntegrity>(dir).OverrunTick,
|
||||||
|
"the soft loss stamps OverrunTick for the HUD flash (END-1 behaviour, unchanged).");
|
||||||
|
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge, "no goal charge on a loss.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Restored_Victory_Does_Not_Rearm_Final_Siege()
|
||||||
|
{
|
||||||
|
// Born-correct of a finished-run Continue (SaveData v5): RunOutcome=Victory restored; RunPhase boots Normal
|
||||||
|
// (server-only, not persisted). The RunOutcome guard must keep GoalReached inert so the win is durable.
|
||||||
|
var (world, group) = MakeWorld("End2Restore", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
||||||
|
core: 100, RunPhaseId.Normal, RunOutcomeId.Victory);
|
||||||
|
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"a restored Victory does NOT re-arm the final siege (Continue loads finished).");
|
||||||
|
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value, "RunPhase stays Normal (no flip).");
|
||||||
|
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value, "the win persists.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Final_Siege_Is_Not_Culled_By_SiegeTimeout()
|
||||||
|
{
|
||||||
|
// F5: the SiegeTimeout cull must be disabled during the final siege — otherwise a timeout-cull trips
|
||||||
|
// DefendCleared and fakes a Victory. (The NORMAL-phase timeout cull is covered by ThreatDirectorSystemTests.)
|
||||||
|
var (world, group) = MakeThreatWorld("End2NoTimeoutCull", serverTick: 1000);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var e = em.CreateEntity();
|
||||||
|
em.AddComponentData(e, new CycleState { Phase = CyclePhase.Siege, CycleNumber = 1 });
|
||||||
|
var cfg = Cfg();
|
||||||
|
cfg.SiegeTimeoutTicks = 10; // would normally fire: 1000 - 900 = 100 ticks elapsed >> 10
|
||||||
|
em.AddComponentData(e, cfg);
|
||||||
|
em.AddComponentData(e, new ThreatState { SiegeStartTick = 900 });
|
||||||
|
em.AddComponentData(e, new RunPhase { Value = RunPhaseId.FinalDefense });
|
||||||
|
em.AddComponentData(e, new RunOutcome { Value = RunOutcomeId.InProgress });
|
||||||
|
var w = em.CreateEntity(typeof(WaveState));
|
||||||
|
em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning });
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
em.CreateEntity(typeof(EnemyTag));
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
||||||
|
Assert.AreEqual(3, huskQ.CalculateEntityCount(),
|
||||||
|
"the final siege is NOT culled by SiegeTimeout (a cull would fake a Victory).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- review-driven additions: M-3/N-4 (full-pipeline arming) + M-4 (multiplier) ----
|
||||||
|
|
||||||
|
static (World world, SimulationSystemGroup group) MakeFullWorld(string name, uint serverTick)
|
||||||
|
{
|
||||||
|
var world = new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
// Sorted by attributes: ThreatDirector [UpdateBefore CyclePhase] -> CyclePhase -> GoalReached [UpdateAfter].
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<GoalReachedSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SetServerTick(World world, uint tick)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||||
|
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
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
|
||||||
|
// 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,
|
||||||
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
|
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
|
||||||
|
em.SetComponentData(dir, cfg);
|
||||||
|
em.SetComponentData(dir, new ThreatState { NextScheduledTick = 150 }); // a scheduled siege is pending
|
||||||
|
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).
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge, "the survived siege reaches the cap.");
|
||||||
|
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,
|
||||||
|
"the final siege is armed at the multiplied size.");
|
||||||
|
|
||||||
|
// Advance the clock so the scheduled source is DUE, then tick: it must NOT stomp the armed final siege;
|
||||||
|
// CyclePhase consumes it into the wave at the FINAL size (not a scheduled SizeBase).
|
||||||
|
SetServerTick(world, 400);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(dir).Phase, "the final siege starts.");
|
||||||
|
Assert.AreEqual(expected, em.GetComponentData<WaveState>(wave).RemainingToSpawn,
|
||||||
|
"the FINAL size (not a scheduled SizeBase) is seeded -> the due scheduler did not stomp it.");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "consumed exactly once.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FinalSiegeMultiplier_LiveOverride_Scales_Final_Size()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("End2MultOverride", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
||||||
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
|
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
||||||
|
var tc = em.CreateEntity(typeof(TuningConfig));
|
||||||
|
var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 1.5f;
|
||||||
|
em.SetComponentData(tc, cfg);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
int normal = 5 + 1 * 4; // 9
|
||||||
|
Assert.AreEqual((int)(normal * 1.5f), em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"the final size scales by the LIVE FinalSiegeMultiplier (1.5x), not the 2.5 default.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FinalSiegeMultiplier_Below_One_Floors_To_Normal_Size()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("End2MultFloor", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
||||||
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
|
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
||||||
|
var tc = em.CreateEntity(typeof(TuningConfig));
|
||||||
|
var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 0.5f; // degenerate sub-1
|
||||||
|
em.SetComponentData(tc, cfg);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
int normal = 5 + 1 * 4; // 9
|
||||||
|
Assert.AreEqual(normal, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"a sub-1 multiplier floors at 1x (math.max(1,...)) -> the final siege is never smaller than a normal one.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 51539ff68b0fdfe4da4b0210ac19afb5
|
||||||
@@ -108,7 +108,7 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
|
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v4 since END-1).");
|
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP).");
|
||||||
Assert.AreEqual(1, back.Structures.Length);
|
Assert.AreEqual(1, back.Structures.Length);
|
||||||
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
|
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 };
|
var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 };
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-1: new saves write v4.");
|
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2).");
|
||||||
Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON.");
|
Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +162,27 @@ namespace ProjectM.Tests
|
|||||||
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RunOutcome_RoundTrips_And_Writes_Current_Version()
|
||||||
|
{
|
||||||
|
var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory };
|
||||||
|
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
||||||
|
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "END-2: new saves write v5.");
|
||||||
|
Assert.AreEqual(5, SaveData.CurrentVersion, "SaveData is at v5 (END-2 added RunOutcome).");
|
||||||
|
Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pre_END2_Save_Missing_RunOutcome_Defaults_To_InProgress()
|
||||||
|
{
|
||||||
|
// A pre-END-2 (v4) save JSON lacks RunOutcome -> JsonUtility defaults it to 0 (InProgress) -> the run loads
|
||||||
|
// as in-progress, NOT a finished run. Additive: no field, no break; v4 stays within the load floor.
|
||||||
|
var back = JsonUtility.FromJson<SaveData>("{\"Version\":4,\"GoalCharge\":2,\"GoalTarget\":10,\"CoreCurrent\":50}");
|
||||||
|
Assert.AreEqual((int)RunOutcomeId.InProgress, back.RunOutcome, "missing RunOutcome -> 0 (InProgress).");
|
||||||
|
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v4 stays within the additive load floor.");
|
||||||
|
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default");
|
Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default");
|
||||||
Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default");
|
Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default");
|
||||||
Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)");
|
Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)");
|
||||||
|
Assert.AreEqual(2.5f, d.FinalSiegeMultiplier, 1e-6f, "END-2 FinalSiegeMultiplier default (~2.5x a normal siege)");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user