LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7

Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:27:12 -07:00
parent 835dace213
commit b34945c2d2
74 changed files with 233 additions and 3955 deletions
@@ -1,181 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// END-1 — plain-Entities EditMode tests for the Engine Core server systems. <see cref="CoreDamageSystem"/>:
/// a Husk that reaches the base <see cref="BaseGridMath.PlotCenter"/> drains integrity (the live
/// <see cref="TuningConfig"/> default with no singleton) and is consumed; a distant Husk is untouched; at 0
/// the system idles (the lose-edge owns resolution). <see cref="CoreRestoreSystem"/>: the Core regenerates
/// exactly +1 across one regen interval ONLY in Calm, never mid-Siege, and never past Max. The lose-edge
/// itself is covered in <c>CyclePhaseSystemTests</c>. BaseAnchor is configured so PlotCenter == origin.
/// </summary>
public class CoreSystemsTests
{
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick)
where T : unmanaged, ISystem
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakeCore(EntityManager em, int current, int max)
{
var e = em.CreateEntity(typeof(CoreIntegrity));
em.SetComponentData(e, new CoreIntegrity { Current = current, Max = max });
return e;
}
// PlotCenter = GridOrigin.xz + GridDims*CellSize*0.5; origin + zero dims => (0,0,0).
static void MakeBaseAnchor(EntityManager em)
{
var e = em.CreateEntity(typeof(BaseAnchor));
em.SetComponentData(e, new BaseAnchor
{
AnchorPos = float3.zero,
GridOrigin = float3.zero,
CellSize = 1f,
GridDims = int2.zero,
});
}
static void MakeHusk(EntityManager em, float3 pos)
{
var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform));
em.SetComponentData(e, LocalTransform.FromPosition(pos));
}
static Entity MakeCycle(EntityManager em, byte phase)
{
var e = em.CreateEntity(typeof(CycleState));
em.SetComponentData(e, new CycleState { Phase = phase });
return e;
}
[Test]
public void CoreDamage_Breaching_Husk_Drains_And_Is_Consumed()
{
var (world, group) = MakeWorld<CoreDamageSystem>("CoreDamage", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var core = MakeCore(em, current: 100, max: 100);
MakeBaseAnchor(em);
MakeHusk(em, new float3(0, 0, 0)); // at the Core -> breaches
MakeHusk(em, new float3(20, 0, 20)); // far -> safe
group.Update();
Assert.AreEqual(90, em.GetComponentData<CoreIntegrity>(core).Current,
"one breaching Husk drains the default 10 integrity.");
using var hq = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(1, hq.CalculateEntityCount(),
"the breaching Husk is consumed; the distant one survives.");
}
}
[Test]
public void CoreDamage_Idles_When_Already_Breached()
{
var (world, group) = MakeWorld<CoreDamageSystem>("CoreBreached", serverTick: 100);
using (world)
{
var em = world.EntityManager;
MakeCore(em, current: 0, max: 100);
MakeBaseAnchor(em);
MakeHusk(em, float3.zero);
group.Update();
using var hq = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(1, hq.CalculateEntityCount(),
"at 0 integrity CoreDamageSystem idles (the lose-edge owns resolution).");
}
}
[Test]
public void CoreRestore_Regens_Exactly_Once_Per_Interval_In_Calm()
{
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreRegenCalm", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var core = MakeCore(em, current: 50, max: 100);
MakeCycle(em, CyclePhase.Calm);
// Across one full default interval (18) of consecutive ticks, exactly ONE is on the regen boundary.
const uint interval = 18;
for (uint t = 100; t < 100 + interval; t++)
{
SetServerTick(world, t);
group.Update();
}
Assert.AreEqual(51, em.GetComponentData<CoreIntegrity>(core).Current,
"Calm regenerates exactly +1 across one regen interval.");
}
}
[Test]
public void CoreRestore_Does_Not_Regen_During_Siege()
{
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreNoRegenSiege", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var core = MakeCore(em, current: 50, max: 100);
MakeCycle(em, CyclePhase.Siege);
for (uint t = 100; t < 100 + 18; t++)
{
SetServerTick(world, t);
group.Update();
}
Assert.AreEqual(50, em.GetComponentData<CoreIntegrity>(core).Current,
"no regen mid-Siege (a chipped Core heals only between sieges).");
}
}
[Test]
public void CoreRestore_Never_Exceeds_Max()
{
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreCap", serverTick: 100);
using (world)
{
var em = world.EntityManager;
var core = MakeCore(em, current: 100, max: 100);
MakeCycle(em, CyclePhase.Calm);
for (uint t = 100; t < 100 + 18; t++)
{
SetServerTick(world, t);
group.Update();
}
Assert.AreEqual(100, em.GetComponentData<CoreIntegrity>(core).Current,
"regen clamps at Max.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0b316df3c18e66c47b2a29316eeaba0e
@@ -1,252 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the PLAYER-DRIVEN
/// run-state director (Calm ↔ Siege). A bare world is seeded with a NetworkTime singleton and a cycle entity
/// 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 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
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
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 Entity MakeCycle(EntityManager em, byte phase, int defendStartWave)
{
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
return e;
}
static void AddThreat(EntityManager em, Entity cycle, int pendingSiegeSize, uint armTick)
{
em.AddComponentData(cycle, new ThreatState { PendingSiegeSize = pendingSiegeSize, ArmTick = armTick });
}
static Entity MakeWaveState(EntityManager em, int waveNumber, byte phase, int remainingToSpawn)
{
var e = em.CreateEntity(typeof(WaveState));
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remainingToSpawn });
return e;
}
[Test]
public void Calm_Holds_When_No_PendingSiege()
{
var (world, group) = MakeWorld("CalmHolds", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"With no pending siege the base stays Calm — no forced timer.");
}
}
[Test]
public void PendingSiege_Enters_Siege_And_Seeds_WaveState_Spawning_With_Exact_Size()
{
var (world, group) = MakeWorld("PendingSiege", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 7, armTick: 0); // armTick 0 => fire immediately
var wave = MakeWaveState(em, waveNumber: 5, phase: WavePhase.Lull, remainingToSpawn: 0);
group.Update();
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(cycle).Phase,
"An armed pending siege enters Siege.");
var w = em.GetComponentData<WaveState>(wave);
Assert.AreEqual(WavePhase.Spawning, w.Phase,
"WaveState is driven into Spawning (bypassing the Lull escalation recompute).");
Assert.AreEqual(7, w.RemainingToSpawn,
"RemainingToSpawn is the EXACT director-chosen siege size (not the escalation curve).");
Assert.AreEqual(6, w.WaveNumber, "WaveNumber advances by one for the siege.");
Assert.AreEqual(5, em.GetComponentData<CycleRuntime>(cycle).DefendStartWave,
"DefendStartWave captures the pre-bump wave number.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(cycle).PendingSiegeSize,
"The pending siege is consumed (zeroed) so it fires exactly once.");
}
}
[Test]
public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal()
{
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
// Wave advanced past the captured start, fully spawned, no Husks alive (none created).
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"A cleared siege returns to Calm.");
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).");
}
}
[Test]
public void Coop_Split_Presence_Keeps_Global_Phase_Calm()
{
var (world, group) = MakeWorld("CoopSplit", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
// One player out on expedition, one home — the GLOBAL phase machine must ignore presence.
var pOut = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
em.SetComponentData(pOut, new RegionTag { Region = RegionId.Expedition });
var pHome = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
em.SetComponentData(pHome, new RegionTag { Region = RegionId.Base });
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"Split presence (one out, one home) never drives the single global phase — Expedition is per-player.");
}
}
[Test]
public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud()
{
var (world, group) = MakeWorld("WaveSync", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
MakeWaveState(em, waveNumber: 4, phase: WavePhase.Spawning, remainingToSpawn: 2);
group.Update();
Assert.AreEqual(4, em.GetComponentData<CycleState>(cycle).WaveNumber,
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD.");
}
}
[Test]
public void Siege_Overrun_Ends_Siege_Drains_Ledger_Despawns_Husks_No_Goal_Charge()
{
var (world, group) = MakeWorld("SiegeOverrun", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 });
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached
var ledger = em.AddBuffer<StorageEntry>(cycle);
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
// two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base)
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"an overrun ends the siege -> Calm (soft loss).");
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(cycle).Charge,
"NO goal charge on a loss (you were overrun, not survived).");
var l = em.GetBuffer<StorageEntry>(cycle);
Assert.AreEqual(50, l[0].Count, "ledger row 1 drained 50% (100 -> 50).");
Assert.AreEqual(20, l[1].Count, "ledger row 2 drained 50% (40 -> 20).");
Assert.AreNotEqual(0u, em.GetComponentData<CoreIntegrity>(cycle).OverrunTick,
"the overrun pulse is stamped for the HUD flash.");
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, huskQ.CalculateEntityCount(),
"remaining husks are despawned (the siege disperses).");
}
}
[Test]
public void Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks()
{
// Slice 3 regression: a BASE Core breach must NOT wipe an in-progress EXPEDITION wave (both share
// EnemyTag but live in different regions). A region-blind cull would also spuriously trip the zone
// director's aliveZone==0 clear/reward edge on the player's return.
var (world, group) = MakeWorld("BaseOverrunSparesExpedition", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 });
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached
var ledger = em.AddBuffer<StorageEntry>(cycle);
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // BASE husk (RegionTag defaults to Region 0 = Base)
var exp = em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
em.SetComponentData(exp, new RegionTag { Region = RegionId.Expedition }); // a husk out on the expedition
group.Update();
Assert.IsTrue(em.Exists(exp), "the expedition husk survives a base Core breach.");
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(1, huskQ.CalculateEntityCount(),
"only the BASE husk is dispersed by the breach; the in-progress expedition wave is untouched.");
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(exp).Region,
"the survivor is the expedition-region husk.");
}
}
[Test]
public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging()
{
var (world, group) = MakeWorld("OverrunOnce", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100 });
em.AddBuffer<StorageEntry>(cycle);
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
group.Update();
group.Update(); // second tick: Calm branch -> must not re-resolve or charge
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase);
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
"the loss never charges the goal across ticks.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: def6f8080b5a28d4eb9ee4781b283752
@@ -57,33 +57,31 @@ namespace ProjectM.Tests
}
[Test]
public void SpawnWave_Arms_PendingSiege()
public void SpawnWave_Forces_Next_Wave_Now()
{
var (world, group) = MakeWorld("DebugSpawnWave");
using (world)
{
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Calm });
MakeRequest(em, DebugOp.SpawnWave, 8, 0, Entity.Null);
var wave = em.CreateEntity(typeof(WaveState));
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, NextActionTick = 999999, RemainingToSpawn = 0 });
MakeRequest(em, DebugOp.SpawnWave, 0, 0, Entity.Null);
group.Update();
Assert.AreEqual(8, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"SpawnWave arms a pending siege of the requested size.");
var w = em.GetComponentData<WaveState>(wave);
Assert.AreEqual(WavePhase.Lull, w.Phase, "SpawnWave resets to a due Lull so WaveSystem starts the next wave.");
Assert.AreEqual(0u, w.NextActionTick, "SpawnWave makes the next wave due immediately.");
}
}
[Test]
public void EndSiege_Forces_WaveState_Lull_And_Clears_Pending()
public void EndSiege_Quiets_The_Arena()
{
var (world, group) = MakeWorld("DebugEndSiege");
using (world)
{
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Siege });
em.SetComponentData(dir, new ThreatState { PendingSiegeSize = 5, SiegeStartTick = 100 });
var wave = em.CreateEntity(typeof(WaveState));
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 });
for (int i = 0; i < 2; i++)
@@ -96,9 +94,9 @@ namespace ProjectM.Tests
var w = em.GetComponentData<WaveState>(wave);
Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull.");
Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "EndSiege clears any pending siege.");
using var husks = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
Assert.AreNotEqual(0u, w.NextActionTick, "EndSiege pushes the next wave far out (quiet arena).");
using (var husks = em.CreateEntityQuery(typeof(EnemyTag)))
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
}
}
@@ -1,399 +0,0 @@
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; 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
/// 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 Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final()
{
var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
using (world)
{
var em = world.EntityManager;
// 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(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).");
}
}
[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), typeof(RegionTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
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), typeof(RegionTag));
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
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), typeof(RegionTag)); // base husks (RegionTag defaults to Base)
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 (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: 4, 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 -> Calm, Charge stays at cap) -> GoalReached (arm).
group.Update();
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,
"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.");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 51539ff68b0fdfe4da4b0210ac19afb5
@@ -1,223 +0,0 @@
using NUnit.Framework;
using ProjectM.Client;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
/// the scheme-aware prompts, and the pointer kinds.
/// </summary>
public class OnboardingStepTests
{
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
// ---- mask helpers (resume point + dormant detection) ----
[Test]
public void FirstIncomplete_EmptyMask_IsWelcome()
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
[Test]
public void FirstIncomplete_SkipsCompletedPrefix()
{
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
}
[Test]
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
{
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
int full = (1 << OnboardingStepMath.StepCount) - 1;
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
}
// ---- per-step completion rules ----
[Test]
public void Welcome_AdvancesOnTimer()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
}
[Test]
public void Move_AdvancesAfterThreshold()
{
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
s.MoveDistance = OnboardingStepMath.MoveThreshold;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
}
[Test]
public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
{
var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
var ready = Empty(); ready.LocalReady = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
}
[Test]
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
{
var s = Empty(); s.TurretCount = 0;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
}
[Test]
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
{
var none = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
var built = Empty(); built.FabricatorCount = 1;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
}
[Test]
public void Rooms_AdvancesInRoomAfterMinimumDwell()
{
var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in
var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room
var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy
}
[Test]
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
{
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward));
var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route));
}
[Test]
public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout()
{
var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition
var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy
var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge)
}
[Test]
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
{
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
}
[Test]
public void Done_LingersThenCompletes()
{
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
s.StepElapsed = OnboardingStepMath.DoneSeconds;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
}
// ---- prompts (scheme-aware, never empty) ----
[Test]
public void Prompts_NonEmptyForEveryStep()
{
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
{
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
}
}
[Test]
public void Prompts_AreSchemeAware()
{
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
}
// ---- pointer kinds ----
[Test]
public void PointerKinds_OnlyTheRoomsStepPoints()
{
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
}
}
/// <summary>
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
/// </summary>
public class OnboardingSettingsTests
{
[Test]
public void Defaults_TutorialOn_MaskEmpty()
{
var d = GameSettings.Defaults();
Assert.AreEqual(1, d.TutorialHints);
Assert.AreEqual(0, d.OnboardingMask);
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
}
[Test]
public void Clamped_NormalizesHints_PreservesMask()
{
var s = GameSettings.Defaults();
s.TutorialHints = 5; // out of the 0/1 range
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
var c = s.Clamped();
Assert.AreEqual(1, c.TutorialHints);
Assert.AreEqual(0x55, c.OnboardingMask);
}
[Test]
public void Defaults_ForceEachLaunch_Off()
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
[Test]
public void Clamped_NormalizesForceEachLaunchToBool()
{
var s = GameSettings.Defaults();
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
s.ForceOnboardingEachLaunch = 0;
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6b7226d166f601d43add545e1532c3e1
@@ -200,23 +200,6 @@ namespace ProjectM.Tests
}
}
[Test]
public void LaunchGuard_BlocksWhenOutcomeLatched()
{
var (world, group, dir) = MakeWorld();
using (world)
{
var em = world.EntityManager;
MakePlayer(em, 1);
var conn = MakeConnection(em, 1);
em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory });
SendToggle(em, conn, 1);
group.Update();
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle,
"a decided run must not launch (F2 guard)");
}
}
}
}
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(SaveRequest));
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.InRoom,
@@ -52,7 +52,6 @@ namespace ProjectM.Tests
ActiveSubSlot = (byte)(currentRoom & 1),
RoomsClearedThisRun = currentRoom, // rooms before this one were cleared
});
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
// Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it.
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
@@ -164,19 +163,14 @@ static int RoomEntities(EntityManager em)
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "win meter +1 on a boss clear");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
var threat = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)");
Assert.AreEqual(1, threat.ExpeditionsCompleted);
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update();
group.Update();
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
}
}
@@ -195,12 +189,9 @@ static int RoomEntities(EntityManager em)
group.Update(); // Returning: depth-only bank -> Staging
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(dir).Charge, "no win credit on an abort (D-F3)");
var meta = em.GetComponentData<MetaCounters>(dir);
Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit");
Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan");
var threat = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort");
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(dir).Pending, "no save spam on abort");
}
}
@@ -34,7 +34,7 @@ namespace ProjectM.Tests
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
typeof(RouteCommand), typeof(MetaCounters), typeof(SaveRequest));
return (world, group, dir);
}
@@ -6,9 +6,10 @@ using UnityEngine;
namespace ProjectM.Tests
{
/// <summary>
/// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe,
/// and the born-correct ledger apply (<see cref="SaveApply.WriteLedger"/>) the server spawn system uses to
/// overwrite a director's StorageEntry buffer from a staged PendingSave.
/// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe
/// (v7 is a FRESH EPOCH — older saves are rejected at MinLoadableVersion), and the born-correct ledger apply
/// (<see cref="SaveApply.WriteLedger"/>) the server spawn system uses to overwrite a director's StorageEntry
/// buffer from a staged PendingSave.
/// </summary>
public class SavePersistenceTests
{
@@ -17,8 +18,8 @@ namespace ProjectM.Tests
{
var data = new SaveData
{
GoalCharge = 42,
GoalTarget = 10,
RunsCompleted = 7,
MaxDepthReached = 9,
Ledger = new[]
{
new LedgerRow { ItemId = 1, Count = 5 },
@@ -31,8 +32,8 @@ namespace ProjectM.Tests
var back = JsonUtility.FromJson<SaveData>(json);
Assert.AreEqual(SaveData.CurrentVersion, back.Version);
Assert.AreEqual(42, back.GoalCharge);
Assert.AreEqual(10, back.GoalTarget);
Assert.AreEqual(7, back.RunsCompleted);
Assert.AreEqual(9, back.MaxDepthReached);
Assert.AreEqual(2, back.Ledger.Length);
Assert.AreEqual(1, back.Ledger[0].ItemId);
Assert.AreEqual(5, back.Ledger[0].Count);
@@ -46,20 +47,27 @@ namespace ProjectM.Tests
{
Assert.DoesNotThrow(() => JsonUtility.FromJson<SaveData>("{}"));
var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 };
var empty = new SaveData();
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(empty));
Assert.IsNotNull(back.Ledger);
Assert.AreEqual(0, back.Ledger.Length);
}
[Test]
public void SaveData_OldVersion_IsDetectable()
public void SaveData_Is_At_V7_Fresh_Epoch()
{
// A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game).
var old = new SaveData { Version = 0, GoalCharge = 7 };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(old));
Assert.AreEqual(0, back.Version);
Assert.AreNotEqual(SaveData.CurrentVersion, back.Version);
Assert.AreEqual(7, SaveData.CurrentVersion, "SaveData is at v7 (the LANTERN fresh epoch).");
Assert.AreEqual(7, SaveData.MinLoadableVersion, "v7 is a fresh epoch: no older save loads.");
}
[Test]
public void Pre_V7_Save_Is_Below_The_Load_Floor()
{
// A v6 (co-op-Hades era) save round-trips with its Version intact and sits BELOW MinLoadableVersion,
// so SaveService.Load rejects it (-> New Game). Fresh epoch, operator-approved (LANTERN purge).
var back = JsonUtility.FromJson<SaveData>("{\"Version\":6,\"RunsCompleted\":3}");
Assert.AreEqual(6, back.Version);
Assert.Less(back.Version, SaveData.MinLoadableVersion, "a v6 save is rejected under the v7 fresh epoch.");
}
[Test]
@@ -103,99 +111,29 @@ namespace ProjectM.Tests
Assert.AreEqual(0, em.GetBuffer<StorageEntry>(e).Length);
}
[Test]
public void StructureSave_HP_RoundTrips_And_Writes_V3()
public void StructureSave_HP_RoundTrips()
{
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP).");
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version.");
Assert.AreEqual(1, back.Structures.Length);
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
}
[Test]
public void V2_Save_IsWithinLoadableRange_And_ZeroHp_Restores_Full()
{
// A pre-EB-1 v2 save sits inside the additive load floor [Min,Current], so SaveService.Load accepts it;
// an unset HP (0) is mapped by BaseRestoreSystem to the baked Max (structures come back at full HP).
var v2 = new SaveData { Version = 2, GoalCharge = 3, GoalTarget = 10, Structures = new[] { new StructureSave { Type = 1, CellX = 2, CellZ = 4 } } };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(v2));
Assert.AreEqual(2, back.Version);
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v2 is at/above the load floor.");
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
Assert.AreEqual(0f, back.Structures[0].HP, 1e-4f, "unset HP (0) -> restore maps to baked Max.");
}
[Test]
public void ToPending_Maps_All_Fields_Including_The_Wounded_HP()
{
// The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught).
var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, Direction = 2, RemainingTicks = 50, ConveyorResId = 1, ConveyorCount = 4, HP = 37f };
var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, HP = 37f };
var p = SaveApply.ToPending(s);
Assert.AreEqual(1, p.Type);
Assert.AreEqual(3, p.CellX);
Assert.AreEqual(-2, p.CellZ);
Assert.AreEqual(2, p.Direction);
Assert.AreEqual(50u, p.RemainingTicks);
Assert.AreEqual(1, p.ConveyorResId);
Assert.AreEqual(4, p.ConveyorCount);
Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy.");
}
[Test]
public void CoreCurrent_RoundTrips_And_Writes_Current_Version()
{
var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 };
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
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.");
}
[Test]
public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero()
{
// A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the
// born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break.
var back = JsonUtility.FromJson<SaveData>("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}");
Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max.");
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor.");
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, "new saves write the current version.");
Assert.AreEqual(6, SaveData.CurrentVersion, "SaveData is at v6 (permanent meta: tier rows + run counters).");
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);
}
[Test]
public void Pre_v6_Save_Missing_Meta_Defaults_To_Empty()
{
// A v5 save JSON lacks MetaUpgrades/RunsCompleted/MaxDepthReached -> the field initializer keeps the
// array EMPTY (never null) and the counters 0-default. Additive: no field, no break; v5 loads.
var back = JsonUtility.FromJson<SaveData>("{\"Version\":5,\"GoalCharge\":3,\"GoalTarget\":4}");
Assert.IsNotNull(back.MetaUpgrades, "missing MetaUpgrades -> empty array, never null.");
Assert.AreEqual(0, back.MetaUpgrades.Length);
Assert.AreEqual(0, back.RunsCompleted, "missing RunsCompleted -> 0 (StagePendingSave floors it to GoalCharge).");
Assert.AreEqual(0, back.MaxDepthReached);
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v5 stays within the additive load floor.");
}
[Test]
public void MetaUpgrades_And_Counters_RoundTrip()
{
@@ -218,6 +156,5 @@ namespace ProjectM.Tests
Assert.AreEqual(3, back.MetaUpgrades[0].Tier);
Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash).");
}
}
}
@@ -18,49 +18,11 @@ namespace ProjectM.Tests
return (world, e);
}
[Test]
public void DrainFraction_Removes_Floored_Fraction_Of_Each_Row()
{
var (world, e) = MakeWorld();
try
{
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
StorageMath.Deposit(buf, 2, 100); // Ore
StorageMath.Deposit(buf, 4, 51); // Charge
StorageMath.DrainFraction(buf, 0.5f);
Assert.AreEqual(50, StorageMath.TotalOf(buf, 2), "100 -> floor(50) drained -> 50 left");
Assert.AreEqual(26, StorageMath.TotalOf(buf, 4), "51 -> floor(25) drained -> 26 left");
}
finally { world.Dispose(); }
}
[Test]
public void DrainFraction_Drops_Rows_That_Hit_Zero_And_Clamps_Above_One()
{
var (world, e) = MakeWorld();
try
{
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
StorageMath.Deposit(buf, 2, 4);
StorageMath.DrainFraction(buf, 1.5f); // clamps to 1.0 -> removes all -> row dropped
Assert.AreEqual(0, buf.Length, "a fully-drained row is removed");
}
finally { world.Dispose(); }
}
[Test]
public void DrainFraction_Zero_Is_NoOp()
{
var (world, e) = MakeWorld();
try
{
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
StorageMath.Deposit(buf, 2, 10);
StorageMath.DrainFraction(buf, 0f);
Assert.AreEqual(10, StorageMath.TotalOf(buf, 2), "0 fraction drains nothing");
}
finally { world.Dispose(); }
}
[Test]
@@ -27,13 +27,13 @@ namespace ProjectM.Tests
// RPC-receive systems ordered before the run director
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
// Run director + the systems ordered around it and the cycle phase
Add<RunDirectorSystem>(); Add<ThreatDirectorSystem>(); Add<RoomFieldSystem>();
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>(); Add<CyclePhaseSystem>();
Add<GoalReachedSystem>(); Add<WaveSystem>();
// Run director + the systems ordered around it (the cycle/siege spine is deleted — LANTERN purge)
Add<RunDirectorSystem>(); Add<RoomFieldSystem>();
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>();
Add<WaveSystem>();
// Combat sub-chain in the same group
Add<EnemyAISystem>(); Add<BossAISystem>(); Add<CoreDamageSystem>();
Add<CoreRestoreSystem>(); Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
Add<EnemyAISystem>(); Add<BossAISystem>();
Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
Assert.DoesNotThrow(() => group.SortSystems(),
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
@@ -1,50 +0,0 @@
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pins <see cref="SaveService.RollTerminalCampaignForward"/>: a TERMINAL save (Victory/Loss latched)
/// Continues as a fresh campaign — outcome / goal meter / core reset to 0 (the spawn restore guards re-map
/// 0 to InProgress / empty / baked-full) — while the permanent channel (meta tiers, run counters, ledger,
/// structures) survives untouched. In-progress saves and null are no-ops.
/// </summary>
public class TerminalCampaignRollTests
{
[Test]
public void TerminalSave_RollsForward_KeepingBaseAndMeta()
{
var data = new SaveData
{
RunOutcome = 1, // Victory latched
GoalCharge = 4,
GoalTarget = 4,
CoreCurrent = 37,
RunsCompleted = 4,
MaxDepthReached = 7,
MetaUpgrades = new[] { new MetaUpgradeSave { ClassId = 0, UpgradeId = 1, Tier = 2 } },
Ledger = new[] { new LedgerRow { ItemId = ResourceId.Ore, Count = 123 } },
};
SaveService.RollTerminalCampaignForward(data);
Assert.AreEqual(0, data.RunOutcome, "outcome latch reset");
Assert.AreEqual(0, data.GoalCharge, "goal meter reset");
Assert.AreEqual(0, data.CoreCurrent, "core resets to 0 -> restore guard re-maps to baked-full");
Assert.AreEqual(4, data.RunsCompleted, "permanent counters survive");
Assert.AreEqual(7, data.MaxDepthReached, "permanent counters survive");
Assert.AreEqual(2, data.MetaUpgrades[0].Tier, "meta tiers survive");
Assert.AreEqual(123, data.Ledger[0].Count, "the ledger survives");
}
[Test]
public void InProgressSave_AndNull_AreNoOps()
{
var data = new SaveData { RunOutcome = 0, GoalCharge = 2, CoreCurrent = 50 };
SaveService.RollTerminalCampaignForward(data);
Assert.AreEqual(2, data.GoalCharge);
Assert.AreEqual(50, data.CoreCurrent);
Assert.DoesNotThrow(() => SaveService.RollTerminalCampaignForward(null));
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 085790610f52ea347aa4ccd208849316
@@ -1,205 +0,0 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ThreatDirectorSystem"/> — the composite
/// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity
/// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a
/// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the
/// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that
/// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick.
/// </summary>
public class ThreatDirectorSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(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);
}
static ThreatConfig DefaultConfig() => new ThreatConfig
{
PostExpeditionEnabled = 1,
PostExpeditionDelayTicks = 300,
SizeBase = 5,
SizePerExpeditionResource = 0,
StartCondition = ThreatStartCondition.Immediate,
SiegeTimeoutTicks = 3600,
};
static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config)
{
var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig));
em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 });
em.SetComponentData(e, threat);
em.SetComponentData(e, config);
return e;
}
[Test]
public void PostExpedition_Return_Edge_Sets_PendingSiegeSize()
{
var (world, group) = MakeWorld("ThreatReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks.");
Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick.");
Assert.AreEqual(0, ts.PendingReturns, "The return is consumed.");
}
}
[Test]
public void Multi_Player_Simultaneous_Return_Charges_Pending_Once()
{
var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup).");
Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed.");
}
}
[Test]
public void Siege_Size_Equals_Config_Not_Escalation_Curve()
{
var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
// A high wave number must NOT influence the event-siege size.
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { WaveNumber = 30 });
group.Update();
Assert.AreEqual(5, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"Event-siege size is the config SizeBase, never the WaveSystem escalation curve.");
}
}
[Test]
public void StartCondition_Immediate_Arms_Via_ArmTick()
{
var (world, group) = MakeWorld("ThreatArm", serverTick: 1000);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionDelayTicks = 120;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config);
group.Update();
Assert.AreEqual(1120u, em.GetComponentData<ThreatState>(dir).ArmTick,
"Immediate start arms the siege at now + the telegraph delay (1000 + 120).");
}
}
[Test]
public void Empty_Base_Siege_Auto_Resolves_Bounded()
{
var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.SiegeTimeoutTicks = 60;
// SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout.
var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config);
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning });
// Three Husks still on the field with no one to clear them.
for (int i = 0; i < 3; i++)
{
var h = em.CreateEntity(typeof(EnemyTag));
em.AddComponentData(h, new RegionTag { Region = RegionId.Base });
}
group.Update();
using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, huskQuery.CalculateEntityCount(),
"A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock.");
Assert.AreEqual(0, em.GetComponentData<WaveState>(w).RemainingToSpawn,
"The wave stops spawning when the siege collapses.");
Assert.AreEqual(0u, em.GetComponentData<ThreatState>(dir).SiegeStartTick,
"The siege clock resets after collapse.");
}
}
[Test]
public void Schedule_First_Pass_Seeds_NextTick_Without_Firing()
{
var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionEnabled = 0;
config.ScheduleEnabled = 1;
config.ScheduleIntervalTicks = 100;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config);
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100).");
Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately.");
}
}
[Test]
public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition()
{
var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionEnabled = 0; // isolate the schedule source
config.ScheduleEnabled = 1;
config.ScheduleIntervalTicks = 100;
config.ScheduleSizePerWave = 0;
config.SizeBase = 5;
config.PostExpeditionDelayTicks = 10;
// NextScheduledTick 300 <= now 400 => the scheduled siege is due.
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config);
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip.");
Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10).");
Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100).");
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 351a99057b08e3847b239782bfef893e
@@ -35,10 +35,6 @@ namespace ProjectM.Tests
// GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it).
Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks");
Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)");
Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk 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(2.5f, d.FinalSiegeMultiplier, 1e-6f, "END-2 FinalSiegeMultiplier default (~2.5x a normal siege)");
}
@@ -47,6 +43,7 @@ namespace ProjectM.Tests
{
for (byte knob = 0; knob < TuningKnob.Count; knob++)
{
if (knob >= 20 && knob <= 23) continue; // retired END-1/END-2 knob ids (LANTERN purge; reserved)
var c = TuningConfig.Defaults();
float baseline = TuningConfig.Get(c, knob);
float target = baseline + 7f; // survives both clamps (positive)
@@ -56,7 +53,7 @@ namespace ProjectM.Tests
// every OTHER knob is untouched
var d = TuningConfig.Defaults();
for (byte other = 0; other < TuningKnob.Count; other++)
if (other != knob)
if (other != knob && !(other >= 20 && other <= 23))
Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f,
$"knob {other} unchanged while editing {knob}");
}
@@ -126,7 +123,10 @@ namespace ProjectM.Tests
TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f);
var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c));
for (byte knob = 0; knob < TuningKnob.Count; knob++)
{
if (knob >= 20 && knob <= 23) continue; // retired knob ids (LANTERN purge)
Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport");
}
}
// ---- consumption (world) ----
@@ -9,16 +9,17 @@ using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave/threat director).
/// A bare world is seeded with NetworkTime + CycleState singletons and a director entity carrying
/// WaveDirector + WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so
/// it is excluded from the alive-Husk query and Instantiate yields plain Husk instances). Pins: a due Lull
/// starts the next (escalating) wave; Spawning emits one Husk per interval; the director is gated off outside
/// Defend; a fully-spawned, cleared wave returns to Lull.
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave director).
/// A bare world is seeded with a NetworkTime singleton and a director entity carrying WaveDirector +
/// WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so it is
/// excluded from the alive-Husk query and Instantiate yields plain Husk instances). Waves are UNGATED
/// (LANTERN purge: the old CycleState Siege gate is deleted — placement of a WaveDirector decides).
/// Pins: a due Lull starts the next (escalating) wave; Spawning emits one Husk per interval; a
/// fully-spawned, cleared wave returns to Lull.
/// </summary>
public class WaveSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase)
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
@@ -28,8 +29,6 @@ namespace ProjectM.Tests
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
var cyc = em.CreateEntity(typeof(CycleState));
em.SetComponentData(cyc, new CycleState { Phase = cyclePhase });
return (world, group);
}
@@ -68,7 +67,7 @@ namespace ProjectM.Tests
[Test]
public void Due_Lull_Starts_Wave_With_Escalating_Count()
{
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100, cyclePhase: CyclePhase.Siege);
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100);
using (world)
{
var em = world.EntityManager;
@@ -88,7 +87,7 @@ namespace ProjectM.Tests
[Test]
public void Spawning_Emits_One_Husk_And_Decrements_Remaining()
{
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100, cyclePhase: CyclePhase.Siege);
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100);
using (world)
{
var em = world.EntityManager;
@@ -104,28 +103,10 @@ namespace ProjectM.Tests
}
}
[Test]
public void Director_Is_Gated_Off_Outside_Defend()
{
var (world, group) = MakeWorld("WaveGated", serverTick: 100, cyclePhase: CyclePhase.Calm);
using (world)
{
var em = world.EntityManager;
var prefab = MakeHuskPrefab(em);
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
group.Update();
var w = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(WavePhase.Lull, w.Phase, "Outside Defend the director does not run.");
Assert.AreEqual(0, w.WaveNumber, "Wave number stays put outside Defend.");
}
}
[Test]
public void Fully_Spawned_Cleared_Wave_Returns_To_Lull()
{
var (world, group) = MakeWorld("WaveCleared", serverTick: 100, cyclePhase: CyclePhase.Siege);
var (world, group) = MakeWorld("WaveCleared", serverTick: 100);
using (world)
{
var em = world.EntityManager;