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:
@@ -6,12 +6,13 @@ using Unity.NetCode;
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their class at base. Honored ONLY in
|
||||
/// Staging (class = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
|
||||
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their frame at base. Honored ONLY in
|
||||
/// 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 +
|
||||
/// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + <see cref="ClassSwapUtil.HealClamp"/>.
|
||||
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. NOT
|
||||
/// Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC — Burst safety over micro-perf).
|
||||
/// permanent-meta re-sync), writes FrameId / PlayerClass, re-seeds the 4-socket Spark loadout, and calls
|
||||
/// <see cref="ClassSwapUtil.HealClamp"/>. Plain server group, before RunDirectorSystem (the receiver convention);
|
||||
/// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC).
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
@@ -50,18 +51,26 @@ namespace ProjectM.Server
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var player))
|
||||
continue;
|
||||
if (!SystemAPI.HasComponent<AbilityRef>(player)) continue;
|
||||
if (!SystemAPI.HasBuffer<AbilitySocket>(player)) continue;
|
||||
|
||||
var mods = SystemAPI.GetBuffer<StatModifier>(player);
|
||||
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
|
||||
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord,
|
||||
out byte newClass, out byte newAbilityId);
|
||||
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, out byte newClass);
|
||||
|
||||
SystemAPI.SetComponent(player, new AbilityRef { Id = newAbilityId });
|
||||
if (SystemAPI.HasComponent<FrameId>(player))
|
||||
SystemAPI.SetComponent(player, new FrameId { Value = newClass });
|
||||
if (SystemAPI.HasComponent<PlayerClass>(player))
|
||||
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
|
||||
if (SystemAPI.HasComponent<AbilityCooldown>(player))
|
||||
SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now
|
||||
// Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (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))
|
||||
{
|
||||
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
|
||||
|
||||
@@ -79,26 +79,24 @@ namespace ProjectM.Server
|
||||
ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value });
|
||||
// Tag the player into the base region (M6 region/relevancy split).
|
||||
ecb.AddComponent(player, new RegionTag { Region = RegionId.Base });
|
||||
// Slice 2: seed the chosen class on the just-instantiated player. AbilityRef selects the Fire slot
|
||||
// (Warrior = cone / Ranger = projectile); the DRG-asymmetry traits ride permanent StatModifiers
|
||||
// (CharacterStatsRef stays Default -> deltas replicate via the OwnerSendType.All buffer). 0 -> Warrior.
|
||||
// Slice 2 -> LANTERN: seed the chosen frame on the just-instantiated player. The 4-socket Spark
|
||||
// loadout IS the ability model (the legacy single-AbilityRef path is deleted); the DRG-asymmetry
|
||||
// traits ride permanent StatModifiers (CharacterStatsRef stays Default -> deltas replicate via the
|
||||
// OwnerSendType.All buffer). 0 -> Warrior/Bathynaut.
|
||||
byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId);
|
||||
ecb.SetComponent(player, new AbilityRef { Id = ClassTraits.AbilityFor(classId) });
|
||||
ClassTraits.AppendSeeds(classId, player, ecb);
|
||||
// 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: 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 = f0 });
|
||||
gymSockets.Add(new AbilitySocket { SparkId = f1 });
|
||||
gymSockets.Add(new AbilitySocket { SparkId = f2 });
|
||||
gymSockets.Add(new AbilitySocket { SparkId = f3 });
|
||||
}
|
||||
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
|
||||
// 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).
|
||||
ClassTraits.FrameLoadout(classId, out byte f0, out byte f1, out byte f2, out byte f3);
|
||||
var sockets = ecb.SetBuffer<AbilitySocket>(player);
|
||||
sockets.Add(new AbilitySocket { SparkId = f0 });
|
||||
sockets.Add(new AbilitySocket { SparkId = f1 });
|
||||
sockets.Add(new AbilitySocket { SparkId = f2 });
|
||||
sockets.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
|
||||
// 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;
|
||||
case DebugOp.SetClass:
|
||||
// Swap an already-spawned player's class IN PLACE (editor dev tool). Class = two replicated
|
||||
// pieces: the AbilityRef Fire slot + the ClassSourceId-tagged StatModifier seeds; the owner's
|
||||
// StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-correct
|
||||
// (same buffer-mutation path as GrantUpgrade). Reapply + AbilityRef run unconditionally so the
|
||||
// class is correct even on a corpse; the heal is gated on a LIVING player so we don't resurrect
|
||||
// it out-of-band and race PlayerRespawnSystem (which refills to the new max on respawn itself).
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
|
||||
// Swap an already-spawned player's frame IN PLACE (editor dev tool). Frame = FrameId + the
|
||||
// ClassSourceId-tagged StatModifier seeds + the 4-socket Spark loadout; the owner's
|
||||
// StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-
|
||||
// correct (same buffer-mutation path as GrantUpgrade). The swap runs even on a corpse; the
|
||||
// heal is gated on a LIVING player so we don't resurrect out-of-band and race
|
||||
// PlayerRespawnSystem (which refills to the new max on respawn itself).
|
||||
if (sender != Entity.Null && SystemAPI.HasBuffer<AbilitySocket>(sender)
|
||||
&& SystemAPI.HasBuffer<StatModifier>(sender))
|
||||
{
|
||||
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
|
||||
@@ -156,15 +156,23 @@ namespace ProjectM.Server
|
||||
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
|
||||
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
|
||||
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.
|
||||
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
|
||||
out byte swNewClass, out byte swNewAbility);
|
||||
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
|
||||
out byte swNewClass);
|
||||
if (SystemAPI.HasComponent<FrameId>(sender))
|
||||
SystemAPI.SetComponent(sender, new FrameId { Value = swNewClass });
|
||||
if (SystemAPI.HasComponent<PlayerClass>(sender))
|
||||
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
|
||||
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
|
||||
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
|
||||
ClassTraits.FrameLoadout(swNewClass, out byte sf0, out byte sf1, out byte sf2, out byte sf3);
|
||||
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)
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
|
||||
{
|
||||
@@ -178,6 +186,7 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case DebugOp.SpawnEnemy:
|
||||
// GYM: spawn a chosen enemy KIND (Drowner/Grindylow) from the baked roster near the 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:
|
||||
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.Damage, (byte)ModOp.Flat, 50f);
|
||||
/// DebugModifierInjectionSystem.AddModifier((byte)StatTarget.MoveSpeed, (byte)ModOp.PercentAdd, 0.5f);
|
||||
/// DebugModifierInjectionSystem.CycleAbility(); // Primary -> FastLight -> SlowHeavy -> Primary
|
||||
/// DebugModifierInjectionSystem.ClearModifiers();
|
||||
/// All applied to the first player on the next server tick.
|
||||
/// </summary>
|
||||
@@ -25,7 +24,6 @@ namespace ProjectM.Server
|
||||
|
||||
static readonly List<PendingModifier> s_Pending = new List<PendingModifier>();
|
||||
static bool s_Clear;
|
||||
static bool s_Cycle;
|
||||
|
||||
/// <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)
|
||||
@@ -36,17 +34,15 @@ namespace ProjectM.Server
|
||||
/// <summary>Clear the first player's whole modifier stack on the next server tick.</summary>
|
||||
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()
|
||||
{
|
||||
if (s_Pending.Count == 0 && !s_Clear && !s_Cycle)
|
||||
if (s_Pending.Count == 0 && !s_Clear)
|
||||
return;
|
||||
|
||||
Entity player = Entity.Null;
|
||||
foreach (var (abilityRef, e) in
|
||||
SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
foreach (var (tag, e) in
|
||||
SystemAPI.Query<RefRO<PlayerTag>>().WithAll<StatModifier>().WithEntityAccess())
|
||||
{
|
||||
player = e;
|
||||
break;
|
||||
@@ -70,19 +66,6 @@ namespace ProjectM.Server
|
||||
}
|
||||
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 -> NetworkId -> GhostOwner, the AbilityUpgradeSystem /
|
||||
/// 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),
|
||||
/// sets <see cref="AbilityRef"/>.Id from the Weapon slot (restoring <see cref="DefaultAbility"/> on
|
||||
/// weapon-unequip), and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
|
||||
/// 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
|
||||
/// <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
|
||||
/// (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
|
||||
/// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server
|
||||
/// SimulationSystemGroup (NOT predicted -> 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)
|
||||
{
|
||||
// Weapon slot drives the active ability (swaps prefab + base stats via StatRecomputeSystem).
|
||||
if (slot == EquipSlotId.Weapon && def.GrantedAbilityId != 0)
|
||||
state.EntityManager.SetComponentData(player, new AbilityRef { Id = def.GrantedAbilityId });
|
||||
|
||||
// LANTERN purge: weapons are stat-sticks — the old weapon->AbilityRef ability grant is deleted
|
||||
// (abilities live in the 4-socket Spark loadout).
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
uint sourceId = Tuning.EquipSourceIdBase + (uint)slot;
|
||||
for (int i = 0; i < ItemDefBlob.MaxMods; i++)
|
||||
@@ -145,13 +143,6 @@ namespace ProjectM.Server
|
||||
{
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user