using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
///
/// LANTERN 4-socket predicted "fire". On the single fully-predicting pass of each tick, for every player
/// it loops the 4 ability sockets: a socket whose Spark is set (AbilitySocket.SparkId != 0), whose own
/// fire event (PlayerInput.Socket0..3) is set this tick, and whose per-socket SocketCooldown has elapsed
/// fires that Spark. Runs in both worlds: the owning client predict-spawns a Projectile ghost (classified
/// into the authoritative ghost by ProjectileClassificationSystem via Projectile.SpawnId), and the server
/// spawns the replicated truth.
///
/// The single AbilityRef/AbilityCooldown/EffectiveAbilityStats model was replaced by the socket kit:
/// AbilitySocket (loadout), SocketCooldown (hot per-socket cooldown), EffectiveSocketStats (per-socket
/// folded stats from StatRecomputeSystem). To stay under the 7-type SystemAPI.Query cap, the query holds
/// only PlayerInput/PlayerFacing/LocalTransform/GhostOwner and reads the socket data by entity via
/// BufferLookup/ComponentLookup (mirroring the BoonEffects lookup).
///
/// SpawnId key (owner14 | socket2 | fireCount12 | fork4) reserves socket bits so two sockets firing the
/// same-prefab projectile on one tick classify to DISTINCT ghosts (the review's NP-1/RS-1/DB-3 fix). The
/// per-socket fire count comes from the replicated command buffer at ServerTick (not a local counter) so
/// client and server agree. FORK fans extra predicted projectiles, each with a unique fork index in the
/// low bits; PIERCE/CHAIN/PULL seed the server-only ProjectileEffectState.
///
/// Determinism/idempotency: gated behind IsFirstTimeFullyPredictingTick so a rollback re-sim never
/// double-spawns. No wall-clock, no System.Random. Predict-spawn is reserved for the Projectile archetype;
/// Cone/Aoe/Hitscan apply their effect SERVER-ONLY (cooldown predicted on both worlds); the Aoe archetype
/// instantiates its interpolated ghost server-only. Movement (Blink) falls through to BlinkSystem. Auto-target stays server-only + gamepad-only.
///
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
// Server-only knockback stamp for the Cone (guarded + boss-immune).
ComponentLookup m_KnockbackLookup;
ComponentLookup m_BossLookup;
// Owner-replicated mechanic-changer boons, read by the player entity.
ComponentLookup m_BoonEffectsLookup;
// LANTERN socket kit, read by the player entity so the fire query stays at 4 type args (7-arg cap).
BufferLookup m_SocketLookup;
ComponentLookup m_SocketCdLookup;
BufferLookup m_EffSocketLookup;
// Prefab-membership + baked transform of an Aoe spawn prefab, read on the PREFAB entity to pick
// ZoneEffect vs DecoyTag and preserve baked scale. RO; the actual Instantiate is server-only.
ComponentLookup m_ZoneLookup;
ComponentLookup m_DecoyLookup;
ComponentLookup m_LtLookup;
// 07-21 G6: server-only scheduled Cone cleave (the MeleeCleavePending idiom on the socket kit).
ComponentLookup m_ConePendingLookup;
/// ~9 degree gap between adjacent Split-Shot projectiles (tunable).
const float k_ForkSpreadRad = 0.157f;
const float k_AoeCastAhead = 2.5f; // world units ahead of the caster to drop an Aoe/zone/decoy ghost
const float k_HitscanHalfWidth = 0.6f; // hitscan beam half-width (world units)
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
state.RequireForUpdate();
m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false);
m_BossLookup = state.GetComponentLookup(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup(isReadOnly: true);
m_SocketLookup = state.GetBufferLookup(isReadOnly: true);
m_SocketCdLookup = state.GetComponentLookup(isReadOnly: false);
m_EffSocketLookup = state.GetBufferLookup(isReadOnly: true);
m_ZoneLookup = state.GetComponentLookup(isReadOnly: true);
m_DecoyLookup = state.GetComponentLookup(isReadOnly: true);
m_LtLookup = state.GetComponentLookup(isReadOnly: true);
m_ConePendingLookup = state.GetComponentLookup(isReadOnly: false);
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton();
if (!networkTime.IsFirstTimeFullyPredictingTick)
return;
var serverTick = networkTime.ServerTick;
if (!serverTick.IsValid)
return;
var dbEntity = SystemAPI.GetSingletonEntity();
var abilityPrefabs = SystemAPI.GetBuffer(dbEntity);
var abilityDb = SystemAPI.GetSingleton();
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(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);
m_SocketLookup.Update(ref state);
m_SocketCdLookup.Update(ref state);
m_EffSocketLookup.Update(ref state);
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(Allocator.Temp);
var coneTargets = new NativeList(Allocator.Temp);
var coneTargetPos = new NativeList(Allocator.Temp);
if (isServer)
{
foreach (var (tx, th, te) in
SystemAPI.Query, RefRO>().WithAll().WithEntityAccess())
{
if (th.ValueRO.Current <= 0f) continue; // corpses are neither aim magnets nor cleave targets
candidatePositions.Add(tx.ValueRO.Position);
coneTargets.Add(te); coneTargetPos.Add(tx.ValueRO.Position);
}
}
var candidates = candidatePositions.AsArray();
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
foreach (var (input, facing, xform, owner, entity) in
SystemAPI.Query, RefRO, RefRO, RefRO>()
.WithAll().WithDisabled()
.WithEntityAccess())
{
if (!m_SocketLookup.HasBuffer(entity) || !m_SocketCdLookup.HasComponent(entity) || !m_EffSocketLookup.HasBuffer(entity))
continue;
var sockets = m_SocketLookup[entity];
var effSockets = m_EffSocketLookup[entity];
var cd = m_SocketCdLookup[entity]; // struct copy; written back after mutation
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>(entity);
bool cdDirty = false;
int socketCount = math.min(SocketId.Count, sockets.Length);
for (int sk = 0; sk < socketCount; sk++)
{
byte sparkId = sockets[sk].SparkId;
if (sparkId == 0) continue; // empty socket
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
// MANUAL-AIM WINDUP (Build Spec §4/DB-6): resolve when the command WindupTicks ago had this socket
// set, so the resolve tick is a pure fn of replicated data on both worlds (no local fireAtTick that
// diverges under reprediction). fireCount comes from THAT command; the spawn uses the CURRENT tick's
// aim/position. WindupTicks == 0 = instant. A history gap (older than the buffer window) = no-fire.
NetworkTick resolveTick = serverTick;
if (adef.WindupTicks > 0)
{
uint riNow = serverTick.TickIndexForValidTick;
if (riNow <= (uint)adef.WindupTicks) continue; // not enough history yet -> no-fire
resolveTick = new NetworkTick(riNow - (uint)adef.WindupTicks);
}
if (!inputBuffer.GetDataAtTick(resolveTick, out var applied)) continue; // history gap -> no-fire
// 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);
if (nextFireRaw != 0)
{
var nextTick = new NetworkTick(nextFireRaw);
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
}
// 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
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)));
cdDirty = true;
continue;
}
// AOE / ZONE / DECOY (decoy-wisp, vortex, light-zone): predict the cooldown on BOTH worlds;
// instantiate the interpolated ownerless ghost SERVER-ONLY -- the client must NEVER instantiate it (Build Spec §5).
if (archetype == (byte)AbilityArchetype.Aoe)
{
if (isServer)
{
Entity aoePrefab = Entity.Null;
for (int i = 0; i < abilityPrefabs.Length; i++)
if (abilityPrefabs[i].Id == sparkId) { aoePrefab = abilityPrefabs[i].Prefab; break; }
if (aoePrefab != Entity.Null)
{
float2 aFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15)
float3 spawnPos = xform.ValueRO.Position + new float3(aFace.x, 0f, aFace.y) * k_AoeCastAhead;
spawnPos.y = xform.ValueRO.Position.y;
uint expire = adef.DurationTicks > 0
? TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)adef.DurationTicks) : 0u;
var bakedLt = m_LtLookup.HasComponent(aoePrefab) ? m_LtLookup[aoePrefab] : LocalTransform.Identity;
var effect = ecb.Instantiate(aoePrefab);
ecb.SetComponent(effect, bakedLt.WithPosition(spawnPos));
if (m_ZoneLookup.HasComponent(aoePrefab))
ecb.SetComponent(effect, new ZoneEffect
{
CasterNetworkId = owner.ValueRO.NetworkId,
Radius = math.max(0.1f, es.Range),
DamagePerPulse = es.Damage,
NextTick = 0u, // lazy-stamped born-correct by ZonePulseSystem (never storm-fires)
ExpireTick = expire,
Flags = m_ZoneLookup[aoePrefab].Flags, // Vortex bit baked on the prefab
});
else if (m_DecoyLookup.HasComponent(aoePrefab))
ecb.SetComponent(effect, new DecoyTag { ExpireTick = expire });
}
}
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
cdDirty = true;
continue;
}
// HITSCAN: no ghost on either world; predict the cooldown both worlds; server-only swept-beam damage.
// (No Phase-1 Spark uses Hitscan yet; the branch completes the archetype dispatch per Build Spec §5.)
if (archetype == (byte)AbilityArchetype.Hitscan)
{
if (isServer)
{
float2 hFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15)
float hRange = math.max(0.1f, es.Range);
uint hStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
for (int hi = 0; hi < coneTargets.Length; hi++)
{
float2 rel = coneTargetPos[hi].xz - xform.ValueRO.Position.xz;
float t = math.dot(rel, hFace);
if (t < 0f || t > hRange) continue;
float2 perp = rel - hFace * t;
if (math.lengthsq(perp) > k_HitscanHalfWidth * k_HitscanHalfWidth) continue;
ecb.AppendToBuffer(coneTargets[hi], new DamageEvent
{
Amount = es.Damage,
SourceNetworkId = owner.ValueRO.NetworkId,
SourceTick = hStamp,
});
}
}
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
cdDirty = true;
continue;
}
// MOVEMENT (Blink) + any other non-Projectile: fall through (BlinkSystem owns the movement socket's cooldown row).
if (archetype != (byte)AbilityArchetype.Projectile)
continue;
// Resolve the projectile ghost prefab for this Spark id.
Entity prefab = Entity.Null;
for (int i = 0; i < abilityPrefabs.Length; i++)
{
if (abilityPrefabs[i].Id == sparkId) { prefab = abilityPrefabs[i].Prefab; break; }
}
if (prefab == Entity.Null) continue;
uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;
// Manual-aim (07-15): the projectile (and the server's auto-target seed below) fires along the
// CURRENT tick's replicated Aim — PlayerFacing is body-yaw only under the SoD facing model.
float2 rawAim = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
// Client fires along raw aim; only the server applies the gamepad auto-target assist.
float2 dir = rawAim;
if (isServer && es.AutoTargetRange > 0f && applied.InternalInput.Scheme == InputSchemeId.Gamepad)
{
dir = AutoTarget.Resolve(xform.ValueRO.Position, rawAim, es.AutoTargetRange, es.AutoTargetConeRadians, candidates);
}
byte pierce = bfx.Pierce;
byte chain = bfx.Chain;
byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags);
int shots = 1 + math.min((int)bfx.Fork, 8);
for (int s = 0; s < shots; s++)
{
float offset = (s - (shots - 1) * 0.5f) * k_ForkSpreadRad;
math.sincos(offset, out float sa, out float ca);
float2 sdir = math.normalize(new float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca));
// SpawnId: owner(14) | socket(2) | fireCount(12) | fork(4) -- see ProjectileSpawnId.
uint spawnId = ProjectileSpawnId.Pack(owner.ValueRO.NetworkId, sk, socketFireCount, s);
var projectile = ecb.Instantiate(prefab);
float3 planarDir = new float3(sdir.x, 0f, sdir.y);
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
spawnPos.y = xform.ValueRO.Position.y;
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
ecb.SetComponent(projectile, new Projectile
{
Direction = sdir,
SpawnId = spawnId,
Speed = es.ProjectileSpeed,
Damage = es.Damage,
Range = es.Range,
DistanceTravelled = 0f,
});
ecb.SetComponent(projectile, new ProjectileEffectState
{
PierceRemaining = pierce,
ChainRemaining = chain,
Flags = projFlags,
});
}
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
cdDirty = true;
}
if (cdDirty) m_SocketCdLookup[entity] = cd;
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
candidatePositions.Dispose();
coneTargets.Dispose();
coneTargetPos.Dispose();
}
/// 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.
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
NetworkTick serverTick, bool pull, in NativeList coneTargets,
in NativeList coneTargetPos, ref EntityCommandBuffer ecb,
ref ComponentLookup knockbackLookup, in ComponentLookup 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);
}
}
}
}