Fix: socket auto-recast at every cooldown reopen (raw InputBufferData IsSet = ever-pressed)

Netcode accumulates InputEvent counts on the wire; only the decoded component is delta-corrected.
AbilityFireSystem's windup resolve gated on raw IsSet, so after the first press every socket
re-fired the instant its cooldown reopened (live repro: recasts at exactly t3933/t4353 with zero
input). Gate is now a count-STEP vs the previous tick's command; missing prior command = no-fire.
Also lands the G6 ConeContactPending schedule machinery + the cone test fixture (wire-true
monotonic counts) + regression Cast_Never_Refires_When_The_Cooldown_Reopens. 430/430; live proof:
one tap -> one cast -> silence across 2+ reopens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:28:08 -07:00
parent 040463ad07
commit 5569fcd552
3 changed files with 414 additions and 17 deletions
@@ -51,6 +51,8 @@ namespace ProjectM.Simulation
ComponentLookup<ZoneEffect> m_ZoneLookup;
ComponentLookup<DecoyTag> m_DecoyLookup;
ComponentLookup<LocalTransform> m_LtLookup;
// 07-21 G6: server-only scheduled Cone cleave (the MeleeCleavePending idiom on the socket kit).
ComponentLookup<ConeContactPending> m_ConePendingLookup;
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
const float k_ForkSpreadRad = 0.157f;
@@ -71,6 +73,7 @@ namespace ProjectM.Simulation
m_ZoneLookup = state.GetComponentLookup<ZoneEffect>(isReadOnly: true);
m_DecoyLookup = state.GetComponentLookup<DecoyTag>(isReadOnly: true);
m_LtLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_ConePendingLookup = state.GetComponentLookup<ConeContactPending>(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<TuningConfig>(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<float3>(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<InputBufferData<PlayerInput>>(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)
{
if (coneContact > 0u && hasConePending)
{
// 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)
{
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
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(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
continue;
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
{
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);
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();
}
/// <summary>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.</summary>
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
NetworkTick serverTick, bool pull, in NativeList<Entity> coneTargets,
in NativeList<float3> coneTargetPos, ref EntityCommandBuffer ecb,
ref ComponentLookup<KnockbackState> knockbackLookup, in ComponentLookup<BossState> 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);
}
}
}
}
@@ -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
{
/// <summary>
/// 07-21 G6 (review wf_98bf1268) — the Cone-socket (SpecialSlam) damage-at-contact schedule: EditMode tests
/// for <see cref="ConeContactPending"/> through the REAL <see cref="AbilityFireSystem"/> (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&lt;PlayerInput&gt; 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).
/// </summary>
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<AbilityDatabaseBlob> BuildConeDb(int cooldownTicks)
{
using var b = new BlobBuilder(Allocator.Temp);
ref var root = ref b.ConstructRoot<AbilityDatabaseBlob>();
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<AbilityDatabaseBlob>(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<AbilityDatabaseBlob> 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<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<AbilityFireSystem>());
if (withDeathSystem)
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerDeathStateSystem>());
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<AbilityPrefabElement>(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<Simulate>(player);
em.AddComponent<Dead>(player);
em.SetComponentEnabled<Dead>(player, false);
if (withPendingSlot) em.AddComponent<ConeContactPending>(player); // review wf_9757d214: the no-slot fallback is a pinned contract
var socks = em.AddBuffer<AbilitySocket>(player);
socks.Add(new AbilitySocket { SparkId = ConeSpark });
em.AddComponentData(player, default(SocketCooldown));
var effs = em.AddBuffer<EffectiveSocketStats>(player);
effs.Add(new EffectiveSocketStats { Damage = 25f, Range = 3f, AutoTargetConeRadians = 0.9f, CooldownTicks = cooldownTicks });
em.AddBuffer<InputBufferData<PlayerInput>>(player);
if (withDeathSystem)
{
em.AddComponent<PlayerTag>(player);
em.AddComponentData(player, new Health { Current = 100f, Max = 100f });
em.AddComponentData(player, new CharacterControl());
}
var enemy = em.CreateEntity();
em.AddComponent<EnemyTag>(enemy);
em.AddComponentData(enemy, new Health { Current = 200f, Max = 200f });
em.AddComponentData(enemy, LocalTransform.FromPosition(new float3(0f, 0f, 2f)));
em.AddBuffer<DamageEvent>(enemy);
em.AddComponentData(enemy, new KnockbackState());
return (world, group, player, enemy, blob);
}
/// <summary>Push a socket-0 press at <paramref name="tick"/> 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).</summary>
static void PressSocket0(EntityManager em, Entity player, uint tick)
{
var buf = em.GetBuffer<InputBufferData<PlayerInput>>(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<PlayerInput> { Tick = new NetworkTick(tick - 1), InternalInput = idle });
buf.Add(new InputBufferData<PlayerInput> { Tick = new NetworkTick(tick), InternalInput = pressed });
buf.Add(new InputBufferData<PlayerInput> { 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<DamageEvent>(enemy).Length, "no damage at the fire tick (scheduled)");
var pend = em.GetComponentData<ConeContactPending>(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<DamageEvent>(enemy).Length, "still counting down 1 tick before contact");
Step(world, group, 121);
var events = em.GetBuffer<DamageEvent>(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<ConeContactPending>(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<DamageEvent>(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<DamageEvent>(enemy).Length, "knob 0 = the legacy at-fire cleave");
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(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<DamageEvent>(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<DamageEvent>(enemy).Length, "recast FLUSHED the armed slam early");
Assert.AreEqual(TickUtil.NonZero(110u + Contact), em.GetComponentData<ConeContactPending>(player).ResolveTick,
"re-armed for the new cast");
Step(world, group, 131);
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(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<ConeContactPending>(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<ConeContactPending>(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<DamageEvent>(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<DamageEvent>(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<DamageEvent>(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<DamageEvent>(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<EnemyTag>(side);
em.AddComponentData(side, new Health { Current = 200f, Max = 200f });
em.AddComponentData(side, Unity.Transforms.LocalTransform.FromPosition(new float3(2f, 0f, 0f)));
em.AddBuffer<DamageEvent>(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<DamageEvent>(enemy).Length, "the cast-tick direction must NOT be latched");
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(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<ConeContactPending>(player).ResolveTick, "armed");
var socks = em.GetBuffer<AbilitySocket>(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<DamageEvent>(enemy).Length,
"mismatched socket at resolve = consume-drop, never a slam with foreign stats");
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "consumed");
}
finally { world.Dispose(); blob.Dispose(); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c98d85ca9d797144bab5a889bfb85fd5