aac1813a93
EndgameWinLoseTests: arms-once+enter, Charge clamp, Victory/Loss edges, the END-1 soft-loss regression (normal overrun stays soft), restored-Victory-no-rearm, SiegeTimeout-not-culling-final, the full ThreatDirector->CyclePhase->GoalReached pipeline (arm-not-stomped-by-scheduler), and FinalSiegeMultiplier override + sub-1 floor. SavePersistenceTests: RunOutcome v5 round-trip + pre-v5 default-to-InProgress. TuningConfigTests: FinalSiegeMultiplier default pin. See DR-036. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
9.6 KiB
C#
195 lines
9.6 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// MC-0 — EditMode tests for the live <see cref="TuningConfig"/> tuning singleton: the GOLDEN pin that
|
|
/// <see cref="TuningConfig.Defaults"/> 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 <c>Apply</c>/<c>Get</c>/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()).
|
|
/// </summary>
|
|
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)");
|
|
Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default");
|
|
Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default");
|
|
Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)");
|
|
Assert.AreEqual(2.5f, d.FinalSiegeMultiplier, 1e-6f, "END-2 FinalSiegeMultiplier default (~2.5x a normal siege)");
|
|
|
|
}
|
|
|
|
[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<DashSystem>("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<DashState>(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<T>(string name, uint serverTick) where T : unmanaged, ISystem
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
|
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<Simulate>(e);
|
|
em.AddComponent<Dead>(e);
|
|
em.SetComponentEnabled<Dead>(e, false);
|
|
return e;
|
|
}
|
|
}
|
|
}
|