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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user