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."); + } } }