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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user