using NUnit.Framework; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Tests { /// /// MC-0 — EditMode tests for the live tuning singleton: the GOLDEN pin that /// reproduces the historical baked consts (so promoting them to a singleton /// is behaviour-preserving — the existing DashSystemTests assert the dash half via the no-singleton fallback), /// the Apply/Get/clamp logic (the design-review F1 divide-by-zero guard lives here + at the /// DashSystem read site), the x1000 wire round-trip, and a world test proving DashSystem actually READS a /// non-default singleton (not just Defaults()). /// public class TuningConfigTests { // ---- pure logic ---- [Test] public void Defaults_Match_The_Historical_Baked_Consts() { var d = TuningConfig.Defaults(); Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance"); Assert.AreEqual(12f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks"); Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks"); Assert.AreEqual(45f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks"); Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness"); Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks"); Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed"); Assert.AreEqual(18f, d.ChargerLungeDurationTicks, 1e-6f, "ChargerLungeDurationTicks"); Assert.AreEqual(36f, d.ChargerWhiffStaggerTicks, 1e-6f, "ChargerWhiffStaggerTicks"); // GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it). Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks"); Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)"); } [Test] public void Apply_Sets_Only_The_Targeted_Knob() { for (byte knob = 0; knob < TuningKnob.Count; knob++) { var c = TuningConfig.Defaults(); float baseline = TuningConfig.Get(c, knob); float target = baseline + 7f; // survives both clamps (positive) TuningConfig.Apply(ref c, knob, target); Assert.AreEqual(target, TuningConfig.Get(c, knob), 1e-4f, $"knob {knob} took the new value"); // every OTHER knob is untouched var d = TuningConfig.Defaults(); for (byte other = 0; other < TuningKnob.Count; other++) if (other != knob) Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f, $"knob {other} unchanged while editing {knob}"); } } [Test] public void Apply_Clamps_Tick_Knobs_To_At_Least_One() { // IFrameWindowTicks at 0 would divide-by-zero in DashSystem — the clamp is the first line of defense. var c = TuningConfig.Defaults(); TuningConfig.Apply(ref c, TuningKnob.IFrameWindowTicks, 0f); Assert.AreEqual(1f, c.IFrameWindowTicks, 1e-6f, "i-frame window floors at 1 (never 0)"); TuningConfig.Apply(ref c, TuningKnob.ChargerWindupTicks, -10f); Assert.AreEqual(1f, c.ChargerWindupTicks, 1e-6f, "tick knobs floor at 1 on negative input"); TuningConfig.Apply(ref c, TuningKnob.GruntWindupTicks, 0f); Assert.AreEqual(1f, c.GruntWindupTicks, 1e-6f, "grunt windup floors at 1"); } [Test] public void Apply_Clamps_Value_Knobs_To_NonNegative() { var c = TuningConfig.Defaults(); TuningConfig.Apply(ref c, TuningKnob.DashDistance, -3f); Assert.AreEqual(0f, c.DashDistance, 1e-6f, "DashDistance floors at 0"); TuningConfig.Apply(ref c, TuningKnob.DashSharpness, -1f); Assert.AreEqual(0f, c.DashSharpness, 1e-6f, "DashSharpness floors at 0"); TuningConfig.Apply(ref c, TuningKnob.ChargerLungeSpeed, -5f); Assert.AreEqual(0f, c.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed floors at 0"); } [Test] public void Apply_Ignores_An_Out_Of_Range_Knob_Index() { var c = TuningConfig.Defaults(); var before = TuningConfig.ToReport(c); Assert.DoesNotThrow(() => TuningConfig.Apply(ref c, 200, 999f), "unknown knob index is a safe no-op"); var after = TuningConfig.ToReport(c); Assert.AreEqual(before.DashDistance, after.DashDistance, 1e-6f, "no field changed on an unknown knob"); Assert.AreEqual(before.IFrameWindowTicks, after.IFrameWindowTicks, 1e-6f); Assert.AreEqual(before.GruntWindupTicks, after.GruntWindupTicks, 1e-6f); } [Test] public void Wire_FixedPoint_RoundTrips_Through_The_x1000_Channel() { // mirror the overlay -> RPC -> server path: argB = round(value*1000); value' = argB/1000f. foreach (var (knob, value) in new[] { (TuningKnob.DashDistance, 4.5f), (TuningKnob.DashSharpness, 175f), (TuningKnob.IFrameWindowTicks, 14f), (TuningKnob.ChargerLungeSpeed, 16.5f), }) { int argB = (int)math.round(value * 1000f); var c = TuningConfig.Defaults(); TuningConfig.Apply(ref c, knob, argB / 1000f); Assert.AreEqual(value, TuningConfig.Get(c, knob), 1e-3f, $"knob {knob} survives the x1000 channel"); } } [Test] public void Report_Projection_Round_Trips() { var c = TuningConfig.Defaults(); TuningConfig.Apply(ref c, TuningKnob.DashDistance, 6f); TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f); var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c)); for (byte knob = 0; knob < TuningKnob.Count; knob++) Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport"); } // ---- consumption (world) ---- [Test] public void DashSystem_Reads_The_Window_From_The_TuningConfig_Singleton() { var (world, group) = MakeWorld("DashTuning", 100); using (world) { var em = world.EntityManager; // A non-default i-frame window proves DashSystem reads the singleton, not the baked Defaults() (12). var cfg = TuningConfig.Defaults(); cfg.IFrameWindowTicks = 20f; var cfgE = em.CreateEntity(typeof(TuningConfig)); em.SetComponentData(cfgE, cfg); var e = MakeDasher(em, new float2(0, 1)); var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi); group.Update(); // tick 100 var ds = em.GetComponentData(e); Assert.AreEqual(120u, ds.IFrameUntilTick, "IFrameUntilTick = now + tuned 20 (singleton honored, not Defaults' 12)."); Assert.AreEqual(129u, ds.RecoverUntilTick, "RecoverUntilTick = now + 20 + default 9."); } } // ---- harness (mirrors DashSystemTests) ---- static void SetServerTick(World world, uint tick) { var em = world.EntityManager; using var q = em.CreateEntityQuery(typeof(NetworkTime)); Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); } static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) where T : unmanaged, ISystem { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); SetServerTick(world, serverTick); return (world, group); } static Entity MakeDasher(EntityManager em, float2 facing) { var e = em.CreateEntity(); em.AddComponentData(e, new DashState()); em.AddComponentData(e, new DashCooldown { NextTick = 0 }); em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero }); em.AddComponentData(e, CharacterComponent.GetDefault()); em.AddComponentData(e, new PlayerInput()); em.AddComponentData(e, new PlayerFacing { Direction = facing }); em.AddComponent(e); em.AddComponent(e); em.SetComponentEnabled(e, false); return e; } } }