Dev tool: switch player class (Warrior/Ranger) at runtime for testing

Editor-only class swap via the existing scalar dev-RPC family (new DebugOp.SetClass): F1/F2 keybind (ClassSwitchHotkeySystem), DebugOverlay '- Class -' buttons, and DebugCommandSendSystem.SetWarrior/SetRanger/SetClass statics. Server (DebugCommandReceiveSystem) swaps class in place on the spawned player: strips+re-seeds the ClassTraits StatModifier seeds, swaps the AbilityRef Fire slot, resets the ability cooldown, and heals a LIVING player to the new max (dead players skip the heal so respawn isn't raced). Server-authoritative + prediction-correct (same buffer-mutation path as GrantUpgrade); wire type unchanged so the RpcCollection hash is unaffected.

ClassTraits gains a shared Seeds core (spawn + swap can't drift), ClassSeedCount, IsClassSeed, a DynamicBuffer AppendSeeds overload, and Reapply. +3 EditMode tests (exact-count round-trip, value-equality fold, boundary/foreign-mod preservation); 351/351 green; Warrior<->Ranger round-trip Play-validated (server+client agree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:23:33 -07:00
parent 4ac1ae5a2e
commit a74b761363
8 changed files with 231 additions and 12 deletions
@@ -172,6 +172,41 @@ namespace ProjectM.Server
SystemAPI.SetSingleton(tuningCfg);
}
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)
&& SystemAPI.HasBuffer<StatModifier>(sender))
{
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
ClassTraits.Reapply(newClass, classMods);
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) });
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready
// Heal a living player to the new class's full max (fold blob base + the just-reseeded
// buffer, like StatRecomputeSystem; Effective* still lags a tick here). Doubles as the
// down-clamp when the new max is lower (nothing else clamps Current off a damage event).
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb))
{
var hp = SystemAPI.GetComponent<Health>(sender);
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
{
hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp);
}
}
}
break;
}
ecb.DestroyEntity(reqEntity);