This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
@@ -23,8 +23,8 @@ namespace ProjectM.Tests
[Test]
public void FirstIncomplete_SkipsCompletedPrefix()
{
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
}
[Test]
@@ -57,12 +57,14 @@ namespace ProjectM.Tests
}
[Test]
public void Mine_AdvancesOnlyWhenLedgerOreRises()
public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
{
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
s.OreNow = 51;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
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]
@@ -86,23 +88,27 @@ namespace ProjectM.Tests
}
[Test]
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective()
{
var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
var onExp = Empty(); onExp.OnExpedition = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExp));
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active));
}
[Test]
public void Clear_AdvancesOnClearedObjective()
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
{
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
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]
@@ -157,11 +163,11 @@ namespace ProjectM.Tests
// ---- pointer kinds ----
[Test]
public void PointerKinds_MatchSpatialStepsOnly()
public void PointerKinds_OnlyTheRoomsStepPoints()
{
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
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));
}
@@ -82,6 +82,12 @@ namespace ProjectM.Tests
return q.CalculateEntityCount();
}
static void AdvanceTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test]
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
{
@@ -91,7 +97,11 @@ namespace ProjectM.Tests
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
group.Update(); // seeds + first slot spawns this tick
group.Update(); // seeds; the landing grace holds the first slot (demo polish)
Assert.AreEqual(slots, em.GetComponentData<ZoneEnemyState>(zoneDir).RemainingToSpawn,
"grace: nothing spawns on the seed tick");
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the first slot drips
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
@@ -121,7 +131,9 @@ namespace ProjectM.Tests
var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
var em = world.EntityManager;
group.Update();
group.Update(); // seed tick (the landing grace holds the boss)
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the boss spawns
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
@@ -54,7 +54,9 @@ namespace ProjectM.Tests
});
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform));
// 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),
typeof(LocalTransform), typeof(RunParticipant));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity);
return (world, group, dir, player);
@@ -0,0 +1,136 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Regression pins for the two post-impl-review fixes on <see cref="RunDirectorSystem"/>:
/// (1) a dead-respawned (base-region) player's stale <see cref="BoonOffer.Pending"/> neither holds the
/// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal
/// pickable through the next fight); (2) room advances teleport ONLY <see cref="RunParticipant"/>s —
/// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner
/// stays at base (spec §2.2 closed party).
/// </summary>
public class RunRosterRegressionTests
{
const uint Seed = 777u;
const uint T0 = 2000;
static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map)
{
var world = new World("RosterTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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(T0) });
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
return (world, group, dir);
}
static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending)
{
var player = participant
? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant))
: em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer));
em.SetComponentData(player, new RegionTag { Region = region });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 });
return player;
}
[Test]
public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit()
{
var (world, group, dir) = MakeWorld(out var map);
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RoomReward,
CurrentRoom = 1,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 2,
// Grace far in the future: ONLY the all-picked path can advance this tick.
RewardGraceTick = TickUtil.NonZero(T0 + 100000),
});
var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle,
"a base-region player's stale Pending must not hold the reward gate");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(deadAtBase).Pending,
"the stale offer is stripped on the gate exit");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(alive).Pending,
"no offer survives the gate");
world.Dispose();
}
[Test]
public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase()
{
var (world, group, dir) = MakeWorld(out var map);
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RouteSelect,
CurrentRoom = 0,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
RouteOptionCount = 1,
RouteOpt0Col = 0,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 1,
RouteGraceTick = TickUtil.NonZero(T0 + 100000),
});
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 });
var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted
var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(inRoom).Position.x, 1000f,
"the in-room participant rides the advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(deadAtBase).Position.x, 1000f,
"a dead-respawned participant is re-conscripted onto the new room");
Assert.AreEqual(0f, em.GetComponentData<LocalTransform>(lateJoiner).Position.x,
"a non-participant late joiner is never yanked into the fight");
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(lateJoiner).Region,
"the late joiner stays in the Base relevancy bucket");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31b988b0527c7c247ad1976aab05d470
@@ -0,0 +1,50 @@
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));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 085790610f52ea347aa4ccd208849316