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
@@ -41,7 +41,6 @@ namespace ProjectM.Authoring
Tier = def.Tier, Tier = def.Tier,
StackMax = def.StackMax, StackMax = def.StackMax,
EquipSlot = def.EquipSlot, EquipSlot = def.EquipSlot,
GrantedAbilityId = def.GrantedAbilityId,
Mod0 = ModAt(def, 0), Mod0 = ModAt(def, 0),
Mod1 = ModAt(def, 1), Mod1 = ModAt(def, 1),
Mod2 = ModAt(def, 2), Mod2 = ModAt(def, 2),
@@ -33,9 +33,6 @@ namespace ProjectM.Authoring
[Tooltip("EquipSlotId byte: 0=Weapon, 1=Armor, 2=Trinket, 3=Tool, 255=not equippable.")] [Tooltip("EquipSlotId byte: 0=Weapon, 1=Armor, 2=Trinket, 3=Tool, 255=not equippable.")]
public byte EquipSlot = 255; public byte EquipSlot = 255;
[Tooltip("AbilityId granted when equipped in the Weapon slot (0=none): 1=Primary, 2=FastLight, 3=SlowHeavy.")]
public byte GrantedAbilityId = 0;
[Tooltip("Stat modifiers granted while equipped (first 4 used).")] [Tooltip("Stat modifiers granted while equipped (first 4 used).")]
public List<ItemModAuthoring> Mods = new List<ItemModAuthoring>(); public List<ItemModAuthoring> Mods = new List<ItemModAuthoring>();
} }
@@ -19,9 +19,6 @@ namespace ProjectM.Authoring
[Tooltip("Character-stats definition (move speed, turn rate, max health). Single source of those values.")] [Tooltip("Character-stats definition (move speed, turn rate, max health). Single source of those values.")]
public CharacterStatsDefinition Character; public CharacterStatsDefinition Character;
[Tooltip("Ability definition occupying the player's primary slot.")]
public AbilityDefinition PrimaryAbility;
[Header("Fallbacks (used only if a definition above is unassigned)")] [Header("Fallbacks (used only if a definition above is unassigned)")]
[Min(0f)] public float FallbackMaxHealth = 100f; [Min(0f)] public float FallbackMaxHealth = 100f;
@@ -42,14 +39,11 @@ namespace ProjectM.Authoring
{ {
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
// Re-bake when a referenced definition's serialized values change. // Re-bake when the referenced definition's serialized values change.
if (authoring.Character != null) DependsOn(authoring.Character); if (authoring.Character != null) DependsOn(authoring.Character);
if (authoring.PrimaryAbility != null) DependsOn(authoring.PrimaryAbility);
byte characterId = authoring.Character != null byte characterId = authoring.Character != null
? (byte)authoring.Character.Id : (byte)CharacterId.Default; ? (byte)authoring.Character.Id : (byte)CharacterId.Default;
byte abilityId = authoring.PrimaryAbility != null
? (byte)authoring.PrimaryAbility.Id : (byte)AbilityId.Primary;
float maxHealth = authoring.Character != null float maxHealth = authoring.Character != null
? authoring.Character.MaxHealth : authoring.FallbackMaxHealth; ? authoring.Character.MaxHealth : authoring.FallbackMaxHealth;
@@ -57,14 +51,11 @@ namespace ProjectM.Authoring
AddComponent<PlayerFacing>(entity); AddComponent<PlayerFacing>(entity);
AddComponent<PlayerInput>(entity); AddComponent<PlayerInput>(entity);
// Data-driven stat refs (replace M2's inlined PlayerMoveStats / AbilityStats values). // Data-driven stat ref (replaces M2's inlined PlayerMoveStats values); the ability model is the
// 4-socket kit below (the legacy AbilityRef/DefaultAbility/EffectiveAbilityStats bakes are deleted).
AddComponent(entity, new CharacterStatsRef { Id = characterId }); AddComponent(entity, new CharacterStatsRef { Id = characterId });
AddComponent(entity, new AbilityRef { Id = abilityId });
// Unarmed/base ability restored on weapon-unequip (AbilityRef.Id mutates when a weapon is equipped).
AddComponent(entity, new DefaultAbility { Id = abilityId });
// Effective stats: zeroed at bake, recomputed every predicted tick by StatRecomputeSystem. // Effective stats: zeroed at bake, recomputed every predicted tick by StatRecomputeSystem.
AddComponent(entity, new EffectiveAbilityStats());
AddComponent(entity, new EffectiveCharacterStats()); AddComponent(entity, new EffectiveCharacterStats());
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative). // Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
@@ -82,7 +73,6 @@ namespace ProjectM.Authoring
// damageable hit radius, predicted cooldown state, and the per-tick damage inbox. // damageable hit radius, predicted cooldown state, and the per-tick damage inbox.
AddComponent(entity, new Health { Current = maxHealth, Max = maxHealth }); AddComponent(entity, new Health { Current = maxHealth, Max = maxHealth });
AddComponent(entity, new HitRadius { Value = authoring.HitRadius }); AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
AddComponent<AbilityCooldown>(entity);
AddBuffer<DamageEvent>(entity); AddBuffer<DamageEvent>(entity);
// MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready. // MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready.
AddComponent<DashState>(entity); AddComponent<DashState>(entity);
@@ -110,17 +100,17 @@ namespace ProjectM.Authoring
// the Returning edge) + the server-only Blade-Dash per-dash dedup accumulator (non-replicated). // the Returning edge) + the server-only Blade-Dash per-dash dedup accumulator (non-replicated).
AddComponent<BoonEffects>(entity); AddComponent<BoonEffects>(entity);
AddComponent<DashTrailState>(entity); AddComponent<DashTrailState>(entity);
// LANTERN Phase 1 (Step 1): 4-socket kit data model - parallels AbilityRef/AbilityCooldown // LANTERN Phase 1 (Step 1): 4-socket kit data model — THE ability model (the legacy single
// (both kept until the AbilityFireSystem migration in Step 2). AbilitySocket = cold per-socket // AbilityRef/AbilityCooldown path is deleted). AbilitySocket = cold per-socket loadout
// loadout (EquipmentSlot-modelled, 4 empty rows); SocketCooldown = hot owner-predicted per-socket // (EquipmentSlot-modelled, 4 empty rows; GoInGameServerSystem seeds the frame loadout at spawn);
// cooldown; FrameId = replicated frame/class signal (baked 0, written server-side at frame select). // SocketCooldown = hot owner-predicted per-socket cooldown; FrameId = replicated frame/class signal
// (baked 0, written server-side at frame select).
var sockets = AddBuffer<AbilitySocket>(entity); var sockets = AddBuffer<AbilitySocket>(entity);
for (int sk = 0; sk < SocketId.Count; sk++) for (int sk = 0; sk < SocketId.Count; sk++)
sockets.Add(new AbilitySocket { SparkId = 0 }); sockets.Add(new AbilitySocket { SparkId = 0 });
AddComponent<SocketCooldown>(entity); AddComponent<SocketCooldown>(entity);
AddComponent<FrameId>(entity); AddComponent<FrameId>(entity);
// Step 1b: per-socket effective-stats buffer (4 rows), folded each predicted tick by // Step 1b: per-socket effective-stats buffer (4 rows), folded each predicted tick by StatRecomputeSystem.
// StatRecomputeSystem (additive; the legacy single EffectiveAbilityStats stays until steps 2/2.5).
var effSockets = AddBuffer<EffectiveSocketStats>(entity); var effSockets = AddBuffer<EffectiveSocketStats>(entity);
for (int sk2 = 0; sk2 < SocketId.Count; sk2++) for (int sk2 = 0; sk2 < SocketId.Count; sk2++)
effSockets.Add(new EffectiveSocketStats()); effSockets.Add(new EffectiveSocketStats());
@@ -81,9 +81,9 @@ namespace ProjectM.Client
// Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only). // Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only).
byte localClass = ClassTraits.WarriorClass; byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false; bool haveLocalPlayer = false;
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>()) foreach (var fr in SystemAPI.Query<RefRO<FrameId>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ {
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted)
haveLocalPlayer = true; haveLocalPlayer = true;
break; break;
} }
@@ -134,8 +134,8 @@ namespace ProjectM.Client
// stays correct the day a Health/stats writer is parallelised. // stays correct the day a Health/stats writer is parallelised.
EntityManager.CompleteDependencyBeforeRO<Health>(); EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<EffectiveCharacterStats>(); EntityManager.CompleteDependencyBeforeRO<EffectiveCharacterStats>();
EntityManager.CompleteDependencyBeforeRO<EffectiveAbilityStats>(); EntityManager.CompleteDependencyBeforeRO<SocketCooldown>();
EntityManager.CompleteDependencyBeforeRO<AbilityCooldown>(); EntityManager.CompleteDependencyBeforeRO<EffectiveSocketStats>();
EntityManager.CompleteDependencyBeforeRO<RespawnInvuln>(); EntityManager.CompleteDependencyBeforeRO<RespawnInvuln>();
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
@@ -330,9 +330,9 @@ namespace ProjectM.Client
float hp = 0f, maxHp = 1f, cdFrac = 1f; float hp = 0f, maxHp = 1f, cdFrac = 1f;
bool dead = false, shielded = false; bool dead = false, shielded = false;
foreach (var (health, effChar, effAbility, cd, invuln, entity) in foreach (var (health, effChar, cd, invuln, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>, RefRO<EffectiveAbilityStats>, SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>,
RefRO<AbilityCooldown>, RefRO<RespawnInvuln>>() RefRO<SocketCooldown>, RefRO<RespawnInvuln>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess()) .WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{ {
found = true; found = true;
@@ -340,8 +340,15 @@ namespace ProjectM.Client
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max; maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled<Dead>(entity); dead = SystemAPI.IsComponentEnabled<Dead>(entity);
uint nextFire = cd.ValueRO.NextFireTick; // Cooldown bar = socket 0 (the primary Spark) of the 4-socket kit (the legacy single
int cdTicks = effAbility.ValueRO.CooldownTicks; // AbilityCooldown died — LANTERN purge). EffectiveSocketStats row 0 supplies the duration.
uint nextFire = cd.ValueRO.Get(0);
int cdTicks = 0;
if (SystemAPI.HasBuffer<EffectiveSocketStats>(entity))
{
var effSockets = SystemAPI.GetBuffer<EffectiveSocketStats>(entity);
if (effSockets.Length > 0) cdTicks = effSockets[0].CooldownTicks;
}
var nextTick = new NetworkTick(nextFire); var nextTick = new NetworkTick(nextFire);
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick)) cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks) ? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
@@ -74,9 +74,9 @@ namespace ProjectM.Client
// server-only); tiers from the replicated MetaTierState record on the director ghost. // server-only); tiers from the replicated MetaTierState record on the director ghost.
byte localClass = ClassTraits.WarriorClass; byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false; bool haveLocalPlayer = false;
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>()) foreach (var fr in SystemAPI.Query<RefRO<FrameId>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ {
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted)
haveLocalPlayer = true; haveLocalPlayer = true;
break; break;
} }
@@ -6,12 +6,13 @@ using Unity.NetCode;
namespace ProjectM.Server namespace ProjectM.Server
{ {
/// <summary> /// <summary>
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their class at base. Honored ONLY in /// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their frame at base. Honored ONLY in
/// Staging (class = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the /// Staging (frame = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds + /// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
/// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + <see cref="ClassSwapUtil.HealClamp"/>. /// permanent-meta re-sync), writes FrameId / PlayerClass, re-seeds the 4-socket Spark loadout, and calls
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. NOT /// <see cref="ClassSwapUtil.HealClamp"/>. Plain server group, before RunDirectorSystem (the receiver convention);
/// Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC — Burst safety over micro-perf). /// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC).
/// </summary>
/// </summary> /// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateInGroup(typeof(SimulationSystemGroup))]
@@ -50,18 +51,26 @@ namespace ProjectM.Server
var conn = receive.ValueRO.SourceConnection; var conn = receive.ValueRO.SourceConnection;
if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var player)) if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var player))
continue; continue;
if (!SystemAPI.HasComponent<AbilityRef>(player)) continue; if (!SystemAPI.HasBuffer<AbilitySocket>(player)) continue;
var mods = SystemAPI.GetBuffer<StatModifier>(player); var mods = SystemAPI.GetBuffer<StatModifier>(player);
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default; var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, out byte newClass);
out byte newClass, out byte newAbilityId);
SystemAPI.SetComponent(player, new AbilityRef { Id = newAbilityId }); if (SystemAPI.HasComponent<FrameId>(player))
SystemAPI.SetComponent(player, new FrameId { Value = newClass });
if (SystemAPI.HasComponent<PlayerClass>(player)) if (SystemAPI.HasComponent<PlayerClass>(player))
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass }); SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
if (SystemAPI.HasComponent<AbilityCooldown>(player)) // Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (fires now).
SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now ClassTraits.FrameLoadout(newClass, out byte f0, out byte f1, out byte f2, out byte f3);
var sockets = SystemAPI.GetBuffer<AbilitySocket>(player);
sockets.Clear();
sockets.Add(new AbilitySocket { SparkId = f0 });
sockets.Add(new AbilitySocket { SparkId = f1 });
sockets.Add(new AbilitySocket { SparkId = f2 });
sockets.Add(new AbilitySocket { SparkId = f3 });
if (SystemAPI.HasComponent<SocketCooldown>(player))
SystemAPI.SetComponent(player, default(SocketCooldown)); // 0 = ready: the swapped kit fires now
if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player)) if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player))
{ {
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id; byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
@@ -79,26 +79,24 @@ namespace ProjectM.Server
ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value }); ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value });
// Tag the player into the base region (M6 region/relevancy split). // Tag the player into the base region (M6 region/relevancy split).
ecb.AddComponent(player, new RegionTag { Region = RegionId.Base }); ecb.AddComponent(player, new RegionTag { Region = RegionId.Base });
// Slice 2: seed the chosen class on the just-instantiated player. AbilityRef selects the Fire slot // Slice 2 -> LANTERN: seed the chosen frame on the just-instantiated player. The 4-socket Spark
// (Warrior = cone / Ranger = projectile); the DRG-asymmetry traits ride permanent StatModifiers // loadout IS the ability model (the legacy single-AbilityRef path is deleted); the DRG-asymmetry
// (CharacterStatsRef stays Default -> deltas replicate via the OwnerSendType.All buffer). 0 -> Warrior. // traits ride permanent StatModifiers (CharacterStatsRef stays Default -> deltas replicate via the
// OwnerSendType.All buffer). 0 -> Warrior/Bathynaut.
byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId); byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId);
ecb.SetComponent(player, new AbilityRef { Id = ClassTraits.AbilityFor(classId) });
ClassTraits.AppendSeeds(classId, player, ecb); ClassTraits.AppendSeeds(classId, player, ecb);
// Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta // Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta
// seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this). // seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this).
ecb.AddComponent(player, new PlayerClass { ClassId = classId }); ecb.AddComponent(player, new PlayerClass { ClassId = classId });
ecb.AddComponent(player, new FrameId { Value = classId }); // Add (not Set): baked on the real player; absent on the minimal test prefab // replicated frame/class signal (HUD reads this, not AbilityRef) ecb.AddComponent(player, new FrameId { Value = classId }); // Add (not Set): baked on the real player; absent on the minimal test prefab // replicated frame/class signal
if (isGym) // Per-frame default Spark loadout on keys 1-4 (UNCONDITIONAL since the legacy path died — without
{ // this a non-gym spawn would have four empty sockets and no abilities at all).
// GYM: per-frame default Spark loadout on keys 1-4 (AbilityFireSystem reads sockets, not AbilityRef).
ClassTraits.FrameLoadout(classId, out byte f0, out byte f1, out byte f2, out byte f3); ClassTraits.FrameLoadout(classId, out byte f0, out byte f1, out byte f2, out byte f3);
var gymSockets = ecb.SetBuffer<AbilitySocket>(player); var sockets = ecb.SetBuffer<AbilitySocket>(player);
gymSockets.Add(new AbilitySocket { SparkId = f0 }); sockets.Add(new AbilitySocket { SparkId = f0 });
gymSockets.Add(new AbilitySocket { SparkId = f1 }); sockets.Add(new AbilitySocket { SparkId = f1 });
gymSockets.Add(new AbilitySocket { SparkId = f2 }); sockets.Add(new AbilitySocket { SparkId = f2 });
gymSockets.Add(new AbilitySocket { SparkId = f3 }); sockets.Add(new AbilitySocket { SparkId = f3 });
}
// Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as // Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as
// meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the // meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the
// ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a // ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a
@@ -142,13 +142,13 @@ namespace ProjectM.Server
} }
break; break;
case DebugOp.SetClass: case DebugOp.SetClass:
// Swap an already-spawned player's class IN PLACE (editor dev tool). Class = two replicated // Swap an already-spawned player's frame IN PLACE (editor dev tool). Frame = FrameId + the
// pieces: the AbilityRef Fire slot + the ClassSourceId-tagged StatModifier seeds; the owner's // ClassSourceId-tagged StatModifier seeds + the 4-socket Spark loadout; the owner's
// StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-correct // StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-
// (same buffer-mutation path as GrantUpgrade). Reapply + AbilityRef run unconditionally so the // correct (same buffer-mutation path as GrantUpgrade). The swap runs even on a corpse; the
// class is correct even on a corpse; the heal is gated on a LIVING player so we don't resurrect // heal is gated on a LIVING player so we don't resurrect out-of-band and race
// it out-of-band and race PlayerRespawnSystem (which refills to the new max on respawn itself). // PlayerRespawnSystem (which refills to the new max on respawn itself).
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender) if (sender != Entity.Null && SystemAPI.HasBuffer<AbilitySocket>(sender)
&& SystemAPI.HasBuffer<StatModifier>(sender)) && SystemAPI.HasBuffer<StatModifier>(sender))
{ {
var classMods = SystemAPI.GetBuffer<StatModifier>(sender); var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
@@ -156,15 +156,23 @@ namespace ProjectM.Server
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2) bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2); && SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default; var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
// DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil, // DR-046: the FULL swap (class seeds + meta re-sync) lives in the shared ClassSwapUtil,
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift. // used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2, ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
out byte swNewClass, out byte swNewAbility); out byte swNewClass);
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility }); if (SystemAPI.HasComponent<FrameId>(sender))
SystemAPI.SetComponent(sender, new FrameId { Value = swNewClass });
if (SystemAPI.HasComponent<PlayerClass>(sender)) if (SystemAPI.HasComponent<PlayerClass>(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass }); SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
if (SystemAPI.HasComponent<AbilityCooldown>(sender)) ClassTraits.FrameLoadout(swNewClass, out byte sf0, out byte sf1, out byte sf2, out byte sf3);
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); var swSockets = SystemAPI.GetBuffer<AbilitySocket>(sender);
swSockets.Clear();
swSockets.Add(new AbilitySocket { SparkId = sf0 });
swSockets.Add(new AbilitySocket { SparkId = sf1 });
swSockets.Add(new AbilitySocket { SparkId = sf2 });
swSockets.Add(new AbilitySocket { SparkId = sf3 });
if (SystemAPI.HasComponent<SocketCooldown>(sender))
SystemAPI.SetComponent(sender, default(SocketCooldown));
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender) if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2)) && SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
{ {
@@ -178,6 +186,7 @@ namespace ProjectM.Server
} }
} }
break; break;
break;
case DebugOp.SpawnEnemy: case DebugOp.SpawnEnemy:
// GYM: spawn a chosen enemy KIND (Drowner/Grindylow) from the baked roster near the sender. // GYM: spawn a chosen enemy KIND (Drowner/Grindylow) from the baked roster near the sender.
if (sender != Entity.Null && SystemAPI.HasComponent<LocalTransform>(sender) if (sender != Entity.Null && SystemAPI.HasComponent<LocalTransform>(sender)
@@ -14,7 +14,6 @@ namespace ProjectM.Server
/// client. In-editor single-process only (client + server worlds in one process). Poke from execute_code: /// client. In-editor single-process only (client + server worlds in one process). Poke from execute_code:
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.Damage, (byte)ModOp.Flat, 50f); /// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.Damage, (byte)ModOp.Flat, 50f);
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.MoveSpeed, (byte)ModOp.PercentAdd, 0.5f); /// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.MoveSpeed, (byte)ModOp.PercentAdd, 0.5f);
/// DebugModifierInjectionSystem.CycleAbility(); // Primary -> FastLight -> SlowHeavy -> Primary
/// DebugModifierInjectionSystem.ClearModifiers(); /// DebugModifierInjectionSystem.ClearModifiers();
/// All applied to the first player on the next server tick. /// All applied to the first player on the next server tick.
/// </summary> /// </summary>
@@ -25,7 +24,6 @@ namespace ProjectM.Server
static readonly List<PendingModifier> s_Pending = new List<PendingModifier>(); static readonly List<PendingModifier> s_Pending = new List<PendingModifier>();
static bool s_Clear; static bool s_Clear;
static bool s_Cycle;
/// <summary>Queue a modifier to append to the first player on the next server tick.</summary> /// <summary>Queue a modifier to append to the first player on the next server tick.</summary>
public static void AddModifier(byte target, byte op, float value) public static void AddModifier(byte target, byte op, float value)
@@ -36,17 +34,15 @@ namespace ProjectM.Server
/// <summary>Clear the first player's whole modifier stack on the next server tick.</summary> /// <summary>Clear the first player's whole modifier stack on the next server tick.</summary>
public static void ClearModifiers() => s_Clear = true; public static void ClearModifiers() => s_Clear = true;
/// <summary>Cycle the first player's primary ability id on the next server tick.</summary>
public static void CycleAbility() => s_Cycle = true;
protected override void OnUpdate() protected override void OnUpdate()
{ {
if (s_Pending.Count == 0 && !s_Clear && !s_Cycle) if (s_Pending.Count == 0 && !s_Clear)
return; return;
Entity player = Entity.Null; Entity player = Entity.Null;
foreach (var (abilityRef, e) in foreach (var (tag, e) in
SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess()) SystemAPI.Query<RefRO<PlayerTag>>().WithAll<StatModifier>().WithEntityAccess())
{ {
player = e; player = e;
break; break;
@@ -70,19 +66,6 @@ namespace ProjectM.Server
} }
s_Pending.Clear(); s_Pending.Clear();
} }
if (s_Cycle)
{
var abilityRef = EntityManager.GetComponentData<AbilityRef>(player);
abilityRef.Id = abilityRef.Id switch
{
(byte)AbilityId.Primary => (byte)AbilityId.FastLight,
(byte)AbilityId.FastLight => (byte)AbilityId.SlowHeavy,
_ => (byte)AbilityId.Primary,
};
EntityManager.SetComponentData(player, abilityRef);
s_Cycle = false;
}
} }
} }
} }
@@ -11,14 +11,14 @@ namespace ProjectM.Server
/// Resolves the sender's player (SourceConnection -&gt; NetworkId -&gt; GhostOwner, the AbilityUpgradeSystem / /// Resolves the sender's player (SourceConnection -&gt; NetworkId -&gt; GhostOwner, the AbilityUpgradeSystem /
/// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the /// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the
/// personal <see cref="InventorySlot"/> bag and the <see cref="EquipmentSlot"/> loadout (buffer index = slot), /// personal <see cref="InventorySlot"/> bag and the <see cref="EquipmentSlot"/> loadout (buffer index = slot),
/// sets <see cref="AbilityRef"/>.Id from the Weapon slot (restoring <see cref="DefaultAbility"/> on /// and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
/// weapon-unequip), and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
/// per-slot SourceId (<c>Tuning.EquipSourceIdBase + slot</c>), stripped TARGET-AGNOSTICALLY via /// per-slot SourceId (<c>Tuning.EquipSourceIdBase + slot</c>), stripped TARGET-AGNOSTICALLY via
/// <see cref="TimedModifierUtil.RemoveBySourceId"/>. /// <see cref="TimedModifierUtil.RemoveBySourceId"/>. (LANTERN purge: weapons are stat-sticks — the old
/// weapon->ability grant is deleted; abilities live in the 4-socket Spark loadout.)
/// ///
/// Effects are EVENT-DRIVEN (applied once here): AbilityRef + StatModifier are [GhostField]s re-folded by the /// Effects are EVENT-DRIVEN (applied once here): StatModifier is a [GhostField] buffer re-folded by the
/// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct /// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct
/// (DebugModifierInjectionSystem.CycleAbility is the precedent) and survives respawn (the entity persists). /// and survives respawn (the entity persists).
/// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any /// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any
/// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server /// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server
/// SimulationSystemGroup (NOT predicted -&gt; applied once, no rollback double-apply); only the request entity /// SimulationSystemGroup (NOT predicted -&gt; applied once, no rollback double-apply); only the request entity
@@ -127,10 +127,8 @@ namespace ProjectM.Server
static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def) static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def)
{ {
// Weapon slot drives the active ability (swaps prefab + base stats via StatRecomputeSystem). // LANTERN purge: weapons are stat-sticks — the old weapon->AbilityRef ability grant is deleted
if (slot == EquipSlotId.Weapon && def.GrantedAbilityId != 0) // (abilities live in the 4-socket Spark loadout).
state.EntityManager.SetComponentData(player, new AbilityRef { Id = def.GrantedAbilityId });
var mods = state.EntityManager.GetBuffer<StatModifier>(player); var mods = state.EntityManager.GetBuffer<StatModifier>(player);
uint sourceId = Tuning.EquipSourceIdBase + (uint)slot; uint sourceId = Tuning.EquipSourceIdBase + (uint)slot;
for (int i = 0; i < ItemDefBlob.MaxMods; i++) for (int i = 0; i < ItemDefBlob.MaxMods; i++)
@@ -145,13 +143,6 @@ namespace ProjectM.Server
{ {
var mods = state.EntityManager.GetBuffer<StatModifier>(player); var mods = state.EntityManager.GetBuffer<StatModifier>(player);
TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot); TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot);
// Weapon slot: restore the unarmed/base ability.
if (slot == EquipSlotId.Weapon)
{
byte fallback = state.EntityManager.GetComponentData<DefaultAbility>(player).Id;
state.EntityManager.SetComponentData(player, new AbilityRef { Id = fallback });
}
} }
static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId) static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId)
@@ -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 /// <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="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> /// <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, public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord, bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord,
out byte newClass, out byte newAbilityId) out byte newClass)
{ {
newClass = ClassTraits.Normalize(rawClass); newClass = ClassTraits.Normalize(rawClass);
ClassTraits.Reapply(newClass, mods); 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 // 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. // 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; 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> /// <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 /// <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 /// 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> /// 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 = /// <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> /// 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> /// <summary>
/// Folds each modifiable entity's authored base stats (from the AbilityDatabase blob, keyed by /// Folds each modifiable entity's authored base stats (from the AbilityDatabase blob, keyed by
/// AbilityRef / CharacterStatsRef) with its replicated StatModifier buffer into the /// CharacterStatsRef / the AbilitySocket loadout) with its replicated StatModifier buffer into the
/// EffectiveAbilityStats / EffectiveCharacterStats components - every predicted tick, on both worlds. /// 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; /// Runs at the head of the predicted group (UpdateBefore PlayerAimSystem;
/// AbilityFireSystem runs after PlayerAimSystem, so it sees fresh values too). Recompute is /// AbilityFireSystem runs after PlayerAimSystem, so it sees fresh values too). Recompute is
@@ -34,24 +35,11 @@ namespace ProjectM.Simulation
var database = SystemAPI.GetSingleton<AbilityDatabase>(); var database = SystemAPI.GetSingleton<AbilityDatabase>();
ref var db = ref database.Value.Value; ref var db = ref database.Value.Value;
foreach (var (abilityRef, charRef, mods, effAbility, effChar) in foreach (var (charRef, mods, effChar) in
SystemAPI.Query<RefRO<AbilityRef>, RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>, SystemAPI.Query<RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>,
RefRW<EffectiveAbilityStats>, RefRW<EffectiveCharacterStats>>() RefRW<EffectiveCharacterStats>>()
.WithAll<Simulate>()) .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)) if (db.TryGetCharacter(charRef.ValueRO.Id, out var c))
{ {
effChar.ValueRW = new EffectiveCharacterStats 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 // 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). // 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 // Separate query so this system stays under the 7-arg cap.
// until AbilityFireSystem + the feel layer migrate to the buffer (steps 2/2.5).
foreach (var (sockets, socketMods, effSockets) in foreach (var (sockets, socketMods, effSockets) in
SystemAPI.Query<DynamicBuffer<AbilitySocket>, DynamicBuffer<StatModifier>, DynamicBuffer<EffectiveSocketStats>>() SystemAPI.Query<DynamicBuffer<AbilitySocket>, DynamicBuffer<StatModifier>, DynamicBuffer<EffectiveSocketStats>>()
.WithAll<Simulate>()) .WithAll<Simulate>())
@@ -1,17 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// The player's "unarmed" / base ability id, baked from PlayerAuthoring.PrimaryAbility. Restored into
/// <see cref="AbilityRef"/>.Id by EquipSystem when a weapon is unequipped. NOT replicated (it never changes,
/// so a [GhostField] would waste snapshot bytes and there is no client consumer). AbilityRef itself cannot
/// serve double duty because EquipSystem overwrites AbilityRef.Id when a weapon is equipped — this preserves
/// the immutable default to fall back to. Server-read only.
/// </summary>
public struct DefaultAbility : IComponentData
{
/// <summary>The <see cref="AbilityId"/> (as a byte) the player fires with no weapon equipped.</summary>
public byte Id;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4c6831e7f8bb98d448917f88dcbe12db
@@ -50,9 +50,6 @@ namespace ProjectM.Simulation
/// <summary>Equip slot (see <see cref="EquipSlotId"/>); 255 = not equippable.</summary> /// <summary>Equip slot (see <see cref="EquipSlotId"/>); 255 = not equippable.</summary>
public byte EquipSlot; public byte EquipSlot;
/// <summary>AbilityId granted when equipped in the Weapon slot (0 = none); the equip handler writes it into AbilityRef.Id.</summary>
public byte GrantedAbilityId;
/// <summary>Up to <see cref="MaxMods"/> INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray.</summary> /// <summary>Up to <see cref="MaxMods"/> INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray.</summary>
public ItemModSpec Mod0, Mod1, Mod2, Mod3; public ItemModSpec Mod0, Mod1, Mod2, Mod3;
@@ -25,11 +25,8 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void AbilityFor_And_Normalize_DefaultToWarrior() public void Normalize_DefaultsToWarrior()
{ {
Assert.AreEqual((byte)AbilityId.WarriorCone, ClassTraits.AbilityFor(ClassTraits.WarriorClass));
Assert.AreEqual((byte)AbilityId.Primary, ClassTraits.AbilityFor(ClassTraits.RangerClass));
Assert.AreEqual((byte)AbilityId.WarriorCone, ClassTraits.AbilityFor(0), "unknown class -> Warrior cone");
Assert.AreEqual(ClassTraits.WarriorClass, ClassTraits.Normalize(0)); Assert.AreEqual(ClassTraits.WarriorClass, ClassTraits.Normalize(0));
Assert.AreEqual(ClassTraits.WarriorClass, ClassTraits.Normalize(99)); Assert.AreEqual(ClassTraits.WarriorClass, ClassTraits.Normalize(99));
Assert.AreEqual(ClassTraits.RangerClass, ClassTraits.Normalize(ClassTraits.RangerClass)); Assert.AreEqual(ClassTraits.RangerClass, ClassTraits.Normalize(ClassTraits.RangerClass));
@@ -10,11 +10,12 @@ namespace ProjectM.Tests
{ {
/// <summary> /// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="EquipSystem"/>. Seeds a player /// Plain-Entities EditMode tests for the server-only <see cref="EquipSystem"/>. Seeds a player
/// (GhostOwner + PlayerTag + InventorySlot + EquipmentSlot[4 rows] + StatModifier + AbilityRef + /// (GhostOwner + PlayerTag + InventorySlot + EquipmentSlot[4 rows] + StatModifier), an inline-built
/// DefaultAbility), an inline-built ItemDatabase singleton, a mock connection, and an Equip/Unequip RPC. /// ItemDatabase singleton, a mock connection, and an Equip/Unequip RPC. Weapons are STAT-STICKS
/// Pins: weapon-equip sets AbilityRef + adds the slot-tagged mod + moves the item bag->slot; unequip reverses /// (LANTERN purge: the old weapon->AbilityRef grant is deleted; abilities live in the socket kit).
/// and restores DefaultAbility; equip-over-occupied swaps the old item back; a full-bag swap is rejected with /// Pins: weapon-equip adds the slot-tagged mod + moves the item bag->slot; unequip reverses;
/// no item loss; non-equippable / absent / unresolvable-connection requests no-op (request still consumed); /// equip-over-occupied swaps the old item back; a full-bag swap is rejected with no item loss;
/// non-equippable / absent / unresolvable-connection requests no-op (request still consumed);
/// the unequip strip removes ONLY the slot sentinel, leaving foreign-SourceId mods (pickup 0u, upgrade) intact. /// the unequip strip removes ONLY the slot sentinel, leaving foreign-SourceId mods (pickup 0u, upgrade) intact.
/// </summary> /// </summary>
public class EquipSystemTests public class EquipSystemTests
@@ -31,13 +32,13 @@ namespace ProjectM.Tests
static ItemModSpec NoMod() => new ItemModSpec { Target = 255 }; static ItemModSpec NoMod() => new ItemModSpec { Target = 255 };
static ItemDefBlob Mk(ushort id, byte slot, byte ability, ItemModSpec m0) static ItemDefBlob Mk(ushort id, byte slot, ItemModSpec m0)
{ {
int stackMax = slot <= EquipSlotId.Tool ? 1 : 999; int stackMax = slot <= EquipSlotId.Tool ? 1 : 999;
return new ItemDefBlob return new ItemDefBlob
{ {
ItemId = id, Category = 0, Tier = 0, StackMax = stackMax, ItemId = id, Category = 0, Tier = 0, StackMax = stackMax,
EquipSlot = slot, GrantedAbilityId = ability, EquipSlot = slot,
Mod0 = m0, Mod1 = NoMod(), Mod2 = NoMod(), Mod3 = NoMod(), Mod0 = m0, Mod1 = NoMod(), Mod2 = NoMod(), Mod3 = NoMod(),
}; };
} }
@@ -54,10 +55,10 @@ namespace ProjectM.Tests
var builder = new BlobBuilder(Allocator.Temp); var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>(); ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
var arr = builder.Allocate(ref root.Items, 4); var arr = builder.Allocate(ref root.Items, 4);
arr[0] = Mk(WeaponA, EquipSlotId.Weapon, (byte)AbilityId.FastLight, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f }); arr[0] = Mk(WeaponA, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f });
arr[1] = Mk(WeaponB, EquipSlotId.Weapon, (byte)AbilityId.SlowHeavy, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f }); arr[1] = Mk(WeaponB, EquipSlotId.Weapon, new ItemModSpec { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f });
arr[2] = Mk(GearArmor, EquipSlotId.Armor, 0, new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.1f }); arr[2] = Mk(GearArmor, EquipSlotId.Armor, new ItemModSpec { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentAdd, Value = 0.1f });
arr[3] = Mk(Ore, EquipSlotId.None, 0, NoMod()); arr[3] = Mk(Ore, EquipSlotId.None, NoMod());
_blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent); _blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
builder.Dispose(); builder.Dispose();
var dbE = em.CreateEntity(typeof(ItemDatabase)); var dbE = em.CreateEntity(typeof(ItemDatabase));
@@ -78,8 +79,6 @@ namespace ProjectM.Tests
var e = em.CreateEntity(); var e = em.CreateEntity();
em.AddComponentData(e, new GhostOwner { NetworkId = networkId }); em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponent<PlayerTag>(e); em.AddComponent<PlayerTag>(e);
em.AddComponentData(e, new AbilityRef { Id = (byte)AbilityId.Primary });
em.AddComponentData(e, new DefaultAbility { Id = (byte)AbilityId.Primary });
var bag = em.AddBuffer<InventorySlot>(e); var bag = em.AddBuffer<InventorySlot>(e);
foreach (var it in bagItems) bag.Add(new InventorySlot { ItemId = it.id, Count = it.count }); foreach (var it in bagItems) bag.Add(new InventorySlot { ItemId = it.id, Count = it.count });
var slots = em.AddBuffer<EquipmentSlot>(e); var slots = em.AddBuffer<EquipmentSlot>(e);
@@ -102,7 +101,6 @@ namespace ProjectM.Tests
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn }); em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
} }
static byte Ability(EntityManager em, Entity p) => em.GetComponentData<AbilityRef>(p).Id;
static ushort Slot(EntityManager em, Entity p, byte slot) => em.GetBuffer<EquipmentSlot>(p)[slot].ItemId; static ushort Slot(EntityManager em, Entity p, byte slot) => em.GetBuffer<EquipmentSlot>(p)[slot].ItemId;
static int Bag(EntityManager em, Entity p, ushort id) => InventoryMath.CountOf(em.GetBuffer<InventorySlot>(p), id); static int Bag(EntityManager em, Entity p, ushort id) => InventoryMath.CountOf(em.GetBuffer<InventorySlot>(p), id);
static int RequestsLeft(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ReceiveRpcCommandRequest)); return q.CalculateEntityCount(); } static int RequestsLeft(EntityManager em) { using var q = em.CreateEntityQuery(typeof(ReceiveRpcCommandRequest)); return q.CalculateEntityCount(); }
@@ -125,7 +123,7 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Equip_Weapon_Sets_Ability_Adds_Mod_Moves_Item() public void Equip_Weapon_Adds_Mod_Moves_Item()
{ {
var (world, group) = MakeWorld("EquipWeapon"); var (world, group) = MakeWorld("EquipWeapon");
using (world) using (world)
@@ -137,7 +135,6 @@ namespace ProjectM.Tests
group.Update(); group.Update();
Assert.AreEqual((byte)AbilityId.FastLight, Ability(em, player), "The weapon grants its ability into AbilityRef.");
Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The weapon occupies the Weapon slot."); Assert.AreEqual(WeaponA, Slot(em, player, EquipSlotId.Weapon), "The weapon occupies the Weapon slot.");
Assert.AreEqual(0, Bag(em, player, WeaponA), "The weapon left the bag."); Assert.AreEqual(0, Bag(em, player, WeaponA), "The weapon left the bag.");
Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is tagged the weapon-slot sentinel."); Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is tagged the weapon-slot sentinel.");
@@ -146,7 +143,7 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Unequip_Weapon_Restores_Default_Strips_Mods_Returns_Item() public void Unequip_Weapon_Strips_Mods_Returns_Item()
{ {
var (world, group) = MakeWorld("UnequipWeapon"); var (world, group) = MakeWorld("UnequipWeapon");
using (world) using (world)
@@ -160,7 +157,6 @@ namespace ProjectM.Tests
MakeUnequip(em, EquipSlotId.Weapon, conn); MakeUnequip(em, EquipSlotId.Weapon, conn);
group.Update(); group.Update();
Assert.AreEqual((byte)AbilityId.Primary, Ability(em, player), "Unequip restores the DefaultAbility.");
Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "The Weapon slot is empty."); Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "The Weapon slot is empty.");
Assert.AreEqual(1, Bag(em, player, WeaponA), "The weapon is back in the bag."); Assert.AreEqual(1, Bag(em, player, WeaponA), "The weapon is back in the bag.");
Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is stripped."); Assert.AreEqual(0, SlotModCount(em, player, EquipSlotId.Weapon), "The weapon's mod is stripped.");
@@ -183,7 +179,6 @@ namespace ProjectM.Tests
group.Update(); group.Update();
Assert.AreEqual(WeaponB, Slot(em, player, EquipSlotId.Weapon), "Weapon B now occupies the slot."); Assert.AreEqual(WeaponB, Slot(em, player, EquipSlotId.Weapon), "Weapon B now occupies the slot.");
Assert.AreEqual((byte)AbilityId.SlowHeavy, Ability(em, player), "AbilityRef swaps to weapon B's ability.");
Assert.AreEqual(1, Bag(em, player, WeaponA), "Weapon A swapped back into the bag."); Assert.AreEqual(1, Bag(em, player, WeaponA), "Weapon A swapped back into the bag.");
Assert.AreEqual(0, Bag(em, player, WeaponB), "Weapon B left the bag."); Assert.AreEqual(0, Bag(em, player, WeaponB), "Weapon B left the bag.");
Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "Exactly weapon B's single mod remains (A's stripped)."); Assert.AreEqual(1, SlotModCount(em, player, EquipSlotId.Weapon), "Exactly weapon B's single mod remains (A's stripped).");
@@ -231,7 +226,6 @@ namespace ProjectM.Tests
Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "Nothing equipped."); Assert.AreEqual(0, Slot(em, player, EquipSlotId.Weapon), "Nothing equipped.");
Assert.AreEqual(5, Bag(em, player, Ore), "The resource is untouched."); Assert.AreEqual(5, Bag(em, player, Ore), "The resource is untouched.");
Assert.AreEqual((byte)AbilityId.Primary, Ability(em, player), "AbilityRef unchanged.");
Assert.AreEqual(0, RequestsLeft(em), "Both requests are consumed."); Assert.AreEqual(0, RequestsLeft(em), "Both requests are consumed.");
} }
} }
@@ -31,9 +31,10 @@ namespace ProjectM.Tests
static Entity MakePlayerPrefab(EntityManager em) static Entity MakePlayerPrefab(EntityManager em)
{ {
var e = em.CreateEntity(typeof(LocalTransform), typeof(GhostOwner), typeof(AbilityRef), typeof(PlayerTag)); var e = em.CreateEntity(typeof(LocalTransform), typeof(GhostOwner), typeof(FrameId), typeof(PlayerTag));
em.SetComponentData(e, LocalTransform.Identity); em.SetComponentData(e, LocalTransform.Identity);
em.AddBuffer<StatModifier>(e); em.AddBuffer<StatModifier>(e);
em.AddBuffer<AbilitySocket>(e); // the spawn path SetBuffers the frame loadout unconditionally (LANTERN purge)
em.AddComponent<Prefab>(e); em.AddComponent<Prefab>(e);
return e; return e;
} }
@@ -7,9 +7,10 @@ namespace ProjectM.Tests
{ {
/// <summary> /// <summary>
/// Plain-Entities test for <see cref="StatRecomputeSystem"/>: builds an AbilityDatabase blob singleton /// Plain-Entities test for <see cref="StatRecomputeSystem"/>: builds an AbilityDatabase blob singleton
/// + a player-like entity (refs / modifier buffer / effective components), ticks the /// + a player-like entity (CharacterStatsRef / socket loadout / modifier buffer / effective components),
/// SimulationSystemGroup, and asserts the effective stats equal the folded (base + modifiers) values /// ticks the SimulationSystemGroup, and asserts the effective stats equal the folded (base + modifiers)
/// and stay stable across repeated ticks (the every-tick recompute is idempotent). Version-independent. /// values and stay stable across repeated ticks (the every-tick recompute is idempotent). The legacy
/// AbilityRef -> EffectiveAbilityStats fold is deleted (LANTERN purge); the socket fold is the ability path.
/// </summary> /// </summary>
public class StatRecomputeSystemTests public class StatRecomputeSystemTests
{ {
@@ -47,9 +48,8 @@ namespace ProjectM.Tests
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob }); em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
var player = em.CreateEntity( var player = em.CreateEntity(
typeof(AbilityRef), typeof(CharacterStatsRef), typeof(StatModifier), typeof(CharacterStatsRef), typeof(StatModifier),
typeof(EffectiveAbilityStats), typeof(EffectiveCharacterStats), typeof(Simulate)); typeof(EffectiveCharacterStats), typeof(Simulate));
em.SetComponentData(player, new AbilityRef { Id = AbilityPrimary });
em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault }); em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault });
return (world, player); return (world, player);
} }
@@ -67,12 +67,9 @@ namespace ProjectM.Tests
try try
{ {
world.GetExistingSystemManaged<SimulationSystemGroup>().Update(); world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var ea = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player);
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player); var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
Assert.AreEqual(20f, ea.Damage, 1e-3f);
Assert.AreEqual(25f, ea.ProjectileSpeed, 1e-3f);
Assert.AreEqual(12, ea.CooldownTicks);
Assert.AreEqual(6f, ec.MoveSpeed, 1e-3f); Assert.AreEqual(6f, ec.MoveSpeed, 1e-3f);
Assert.AreEqual(12.5f, ec.TurnRateRadiansPerSec, 1e-3f);
Assert.AreEqual(100f, ec.MaxHealth, 1e-3f); Assert.AreEqual(100f, ec.MaxHealth, 1e-3f);
} }
finally { world.Dispose(); blob.Dispose(); } finally { world.Dispose(); blob.Dispose(); }
@@ -84,14 +81,12 @@ namespace ProjectM.Tests
var (world, player) = MakeWorld(out var blob); var (world, player) = MakeWorld(out var blob);
try try
{ {
AddMod(world, player, StatTarget.Damage, ModOp.Flat, 5f);
AddMod(world, player, StatTarget.Damage, ModOp.PercentAdd, 0.5f); // (20+5)*1.5 = 37.5
AddMod(world, player, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.5f); // 6*1.5 = 9 AddMod(world, player, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.5f); // 6*1.5 = 9
AddMod(world, player, StatTarget.MaxHealth, ModOp.Flat, 30f); // 100+30 = 130
world.GetExistingSystemManaged<SimulationSystemGroup>().Update(); world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
var ea = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player);
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player); var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
Assert.AreEqual(37.5f, ea.Damage, 1e-3f);
Assert.AreEqual(9f, ec.MoveSpeed, 1e-3f); Assert.AreEqual(9f, ec.MoveSpeed, 1e-3f);
Assert.AreEqual(130f, ec.MaxHealth, 1e-3f);
} }
finally { world.Dispose(); blob.Dispose(); } finally { world.Dispose(); blob.Dispose(); }
} }
@@ -102,13 +97,13 @@ namespace ProjectM.Tests
var (world, player) = MakeWorld(out var blob); var (world, player) = MakeWorld(out var blob);
try try
{ {
AddMod(world, player, StatTarget.Damage, ModOp.Flat, 10f); // 20+10 = 30 AddMod(world, player, StatTarget.MaxHealth, ModOp.Flat, 10f); // 100+10 = 110
var group = world.GetExistingSystemManaged<SimulationSystemGroup>(); var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
group.Update(); group.Update();
var first = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player).Damage; var first = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player).MaxHealth;
for (int i = 0; i < 5; i++) group.Update(); for (int i = 0; i < 5; i++) group.Update();
var last = world.EntityManager.GetComponentData<EffectiveAbilityStats>(player).Damage; var last = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player).MaxHealth;
Assert.AreEqual(30f, first, 1e-3f); Assert.AreEqual(110f, first, 1e-3f);
Assert.AreEqual(first, last, 1e-4f); Assert.AreEqual(first, last, 1e-4f);
} }
finally { world.Dispose(); blob.Dispose(); } finally { world.Dispose(); blob.Dispose(); }
@@ -128,13 +123,10 @@ namespace ProjectM.Tests
var dbEntity = em.CreateEntity(typeof(AbilityDatabase)); var dbEntity = em.CreateEntity(typeof(AbilityDatabase));
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob }); em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
// player carrying the socket loadout + per-socket effective buffer (plus the legacy singles the // player carrying the socket loadout + per-socket effective buffer.
// first fold loop still needs during the step-1b transition).
var player = em.CreateEntity( var player = em.CreateEntity(
typeof(AbilityRef), typeof(CharacterStatsRef), typeof(StatModifier), typeof(CharacterStatsRef), typeof(StatModifier), typeof(EffectiveCharacterStats),
typeof(EffectiveAbilityStats), typeof(EffectiveCharacterStats),
typeof(AbilitySocket), typeof(EffectiveSocketStats), typeof(Simulate)); typeof(AbilitySocket), typeof(EffectiveSocketStats), typeof(Simulate));
em.SetComponentData(player, new AbilityRef { Id = AbilityPrimary });
em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault }); em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault });
var socketBuf = em.GetBuffer<AbilitySocket>(player); var socketBuf = em.GetBuffer<AbilitySocket>(player);
var effBuf = em.GetBuffer<EffectiveSocketStats>(player); var effBuf = em.GetBuffer<EffectiveSocketStats>(player);