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:
@@ -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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <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<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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c98d85ca9d797144bab5a889bfb85fd5
|
||||
Reference in New Issue
Block a user