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:
@@ -0,0 +1,33 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EDITOR-ONLY dev hotkey to switch the local player's class while playtesting: <b>F1 = Warrior</b>,
|
||||||
|
/// <b>F2 = Ranger</b>. The <see cref="DebugOverlay"/>'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 <see cref="ProjectM.Simulation.DebugCommandRequest"/> (DebugOp.SetClass) the overlay buttons
|
||||||
|
/// do, via <see cref="DebugCommandSendSystem"/> — the server strips/re-seeds the class traits, swaps the Fire
|
||||||
|
/// ability, and heals a living player to the new class's max. Reads <see cref="Keyboard"/> directly (no
|
||||||
|
/// .inputactions edit) and edge-detects with wasPressedThisFrame. Stripped from player builds (#if UNITY_EDITOR).
|
||||||
|
/// </summary>
|
||||||
|
[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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d255b172df6aa27499947eba62c8be78
|
||||||
@@ -40,6 +40,10 @@ namespace ProjectM.Client
|
|||||||
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
|
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
|
||||||
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
|
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
|
||||||
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
|
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
|
||||||
|
/// <summary>Swap the sender's class to <paramref name="classId"/> (a <see cref="ProjectM.Simulation.CharacterId"/> byte); server-authoritative (class-switch dev tool).</summary>
|
||||||
|
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)]
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
static void ResetOnEnterPlayMode() => s_Pending.Clear();
|
static void ResetOnEnterPlayMode() => s_Pending.Clear();
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ namespace ProjectM.Client
|
|||||||
if (GUILayout.Button("Go Base")) DebugCommandSendSystem.Teleport(RegionId.Base);
|
if (GUILayout.Button("Go Base")) DebugCommandSendSystem.Teleport(RegionId.Base);
|
||||||
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
|
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
|
||||||
GUILayout.EndHorizontal();
|
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.Space(6);
|
||||||
GUILayout.Label("- Telemetry (MC-0) -");
|
GUILayout.Label("- Telemetry (MC-0) -");
|
||||||
|
|||||||
@@ -172,6 +172,41 @@ namespace ProjectM.Server
|
|||||||
SystemAPI.SetSingleton(tuningCfg);
|
SystemAPI.SetSingleton(tuningCfg);
|
||||||
}
|
}
|
||||||
break;
|
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);
|
ecb.DestroyEntity(reqEntity);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using Unity.Entities;
|
using Unity.Entities;
|
||||||
|
|
||||||
namespace ProjectM.Simulation
|
namespace ProjectM.Simulation
|
||||||
@@ -5,8 +6,8 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slice 2 — pure mapping from a chosen class (a <see cref="CharacterId"/> byte) to its spawn-time setup: the
|
/// 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
|
/// 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
|
/// <see cref="Tuning.ClassSourceId"/> range, NEVER stripped by the generic modifier systems). Applied by
|
||||||
/// player and unit-tested. Burst-safe (byte-only, no managed types).
|
/// GoInGameServerSystem on the just-spawned player and unit-tested. Burst-safe (byte/uint only, no managed types).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Trait deltas seed onto the <see cref="CharacterId.Default"/> character (no per-class blob row needed — the
|
/// 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
|
/// 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
|
/// 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"/>).
|
/// Ranger's Fire = the default projectile (<see cref="AbilityId.Primary"/>).
|
||||||
/// </para>
|
/// </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>
|
/// </summary>
|
||||||
public static class ClassTraits
|
public static class ClassTraits
|
||||||
{
|
{
|
||||||
public const byte WarriorClass = (byte)CharacterId.Warrior;
|
public const byte WarriorClass = (byte)CharacterId.Warrior;
|
||||||
public const byte RangerClass = (byte)CharacterId.Ranger;
|
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>
|
/// <summary>Normalize a wire ClassId to a known class (0 / unknown -> Warrior).</summary>
|
||||||
public static byte Normalize(byte classId) => classId == RangerClass ? RangerClass : WarriorClass;
|
public static byte Normalize(byte classId) => classId == RangerClass ? RangerClass : WarriorClass;
|
||||||
|
|
||||||
@@ -29,24 +39,65 @@ namespace ProjectM.Simulation
|
|||||||
public static byte AbilityFor(byte classId)
|
public static byte AbilityFor(byte classId)
|
||||||
=> classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone;
|
=> 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>
|
/// <summary>True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).</summary>
|
||||||
public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
|
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;
|
uint src = Tuning.ClassSourceId;
|
||||||
if (classId == RangerClass)
|
if (classId == RangerClass)
|
||||||
{
|
{
|
||||||
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.15f, SourceId = src });
|
seeds[0] = 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 });
|
seeds[1] = 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 });
|
seeds[2] = 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[3] = new StatModifier { Target = (byte)StatTarget.AutoTargetRange, Op = (byte)ModOp.Flat, Value = 3f, SourceId = src + 3u };
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f, SourceId = src });
|
seeds[0] = 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 });
|
seeds[1] = 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 });
|
seeds[2] = 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[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,5 +63,10 @@ namespace ProjectM.Simulation
|
|||||||
|
|
||||||
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
|
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
|
||||||
public const byte SetTuning = 12;
|
public const byte SetTuning = 12;
|
||||||
|
|
||||||
|
/// <summary>Swap the sender's class to ArgA (a <see cref="CharacterId"/> 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.</summary>
|
||||||
|
public const byte SetClass = 13;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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).");
|
Assert.AreEqual(10f, StatMath.Apply(10f, StatTarget.MeleeDamage, mods), 0.001f, "Ranger melee is unbuffed (weaker than the Warrior).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int CountClassSeeds(DynamicBuffer<StatModifier> 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<StatModifier> 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<StatModifier>(e);
|
||||||
|
ClassTraits.AppendSeeds(classId, em.GetBuffer<StatModifier>(e));
|
||||||
|
var seeded = em.GetBuffer<StatModifier>(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<StatModifier>(e);
|
||||||
|
|
||||||
|
// Seed Warrior, swap to Ranger, then back to Warrior.
|
||||||
|
ClassTraits.Reapply(ClassTraits.WarriorClass, em.GetBuffer<StatModifier>(e));
|
||||||
|
Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(em.GetBuffer<StatModifier>(e)), "Warrior seeds the class-seed count.");
|
||||||
|
|
||||||
|
ClassTraits.Reapply(ClassTraits.RangerClass, em.GetBuffer<StatModifier>(e));
|
||||||
|
Assert.AreEqual(ClassTraits.ClassSeedCount, CountClassSeeds(em.GetBuffer<StatModifier>(e)), "Ranger leaves exactly the class-seed count (old seeds stripped).");
|
||||||
|
Assert.Greater(StatMath.Apply(5f, StatTarget.MoveSpeed, em.GetBuffer<StatModifier>(e)), 5f, "Ranger moves faster after the swap.");
|
||||||
|
|
||||||
|
ClassTraits.Reapply(ClassTraits.WarriorClass, em.GetBuffer<StatModifier>(e));
|
||||||
|
var back = em.GetBuffer<StatModifier>(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<StatModifier>(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<StatModifier>(e));
|
||||||
|
var after = em.GetBuffer<StatModifier>(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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user