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
@@ -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