test(anim): PlayAction conformance — Action, Modifier, Emote

Four new tests covering the PlayAction routing paths that the new
UpdateMotion Commands[] handler relies on:

- PlayAction_Action_ResolvesFromLinksDict — a ThrustMed attack in
  SwordCombat stance resolves via Links[(SwordCombat, Ready)][ThrustMed]
  and its anim frames become visible after PlayAction is called.
- PlayAction_Modifier_ResolvesFromModifiersDict — Jump (0x2500003B,
  Modifier class) resolves via Modifiers[(Style, Jump)] and its anim
  plays on top of the current cycle.
- PlayAction_Emote_RoutesThroughActionBranch — Wave (0x13000087, class
  byte 0x13 = Action | ChatEmote | Mappable) goes through the Action
  branch because the Action bit is set, resolving from Links just like
  attacks. Validates the class-bit math.
- PlayAction_NoEntryInTable_IsNoOp — silent no-op when the table has
  no entry for the motion, with the queue length unchanged.

Together these lock in that the same PlayAction path correctly routes
the three major one-shot classes the Commands[] handler fans out to
NPCs and remote players. 658 tests green (was 654).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:38:01 +02:00
parent 11649da1cf
commit 6e589d3b89

View file

@ -1174,6 +1174,165 @@ public sealed class AnimationSequencerTests
Assert.Equal(1.5f, seq.CurrentSpeedMod, 3);
}
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
[Fact]
public void PlayAction_Action_ResolvesFromLinksDict()
{
// An Action-class command (mask 0x10) resolves via the Links dict
// keyed by (style, currentSubstate) → motion. Example: a ThrustMed
// attack while in SwordCombat stance.
const uint Style = 0x003Eu; // SwordCombat
const uint IdleMotion = 0x41000003u; // Ready
const uint ActionMotion = 0x10000058u; // ThrustMed (Action class)
const uint IdleAnimId = 0x03000501u;
const uint ActionAnimId= 0x03000502u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
// Action anim: distinct non-zero origin so we can detect it played.
var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Link: (SwordCombat, Ready) → ThrustMed
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(ActionAnimId, actionAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.01f); // burn the first idle frame
// Fire the action.
seq.PlayAction(ActionMotion);
// After a small advance, we should be reading the action anim (origin X=99).
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{
// A Modifier-class command (mask 0x20) — like Jump (0x2500003B) —
// resolves from the Modifiers dict, first with style-specific key
// then with unstyled fallback. Empirically: the modifier's anim
// plays on top of the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint JumpMotion = 0x2500003Bu; // Modifier class
const uint IdleAnimId = 0x03000510u;
const uint JumpAnimId = 0x03000511u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Modifier: (Style, Jump)
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(JumpAnimId, jumpAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(JumpMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(77f, fr[0].Origin.Z, 1);
}
[Fact]
public void PlayAction_Emote_RoutesThroughActionBranch()
{
// ChatEmotes like Wave (0x13000087) have class byte 0x13 =
// Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the
// Action bit is set, they route through the Links-dict lookup just
// like attacks. Verifies the class-bit math.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint WaveMotion = 0x13000087u;
const uint IdleAnimId = 0x03000520u;
const uint WaveAnimId = 0x03000521u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Register Links[(style, Ready)][Wave] = wave anim.
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(WaveAnimId, waveAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(WaveMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(55f, fr[0].Origin.Y, 1);
}
[Fact]
public void PlayAction_NoEntryInTable_IsNoOp()
{
// If neither Links nor Modifiers has the motion, PlayAction should
// silently return without disturbing the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint IdleAnimId = 0x03000530u;
const uint UnknownAction = 0x10001234u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.05f);
int queueBefore = seq.QueueCount;
seq.PlayAction(UnknownAction); // unknown motion → no-op
Assert.Equal(queueBefore, seq.QueueCount);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>