Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
T
kronic 62e48a3b0b LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)
The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:59:39 -07:00

412 lines
25 KiB
C#

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// 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.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
// Server-only knockback stamp for the Cone.ss-immune).
ComponentLookup<KnockbackState> 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<AbilitySocket> m_SocketLookup;
ComponentLookup<SocketCooldown> m_SocketCdLookup;
BufferLookup<EffectiveSocketStats> 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<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;
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<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_SocketLookup = state.GetBufferLookup<AbilitySocket>(isReadOnly: true);
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
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]
public void OnUpdate(ref SystemState state)
{
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
if (!networkTime.IsFirstTimeFullyPredictingTick)
return;
var serverTick = networkTime.ServerTick;
if (!serverTick.IsValid)
return;
var dbEntity = SystemAPI.GetSingletonEntity<AbilityDatabase>();
var abilityPrefabs = SystemAPI.GetBuffer<AbilityPrefabElement>(dbEntity);
var abilityDb = SystemAPI.GetSingleton<AbilityDatabase>();
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_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<float3>(Allocator.Temp);
var coneTargets = new NativeList<Entity>(Allocator.Temp);
var coneTargetPos = new NativeList<float3>(Allocator.Temp);
if (isServer)
{
foreach (var (tx, th, te) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAll<EnemyTag>().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<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>>()
.WithAll<Simulate>().WithDisabled<Dead>()
.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<InputBufferData<PlayerInput>>(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();
}
/// <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)
{
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);
}
}
}
}