LANTERN purge B6: delete the legacy single-ability path (sockets are THE ability model)

AbilityRef, AbilityCooldown, EffectiveAbilityStats, DefaultAbility deleted.
GoInGameServerSystem seeds the per-frame 4-socket Spark loadout UNCONDITIONALLY
(was gym-only); ClassSelectReceiveSystem + DebugOp.SetClass swap FrameId +
re-seed sockets + zero SocketCooldown; ClassSwapUtil.Apply drops newAbilityId;
EquipSystem weapons become stat-sticks (GrantedAbilityId removed from the item
blob/authoring); StatRecomputeSystem folds CharacterStatsRef + sockets only;
HUD cooldown bar reads socket 0 of SocketCooldown/EffectiveSocketStats; class
HUD readers are FrameId-only (ClassForAbility/AbilityFor deleted);
DebugModifierInjectionSystem drops CycleAbility.

390 tests green; Play-verified: a non-gym spawn gets frame=2 with Sparks
[7,8,6,9] and no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:47:12 -07:00
parent b34945c2d2
commit 4a8220ad3e
27 changed files with 137 additions and 275 deletions
@@ -1,30 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted per-player ability cooldown gate. Holds the earliest server tick at which the
/// owning player may fire again, so <see cref="AbilityFireSystem"/> can throttle shots
/// deterministically across client prediction and server simulation.
/// <para>
/// Replicated as a <see cref="GhostField"/> so the cooldown survives the frame→tick→rollback
/// boundary: when the client re-predicts ticks after a snapshot, it sees the same authoritative
/// gate the server applied and converges without double-firing. Stored as a raw <c>uint</c>
/// rather than a <see cref="NetworkTick"/> for simple, quantization-free serialization; compare
/// by wrapping it back into a <see cref="NetworkTick"/> and using
/// <see cref="NetworkTick.IsNewerThan"/> (raw subtraction is unsafe across tick wraparound).
/// </para>
/// </summary>
public struct AbilityCooldown : IComponentData
{
/// <summary>
/// Raw tick value of the earliest tick the player may fire again. <c>0</c> = ready (no
/// cooldown pending). Set by <see cref="AbilityFireSystem"/> to
/// <c>serverTick + max(1, CooldownTicks)</c> on fire; treat as "still cooling down" only
/// while a valid <see cref="NetworkTick"/> built from it is newer than the current
/// <c>ServerTick</c>.
/// </summary>
[GhostField] public uint NextFireTick;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b7a2b67b22b2a4abaa8efd84759445c0
@@ -1,15 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Which authored ability definition occupies this entity's primary slot - a light replicated key
/// into the AbilityDatabase blob, replacing M2's inlined AbilityStats values. Replicated so an
/// ability swap is server-authoritative and prediction-correct. <c>Id</c> stores an <see cref="AbilityId"/>.
/// </summary>
public struct AbilityRef : IComponentData
{
[GhostField] public byte Id;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d6ea08a11ef3d4afdb722b735ca3ed03
@@ -18,13 +18,16 @@ namespace ProjectM.Simulation
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
/// <paramref name="mods"/>. Returns the normalized class + its Fire ability id (the caller sets AbilityRef).
/// <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the strip still runs).</summary>
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
/// <paramref name="mods"/>. Returns the normalized class (the caller writes FrameId/PlayerClass + re-seeds
/// the socket loadout). <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the
/// strip still runs).</summary>
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord,
out byte newClass, out byte newAbilityId)
out byte newClass)
{
newClass = ClassTraits.Normalize(rawClass);
ClassTraits.Reapply(newClass, mods);
newAbilityId = ClassTraits.AbilityFor(newClass);
// Strip the OLD class's meta rows (Reapply only touched the class-seed band), then replay the NEW class's
// persisted tiers (the GoInGame skip/clamp rules) so the permanent channel stays correct across the swap.
@@ -36,14 +36,12 @@ namespace ProjectM.Simulation
public static byte Normalize(byte classId) => classId == RangerClass ? RangerClass : WarriorClass;
/// <summary>The Fire-slot ability id for a class (Warrior = cone, Ranger = the default projectile).</summary>
public static byte AbilityFor(byte classId)
=> classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone;
/// <summary>The class a Fire-slot ability id implies — the exact inverse of <see cref="AbilityFor"/> (Ranger
/// iff Primary). Lets the CLIENT derive the local class from the replicated <see cref="AbilityRef"/> (tracks
/// the dev class-switch, unlike the menu's ClassSelection static; PlayerClass itself is server-only).</summary>
public static byte ClassForAbility(byte abilityId)
=> abilityId == (byte)AbilityId.Primary ? RangerClass : WarriorClass;
/// <summary>Default 4-socket Spark loadout per frame (Harpooner = the Ranger slot, line-and-iron; Bathynaut =
/// the Warrior slot, anchor-and-crash). Tunable; drives the gym's per-frame default sockets (Build Spec step 5).</summary>
@@ -1,20 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-entity effective ability stats: the authored base (from the AbilityDatabase blob keyed by
/// AbilityRef) folded with the entity's StatModifier buffer by StatRecomputeSystem each predicted
/// tick. Derived/local, NOT replicated - both worlds recompute it deterministically from the
/// replicated modifier buffer, so it matches under prediction without being in the snapshot.
/// </summary>
public struct EffectiveAbilityStats : IComponentData
{
public float Damage;
public float ProjectileSpeed;
public float Range;
public float AutoTargetRange;
public float AutoTargetConeRadians;
public int CooldownTicks;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a8bb3a5c343e74e7fb249e96c0c55fdc
@@ -7,8 +7,9 @@ namespace ProjectM.Simulation
{
/// <summary>
/// Folds each modifiable entity's authored base stats (from the AbilityDatabase blob, keyed by
/// AbilityRef / CharacterStatsRef) with its replicated StatModifier buffer into the
/// EffectiveAbilityStats / EffectiveCharacterStats components - every predicted tick, on both worlds.
/// CharacterStatsRef / the AbilitySocket loadout) with its replicated StatModifier buffer into the
/// EffectiveCharacterStats / EffectiveSocketStats components - every predicted tick, on both worlds.
/// (The legacy single AbilityRef -> EffectiveAbilityStats fold is deleted — LANTERN purge.)
///
/// Runs at the head of the predicted group (UpdateBefore PlayerAimSystem;
/// AbilityFireSystem runs after PlayerAimSystem, so it sees fresh values too). Recompute is
@@ -34,24 +35,11 @@ namespace ProjectM.Simulation
var database = SystemAPI.GetSingleton<AbilityDatabase>();
ref var db = ref database.Value.Value;
foreach (var (abilityRef, charRef, mods, effAbility, effChar) in
SystemAPI.Query<RefRO<AbilityRef>, RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>,
RefRW<EffectiveAbilityStats>, RefRW<EffectiveCharacterStats>>()
foreach (var (charRef, mods, effChar) in
SystemAPI.Query<RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>,
RefRW<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
if (db.TryGetAbility(abilityRef.ValueRO.Id, out var a))
{
effAbility.ValueRW = new EffectiveAbilityStats
{
Damage = StatMath.Apply(a.Damage, StatTarget.Damage, mods),
ProjectileSpeed = StatMath.Apply(a.ProjectileSpeed, StatTarget.ProjectileSpeed, mods),
Range = StatMath.Apply(a.Range, StatTarget.Range, mods),
AutoTargetRange = StatMath.Apply(a.AutoTargetRange, StatTarget.AutoTargetRange, mods),
AutoTargetConeRadians = StatMath.Apply(a.AutoTargetConeRadians, StatTarget.AutoTargetConeRadians, mods),
CooldownTicks = (int)math.round(StatMath.Apply(a.CooldownTicks, StatTarget.CooldownTicks, mods)),
};
}
if (db.TryGetCharacter(charRef.ValueRO.Id, out var c))
{
effChar.ValueRW = new EffectiveCharacterStats
@@ -65,8 +53,7 @@ namespace ProjectM.Simulation
// LANTERN Phase 1 (Step 1b): per-socket fold - each socket's Spark base folded with the SHARED
// StatModifier band into its EffectiveSocketStats row (uniform band; per-Spark warping is Phase 4).
// Separate query so this system stays under the 7-arg cap; the legacy single fold above stays
// until AbilityFireSystem + the feel layer migrate to the buffer (steps 2/2.5).
// Separate query so this system stays under the 7-arg cap.
foreach (var (sockets, socketMods, effSockets) in
SystemAPI.Query<DynamicBuffer<AbilitySocket>, DynamicBuffer<StatModifier>, DynamicBuffer<EffectiveSocketStats>>()
.WithAll<Simulate>())