From 12f86c86def53bcd1cc15c39976baa5ed4d61956 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 14 Jul 2026 10:59:45 -0700 Subject: [PATCH] LANTERN P1 step 2: AbilityFireSystem socket restructure + SpawnId repack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The netcode core of the 4-socket kit (Phase1_Combat_Gym_Build_Spec §1,§2,§5; review NP-1/ RS-1/DB-1/DB-3): - PlayerInput: +4 Socket0..3 InputEvents + Burst-safe GetSocket(i); legacy Fire kept vestigial. - PlayerInputGatherSystem: gather sockets from keyboard 1..4 (+gamepad RT/LB/RB); socket 0 also fires on the legacy primary (right-click / pad LT) as a bridge. - AbilityFireSystem: query dropped to 4 type args (PlayerInput/PlayerFacing/LocalTransform/ GhostOwner) + BufferLookup/ComponentLookup/BufferLookup< EffectiveSocketStats> (the 7-arg-cap fix); loops 4 sockets; per-socket cooldown + per-socket effective stats; Cone + Projectile dispatch per socket (predict-spawn Projectile-only). - ProjectileSpawnId.Pack: pure Burst-safe key owner14|socket2|fireCount12|fork4 so same-tick multi-socket projectiles never collide; AbilityFireSystem uses it. L1 clean; L2 494/494 (+5 ProjectileSpawnId tests: distinct-per-socket, fork, owner, bit-ranges, count-wrap). L3 two-player + rollback is the group-A gate (after step 2.5). Note: the client feel layer (muzzle/anim) still reads the legacy cooldown until step 2.5; sockets bake empty until content (step 4), so nothing fires in-game yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Client/Input/PlayerInputGatherSystem.cs | 11 + .../Simulation/Combat/AbilityFireSystem.cs | 339 ++++++++---------- .../Simulation/Combat/ProjectileSpawnId.cs | 29 ++ .../Combat/ProjectileSpawnId.cs.meta | 2 + .../Scripts/Simulation/Player/PlayerInput.cs | 20 ++ .../Tests/EditMode/ProjectileSpawnIdTests.cs | 68 ++++ .../EditMode/ProjectileSpawnIdTests.cs.meta | 2 + 7 files changed, 290 insertions(+), 181 deletions(-) create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ProjectileSpawnId.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ProjectileSpawnIdTests.cs.meta 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