using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Collections; using Unity.Core; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; using System.Collections.Generic; namespace ProjectM.Tests { /// /// Pins the two-channel boon lifecycle (Phase 1.7 table). : a valid STAT pick appends /// exactly ONE boon-band and clears Pending; a MECHANIC-CHANGER pick mutates /// (no StatModifier row); out-of-range / not-pending / closed-lifecycle picks are rejected; /// the grace auto-pick deals Option0. The RunDirector Returning-edge strip: every boon-band StatModifier dies, /// BoonEffects is zeroed, the Frenzy timed row is removed from BOTH buffers, and class/meta/equip bands survive. /// New default table ids: 1 Piercing (effect), 4 Detonating (effect), 9 Executioner (Damage +50%), /// 10 Titan (MaxHealth +60), 11 Fleet Foot (MoveSpeed +18%). /// public class BoonApplyTests { const uint T0 = 3000; readonly List _worlds = new(); [TearDown] public void Cleanup() { foreach (var w in _worlds) if (w.IsCreated) w.Dispose(); _worlds.Clear(); } static (World world, SimulationSystemGroup group, Entity dir, Entity catalog) MakeWorld(byte lifecycle) { var world = new World("BoonApplyTest"); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); 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) }); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime)); em.SetComponentData(dir, new RunInfo { Lifecycle = lifecycle, CurrentRoom = 1 }); em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RoomEpoch = 2, RewardGraceTick = T0 + 1800 }); var catalog = em.CreateEntity(typeof(BoonCatalog)); em.SetComponentData(catalog, new BoonCatalog { Value = BoonCatalogData.BuildDefault() }); return (world, group, dir, catalog); } // Defaults to STAT ids so an accepted pick appends a StatModifier row (o1 = 11 Fleet Foot). static Entity MakePicker(EntityManager em, int netId, byte o0 = 9, byte o1 = 11, byte o2 = 10) { var e = em.CreateEntity(typeof(PlayerTag), typeof(BoonOffer), typeof(GhostOwner), typeof(RegionTag), typeof(BoonEffects)); em.AddBuffer(e); em.SetComponentData(e, new GhostOwner { NetworkId = netId }); em.SetComponentData(e, new RegionTag { Region = RegionId.Expedition }); em.SetComponentData(e, new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 }); return e; } static void SendPick(EntityManager em, int netId, byte index) { var conn = em.CreateEntity(typeof(NetworkId)); em.SetComponentData(conn, new NetworkId { Value = netId }); var req = em.CreateEntity(typeof(BoonPickRequest), typeof(ReceiveRpcCommandRequest)); em.SetComponentData(req, new BoonPickRequest { Index = index }); em.SetComponentData(req, new ReceiveRpcCommandRequest { SourceConnection = conn }); } static int BoonRows(EntityManager em, Entity player) { var mods = em.GetBuffer(player); int n = 0; for (int i = 0; i < mods.Length; i++) if (mods[i].SourceId >= Tuning.BoonSourceIdBase && mods[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan) n++; return n; } [Test] public void ValidStatPick_AppendsBoonBandRow_AndClearsPending() { var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); using (world) { var em = world.EntityManager; var player = MakePicker(em, 1); SendPick(em, 1, index: 1); // Option1 = id 11 (Fleet Foot, MoveSpeed +18%) group.Update(); Assert.AreEqual(1, BoonRows(em, player), "exactly one boon-band row appended"); var mods = em.GetBuffer(player); Assert.AreEqual((byte)StatTarget.MoveSpeed, mods[0].Target, "the picked def's target"); Assert.AreEqual((byte)ModOp.PercentAdd, mods[0].Op); Assert.AreEqual(0.18f, mods[0].Value, 1e-4f); Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); Assert.AreEqual(1u, em.GetComponentData(dir).BoonPickCounter, "band provenance advanced"); } } [Test] public void EffectPick_MutatesBoonEffects_AppendsNoStatRow() { var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); using (world) { var em = world.EntityManager; var player = MakePicker(em, 1, o0: 1); // Option0 = id 1 (Piercing Shots — a mechanic-changer) SendPick(em, 1, index: 0); group.Update(); Assert.AreEqual(0, BoonRows(em, player), "a mechanic-changer appends NO StatModifier row"); Assert.AreEqual(1, em.GetComponentData(player).Pierce, "Pierce incremented"); Assert.AreEqual(0, em.GetComponentData(player).Pending, "pick consumed"); Assert.AreEqual(0u, em.GetComponentData(dir).BoonPickCounter, "no band row → counter unchanged"); } } [Test] public void Rejects_NotPending_ClosedLifecycle_KeepsBufferClean() { // Not pending. var (w1, g1, d1, c1) = MakeWorld(RunLifecycle.RoomReward); _worlds.Add(w1); var p1 = MakePicker(w1.EntityManager, 1); w1.EntityManager.SetComponentData(p1, new BoonOffer { Pending = 0, Option0 = 9 }); SendPick(w1.EntityManager, 1, 0); g1.Update(); Assert.AreEqual(0, BoonRows(w1.EntityManager, p1), "not-pending pick rejected"); // Lifecycle closed (Returning): the straggler pick dies BEFORE any strip could be out-run (D-F4). var (w2, g2, d2, c2) = MakeWorld(RunLifecycle.Returning); _worlds.Add(w2); var p2 = MakePicker(w2.EntityManager, 1); SendPick(w2.EntityManager, 1, 0); g2.Update(); Assert.AreEqual(0, BoonRows(w2.EntityManager, p2), "closed-lifecycle pick rejected"); using (var q = w2.EntityManager.CreateEntityQuery(typeof(BoonPickRequest))) Assert.AreEqual(0, q.CalculateEntityCount(), "request still consumed"); } [Test] public void GraceElapsed_AutoPicksOption0_ForPendingExpeditionPlayers() { var (world, group, dir, catalog) = MakeWorld(RunLifecycle.RoomReward); using (world) { var em = world.EntityManager; var afk = MakePicker(em, 1, o0: 10); // Option0 = id 10 (Titan's Vigor, +60 MaxHealth) var run = em.GetComponentData(dir); run.RewardGraceTick = T0 - 10; // already elapsed em.SetComponentData(dir, run); group.Update(); Assert.AreEqual(1, BoonRows(em, afk), "AFK player auto-dealt Option0"); var mods = em.GetBuffer(afk); Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target); Assert.AreEqual(0, em.GetComponentData(afk).Pending, "gate released"); } } [Test] public void ReturningStrip_KillsBoonBand_ZeroesEffects_SparesClassMetaEquip() { // Drive the REAL RunDirectorSystem Returning edge over a player carrying all four StatModifier bands // PLUS mechanic-changer BoonEffects + a Frenzy timed row (StatModifier + TimedModifier). var world = new World("BoonStripTest"); using (world) { var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); 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) }); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(RouteCommand)); em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.Returning, CurrentRoom = 3, RoomCount = 8 }); em.SetComponentData(dir, new RunRuntime { RunSeed = 7u, RunEpoch = 1, RoomsClearedThisRun = 3 }); var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(BoonOffer), typeof(RegionTag), typeof(LocalTransform), typeof(BoonEffects)); em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); em.SetComponentData(player, LocalTransform.Identity); em.SetComponentData(player, new BoonOffer { Pending = 1, Option0 = 1 }); em.SetComponentData(player, new BoonEffects { Pierce = 2, Flags = BoonFlag.Frenzy }); var mods = em.AddBuffer(player); mods.Add(new StatModifier { Target = 0, Op = 1, Value = 0.2f, SourceId = Tuning.BoonSourceIdBase }); // boon mods.Add(new StatModifier { Target = 1, Op = 2, Value = -0.3f, SourceId = Tuning.FrenzySourceId }); // Frenzy (boon band top) mods.Add(new StatModifier { Target = 6, Op = 1, Value = 0.1f, SourceId = Tuning.ClassSourceId }); // class mods.Add(new StatModifier { Target = 8, Op = 0, Value = 10f, SourceId = 0x00E7A000u }); // meta (12a band) mods.Add(new StatModifier { Target = 0, Op = 0, Value = 5f, SourceId = Tuning.EquipSourceIdBase }); // equip var timed = em.AddBuffer(player); timed.Add(new TimedModifier { SourceId = Tuning.FrenzySourceId, UntilTick = T0 + 100 }); group.Update(); // Returning: strip + bank + home -> Staging var after = em.GetBuffer(player); Assert.AreEqual(3, after.Length, "both boon-band rows (incl. Frenzy) stripped, three permanent bands survive"); for (int i = 0; i < after.Length; i++) Assert.IsFalse(after[i].SourceId >= Tuning.BoonSourceIdBase && after[i].SourceId < Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan, "no boon-band survivor"); Assert.AreEqual(0, em.GetBuffer(player).Length, "Frenzy timed row stripped"); Assert.AreEqual(default(BoonEffects), em.GetComponentData(player), "mechanic-changer effects zeroed"); Assert.AreEqual(0, em.GetComponentData(player).Pending, "straggler offer zeroed"); Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); } } } }