using NUnit.Framework; using ProjectM.Simulation; using Unity.Collections; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Tests { /// /// 07-21 G6 (review wf_98bf1268) — the Cone-socket (SpecialSlam) damage-at-contact schedule: EditMode tests /// for through the REAL (a GameServer-flagged /// world so IsServer gates damage on, NetworkTime flagged IsFirstTimeFullyPredictingTick so the system runs, /// an AbilityDatabase blob with a Cone Spark, and InputBufferData<PlayerInput> command pushes — the /// review-specified fixture; MeleeComboTests' lighter harness cannot exercise the socket loop). Effective /// socket stats are hand-filled (no StatRecomputeSystem — removes same-tick ordering flake). Every press is /// followed by a release command so a held InputEvent can never re-fire across the cooldown edge (the /// MeleeComboTests C6 lesson). Pins: schedule + exactly-once fire + consume · knob 0 = legacy immediate · /// early-flush on recast-before-contact (no slam is ever lost) · death clears the armed pending (never a /// respawn-position slam) · socket-swap consume-drop (SetClass mid-flight). /// public class AbilityFireSystemConeTests { const byte ConeSpark = 4; // AbilityId.WarriorCone const uint Contact = 21; // pinned knob 32 value for these tests (defaults may drift for feel) static BlobAssetReference BuildConeDb(int cooldownTicks) { using var b = new BlobBuilder(Allocator.Temp); ref var root = ref b.ConstructRoot(); var a = b.Allocate(ref root.Abilities, 1); a[0] = new AbilityDefBlob { Id = ConeSpark, Archetype = (byte)AbilityArchetype.Cone, Damage = 25f, Range = 3f, AutoTargetConeRadians = 0.9f, CooldownTicks = cooldownTicks, Name = "TestCone" }; var c = b.Allocate(ref root.Characters, 1); c[0] = new CharacterStatsBlob { Id = 0, MoveSpeed = 6f, TurnRateRadiansPerSec = 12.5f, MaxHealth = 100f, Name = "T" }; return b.CreateBlobAssetReference(Allocator.Persistent); } // NetworkTime.Flags is internal (review fixture note): outside the real prediction groups the // IsFirstTimeFullyPredictingTick gate can only be satisfied via reflection — test-only, editor-only. static readonly System.Reflection.FieldInfo s_FlagsField = typeof(NetworkTime).GetField( "Flags", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); static NetworkTime MakePredictingTime(uint tick) { object boxed = new NetworkTime { ServerTick = new NetworkTick(tick) }; s_FlagsField.SetValue(boxed, NetworkTimeFlags.IsInPredictionLoop | NetworkTimeFlags.IsFirstTimeFullyPredictingTick); return (NetworkTime)boxed; } static void SetTick(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, MakePredictingTime(tick)); } static (World world, SimulationSystemGroup group, Entity player, Entity enemy, BlobAssetReference blob) MakeWorld(uint tick, float coneContactKnob, int cooldownTicks = 22, bool withDeathSystem = false, bool withPendingSlot = true) { var world = new World("ConeTest", WorldFlags.Game | WorldFlags.GameServer); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); if (withDeathSystem) group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetTick(world, tick); var em = world.EntityManager; var blob = BuildConeDb(cooldownTicks); var dbEntity = em.CreateEntity(typeof(AbilityDatabase)); em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob }); em.AddBuffer(dbEntity); // required by the fire path; the Cone branch spawns nothing var tuning = TuningConfig.Defaults(); tuning.ConeContactTicks = coneContactKnob; em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), tuning); var player = em.CreateEntity(); em.AddComponentData(player, new PlayerInput()); em.AddComponentData(player, new PlayerFacing { Direction = new float2(0f, 1f) }); em.AddComponentData(player, LocalTransform.FromPosition(float3.zero)); em.AddComponentData(player, new GhostOwner { NetworkId = 7 }); em.AddComponent(player); em.AddComponent(player); em.SetComponentEnabled(player, false); if (withPendingSlot) em.AddComponent(player); // review wf_9757d214: the no-slot fallback is a pinned contract var socks = em.AddBuffer(player); socks.Add(new AbilitySocket { SparkId = ConeSpark }); em.AddComponentData(player, default(SocketCooldown)); var effs = em.AddBuffer(player); effs.Add(new EffectiveSocketStats { Damage = 25f, Range = 3f, AutoTargetConeRadians = 0.9f, CooldownTicks = cooldownTicks }); em.AddBuffer>(player); if (withDeathSystem) { em.AddComponent(player); em.AddComponentData(player, new Health { Current = 100f, Max = 100f }); em.AddComponentData(player, new CharacterControl()); } var enemy = em.CreateEntity(); em.AddComponent(enemy); em.AddComponentData(enemy, new Health { Current = 200f, Max = 200f }); em.AddComponentData(enemy, LocalTransform.FromPosition(new float3(0f, 0f, 2f))); em.AddBuffer(enemy); em.AddComponentData(enemy, new KnockbackState()); return (world, group, player, enemy, blob); } /// Push a socket-0 press at the way the WIRE actually carries it (07-21 /// auto-recast repro): InputEvent counts ACCUMULATE monotonically across commands — the per-frame gather /// reset never reaches the buffer — so a press is a count STEP at one tick and the stepped count PERSISTS /// in every later command. Pushes baseline(tick-1) + press(tick, +1) + held(tick+1, same stepped count). static void PressSocket0(EntityManager em, Entity player, uint tick) { var buf = em.GetBuffer>(player); uint baseCount = 0; if (buf.Length > 0) baseCount = buf[buf.Length - 1].InternalInput.Socket0.Count; var idle = new PlayerInput(); idle.Socket0.Count = baseCount; var pressed = new PlayerInput(); pressed.Socket0.Count = baseCount + 1; buf.Add(new InputBufferData { Tick = new NetworkTick(tick - 1), InternalInput = idle }); buf.Add(new InputBufferData { Tick = new NetworkTick(tick), InternalInput = pressed }); buf.Add(new InputBufferData { Tick = new NetworkTick(tick + 1), InternalInput = pressed }); } static void Step(World world, SimulationSystemGroup group, uint tick) { SetTick(world, tick); group.Update(); } [Test] public void Cone_Resolves_At_The_Contact_Tick_Exactly_Once() { var (world, group, player, enemy, blob) = MakeWorld(100, Contact); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "no damage at the fire tick (scheduled)"); var pend = em.GetComponentData(player); Assert.AreEqual(TickUtil.NonZero(100u + Contact), pend.ResolveTick, "pending armed at fire+contact"); Assert.AreEqual(0, pend.Socket, "armed socket index"); Step(world, group, 120); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "still counting down 1 tick before contact"); Step(world, group, 121); var events = em.GetBuffer(enemy); Assert.AreEqual(1, events.Length, "exactly one cleave at the contact tick"); Assert.AreEqual(25f, events[0].Amount, 1e-3f, "live folded socket damage"); Assert.AreEqual(7, events[0].SourceNetworkId, "credited to the caster"); Assert.AreEqual(0u, em.GetComponentData(player).ResolveTick, "consumed by zeroing"); Step(world, group, 121); // same-tick re-run (batching shape): consumed pending must not refire Assert.AreEqual(1, em.GetBuffer(enemy).Length, "never a second fire"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Cone_Knob_Zero_Is_Legacy_Immediate() { var (world, group, player, enemy, blob) = MakeWorld(100, 0f); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreEqual(1, em.GetBuffer(enemy).Length, "knob 0 = the legacy at-fire cleave"); Assert.AreEqual(0u, em.GetComponentData(player).ResolveTick, "nothing scheduled"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Cone_Recast_Early_Flushes_The_Armed_Pending() { // Cooldown 10 < contact 21: the recast lands BEFORE the first slam's contact -> the armed pending is // FLUSHED (fired early) then re-armed. No knob combination may lose a slam (the melee C0/C12 contract). var (world, group, player, enemy, blob) = MakeWorld(100, Contact, cooldownTicks: 10); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreEqual(0, em.GetBuffer(enemy).Length); PressSocket0(em, player, 110); // cooldown re-opened at 110; pending (121) still counting down Step(world, group, 110); Assert.AreEqual(1, em.GetBuffer(enemy).Length, "recast FLUSHED the armed slam early"); Assert.AreEqual(TickUtil.NonZero(110u + Contact), em.GetComponentData(player).ResolveTick, "re-armed for the new cast"); Step(world, group, 131); Assert.AreEqual(2, em.GetBuffer(enemy).Length, "second slam lands at ITS contact; none lost"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Death_Clears_The_Armed_Pending_No_Respawn_Slam() { var (world, group, player, enemy, blob) = MakeWorld(100, Contact, withDeathSystem: true); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreNotEqual(0u, em.GetComponentData(player).ResolveTick, "armed"); em.SetComponentData(player, new Health { Current = 0f, Max = 100f }); // die 5 ticks before contact Step(world, group, 105); Assert.AreEqual(0u, em.GetComponentData(player).ResolveTick, "death zeroes the pending (review wf_98bf1268: a pending surviving death slams from the respawn ring)"); em.SetComponentData(player, new Health { Current = 100f, Max = 100f }); // revive well past contact Step(world, group, 140); Step(world, group, 141); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "no ghost slam after respawn"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Cast_Never_Refires_When_The_Cooldown_Reopens() { // THE 07-21 operator-reported auto-recast (live repro: one press → a recast at EVERY reopen): wire // counts persist in every later command, and the old raw-IsSet gate read "ever pressed". Knob 0 // isolates the cast gate (no pending schedule in the way). var (world, group, player, enemy, blob) = MakeWorld(100, 0f); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreEqual(1, em.GetBuffer(enemy).Length, "the press casts once"); Step(world, group, 122); // cooldown (22t) reopened; the held stepped-count command still answers GetDataAtTick Step(world, group, 123); Step(world, group, 200); Assert.AreEqual(1, em.GetBuffer(enemy).Length, "a reopened cooldown must NEVER re-fire a stale press"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Cone_Missing_Pending_Slot_Falls_Back_To_Immediate() { // knob > 0 but NO baked pending slot (plain/legacy worlds): the hasPending fallback fires at-cast. var (world, group, player, enemy, blob) = MakeWorld(100, Contact, withPendingSlot: false); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreEqual(1, em.GetBuffer(enemy).Length, "no slot = legacy immediate, never a silent no-damage"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Cone_Resolves_With_The_Aim_At_Contact_Not_Cast() { // The pending stores {tick, socket} ONLY — the cast-turn steers the cone until contact (live-state resolve). var (world, group, player, enemy, blob) = MakeWorld(100, Contact); try { var em = world.EntityManager; var side = em.CreateEntity(); em.AddComponent(side); em.AddComponentData(side, new Health { Current = 200f, Max = 200f }); em.AddComponentData(side, Unity.Transforms.LocalTransform.FromPosition(new float3(2f, 0f, 0f))); em.AddBuffer(side); em.AddComponentData(side, new KnockbackState()); PressSocket0(em, player, 100); Step(world, group, 100); // armed facing +z (enemy 'enemy' at (0,0,2) is in the cast-time cone) em.SetComponentData(player, new PlayerFacing { Direction = new float2(1f, 0f) }); // turn to +x before contact Step(world, group, 121); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "the cast-tick direction must NOT be latched"); Assert.AreEqual(1, em.GetBuffer(side).Length, "damage follows the aim AT the contact tick"); } finally { world.Dispose(); blob.Dispose(); } } [Test] public void Socket_Swap_Mid_Flight_Consume_Drops() { var (world, group, player, enemy, blob) = MakeWorld(100, Contact); try { var em = world.EntityManager; PressSocket0(em, player, 100); Step(world, group, 100); Assert.AreNotEqual(0u, em.GetComponentData(player).ResolveTick, "armed"); var socks = em.GetBuffer(player); socks[0] = new AbilitySocket { SparkId = 99 }; // SetClass-style swap: no longer a known Cone Spark Step(world, group, 121); Assert.AreEqual(0, em.GetBuffer(enemy).Length, "mismatched socket at resolve = consume-drop, never a slam with foreign stats"); Assert.AreEqual(0u, em.GetComponentData(player).ResolveTick, "consumed"); } finally { world.Dispose(); blob.Dispose(); } } } }