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.okup. /// /// 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.ss-immune). ComponentLookup m_KnockbackLookup; // Boss knockback-immunity and BoonEffects lookups deleted 2026-08-07 (audit purge). // 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_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_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 // Boons deleted 2026-08-07 (audit purge): pull was the KnockToPull mechanic-changer. const bool pull = false; // 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_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_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); } } 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 = 0; // boon Pierce deleted 2026-08-07 byte chain = 0; // boon Chain deleted 2026-08-07 byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags); int shots = 1; // boon Fork deleted 2026-08-07 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) { 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, coneTargets[ci], casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull); } } } }