diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index f08350132..85d068555 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -51,6 +51,8 @@ namespace ProjectM.Simulation ComponentLookup m_ZoneLookup; ComponentLookup m_DecoyLookup; ComponentLookup m_LtLookup; + // 07-21 G6: server-only scheduled Cone cleave (the MeleeCleavePending idiom on the socket kit). + ComponentLookup m_ConePendingLookup; /// ~9 degree gap between adjacent Split-Shot projectiles (tunable). const float k_ForkSpreadRad = 0.157f; @@ -71,6 +73,7 @@ namespace ProjectM.Simulation m_ZoneLookup = state.GetComponentLookup(isReadOnly: true); m_DecoyLookup = state.GetComponentLookup(isReadOnly: true); m_LtLookup = state.GetComponentLookup(isReadOnly: true); + m_ConePendingLookup = state.GetComponentLookup(isReadOnly: false); } [BurstCompile] @@ -89,6 +92,9 @@ namespace ProjectM.Simulation ref var adb = ref abilityDb.Value.Value; bool isServer = state.WorldUnmanaged.IsServer(); + // 07-21 G6: cone contact knob (0 = legacy immediate). Defaults() fallback matches release servers. + var tcfg = SystemAPI.TryGetSingleton(out var tcv) ? tcv : TuningConfig.Defaults(); + uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks); m_KnockbackLookup.Update(ref state); m_BossLookup.Update(ref state); m_BoonEffectsLookup.Update(ref state); @@ -98,6 +104,7 @@ namespace ProjectM.Simulation m_ZoneLookup.Update(ref state); m_DecoyLookup.Update(ref state); m_LtLookup.Update(ref state); + m_ConePendingLookup.Update(ref state); // Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once. var candidatePositions = new NativeList(Allocator.Temp); @@ -131,6 +138,28 @@ namespace ProjectM.Simulation BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default; bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0; + // 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the + // MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing). + // Server-only state; the client copy stays zero (contact cues are presentation-side). The armed + // socket is RE-VALIDATED (SetClass can swap the loadout mid-flight) — consume-drop on mismatch. + bool hasConePending = m_ConePendingLookup.HasComponent(entity); + if (isServer && hasConePending) + { + var pend = m_ConePendingLookup[entity]; + if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick)) + { + if (pend.Socket < sockets.Length && pend.Socket < effSockets.Length + && adb.TryGetAbility(sockets[pend.Socket].SparkId, out var pendDef) + && pendDef.Archetype == (byte)AbilityArchetype.Cone) + { + float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); + FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId, + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); + } + m_ConePendingLookup[entity] = default; // consume (drop on mismatch) + } + } + // Replicated command buffer (windup resolve + per-socket fire count for the SpawnId + scheme for aim assist). var inputBuffer = SystemAPI.GetBuffer>(entity); @@ -156,7 +185,17 @@ namespace ProjectM.Simulation resolveTick = new NetworkTick(riNow - (uint)adef.WindupTicks); } if (!inputBuffer.GetDataAtTick(resolveTick, out var applied)) continue; // history gap -> no-fire - if (!applied.InternalInput.GetSocket(sk).IsSet) continue; // socket not fired at the resolve tick + // 07-21 AUTO-RECAST FIX (live repro: one press → a recast at EVERY cooldown reopen, forever): + // the netcode copy layer ACCUMULATES InputEvent counts on the wire — a raw buffer entry's + // IsSet means "ever pressed", not "pressed THIS tick" (only the decoded COMPONENT is + // delta-corrected). A press AT resolveTick = a count STEP vs the previous tick's command + // (Netcode's own decode semantics). Missing previous command → no-fire (dropping a + // buffer-edge windup press beats an infinite recast loop). + var prevTick = resolveTick; + prevTick.Decrement(); + if (!inputBuffer.GetDataAtTick(prevTick, out var prevCmd)) continue; + if (applied.InternalInput.GetSocket(sk).Count == prevCmd.InternalInput.GetSocket(sk).Count) + continue; // no NEW press at the resolve tick // Per-socket cooldown gate (0 = ready). uint nextFireRaw = cd.Get(sk); @@ -166,28 +205,38 @@ namespace ProjectM.Simulation if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue; } - // CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave. + // CONE (SpecialSlam): no projectile ghost. Predict the cooldown on both worlds; server-only cleave. + // 07-21 G6 (review wf_98bf1268): with the contact knob armed, damage lands at the slam's visual + // contact via ConeContactPending (schedule-and-consume); knob 0 / missing slot = legacy at-fire. if (archetype == (byte)AbilityArchetype.Cone) { if (isServer) { - float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick - float cRange = math.max(0.1f, es.Range); - float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f)); - uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); - for (int ci = 0; ci < coneTargets.Length; ci++) + if (coneContact > 0u && hasConePending) { - if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci])) - continue; - ecb.AppendToBuffer(coneTargets[ci], new DamageEvent + // EARLY-FLUSH a still-armed pending (re-validated) so no knob combination can lose a slam. + var armed = m_ConePendingLookup[entity]; + if (armed.ResolveTick != 0u + && armed.Socket < sockets.Length && armed.Socket < effSockets.Length + && adb.TryGetAbility(sockets[armed.Socket].SparkId, out var flushDef) + && flushDef.Archetype == (byte)AbilityArchetype.Cone) { - Amount = es.Damage, - SourceNetworkId = owner.ValueRO.NetworkId, - SourceTick = cStamp, - }); - KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci], - xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed, - TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull); + float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); + FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId, + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); + } + m_ConePendingLookup[entity] = new ConeContactPending + { + ResolveTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneContact), + Socket = (byte)sk, + }; + } + else + { + // Legacy immediate (knob 0, or a plain test world without the baked pending slot). + float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick + FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId, + serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup); } } cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks))); @@ -336,5 +385,31 @@ namespace ProjectM.Simulation coneTargets.Dispose(); coneTargetPos.Dispose(); } + + /// Resolve one Cone-socket cleave from LIVE state — shared by the legacy immediate path, the + /// due-fire and the early-flush (review wf_98bf1268: ONE resolve path, no drift). Server-only callers. + static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId, + NetworkTick serverTick, bool pull, in NativeList coneTargets, + in NativeList coneTargetPos, ref EntityCommandBuffer ecb, + ref ComponentLookup knockbackLookup, in ComponentLookup bossLookup) + { + float cRange = math.max(0.1f, es.Range); + float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f)); + uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); + for (int ci = 0; ci < coneTargets.Length; ci++) + { + if (!MeleeConeMath.InCone(casterPos, face, cRange, cCosHalf, coneTargetPos[ci])) + continue; + ecb.AppendToBuffer(coneTargets[ci], new DamageEvent + { + Amount = es.Damage, + SourceNetworkId = ownerNetId, + SourceTick = cStamp, + }); + KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci], + casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed, + TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull); + } + } } } diff --git a/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs b/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs new file mode 100644 index 000000000..85e6e6724 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs @@ -0,0 +1,320 @@ +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(); } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs.meta b/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs.meta new file mode 100644 index 000000000..421aa63c3 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/AbilityFireSystemConeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c98d85ca9d797144bab5a889bfb85fd5 \ No newline at end of file