LANTERN P1 step 5 + §8: two-frame identity via FrameId + per-frame gym loadouts

FrameId (replicated frame/class signal) now WRITTEN server-side at spawn for all
players; the two class-HUD readers (ClassPrepPortal, MetaShop) read FrameId (with
an AbilityRef fallback for pre-FrameId players) instead of ClassForAbility. The
gym seeds a PER-FRAME default Spark loadout via ClassTraits.FrameLoadout —
Harpooner (Ranger slot): reel/mobility/skillshots; Bathynaut (Warrior slot):
pull-in/dash/zone-control. FrameId uses ecb.AddComponent (baked on the real
player; absent on the minimal MetaSeeding test prefab). 497 EditMode green.

Deferred to a focused pass (risky wholesale refactor, "still works" via the
transition): full removal of legacy AbilityRef/AbilityCooldown/EffectiveAbility
Stats (woven through equip/class/meta/StatRecompute + tests) and the CharacterId
Bathynaut/Harpooner rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:45:45 -07:00
parent 8f099c32ba
commit 342a39a540
4 changed files with 25 additions and 10 deletions
@@ -83,9 +83,9 @@ namespace ProjectM.Client
// Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only).
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback
haveLocalPlayer = true;
break;
}
@@ -76,9 +76,9 @@ namespace ProjectM.Client
// server-only); tiers from the replicated MetaTierState record on the director ghost.
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback
haveLocalPlayer = true;
break;
}
@@ -88,15 +88,16 @@ namespace ProjectM.Server
// 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).
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)
if (isGym)
{
// GYM: default Spark socket loadout on keys 1-4 (AbilityFireSystem reads sockets, not AbilityRef).
// LightZone(9) is defined + dispatch-ready but off the default bar (socket it via a swap).
// 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);
var gymSockets = ecb.SetBuffer<AbilitySocket>(player);
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.HookPull });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Blink });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.DecoyWisp });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Vortex });
gymSockets.Add(new AbilitySocket { SparkId = f0 });
gymSockets.Add(new AbilitySocket { SparkId = f1 });
gymSockets.Add(new AbilitySocket { SparkId = f2 });
gymSockets.Add(new AbilitySocket { SparkId = f3 });
}
// 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
@@ -45,6 +45,20 @@ namespace ProjectM.Simulation
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>
public static void FrameLoadout(byte classId, out byte s0, out byte s1, out byte s2, out byte s3)
{
if (classId == RangerClass) // Harpooner: reel primary + mobility + skillshots
{
s0 = (byte)AbilityId.HookPull; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.DecoyWisp; s3 = (byte)AbilityId.LightZone;
}
else // Bathynaut: pull enemies in + dash + zone control (melee via the combo chassis)
{
s0 = (byte)AbilityId.Vortex; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.HookPull; s3 = (byte)AbilityId.LightZone;
}
}
/// <summary>True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).</summary>
public static bool IsClassSeed(uint sourceId)
=> sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount;