diff --git a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
index 2644b8902..6c7d76248 100644
--- a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
+++ b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
@@ -81,6 +81,12 @@ namespace ProjectM.Client
// MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged projectile demoted to right-click / pad left-trigger. Both suppressed while placing a build (like dash/old fire).
bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame)) && !BuildPaletteState.Active;
bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active;
+ // LANTERN 4-socket kit bindings: keyboard 1..4 (+ gamepad RT/LB/RB); socket 0 also fires on the
+ // legacy primary (right-click / pad LT). Suppressed while placing a build.
+ bool socket0Pressed = firePressed || ((keyboard != null && keyboard.digit1Key.wasPressedThisFrame) && !BuildPaletteState.Active);
+ bool socket1Pressed = ((keyboard != null && keyboard.digit2Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active;
+ bool socket2Pressed = ((keyboard != null && keyboard.digit3Key.wasPressedThisFrame) || (gamepad != null && gamepad.leftShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active;
+ bool socket3Pressed = ((keyboard != null && keyboard.digit4Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active;
float2 rightStick = float2.zero;
bool gamepadActive = false;
@@ -170,6 +176,11 @@ namespace ProjectM.Client
input.ValueRW.Attack = default;
if (attackPressed)
input.ValueRW.Attack.Set();
+ // LANTERN 4-socket kit: reset then raise each socket's fire event on the press edge.
+ input.ValueRW.Socket0 = default; if (socket0Pressed) input.ValueRW.Socket0.Set();
+ input.ValueRW.Socket1 = default; if (socket1Pressed) input.ValueRW.Socket1.Set();
+ input.ValueRW.Socket2 = default; if (socket2Pressed) input.ValueRW.Socket2.Set();
+ input.ValueRW.Socket3 = default; if (socket3Pressed) input.ValueRW.Socket3.Set();
}
}
diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
index 055222762..79dd99a68 100644
--- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs
@@ -8,49 +8,46 @@ using Unity.Transforms;
namespace ProjectM.Simulation
{
///
- /// Predicted "fire" ability: on the single fully-predicting pass of each tick, spawns a
- /// Projectile ghost for every player whose PlayerInput.Fire event is set this tick and whose
- /// AbilityCooldown has elapsed. Runs in both worlds: the owning client predict-spawns the
- /// projectile (classified into the authoritative ghost by ProjectileClassificationSystem via the
- /// Projectile.SpawnId key), and the server spawns the replicated truth.
+ /// 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.
///
- /// M3 data-driven: ability stats are read from the per-entity EffectiveAbilityStats (authored base
- /// from the AbilityDatabase blob keyed by AbilityRef, folded with the replicated StatModifier buffer
- /// by StatRecomputeSystem earlier this tick). The projectile ghost prefab is resolved per ability via
- /// the AbilityPrefabElement buffer on the AbilityDatabase singleton. Effective Speed/Damage/Range are
- /// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
- /// predicted + server projectiles match (both folded the same replicated modifiers).
+ /// 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).
///
- /// Phase 1.7 mechanic-changer boons ride the owner-replicated (SendToOwner, so the
- /// predicting owner has it — the .WithAll<Simulate>() filter means only the owner's own player is processed
- /// client-side), read via a ComponentLookup keyed by the player (the query is already at the 7-type SystemAPI
- /// limit). FORK fans Fork extra predicted projectiles in a symmetric spread, each with a UNIQUE
- /// deterministic SpawnId (fork index packed into the low bits) so classification predicts each. PIERCE/CHAIN/PULL
- /// are seeded into the server-only at spawn (resolved by ProjectileDamageSystem).
+ /// 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: the prediction loop re-runs this system on rollback, so all
- /// non-idempotent effects (spawning, cooldown advance) are gated behind
- /// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
- /// fire count comes from the replicated input command buffer at NetworkTime.ServerTick (not a
- /// local counter) so the SpawnId matches on client and server. No wall-clock, no System.Random,
- /// no UnityEngine.Time.
- ///
- /// Auto-target is intentionally server-only: the client fires along raw aim, and the server's
- /// authoritative Projectile.Direction GhostField reconciles the predicted projectile to the
- /// assisted heading.
+ /// 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 applies its effect server-only (cooldown predicted both worlds). Aoe/Hitscan/movement archetypes
+ /// are handled elsewhere (steps 3/4) and fall through here. Auto-target stays server-only + gamepad-only.
///
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
- // C3/A4: knockback stamp for the Warrior CONE (guarded HasComponent + boss-immune). Server-only use.
+ // Server-only knockback stamp for the Cone (guarded + boss-immune).
ComponentLookup m_KnockbackLookup;
ComponentLookup m_BossLookup;
- // Phase 1.7: owner-replicated mechanic-changer boons, read by the player entity (query is at the 7-type cap).
+ // 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;
- /// ~9° gap between adjacent Split-Shot projectiles (tunable).
+ /// ~9 degree gap between adjacent Split-Shot projectiles (tunable).
const float k_ForkSpreadRad = 0.157f;
[BurstCompile]
@@ -61,33 +58,35 @@ namespace ProjectM.Simulation
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);
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
- // Spawning is a one-off effect: only run on the unique fully-predicting pass of this tick
- // so a rollback re-simulation does not double-spawn.
var networkTime = SystemAPI.GetSingleton();
if (!networkTime.IsFirstTimeFullyPredictingTick)
return;
-
var serverTick = networkTime.ServerTick;
if (!serverTick.IsValid)
return;
- // Per-ability projectile ghost prefabs live on the AbilityDatabase singleton's companion buffer.
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();
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);
- // Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad
- // auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave.
+ // 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);
@@ -96,7 +95,7 @@ namespace ProjectM.Simulation
foreach (var (tx, th, te) in
SystemAPI.Query, RefRO>().WithAll().WithEntityAccess())
{
- if (th.ValueRO.Current <= 0f) continue; // B3: corpses are neither aim magnets nor cleave targets
+ 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);
}
@@ -105,163 +104,141 @@ namespace ProjectM.Simulation
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
- foreach (var (input, facing, xform, eff, abilityRef, cd, owner, entity) in
- SystemAPI.Query, RefRO, RefRO,
- RefRO, RefRO, RefRW,
- RefRO>()
+ foreach (var (input, facing, xform, owner, entity) in
+ SystemAPI.Query, RefRO, RefRO, RefRO>()
.WithAll().WithDisabled()
.WithEntityAccess())
{
- // The InputEvent on the component carries the per-tick delta: set => fired this tick.
- if (!input.ValueRO.Fire.IsSet)
+ 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
- // Cooldown gate. NextFireTick == 0 means "ready". Otherwise the player may fire only
- // once serverTick is at-or-newer than the stored tick (i.e. the stored tick is not
- // strictly newer than now).
- uint nextFireRaw = cd.ValueRO.NextFireTick;
- if (nextFireRaw != 0)
- {
- var nextTick = new NetworkTick(nextFireRaw);
- if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
- continue; // still cooling down
- }
-
- // Phase 1.7 mechanic-changer boons (owner-replicated; read by entity — see class doc for the 7-type cap).
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
- // MC-4 spike for MC-6: dispatch on the authored ability ARCHETYPE byte (baked in the blob, read here -- NOT
- // folded through EffectiveAbilityStats; it is static identity, not a tunable stat). All current
- // abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6.
- ref var adb = ref abilityDb.Value.Value;
- byte archetype = adb.TryGetAbility(abilityRef.ValueRO.Id, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
-
- // Slice 2: the Warrior's aim-directed CONE secondary (no projectile ghost). Predict the cooldown on
- // both worlds; apply server-only cone damage to living enemies (mirrors the MeleeComboSystem cleave,
- // same-tick: this runs before HealthApplyDamageSystem in the predicted group). SourceTick-stamped.
- if (archetype == (byte)AbilityArchetype.Cone)
- {
- if (isServer)
- {
- float2 cFace = facing.ValueRO.Direction;
- cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
- float cRange = math.max(0.1f, eff.ValueRO.Range);
- float cCosHalf = math.cos(math.clamp(eff.ValueRO.AutoTargetConeRadians, 0.01f, 3.14159f));
- uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
- for (int ci = 0; ci < coneTargets.Length; ci++)
- {
- if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
- continue;
- ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
- {
- Amount = eff.ValueRO.Damage,
- SourceNetworkId = owner.ValueRO.NetworkId,
- SourceTick = cStamp,
- });
- // C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
- // (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
- // Phase 1.7 Gravity Pull: drag toward the player instead of away.
- 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);
- }
- }
- uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
- cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneCd);
- continue;
- }
- if (archetype != (byte)AbilityArchetype.Projectile)
- continue;
-
- // Resolve the projectile ghost prefab for this player's selected ability id.
- Entity prefab = Entity.Null;
- for (int i = 0; i < abilityPrefabs.Length; i++)
- {
- if (abilityPrefabs[i].Id == abilityRef.ValueRO.Id)
- {
- prefab = abilityPrefabs[i].Prefab;
- break;
- }
- }
- if (prefab == Entity.Null)
- continue; // ability has no projectile prefab wired
-
- // Absolute (monotonic) fire count from the replicated command buffer at this tick.
- // This is the classification key shared by client prediction and server truth.
+ // Replicated command buffer at this tick (per-socket fire count for the SpawnId + scheme for aim assist).
var inputBuffer = SystemAPI.GetBuffer>(entity);
- if (!inputBuffer.GetDataAtTick(serverTick, out var applied))
- continue;
- uint absoluteFireCount = applied.InternalInput.Fire.Count;
+ bool haveApplied = inputBuffer.GetDataAtTick(serverTick, out var applied);
- float2 rawAim = facing.ValueRO.Direction;
- if (math.lengthsq(rawAim) < 1e-6f)
- rawAim = new float2(0f, 1f);
- else
- rawAim = math.normalize(rawAim);
-
- // Client fires along raw aim. Only the server applies the auto-target assist cone, and only for
- // GAMEPAD shots (precise mouse aim is left exact per the active input scheme).
- float2 dir = rawAim;
- if (isServer && eff.ValueRO.AutoTargetRange > 0f
- && applied.InternalInput.Scheme == InputSchemeId.Gamepad)
+ bool cdDirty = false;
+ int socketCount = math.min(SocketId.Count, sockets.Length);
+ for (int sk = 0; sk < socketCount; sk++)
{
- dir = AutoTarget.Resolve(
- xform.ValueRO.Position,
- rawAim,
- eff.ValueRO.AutoTargetRange,
- eff.ValueRO.AutoTargetConeRadians,
- candidates);
+ byte sparkId = sockets[sk].SparkId;
+ if (sparkId == 0) continue; // empty socket
+ if (!input.ValueRO.GetSocket(sk).IsSet) continue; // this socket not fired this 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;
+ }
+
+ EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
+ byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
+
+ // CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
+ if (archetype == (byte)AbilityArchetype.Cone)
+ {
+ if (isServer)
+ {
+ float2 cFace = facing.ValueRO.Direction;
+ cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace);
+ 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(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
+ continue;
+ ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
+ {
+ 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);
+ }
+ }
+ cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
+ cdDirty = true;
+ continue;
+ }
+ // Aoe/Hitscan (steps 3) and movement/Blink (step 4) are not dispatched here yet.
+ 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;
+ if (!haveApplied) continue;
+
+ uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;
+
+ float2 rawAim = facing.ValueRO.Direction;
+ rawAim = math.lengthsq(rawAim) < 1e-6f ? new float2(0f, 1f) : math.normalize(rawAim);
+
+ // 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 = pull ? ProjectileEffectFlag.Pull : (byte)0;
+ 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;
}
- // Phase 1.7 mechanic-changer seeds. Fork fans (1 + Fork) shots in a symmetric spread; each carries the
- // pierce/chain/pull state into its server-only ProjectileEffectState.
- byte pierce = bfx.Pierce;
- byte chain = bfx.Chain;
- byte projFlags = pull ? ProjectileEffectFlag.Pull : (byte)0;
- int shots = 1 + math.min((int)bfx.Fork, 8); // cap forks: forkIndex is 4 spawnId bits (no wrap) + a sane spread ceiling
-
- for (int s = 0; s < shots; s++)
- {
- // Symmetric fan around the (assisted) aim heading; s=0 is centred when there is no fork.
- 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));
-
- // Unique deterministic classification key: owner(16) | fireCount(12) | forkIndex(4).
- uint spawnId = (((uint)owner.ValueRO.NetworkId) << 16) | ((absoluteFireCount & 0x0FFFu) << 4) | (uint)(s & 0xF);
-
- 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 });
- // Snapshot the effective ability stats into the projectile (base + modifiers, computed
- // identically on both worlds), so the move/damage systems need no modifier lookup.
- ecb.SetComponent(projectile, new Projectile
- {
- Direction = sdir,
- SpawnId = spawnId,
- Speed = eff.ValueRO.ProjectileSpeed,
- Damage = eff.ValueRO.Damage,
- Range = eff.ValueRO.Range,
- DistanceTravelled = 0f,
- });
- // Server-only pierce/chain/pull seed (baked inert on the prefab; harmless on the client copy).
- ecb.SetComponent(projectile, new ProjectileEffectState
- {
- PierceRemaining = pierce,
- ChainRemaining = chain,
- Flags = projFlags,
- });
- }
-
- // Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
- uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
- cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
+ if (cdDirty) m_SocketCdLookup[entity] = cd;
}
ecb.Playback(state.EntityManager);
diff --git a/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs b/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs
new file mode 100644
index 000000000..6fb30e15d
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs
@@ -0,0 +1,29 @@
+namespace ProjectM.Simulation
+{
+ ///
+ /// Pure, Burst-safe packing of the predicted-projectile classification key
+ /// (). Layout: owner(14) | socket(2) | fireCount(12) | fork(4) = 32
+ /// bits exact. Reserving 2 socket bits is the review's NP-1/RS-1/DB-3 fix: under the LANTERN 4-socket kit,
+ /// two sockets firing the same-prefab projectile on ONE tick carry independent per-socket fire counts that
+ /// are near-guaranteed equal early (both 0->1); without a socket discriminator their SpawnIds collide and
+ /// ProjectileClassificationSystem cross-adopts one predicted entity and orphans the other. Fork keeps
+ /// all 4 low bits (Phase-4 Fork mutation fans up to 8 shots). Extracted so the collision-free property is
+ /// unit-tested independently of the netcode prediction context.
+ ///
+ public static class ProjectileSpawnId
+ {
+ public const int OwnerBits = 14;
+ public const int SocketBits = 2;
+ public const int FireCountBits = 12;
+ public const int ForkBits = 4;
+
+ /// Pack the classification key. Inputs are masked to their bit widths (wrap, not overflow).
+ public static uint Pack(int netId, int socket, uint fireCount, int fork)
+ {
+ return ((((uint)netId) & 0x3FFFu) << (SocketBits + FireCountBits + ForkBits)) // owner -> bits 18..31
+ | (((uint)socket & 0x3u) << (FireCountBits + ForkBits)) // socket -> bits 16..17
+ | ((fireCount & 0x0FFFu) << ForkBits) // fireCount -> bits 4..15
+ | ((uint)fork & 0xFu); // fork -> bits 0..3
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs.meta
new file mode 100644
index 000000000..e54ae98cc
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: def0bbf555a324e44b7fd16953b3adf5
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs b/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs
index 944050677..359067ffb 100644
--- a/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs
+++ b/Assets/_Project/Scripts/Simulation/Player/PlayerInput.cs
@@ -29,11 +29,31 @@ namespace ProjectM.Simulation
/// one swing attempt across the frame->tick->rollback boundary; read by the predicted MeleeComboSystem.
[GhostField] public InputEvent Attack;
+ /// LANTERN 4-socket kit: one fire event per ability socket (0..3). A press sets that socket's
+ /// event for the tick; AbilityFireSystem fires the Spark in that socket. InputEvent survives the
+ /// frame->tick->rollback boundary so one press fires once. Read per-socket via .
+ [GhostField] public InputEvent Socket0;
+ [GhostField] public InputEvent Socket1;
+ [GhostField] public InputEvent Socket2;
+ [GhostField] public InputEvent Socket3;
+
/// Active input scheme this tick (: 0 = mouse/keyboard, 1 = gamepad).
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
/// exact. A byte (not an enum): it is compared inside the Burst-compiled AbilityFireSystem.
[GhostField] public byte Scheme;
+ /// The fire event for ability socket (0..3). Burst-safe (switch, not a field index).
+ public InputEvent GetSocket(int i)
+ {
+ switch (i)
+ {
+ case 0: return Socket0;
+ case 1: return Socket1;
+ case 2: return Socket2;
+ default: return Socket3;
+ }
+ }
+
public FixedString512Bytes ToFixedString()
{
var s = new FixedString512Bytes();
diff --git a/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs b/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs
new file mode 100644
index 000000000..e2ccf4ac0
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs
@@ -0,0 +1,68 @@
+using NUnit.Framework;
+using ProjectM.Simulation;
+
+namespace ProjectM.Tests
+{
+ ///
+ /// Pure tests for the predicted-projectile SpawnId pack (). The
+ /// load-bearing property (review NP-1/RS-1/DB-3): two ability sockets firing the same-prefab projectile on
+ /// ONE tick with EQUAL per-socket fire counts must produce DISTINCT SpawnIds, so
+ /// ProjectileClassificationSystem binds each predicted entity to its own server ghost instead of
+ /// cross-adopting one and orphaning the other (the visible mis-prediction snap the old 32-bit key caused).
+ ///
+ public class ProjectileSpawnIdTests
+ {
+ [Test]
+ public void DifferentSocket_SameOwnerCountFork_ProducesDistinctIds()
+ {
+ // The exact collision scenario: owner 1, every socket's first shot (count 1), fork 0.
+ uint a = ProjectileSpawnId.Pack(netId: 1, socket: 0, fireCount: 1, fork: 0);
+ uint b = ProjectileSpawnId.Pack(netId: 1, socket: 1, fireCount: 1, fork: 0);
+ uint c = ProjectileSpawnId.Pack(netId: 1, socket: 2, fireCount: 1, fork: 0);
+ uint d = ProjectileSpawnId.Pack(netId: 1, socket: 3, fireCount: 1, fork: 0);
+ Assert.AreNotEqual(a, b);
+ Assert.AreNotEqual(a, c);
+ Assert.AreNotEqual(a, d);
+ Assert.AreNotEqual(b, c);
+ Assert.AreNotEqual(b, d);
+ Assert.AreNotEqual(c, d);
+ }
+
+ [Test]
+ public void DifferentFork_ProducesDistinctIds()
+ {
+ uint s0 = ProjectileSpawnId.Pack(2, 1, 5, 0);
+ uint s1 = ProjectileSpawnId.Pack(2, 1, 5, 1);
+ uint s2 = ProjectileSpawnId.Pack(2, 1, 5, 7);
+ Assert.AreNotEqual(s0, s1);
+ Assert.AreNotEqual(s0, s2);
+ Assert.AreNotEqual(s1, s2);
+ }
+
+ [Test]
+ public void DifferentOwner_ProducesDistinctIds()
+ {
+ Assert.AreNotEqual(ProjectileSpawnId.Pack(1, 0, 0, 0), ProjectileSpawnId.Pack(2, 0, 0, 0));
+ }
+
+ [Test]
+ public void FieldsDecodeToTheirBitRanges()
+ {
+ uint id = ProjectileSpawnId.Pack(netId: 3, socket: 2, fireCount: 7, fork: 5);
+ Assert.AreEqual(5u, id & 0xFu, "fork in bits 0-3");
+ Assert.AreEqual(7u, (id >> 4) & 0x0FFFu, "fireCount in bits 4-15");
+ Assert.AreEqual(2u, (id >> 16) & 0x3u, "socket in bits 16-17");
+ Assert.AreEqual(3u, (id >> 18) & 0x3FFFu, "owner in bits 18-31");
+ }
+
+ [Test]
+ public void FireCountWrapsAt4096_WithoutBleedingIntoSocket()
+ {
+ // count 4096 wraps to 0 within its 12-bit field and must not flip the socket bits.
+ uint wrapped = ProjectileSpawnId.Pack(1, 1, 4096, 0);
+ uint zero = ProjectileSpawnId.Pack(1, 1, 0, 0);
+ Assert.AreEqual(zero, wrapped, "count 4096 wraps to 0 within its 12 bits");
+ Assert.AreEqual(1u, (wrapped >> 16) & 0x3u, "socket bit intact across the wrap");
+ }
+ }
+}
diff --git a/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs.meta b/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs.meta
new file mode 100644
index 000000000..44e266c75
--- /dev/null
+++ b/Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 37c27116c8733d041b10691657a137ce
\ No newline at end of file