Files
Project-M/Assets/_Project/Tests/EditMode/TickWindowMathTests.cs
T
kronic 7571091394 LANTERN feel pass (DR-052 + gap list): SoD facing, underwater feel, Bathynaut kit, walk/run gait, suit lamp + new Synty anim packs
- SoD facing: PlayerFacing = body-yaw only (move-facing / cast-turn / idle-hold); every fire
  direction re-sourced to FacingMath.ResolveAim (pre-code review blocking catch); TickWindowMath
  shared windows with Movement-skip; reticle/FX coupled to the damage direction; cursor-dash kept.
- Underwater feel: sharpness 15->6, turn 720->360, MoveSpeed 6->4.2; TuningKnob 26-28
  (0 = no-override sentinels, dev-protocol bump on DebugTuningReport); stride footsteps + silt +
  cadence floor; bubbles; underwater ambience bed + distant groans; camera drag + dev scroll zoom.
- Bathynaut kit in-engine: dome/tank/shoulder-lamp + bare head grafted (GraftSmr rigid rebase,
  RecalculateTangents); EmissiveGloamSkinned shader (Rukhanka deformation); shoulder lamp CASTS
  (warm steady spot on body yaw).
- Gait: two-ring walk/run FreeformDirectional tree (walk @0.35, run @1.0) + blended-natural
  StrideScale; additive Posture(Bank) + Lead(chest-lead) layers; idle = AnimationIdles Base;
  banking driven from facing turn rate; flat terrain (Env_SeabedKit seabed squashed - CC is planar).
- New packs: Synty AnimationIdles + AnimationSwordCombat (combat pass queued) + SyntyPropBoneTool;
  four authored clips (sway/trudge/banks/lean) + Anim_Player_Underwater.blend + suit-kit FBX.
- Validation: 411/411 EditMode green; Play smokes (server==client facing, Aim-true projectile,
  bank/stride live-sampled, lamp beam verified); pre-code + post-impl adversarial reviews applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:56:02 -07:00

156 lines
7.3 KiB
C#

using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Coverage for the shared tick-window predicates (extracted 07-15 from PlayerAnimationDriveSystem so the
/// predicted facing path and the anim path share one implementation): swing/fire window boundaries
/// (wrap-safe NetworkTick math, 0-sentinel guards) and the Movement-archetype skip (a blink is a dodge,
/// never a cast — and never a phantom IsFiring at its cooldown tail).
/// </summary>
public class TickWindowMathTests
{
const uint N = 13; // window ticks (the anim pulse length; callers pass their own)
// ---- SwingActive ----
[Test]
public void SwingActive_Window_Is_HalfOpen_From_Start()
{
var mc = new MeleeCombo { SwingStartTick = 100u };
Assert.IsFalse(TickWindowMath.SwingActive(mc, new NetworkTick(99u), N), "before start");
Assert.IsTrue(TickWindowMath.SwingActive(mc, new NetworkTick(100u), N), "start tick included");
Assert.IsTrue(TickWindowMath.SwingActive(mc, new NetworkTick(112u), N), "last tick included");
Assert.IsFalse(TickWindowMath.SwingActive(mc, new NetworkTick(113u), N), "end excluded");
}
[Test]
public void SwingActive_Zero_Stamp_Or_Invalid_Tick_Is_False()
{
Assert.IsFalse(TickWindowMath.SwingActive(default, new NetworkTick(100u), N));
Assert.IsFalse(TickWindowMath.SwingActive(new MeleeCombo { SwingStartTick = 100u }, default, N));
}
// ---- FireActive ----
[Test]
public void FireActive_Reconstructs_The_Window_From_The_Cooldown_Stamp()
{
// NextFireTick 120, CooldownTicks 20 -> fired at 100 -> window [100, 113).
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, new NetworkTick(99u), N));
Assert.IsTrue(TickWindowMath.FireActive(120u, 20, new NetworkTick(100u), N));
Assert.IsTrue(TickWindowMath.FireActive(120u, 20, new NetworkTick(112u), N));
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, new NetworkTick(113u), N));
}
[Test]
public void FireActive_Sentinels_Are_False()
{
Assert.IsFalse(TickWindowMath.FireActive(0u, 20, new NetworkTick(100u), N), "no stamp");
Assert.IsFalse(TickWindowMath.FireActive(120u, 0, new NetworkTick(100u), N), "no cooldown");
Assert.IsFalse(TickWindowMath.FireActive(120u, 20, default, N), "invalid now");
}
[Test]
public void FireActive_Window_Start_Wrapping_To_Zero_Coerces_Through_NonZero()
{
// The project's canonical tick-wrap hazard: a computed start of exactly 0 (the "invalid" sentinel)
// must coerce through TickUtil.NonZero to 1, not read as no-window. NextFireTick 20, CooldownTicks 20
// -> raw start 0 -> window [1, 14).
Assert.IsTrue(TickWindowMath.FireActive(20u, 20, new NetworkTick(5u), N));
Assert.IsTrue(TickWindowMath.FireActive(20u, 20, new NetworkTick(13u), N));
Assert.IsFalse(TickWindowMath.FireActive(20u, 20, new NetworkTick(14u), N), "end excluded");
}
[Test]
public void Missing_AbilityDb_Falls_Back_To_Include_All()
{
// Without a blob the archetype is unknown: the socket is INCLUDED (matches the old include-all
// behavior) — even a Movement spark counts, because it cannot be identified as one.
using var world = new World("TickWindowTestNoDb");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<AbilitySocket>(e);
em.AddBuffer<EffectiveSocketStats>(e);
var sockets = em.GetBuffer<AbilitySocket>(e);
sockets.Add(new AbilitySocket { SparkId = 2 });
for (int i = 1; i < SocketId.Count; i++) sockets.Add(default);
var effs = em.GetBuffer<EffectiveSocketStats>(e);
effs.Add(new EffectiveSocketStats { CooldownTicks = 20 });
for (int i = 1; i < SocketId.Count; i++) effs.Add(default);
var cd = default(SocketCooldown);
cd.Set(0, 120u);
TickWindowMath.SocketFireAndCone(cd, em.GetBuffer<AbilitySocket>(e), em.GetBuffer<EffectiveSocketStats>(e),
default, new NetworkTick(105u), N, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsFalse(cone);
}
// ---- SocketFireAndCone (Movement skip) ----
static BlobAssetReference<AbilityDatabaseBlob> BuildDb()
{
using var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<AbilityDatabaseBlob>();
var abilities = builder.Allocate(ref root.Abilities, 3);
abilities[0] = new AbilityDefBlob { Id = 1, Archetype = (byte)AbilityArchetype.Projectile, CooldownTicks = 20, Name = "Proj" };
abilities[1] = new AbilityDefBlob { Id = 2, Archetype = (byte)AbilityArchetype.Movement, CooldownTicks = 20, Name = "Blink" };
abilities[2] = new AbilityDefBlob { Id = 3, Archetype = (byte)AbilityArchetype.Cone, CooldownTicks = 20, Name = "Cone" };
builder.Allocate(ref root.Characters, 0);
return builder.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
}
static void RunSocketCase(byte sparkId, out bool firing, out bool cone)
{
using var world = new World("TickWindowTest");
var em = world.EntityManager;
var e = em.CreateEntity();
em.AddBuffer<AbilitySocket>(e);
em.AddBuffer<EffectiveSocketStats>(e);
var sockets = em.GetBuffer<AbilitySocket>(e);
sockets.Add(new AbilitySocket { SparkId = sparkId });
for (int i = 1; i < SocketId.Count; i++) sockets.Add(default);
var effs = em.GetBuffer<EffectiveSocketStats>(e);
effs.Add(new EffectiveSocketStats { CooldownTicks = 20 });
for (int i = 1; i < SocketId.Count; i++) effs.Add(default);
var db = BuildDb();
try
{
var cd = default(SocketCooldown);
cd.Set(0, 120u); // fired at 100 (CooldownTicks 20) -> window [100, 113)
TickWindowMath.SocketFireAndCone(cd, em.GetBuffer<AbilitySocket>(e), em.GetBuffer<EffectiveSocketStats>(e),
db, new NetworkTick(105u), N, out firing, out cone);
}
finally { db.Dispose(); }
}
[Test]
public void Movement_Socket_Never_Counts_As_Firing()
{
RunSocketCase(2, out bool firing, out bool cone);
Assert.IsFalse(firing, "a blink (Movement archetype) is a dodge, never a cast/IsFiring pulse");
Assert.IsFalse(cone);
}
[Test]
public void Projectile_Socket_Mid_Window_Fires_Without_Cone()
{
RunSocketCase(1, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsFalse(cone);
}
[Test]
public void Cone_Socket_Mid_Window_Fires_With_Cone()
{
RunSocketCase(3, out bool firing, out bool cone);
Assert.IsTrue(firing);
Assert.IsTrue(cone);
}
}
}