diff --git a/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs b/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs
new file mode 100644
index 000000000..af0e1280e
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs
@@ -0,0 +1,33 @@
+#if UNITY_EDITOR
+using Unity.Entities;
+using Unity.NetCode;
+using UnityEngine.InputSystem;
+
+namespace ProjectM.Client
+{
+ ///
+ /// EDITOR-ONLY dev hotkey to switch the local player's class while playtesting: F1 = Warrior,
+ /// F2 = Ranger. The 's Class buttons only live in the DevSandbox scene, so this
+ /// keybind makes the swap reachable in the real menu -> Game flow too (no scene wiring needed). It enqueues the
+ /// SAME authoritative (DebugOp.SetClass) the overlay buttons
+ /// do, via — the server strips/re-seeds the class traits, swaps the Fire
+ /// ability, and heals a living player to the new class's max. Reads directly (no
+ /// .inputactions edit) and edge-detects with wasPressedThisFrame. Stripped from player builds (#if UNITY_EDITOR).
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ public partial class ClassSwitchHotkeySystem : SystemBase
+ {
+ protected override void OnUpdate()
+ {
+ var kb = Keyboard.current;
+ if (kb == null)
+ return;
+
+ if (kb.f1Key.wasPressedThisFrame)
+ DebugCommandSendSystem.SetWarrior();
+ else if (kb.f2Key.wasPressedThisFrame)
+ DebugCommandSendSystem.SetRanger();
+ }
+ }
+}
+#endif
diff --git a/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs.meta b/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs.meta
new file mode 100644
index 000000000..521e7dd4d
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Debug/ClassSwitchHotkeySystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d255b172df6aa27499947eba62c8be78
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
index 7eaa14344..bf6fccf86 100644
--- a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
+++ b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs
@@ -40,6 +40,10 @@ namespace ProjectM.Client
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
/// Set the knob to value (server-applied, x1000 fixed-point; MC-0).
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
+ /// Swap the sender's class to (a byte); server-authoritative (class-switch dev tool).
+ public static void SetClass(byte classId) => Send(DebugOp.SetClass, classId);
+ public static void SetWarrior() => SetClass(ClassTraits.WarriorClass);
+ public static void SetRanger() => SetClass(ClassTraits.RangerClass);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => s_Pending.Clear();
diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
index 21276056e..2b281ca51 100644
--- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
+++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs
@@ -65,6 +65,12 @@ namespace ProjectM.Client
if (GUILayout.Button("Go Base")) DebugCommandSendSystem.Teleport(RegionId.Base);
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
GUILayout.EndHorizontal();
+ GUILayout.Space(6);
+ GUILayout.Label("- Class (F1/F2) -");
+ GUILayout.BeginHorizontal();
+ if (GUILayout.Button("Warrior")) DebugCommandSendSystem.SetWarrior();
+ if (GUILayout.Button("Ranger")) DebugCommandSendSystem.SetRanger();
+ GUILayout.EndHorizontal();
GUILayout.Space(6);
GUILayout.Label("- Telemetry (MC-0) -");
diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
index 6cd7e0ba2..cf8065f98 100644
--- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
+++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
@@ -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(sender)
+ && SystemAPI.HasBuffer(sender))
+ {
+ byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
+ var classMods = SystemAPI.GetBuffer(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(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(sender) && SystemAPI.HasComponent(sender)
+ && SystemAPI.TryGetSingleton(out var abilityDb))
+ {
+ var hp = SystemAPI.GetComponent(sender);
+ byte charId = SystemAPI.GetComponent(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);
diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs
index d805968d0..dbd64c494 100644
--- a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs
+++ b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs
@@ -1,3 +1,4 @@
+using System;
using Unity.Entities;
namespace ProjectM.Simulation
@@ -5,8 +6,8 @@ namespace ProjectM.Simulation
///
/// Slice 2 — pure mapping from a chosen class (a byte) to its spawn-time setup: the
/// Fire-slot ability id + the permanent trait s (tagged with the reserved
- /// , NEVER stripped). Applied by GoInGameServerSystem on the just-spawned
- /// player and unit-tested. Burst-safe (byte-only, no managed types).
+ /// 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).
///
/// Trait deltas seed onto the 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 (); the
/// Ranger's Fire = the default projectile ().
///
+ ///
+ /// 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
+ /// so the spawn path and the swap path can never drift; couples the strip range
+ /// () to the number of seeds emitted.
+ ///
///
public static class ClassTraits
{
public const byte WarriorClass = (byte)CharacterId.Warrior;
public const byte RangerClass = (byte)CharacterId.Ranger;
+ /// How many trait modifiers a class seeds (each on a distinct SourceId at ClassSourceId + i).
+ public const int ClassSeedCount = 4;
+
/// Normalize a wire ClassId to a known class (0 / unknown -> Warrior).
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;
- /// Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn).
- public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
+ /// True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).
+ public static bool IsClassSeed(uint sourceId)
+ => sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount;
+
+ ///
+ /// The permanent trait deltas for a class — the SINGLE source shared by both the spawn path
+ /// () and the in-place swap path
+ /// ( / ), so they can't drift.
+ /// Fills exactly entries, each tagged ClassSourceId + i.
+ ///
+ static void Seeds(byte classId, Span 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 };
}
}
+
+ /// Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn).
+ public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
+ {
+ Span seeds = stackalloc StatModifier[ClassSeedCount];
+ Seeds(classId, seeds);
+ for (int i = 0; i < ClassSeedCount; i++)
+ ecb.AppendToBuffer(player, seeds[i]);
+ }
+
+ /// Append a class's permanent trait modifiers directly onto a live StatModifier buffer (in-place swap).
+ public static void AppendSeeds(byte classId, DynamicBuffer mods)
+ {
+ Span seeds = stackalloc StatModifier[ClassSeedCount];
+ Seeds(classId, seeds);
+ for (int i = 0; i < ClassSeedCount; i++)
+ mods.Add(seeds[i]);
+ }
+
+ ///
+ /// 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 seeds).
+ /// Foreign modifiers (upgrades, equipment, timed buffs — all on disjoint SourceIds) are preserved.
+ ///
+ public static void Reapply(byte classId, DynamicBuffer mods)
+ {
+ for (int i = mods.Length - 1; i >= 0; i--)
+ if (IsClassSeed(mods[i].SourceId))
+ mods.RemoveAtSwapBack(i);
+ AppendSeeds(classId, mods);
+ }
}
}
diff --git a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
index 2d38ed726..e9f49e846 100644
--- a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
+++ b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs
@@ -63,5 +63,10 @@ namespace ProjectM.Simulation
/// Set the ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).
public const byte SetTuning = 12;
+
+ /// Swap the sender's class to ArgA (a byte: Warrior=2 / Ranger=3).
+ /// Strips the old class trait seeds, re-seeds the new ones, swaps the Fire ability, and heals a living
+ /// player to the new class's max. Editor-only dev tool (class-switch); 0 / unknown -> Warrior.
+ public const byte SetClass = 13;
}
}
diff --git a/Assets/_Project/Tests/EditMode/ClassTraitsTests.cs b/Assets/_Project/Tests/EditMode/ClassTraitsTests.cs
index f18471bec..03f1c5ed1 100644
--- a/Assets/_Project/Tests/EditMode/ClassTraitsTests.cs
+++ b/Assets/_Project/Tests/EditMode/ClassTraitsTests.cs
@@ -61,5 +61,88 @@ namespace ProjectM.Tests
Assert.Greater(StatMath.Apply(0f, StatTarget.AutoTargetRange, mods), 0f, "Ranger has a wider auto-assist (the co-op hook).");
Assert.AreEqual(10f, StatMath.Apply(10f, StatTarget.MeleeDamage, mods), 0.001f, "Ranger melee is unbuffed (weaker than the Warrior).");
}
+
+ static int CountClassSeeds(DynamicBuffer mods)
+ {
+ int n = 0;
+ for (int i = 0; i < mods.Length; i++)
+ if (ClassTraits.IsClassSeed(mods[i].SourceId)) n++;
+ return n;
+ }
+
+ static bool ContainsSource(DynamicBuffer mods, uint sourceId)
+ {
+ for (int i = 0; i < mods.Length; i++)
+ if (mods[i].SourceId == sourceId) return true;
+ return false;
+ }
+
+ [Test]
+ public void AppendSeeds_EmitsExactlyClassSeedCount_AllInClassRange()
+ {
+ foreach (var classId in new[] { ClassTraits.WarriorClass, ClassTraits.RangerClass })
+ {
+ using var world = new World("ClassTraitsCount");
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ em.AddBuffer(e);
+ ClassTraits.AppendSeeds(classId, em.GetBuffer(e));
+ var seeded = em.GetBuffer(e);
+ Assert.AreEqual(ClassTraits.ClassSeedCount, seeded.Length, "AppendSeeds emits exactly ClassSeedCount seeds.");
+ for (int i = 0; i < seeded.Length; i++)
+ Assert.IsTrue(ClassTraits.IsClassSeed(seeded[i].SourceId), "every emitted seed SourceId is inside the class range.");
+ }
+ }
+
+ [Test]
+ public void Reapply_RoundTrip_StaysAtClassSeedCount_AndFoldsLikeFreshSpawn()
+ {
+ using var world = new World("ClassTraitsReapply");
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ em.AddBuffer(e);
+
+ // Seed Warrior, swap to Ranger, then back to Warrior.
+ ClassTraits.Reapply(ClassTraits.WarriorClass, em.GetBuffer(e));
+ Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(em.GetBuffer(e)), "Warrior seeds the class-seed count.");
+
+ ClassTraits.Reapply(ClassTraits.RangerClass, em.GetBuffer(e));
+ Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(em.GetBuffer(e)), "Ranger leaves exactly the class-seed count (old seeds stripped).");
+ Assert.Greater(StatMath.Apply(5f, StatTarget.MoveSpeed, em.GetBuffer(e)), 5f, "Ranger moves faster after the swap.");
+
+ ClassTraits.Reapply(ClassTraits.WarriorClass, em.GetBuffer(e));
+ var back = em.GetBuffer(e);
+ Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(back), "Warrior again: no accumulation across swaps.");
+
+ // Value-equality vs a fresh-spawned Warrior buffer (the ECB overload) — catches same-target PercentMult
+ // doubling or a strip-order regression that a count-only assert would miss.
+ using var refWorld = new World("ClassTraitsWarriorRef");
+ var fresh = SeededBuffer(refWorld, ClassTraits.WarriorClass);
+ Assert.AreEqual(StatMath.Apply(100f, StatTarget.MaxHealth, fresh), StatMath.Apply(100f, StatTarget.MaxHealth, back), 1e-4f, "MaxHealth folds identically to a fresh Warrior.");
+ Assert.AreEqual(StatMath.Apply(5f, StatTarget.MoveSpeed, fresh), StatMath.Apply(5f, StatTarget.MoveSpeed, back), 1e-4f, "MoveSpeed folds identically to a fresh Warrior.");
+ }
+
+ [Test]
+ public void Reapply_StripsOnlyClassSeeds_PreservesForeignAndBoundaryMods()
+ {
+ using var world = new World("ClassTraitsForeign");
+ var em = world.EntityManager;
+ var e = em.CreateEntity();
+ var seed = em.AddBuffer(e);
+
+ // A debug-upgrade mod + a boundary mod at ClassSourceId + ClassSeedCount (first id OUTSIDE the class range).
+ uint boundary = Tuning.ClassSourceId + (uint)ClassTraits.ClassSeedCount;
+ Assert.IsFalse(ClassTraits.IsClassSeed(boundary), "ClassSourceId + ClassSeedCount is just outside the class-seed range.");
+ seed.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentAdd, Value = 0.25f, SourceId = 0x00DEB061u });
+ seed.Add(new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 10f, SourceId = boundary });
+
+ ClassTraits.Reapply(ClassTraits.RangerClass, em.GetBuffer(e));
+ var after = em.GetBuffer(e);
+
+ Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(after), "exactly the class-seed count of class seeds present.");
+ Assert.AreEqual(ClassTraits.ClassSeedCount + 2, after.Length, "both foreign mods survive Reapply.");
+ Assert.IsTrue(ContainsSource(after, 0x00DEB061u), "the debug damage upgrade survives.");
+ Assert.IsTrue(ContainsSource(after, boundary), "the boundary mod at ClassSourceId+ClassSeedCount survives.");
+ }
}
}