09183cc139
Operator: turrets were "super duper cheap", spammable "unlimited", "spaced weirdly", "massive and not textured". All four were real and independently rooted (placement grid was actually fine). - Cost: TurretCostOre 10 -> 40 (authoring default + the serialized Gameplay subscene value, which overrides the code default). A node yields 30 Ore, so a turret is now ~1.3 nodes instead of 1/3 of one. - Cap: new Tuning.TurretCap=6, enforced server-authoritatively in BuildPlaceSystem (count live Base turrets while building the occupancy set; reject placement at the cap, same-tick-safe). Was unlimited. - Model: the 1.6x Synty ballista (~5m on a 1m cell, clipping neighbours) scaled to 0.8 to fit one cell; the C5 BoxCollider shrunk to match (0.8x1.2x0.8, center y 0.6); all 6 sub-renderers swapped off the flat untextured teal Mat_StructureOwned_Cyan to the Synty atlas PolygonFantasyKingdom_Mat_01_A (textured). Play-verified TurretCost=40 Ore / cap=6 baked; no exceptions. Also fixes 3 EditMode tests that pinned the old dash knobs (the prior tuning commit changed iframe 12->14 / cooldown 45->36 but I committed it without re-running tests): DashSystemTests now derives the expected dash speed from TuningConfig.Defaults() (robust to future tuning) + asserts now+14/+36; TuningConfigTests pins the new defaults. 390/390 EditMode green. Investigation: wf_c6c87dc5-9c3 (turret lane). Operator fork: 40 Ore + cap 6 (stricter). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
13 KiB
C#
236 lines
13 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// Plain-Entities EditMode tests for the MC-1 predicted DashSystem (velocity/sharpness OVERRIDE + start/cooldown)
|
|
/// and the PlayerDeathStateSystem dash cleanup. The systems carry [UpdateInGroup(PredictedSimulationSystemGroup)]
|
|
/// but world-system filtering is ignored when added to a group manually, so they run in this netcode-free world.
|
|
/// The override/cleanup logic is fully headless; the input-driven START path uses PlayerInput.Dash.IsSet (the
|
|
/// real apply-under-prediction is a Play-validation item).
|
|
/// </summary>
|
|
public class 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);
|
|
}
|
|
|
|
// Dash speed derived from the live knobs: DashDistance / (IFrameWindowTicks/60). Tracks TuningConfig.Defaults().
|
|
static readonly float ExpectedDashSpeed = TuningConfig.Defaults().DashDistance / (TuningConfig.Defaults().IFrameWindowTicks / 60f);
|
|
|
|
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()); // GroundedMovementSharpness = 15
|
|
em.AddComponentData(e, new PlayerInput());
|
|
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
|
em.AddComponent<Simulate>(e); // enabled by default
|
|
em.AddComponent<Dead>(e);
|
|
em.SetComponentEnabled<Dead>(e, false); // alive
|
|
return e;
|
|
}
|
|
|
|
[Test]
|
|
public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashOverride", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
|
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // as if PlayerControlSystem wrote input velocity
|
|
|
|
group.Update(); // tick 100: i-frame window [100,112) active
|
|
|
|
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
|
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
|
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
|
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (derived from the dash knobs).");
|
|
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
|
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Recovery_Tail_Locks_Movement_And_Restores_Sharpness()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashRecover", 105);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
|
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
|
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) });
|
|
|
|
group.Update(); // tick 105: iFrame ended (100<=105), recover active (109>105)
|
|
|
|
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f,
|
|
"Recovery tail locks movement to zero (the punishable window).");
|
|
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
|
"Recovery tail restores sharpness to the default (crisp stop).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void After_Window_Restores_Sharpness_And_Leaves_Input_Velocity_Untouched()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashRestore", 200);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
|
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
|
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(7, 0, 0) }); // input velocity, must survive
|
|
|
|
group.Update(); // tick 200: window fully elapsed
|
|
|
|
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
|
"Sharpness restored to default after the dash window elapses.");
|
|
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
|
"Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Dash_Starts_On_Press_When_Ready_And_Sets_Window_And_Cooldown()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashStart", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
|
Assert.IsTrue(em.GetComponentData<PlayerInput>(e).Dash.IsSet,
|
|
"Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption).");
|
|
|
|
group.Update(); // tick 100
|
|
|
|
var ds = em.GetComponentData<DashState>(e);
|
|
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
|
Assert.AreEqual(114u, ds.IFrameUntilTick, "IFrameUntilTick = now + 14.");
|
|
Assert.AreEqual(123u, ds.RecoverUntilTick, "RecoverUntilTick = now + 14 + 9.");
|
|
Assert.AreEqual(136u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 36.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Dash_Start_Is_Idempotent_At_The_Same_Tick()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashIdem", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
|
|
|
group.Update(); // starts the dash at tick 100
|
|
var first = em.GetComponentData<DashState>(e);
|
|
group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window)
|
|
var second = em.GetComponentData<DashState>(e);
|
|
|
|
Assert.AreEqual(first.StartTick, second.StartTick, "Re-running the start tick must not re-trigger the dash.");
|
|
Assert.AreEqual(first.IFrameUntilTick, second.IFrameUntilTick, "The window is set exactly once.");
|
|
Assert.AreEqual(first.RecoverUntilTick, second.RecoverUntilTick, "The window is set exactly once.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Dash_Is_Gated_By_Cooldown_Until_It_Expires()
|
|
{
|
|
var (world, group) = MakeWorld<DashSystem>("DashCooldown", 130);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
// As if dashed at 100: window elapsed by 130, cooldown until 145.
|
|
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
|
em.SetComponentData(e, new DashCooldown { NextTick = 145 });
|
|
|
|
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
|
group.Update(); // tick 130 < cooldown 145 -> NO new dash
|
|
Assert.AreEqual(100u, em.GetComponentData<DashState>(e).StartTick, "On cooldown: a press does not start a new dash.");
|
|
|
|
SetServerTick(world, 150); // past the cooldown
|
|
pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
|
group.Update();
|
|
Assert.AreEqual(150u, em.GetComponentData<DashState>(e).StartTick, "Past cooldown: a press starts a new dash.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Death_Mid_Dash_Clears_Window_And_Restores_Sharpness()
|
|
{
|
|
var (world, group) = MakeWorld<PlayerDeathStateSystem>("DashDeath", 100);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = em.CreateEntity();
|
|
em.AddComponent<PlayerTag>(e);
|
|
em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead
|
|
em.AddComponentData(e, new CharacterControl { MoveVelocity = new float3(3, 0, 0) });
|
|
em.AddComponentData(e, new DashState { Dir = new float2(1, 0), StartTick = 90, IFrameUntilTick = 110, RecoverUntilTick = 119 }); // in-flight
|
|
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.AddComponentData(e, cc);
|
|
em.AddComponent<Simulate>(e);
|
|
em.AddComponent<Dead>(e);
|
|
em.SetComponentEnabled<Dead>(e, false);
|
|
|
|
group.Update();
|
|
|
|
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
|
|
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
|
|
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
|
|
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f, "Death zeroes movement.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Rollback_ReSimulated_PreDash_Tick_Gets_No_Override()
|
|
{
|
|
// DashState is NON-replicated, so prediction rollback does NOT restore it: a re-simulated tick
|
|
// BEFORE StartTick still sees the post-press window. The override must include the StartTick lower
|
|
// bound or it stomps dash velocity onto pre-dash ticks (dash-start overshoot under real latency).
|
|
var (world, group) = MakeWorld<DashSystem>("DashRollback", 95); // serverTick 95 < StartTick 100
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var e = MakeDasher(em, new float2(0, 1));
|
|
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
|
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // input velocity of the pre-dash tick
|
|
|
|
group.Update();
|
|
|
|
Assert.AreEqual(5f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
|
"A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock).");
|
|
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
|
"A re-simulated PRE-dash tick keeps base sharpness.");
|
|
}
|
|
}
|
|
}
|
|
}
|