5569fcd552
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>
321 lines
17 KiB
C#
321 lines
17 KiB
C#
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<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).
|
|
/// </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(); }
|
|
}
|
|
}
|
|
}
|