Files
Project-M/Assets/_Project/Scripts/Server/Combat/ClassSelectReceiveSystem.cs
T
kronic 4a8220ad3e 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>
2026-07-15 15:47:12 -07:00

91 lines
4.9 KiB
C#

using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// 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), 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))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct ClassSelectReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded).
Entity dir = Entity.Null;
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ClassSelectRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var 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);
if (SystemAPI.HasComponent<FrameId>(player))
SystemAPI.SetComponent(player, new FrameId { Value = newClass });
if (SystemAPI.HasComponent<PlayerClass>(player))
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
// 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;
if (abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
{
var hp = SystemAPI.GetComponent<Health>(player);
ClassSwapUtil.HealClamp(ref hp, baseChar.MaxHealth, mods);
SystemAPI.SetComponent(player, hp);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
}
}