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
@@ -1,3 +1,4 @@
using System;
using Unity.Entities;
namespace ProjectM.Simulation
@@ -5,8 +6,8 @@ namespace ProjectM.Simulation
/// <summary>
/// Slice 2 — pure mapping from a chosen class (a <see cref="CharacterId"/> byte) to its spawn-time setup: the
/// Fire-slot ability id + the permanent trait <see cref="StatModifier"/>s (tagged with the reserved
/// <see cref="Tuning.ClassSourceId"/>, NEVER stripped). Applied by GoInGameServerSystem on the just-spawned
/// player and unit-tested. Burst-safe (byte-only, no managed types).
/// <see cref="Tuning.ClassSourceId"/> range, NEVER stripped by the generic modifier systems). Applied by
/// GoInGameServerSystem on the just-spawned player and unit-tested. Burst-safe (byte/uint only, no managed types).
/// <para>
/// Trait deltas seed onto the <see cref="CharacterId.Default"/> character (no per-class blob row needed — the
/// deltas ride the replicated StatModifier buffer, OwnerSendType.All, so the owning client folds the correct
@@ -16,12 +17,21 @@ namespace ProjectM.Simulation
/// knockback feeds). The Warrior's Fire = the aim-directed cone (<see cref="AbilityId.WarriorCone"/>); the
/// Ranger's Fire = the default projectile (<see cref="AbilityId.Primary"/>).
/// </para>
/// <para>
/// <see cref="Reapply"/> swaps an already-spawned player's class IN PLACE (strip the old class seeds, re-seed
/// the new ones) — used by the editor-only class-switch dev tool. The single seed set lives in <see cref="Seeds"/>
/// so the spawn path and the swap path can never drift; <see cref="ClassSeedCount"/> couples the strip range
/// (<see cref="IsClassSeed"/>) to the number of seeds emitted.
/// </para>
/// </summary>
public static class ClassTraits
{
public const byte WarriorClass = (byte)CharacterId.Warrior;
public const byte RangerClass = (byte)CharacterId.Ranger;
/// <summary>How many trait modifiers a class seeds (each on a distinct SourceId at ClassSourceId + i).</summary>
public const int ClassSeedCount = 4;
/// <summary>Normalize a wire ClassId to a known class (0 / unknown -> Warrior).</summary>
public static byte Normalize(byte classId) => classId == RangerClass ? RangerClass : WarriorClass;
@@ -29,24 +39,65 @@ namespace ProjectM.Simulation
public static byte AbilityFor(byte classId)
=> classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone;
/// <summary>Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn).</summary>
public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
/// <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;
/// <summary>
/// The permanent trait deltas for a class — the SINGLE source shared by both the spawn path
/// (<see cref="AppendSeeds(byte,Entity,EntityCommandBuffer)"/>) and the in-place swap path
/// (<see cref="AppendSeeds(byte,DynamicBuffer{StatModifier})"/> / <see cref="Reapply"/>), so they can't drift.
/// Fills exactly <see cref="ClassSeedCount"/> entries, each tagged ClassSourceId + i.
/// </summary>
static void Seeds(byte classId, Span<StatModifier> seeds)
{
uint src = Tuning.ClassSourceId;
if (classId == RangerClass)
{
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.15f, SourceId = src });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.Range, Op = (byte)ModOp.PercentAdd, Value = 0.30f, SourceId = src + 2u });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.AutoTargetRange, Op = (byte)ModOp.Flat, Value = 3f, SourceId = src + 3u });
seeds[0] = new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.15f, SourceId = src };
seeds[1] = new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u };
seeds[2] = new StatModifier { Target = (byte)StatTarget.Range, Op = (byte)ModOp.PercentAdd, Value = 0.30f, SourceId = src + 2u };
seeds[3] = new StatModifier { Target = (byte)StatTarget.AutoTargetRange, Op = (byte)ModOp.Flat, Value = 3f, SourceId = src + 3u };
}
else
{
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f, SourceId = src });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.Flat, Value = 6f, SourceId = src + 2u });
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MeleeRange, Op = (byte)ModOp.Flat, Value = 0.8f, SourceId = src + 3u });
seeds[0] = new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f, SourceId = src };
seeds[1] = new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u };
seeds[2] = new StatModifier { Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.Flat, Value = 6f, SourceId = src + 2u };
seeds[3] = new StatModifier { Target = (byte)StatTarget.MeleeRange, Op = (byte)ModOp.Flat, Value = 0.8f, SourceId = src + 3u };
}
}
/// <summary>Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn).</summary>
public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
{
Span<StatModifier> seeds = stackalloc StatModifier[ClassSeedCount];
Seeds(classId, seeds);
for (int i = 0; i < ClassSeedCount; i++)
ecb.AppendToBuffer(player, seeds[i]);
}
/// <summary>Append a class's permanent trait modifiers directly onto a live StatModifier buffer (in-place swap).</summary>
public static void AppendSeeds(byte classId, DynamicBuffer<StatModifier> mods)
{
Span<StatModifier> seeds = stackalloc StatModifier[ClassSeedCount];
Seeds(classId, seeds);
for (int i = 0; i < ClassSeedCount; i++)
mods.Add(seeds[i]);
}
/// <summary>
/// Swap an already-spawned player's class IN PLACE: strip whatever class seeds are present, then re-seed the
/// target class. Order-independent (StatMath folds by Target/Op), so the RemoveAtSwapBack reordering is safe,
/// and idempotent (switching to the same class still leaves exactly <see cref="ClassSeedCount"/> seeds).
/// Foreign modifiers (upgrades, equipment, timed buffs — all on disjoint SourceIds) are preserved.
/// </summary>
public static void Reapply(byte classId, DynamicBuffer<StatModifier> mods)
{
for (int i = mods.Length - 1; i >= 0; i--)
if (IsClassSeed(mods[i].SourceId))
mods.RemoveAtSwapBack(i);
AppendSeeds(classId, mods);
}
}
}