Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side behavior for server-controlled creature locomotion and combat engagement. Shipped as MVP after live visual verification across multiple iteration rounds with the user. Highlights: -186a584— initial Phase L.1c port: extracts Origin / target guid / MovementParameters block from MoveTo packets (movementType 6/7), adds RemoteMoveToDriver per-tick body-orientation steering with ±20° aux-turn-equivalent snap tolerance. -d247aef— corrected arrival predicate semantics + 1.5 s stale-destination timeout for entities leaving the streaming view. -f794832— root-caused "creature won't stop to attack" via two research subagents converging on retail CMotionInterp::move_to_interpreted_state's unconditional forward_command bulk-copy. Lifted ServerMoveToActive flag clearing + InterpretedState bulk-copy out of substate-only branch so Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear stale MoveTo state and zero forward velocity. -ff6d3d0— RemoteMoveToDriver.ClampApproachVelocity caps horizontal velocity at the final-approach tick so body lands EXACTLY at DistanceToObject instead of overshooting through the player. -37de771— bulk-copy ForwardCommand for MoveTo packets too (closed the regression where MoveTo creatures stayed at default ForwardCommand=Ready in InterpretedState and only translated via UpdatePosition snaps). -34d7f4d+e71ed73— AnimationSequencer.HasCycle query + fallback chain (requested → WalkForward → Ready → no-op) at BOTH the OnLiveMotionUpdated path AND the spawn handler. Prevents ClearCyclicTail from wiping the body's cyclic tail when ACE CreateObject carries CurrentMotionState.ForwardCommand pointing to an Action-class motion (e.g. AttackHigh1 from a mid-swing creature) which has no cyclic-table entry — was the "torso on the ground" symptom for monsters seen in combat by a fresh observer. Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt (MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80, CMotionInterp::move_to_interpreted_state 0x00528xxx, MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/ ACE.Server/Physics/Animation/MoveToManager.cs (port aid), references/holtburger/ (cross-check on snapshot-only client behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md (the Phase L.1c pseudocode doc). Tests: 1404 → 1422 (parser type-7 path retention, type-6 target guid retention, driver arrival semantics, retail-faithful chase/flee branches, approach-velocity clamp scenarios, HasCycle present/missing, AttackHigh1 wire layout). Pending follow-ups (filed for future): target-guid live resolution for type 6 packets (residual chase lag), StickToObject sticky-target guid trailing field, full MoveToManager state machine port (CheckProgressMade stall detector, Sticky/StickTo, use_final_heading, pending_actions queue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b93dfe95d8
44 changed files with 4580 additions and 301 deletions
|
|
@ -0,0 +1,89 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Combat;
|
||||
|
||||
public sealed class CombatAnimationPlannerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed
|
||||
[InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh
|
||||
[InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed
|
||||
[InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow
|
||||
[InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
|
||||
[InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
|
||||
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
|
||||
[InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6
|
||||
[InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell
|
||||
[InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff
|
||||
[InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1
|
||||
[InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward
|
||||
[InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead
|
||||
[InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat
|
||||
[InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat
|
||||
[InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat
|
||||
public void ClassifyMotionCommand_RecognisesRetailCombatCommands(
|
||||
uint command,
|
||||
CombatAnimationKind expected)
|
||||
{
|
||||
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh
|
||||
[InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed
|
||||
[InlineData(0x018B, 0x1000018Bu)] // AttackLow6
|
||||
[InlineData(0x018E, 0x1000018Eu)] // PunchFastLow
|
||||
public void MotionCommandResolver_UsesNamedRetailLateCombatCommands(
|
||||
ushort wireCommand,
|
||||
uint expectedFullCommand)
|
||||
{
|
||||
Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Swing_IsActionOverlay()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f);
|
||||
|
||||
Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind);
|
||||
Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind);
|
||||
Assert.Equal(0x10000058u, plan.MotionCommand);
|
||||
Assert.Equal(1.25f, plan.SpeedMod);
|
||||
Assert.True(plan.HasMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Dead_IsPersistentSubState()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011);
|
||||
|
||||
Assert.Equal(CombatAnimationKind.Death, plan.Kind);
|
||||
Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind);
|
||||
Assert.Equal(MotionCommand.Dead, plan.MotionCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromWireCommand_Unknown_IsNone()
|
||||
{
|
||||
var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF);
|
||||
|
||||
Assert.Equal(CombatAnimationPlan.None, plan);
|
||||
Assert.False(plan.HasMotion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CombatAnimationEvent.CombatCommenceAttack)]
|
||||
[InlineData(CombatAnimationEvent.AttackDone)]
|
||||
[InlineData(CombatAnimationEvent.AttackerNotification)]
|
||||
[InlineData(CombatAnimationEvent.DefenderNotification)]
|
||||
[InlineData(CombatAnimationEvent.EvasionAttackerNotification)]
|
||||
[InlineData(CombatAnimationEvent.EvasionDefenderNotification)]
|
||||
[InlineData(CombatAnimationEvent.VictimNotification)]
|
||||
[InlineData(CombatAnimationEvent.KillerNotification)]
|
||||
public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent)
|
||||
{
|
||||
Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent));
|
||||
}
|
||||
}
|
||||
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Core.Tests.Combat;
|
||||
|
||||
public sealed class CombatInputPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
|
||||
{
|
||||
Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
|
||||
Assert.Equal(
|
||||
CombatMode.Missile,
|
||||
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleMode_FromCombat_ReturnsNonCombat()
|
||||
{
|
||||
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
|
||||
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CombatAttackAction.Low, AttackHeight.Low)]
|
||||
[InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
|
||||
[InlineData(CombatAttackAction.High, AttackHeight.High)]
|
||||
public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
|
||||
{
|
||||
Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CombatMode.Melee, true)]
|
||||
[InlineData(CombatMode.Missile, true)]
|
||||
[InlineData(CombatMode.NonCombat, false)]
|
||||
[InlineData(CombatMode.Magic, false)]
|
||||
public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
|
||||
CombatMode mode,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
|
||||
}
|
||||
}
|
||||
155
tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
Normal file
155
tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using AcDream.Core.Combat;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
|
||||
using DatAttackType = DatReaderWriter.Enums.AttackType;
|
||||
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
using DatMotionStance = DatReaderWriter.Enums.MotionStance;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Combat;
|
||||
|
||||
public sealed class CombatManeuverSelectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectMotion_UsesFirstEntryAtOrAboveSubdivision()
|
||||
{
|
||||
var table = MakeTable(
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
|
||||
DatAttackType.Slash, DatMotionCommand.SlashMed),
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
|
||||
DatAttackType.Slash, DatMotionCommand.BackhandMed));
|
||||
|
||||
var atThreshold = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.Medium,
|
||||
DatAttackType.Slash,
|
||||
powerLevel: CombatManeuverSelector.DefaultSubdivision);
|
||||
|
||||
var highPower = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.Medium,
|
||||
DatAttackType.Slash,
|
||||
powerLevel: 1f);
|
||||
|
||||
Assert.Equal(DatMotionCommand.SlashMed, atThreshold.Motion);
|
||||
Assert.Equal(DatMotionCommand.SlashMed, highPower.Motion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectMotion_UsesSecondEntryBelowSubdivision()
|
||||
{
|
||||
var table = MakeTable(
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
|
||||
DatAttackType.Slash, DatMotionCommand.SlashMed),
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium,
|
||||
DatAttackType.Slash, DatMotionCommand.BackhandMed));
|
||||
|
||||
var selection = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.Medium,
|
||||
DatAttackType.Slash,
|
||||
powerLevel: 0.2f);
|
||||
|
||||
Assert.True(selection.Found);
|
||||
Assert.Equal(DatMotionCommand.BackhandMed, selection.Motion);
|
||||
Assert.Equal(DatAttackType.Slash, selection.EffectiveAttackType);
|
||||
Assert.Equal(2, selection.Candidates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectMotion_ThrustSlashWeaponUsesTwoThirdsSubdivision()
|
||||
{
|
||||
var table = MakeTable(
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
|
||||
DatAttackType.Slash, DatMotionCommand.SlashHigh),
|
||||
Entry(DatMotionStance.SwordCombat, DatAttackHeight.High,
|
||||
DatAttackType.Slash, DatMotionCommand.BackhandHigh));
|
||||
|
||||
var normal = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.High,
|
||||
DatAttackType.Slash,
|
||||
powerLevel: 0.5f);
|
||||
|
||||
var thrustSlash = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.High,
|
||||
DatAttackType.Slash,
|
||||
powerLevel: 0.5f,
|
||||
isThrustSlashWeapon: true);
|
||||
|
||||
Assert.Equal(DatMotionCommand.SlashHigh, normal.Motion);
|
||||
Assert.Equal(DatMotionCommand.BackhandHigh, thrustSlash.Motion);
|
||||
Assert.Equal(CombatManeuverSelector.ThrustSlashSubdivision, thrustSlash.Subdivision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectMotion_MissingLookupReturnsNone()
|
||||
{
|
||||
var table = MakeTable(
|
||||
Entry(DatMotionStance.BowCombat, DatAttackHeight.High,
|
||||
DatAttackType.Punch, DatMotionCommand.Shoot));
|
||||
|
||||
var selection = CombatManeuverSelector.SelectMotion(
|
||||
table,
|
||||
DatMotionStance.SwordCombat,
|
||||
DatAttackHeight.High,
|
||||
DatAttackType.Punch,
|
||||
powerLevel: 0.5f);
|
||||
|
||||
Assert.Equal(CombatManeuverSelection.None, selection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMotions_PreservesRetailTableOrder()
|
||||
{
|
||||
var table = MakeTable(
|
||||
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
|
||||
DatAttackType.Kick, DatMotionCommand.AttackLow1),
|
||||
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
|
||||
DatAttackType.Kick, (DatMotionCommand)0x1000018Eu),
|
||||
Entry(DatMotionStance.HandCombat, DatAttackHeight.Low,
|
||||
DatAttackType.Punch, DatMotionCommand.AttackLow2));
|
||||
|
||||
var motions = CombatManeuverSelector.FindMotions(
|
||||
table,
|
||||
DatMotionStance.HandCombat,
|
||||
DatAttackHeight.Low,
|
||||
DatAttackType.Kick);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
DatMotionCommand.AttackLow1,
|
||||
(DatMotionCommand)0x1000018Eu,
|
||||
}, motions);
|
||||
}
|
||||
|
||||
private static CombatTable MakeTable(params CombatManeuver[] maneuvers)
|
||||
{
|
||||
var table = new CombatTable();
|
||||
table.CombatManeuvers.AddRange(maneuvers);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static CombatManeuver Entry(
|
||||
DatMotionStance stance,
|
||||
DatAttackHeight height,
|
||||
DatAttackType type,
|
||||
DatMotionCommand motion)
|
||||
{
|
||||
return new CombatManeuver
|
||||
{
|
||||
Style = stance,
|
||||
AttackHeight = height,
|
||||
AttackType = type,
|
||||
MinSkillLevel = 0,
|
||||
Motion = motion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,51 @@ public sealed class CombatStateTests
|
|||
Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombatMode_UsesRetailAceBitValues()
|
||||
{
|
||||
Assert.Equal(1, (int)CombatMode.NonCombat);
|
||||
Assert.Equal(2, (int)CombatMode.Melee);
|
||||
Assert.Equal(4, (int)CombatMode.Missile);
|
||||
Assert.Equal(8, (int)CombatMode.Magic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttackType_UsesNamedRetailBitValues()
|
||||
{
|
||||
Assert.Equal(0x0001u, (uint)AttackType.Punch);
|
||||
Assert.Equal(0x0002u, (uint)AttackType.Thrust);
|
||||
Assert.Equal(0x0004u, (uint)AttackType.Slash);
|
||||
Assert.Equal(0x0008u, (uint)AttackType.Kick);
|
||||
Assert.Equal(0x0010u, (uint)AttackType.OffhandPunch);
|
||||
Assert.Equal(0x79E0u, (uint)AttackType.MultiStrike);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCombatMode_TracksCurrentMode_AndFiresEvent()
|
||||
{
|
||||
var state = new CombatState();
|
||||
CombatMode? seen = null;
|
||||
state.CombatModeChanged += mode => seen = mode;
|
||||
|
||||
state.SetCombatMode(CombatMode.Missile);
|
||||
|
||||
Assert.Equal(CombatMode.Missile, state.CurrentMode);
|
||||
Assert.Equal(CombatMode.Missile, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCombatCommenceAttack_FiresAttackCommenced()
|
||||
{
|
||||
var state = new CombatState();
|
||||
bool seen = false;
|
||||
state.AttackCommenced += () => seen = true;
|
||||
|
||||
state.OnCombatCommenceAttack();
|
||||
|
||||
Assert.True(seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnVictimNotification_FiresDamageTaken()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class AnimationCommandRouterTests
|
||||
{
|
||||
private const uint NonCombat = 0x8000003Du;
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, AnimationCommandRouteKind.None)]
|
||||
[InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary
|
||||
[InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump
|
||||
[InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave
|
||||
[InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready
|
||||
[InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead
|
||||
[InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style
|
||||
public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected)
|
||||
{
|
||||
Assert.Equal(expected, AnimationCommandRouter.Classify(command));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteWireCommand_SubState_UsesSetCycle()
|
||||
{
|
||||
var seq = MakeEmptySequencer();
|
||||
|
||||
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011);
|
||||
|
||||
Assert.Equal(AnimationCommandRouteKind.SubState, route);
|
||||
Assert.Equal(NonCombat, seq.CurrentStyle);
|
||||
Assert.Equal(MotionCommand.Dead, seq.CurrentMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle()
|
||||
{
|
||||
var seq = MakeEmptySequencer();
|
||||
|
||||
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057);
|
||||
|
||||
Assert.Equal(AnimationCommandRouteKind.Action, route);
|
||||
Assert.Equal(0u, seq.CurrentMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RouteWireCommand_Wave_IsChatEmote()
|
||||
{
|
||||
var seq = MakeEmptySequencer();
|
||||
|
||||
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087);
|
||||
|
||||
Assert.Equal(AnimationCommandRouteKind.ChatEmote, route);
|
||||
}
|
||||
|
||||
private static AnimationSequencer MakeEmptySequencer()
|
||||
{
|
||||
return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCycle_PresentInTable_ReturnsTrue()
|
||||
{
|
||||
// Phase L.1c followup (2026-04-28): regression guard for
|
||||
// "torso on the ground" — caller (GameWindow MoveTo path) needs
|
||||
// to query the table before SetCycle to avoid the
|
||||
// ClearCyclicTail wipe on a missing cycle.
|
||||
const uint Style = 0x003Cu; // HandCombat
|
||||
const uint Motion = 0x0003u; // Ready
|
||||
const uint AnimId = 0x03000001u;
|
||||
|
||||
var setup = Fixtures.MakeSetup(2);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
|
||||
// Caller passes the SAME shape SetCycle expects: full style with
|
||||
// class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
|
||||
Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCycle_MissingFromTable_ReturnsFalse()
|
||||
{
|
||||
const uint Style = 0x003Cu;
|
||||
const uint ReadyMotion = 0x0003u;
|
||||
const uint AnimId = 0x03000001u;
|
||||
|
||||
var setup = Fixtures.MakeSetup(2);
|
||||
var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
|
||||
// RunForward (0x44000007) is NOT in the table — caller should
|
||||
// see false and fall back to a known motion (WalkForward / Ready).
|
||||
Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
||||
{
|
||||
|
|
@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests
|
|||
Assert.Equal(99f, fr[0].Origin.X, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
|
||||
{
|
||||
// ACE broadcasts creature attacks as Action-class ForwardCommand
|
||||
// values followed by Ready. Retail keeps currState.Substate at Ready
|
||||
// while the action link drains, so the Ready echo must not abort the
|
||||
// in-flight swing.
|
||||
const uint Style = 0x003Du;
|
||||
const uint IdleMotion = 0x41000003u;
|
||||
const uint AttackMotion = 0x10000052u;
|
||||
const uint IdleAnimId = 0x03000503u;
|
||||
const uint AttackAnimId = 0x03000504u;
|
||||
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
|
||||
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||||
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||||
|
||||
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||||
var cmdData = new MotionCommandData();
|
||||
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
|
||||
mt.Links[linkOuter] = cmdData;
|
||||
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
|
||||
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, IdleMotion);
|
||||
seq.PlayAction(AttackMotion);
|
||||
|
||||
seq.SetCycle(Style, IdleMotion);
|
||||
|
||||
var fr = seq.Advance(0.01f);
|
||||
Assert.Single(fr);
|
||||
Assert.Equal(12f, fr[0].Origin.X, 1);
|
||||
Assert.Equal(IdleMotion, seq.CurrentMotion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ public class MotionCommandResolverTests
|
|||
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
|
||||
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
|
||||
[InlineData(0x0015, 0x40000015u)] // Falling
|
||||
[InlineData(0x0011, 0x40000011u)] // Dead
|
||||
[InlineData(0x0012, 0x41000012u)] // Crouch
|
||||
[InlineData(0x0013, 0x41000013u)] // Sitting
|
||||
[InlineData(0x0014, 0x41000014u)] // Sleeping
|
||||
// Action-class one-shots: melee attacks, death, portals
|
||||
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
|
||||
[InlineData(0x0058, 0x10000058u)] // ThrustMed
|
||||
|
|
|
|||
|
|
@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests
|
|||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactAllowsMove_DeadState_RejectsMove()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
|
||||
|
||||
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MotionCommand.Crouch)]
|
||||
[InlineData(MotionCommand.Sitting)]
|
||||
[InlineData(MotionCommand.Sleeping)]
|
||||
public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = postureCommand;
|
||||
|
||||
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactAllowsMove_CrouchRange_RejectsMove()
|
||||
{
|
||||
|
|
|
|||
296
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
296
tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
|
||||
/// per-tick steering port of retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
|
||||
/// creatures.
|
||||
/// </summary>
|
||||
public class RemoteMoveToDriverTests
|
||||
{
|
||||
private const float Epsilon = 1e-3f;
|
||||
|
||||
private static float Yaw(Quaternion q)
|
||||
{
|
||||
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
|
||||
return MathF.Atan2(-fwd.X, fwd.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_AlreadyAtTarget_ReportsArrived()
|
||||
{
|
||||
var bodyPos = new Vector3(10f, 20f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(10f, 20.3f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
Assert.Equal(bodyRot, newOrient); // orientation untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
|
||||
{
|
||||
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
|
||||
// Body at 0.5m from target should ARRIVE — not keep oscillating
|
||||
// around the target the way it did pre-fix when only MinDistance
|
||||
// was the gate. This is the "monster keeps running in different
|
||||
// directions when it should be attacking" regression fix.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 0.5f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_FleeArrival_UsesMinDistance()
|
||||
{
|
||||
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
|
||||
// Retail / ACE both use MinDistance for the flee-arrival threshold.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 6f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 5.0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: false,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
|
||||
{
|
||||
// Regression: my earlier max(MinDistance, DistanceToObject) port
|
||||
// would have arrived here because dist (1.5) <= MinDistance (2.0).
|
||||
// Retail uses DistanceToObject for chase arrival, so a chase at
|
||||
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 1.5f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 2.0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_ChasingButNotInRange_ReportsSteering()
|
||||
{
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity; // facing +Y
|
||||
var dest = new Vector3(0f, 50f, 0f); // straight ahead
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Already facing target → snap branch keeps yaw at 0.
|
||||
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
|
||||
{
|
||||
// Body facing +Y; target at (1, 10, 0) — that's a small angle
|
||||
// (about 5.7°), well within the 20° snap tolerance.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(1f, 10f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
|
||||
float expectedYaw = MathF.Atan2(-1f, 10f);
|
||||
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
|
||||
|
||||
// Verify orientation actually transforms +Y onto the (1,10) line.
|
||||
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
|
||||
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
|
||||
{
|
||||
// Body facing +Y; target at (-10, 0) — that's 90° to the left
|
||||
// (well beyond the 20° snap tolerance), so we turn by at most
|
||||
// TurnRateRadPerSec * dt this tick rather than snapping.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity; // yaw = 0
|
||||
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
// We should turn LEFT (positive yaw) toward the target.
|
||||
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
|
||||
{
|
||||
// Body facing +Y; target directly behind at (0, -10, 0).
|
||||
// |delta| = π, equally close either way; the implementation
|
||||
// picks one (sign depends on float wobble) — just assert
|
||||
// we made progress (yaw changed by exactly TurnRate * dt).
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, -10f, 0f);
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_PreservesOrientationAtArrival()
|
||||
{
|
||||
var bodyPos = new Vector3(5f, 5f, 0f);
|
||||
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
|
||||
var dest = new Vector3(5.01f, 5.01f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
// Caller would zero velocity; orientation should be untouched
|
||||
// so the body settles facing whatever direction it was already.
|
||||
Assert.Equal(bodyRot, newOrient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
|
||||
{
|
||||
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
|
||||
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
|
||||
// on the threshold. With threshold=0.6 and remaining=0.4, the
|
||||
// clamp should let the full velocity through (advance == remaining).
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
|
||||
|
||||
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
|
||||
// remaining distance. The clamp may apply a 0.99999×-style
|
||||
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
|
||||
// the input as "no meaningful overshoot prevention applied."
|
||||
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
|
||||
Assert.Equal(0f, clamped.X);
|
||||
Assert.Equal(0f, clamped.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
|
||||
{
|
||||
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
|
||||
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
|
||||
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
|
||||
// Velocity should be halved → 2 m/s.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
|
||||
|
||||
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
|
||||
Assert.Equal(0f, clamped.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
|
||||
{
|
||||
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
|
||||
// Any horizontal velocity would overshoot; clamp must zero it.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 0.6f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
|
||||
|
||||
Assert.Equal(0f, clamped.X);
|
||||
Assert.Equal(0f, clamped.Y);
|
||||
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_FleeBranch_NoOp()
|
||||
{
|
||||
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, -4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
|
||||
|
||||
Assert.Equal(vel, clamped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginToWorld_AppliesLandblockGridShift()
|
||||
{
|
||||
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
|
||||
// at (0xA9, 0xB4), that's one landblock west and zero north,
|
||||
// so origin (10, 20, 0) inside that landblock should map to
|
||||
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
|
||||
var w = RemoteMoveToDriver.OriginToWorld(
|
||||
originCellId: 0xA8B4000Eu,
|
||||
originX: 10f, originY: 20f, originZ: 0f,
|
||||
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
|
||||
|
||||
Assert.Equal(-182f, w.X);
|
||||
Assert.Equal(20f, w.Y);
|
||||
Assert.Equal(0f, w.Z);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class ServerControlledLocomotionTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlanMoveToStart_SeedsImmediateRunCycle()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart();
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(1.0f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanMoveToStart_AppliesRetailRunRate()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
||||
moveToSpeed: 1.25f,
|
||||
runRate: 1.5f,
|
||||
canRun: true);
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(1.875f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
||||
moveToSpeed: 0.75f,
|
||||
runRate: 2.0f,
|
||||
canRun: false);
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
|
||||
Assert.Equal(0.75f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanFromVelocity(
|
||||
new Vector3(0.10f, 0.12f, 3.0f));
|
||||
|
||||
Assert.False(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.Ready, plan.Motion);
|
||||
Assert.Equal(1.0f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromVelocity_WalksForSlowServerControlledMotion()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanFromVelocity(
|
||||
new Vector3(0.0f, 0.80f, 0.0f));
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
|
||||
Assert.InRange(plan.SpeedMod, 0.25f, 0.27f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromVelocity_RunsAtRetailRunScale()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanFromVelocity(
|
||||
new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f));
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(1.0f, plan.SpeedMod, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromVelocity_ClampsVeryFastSnapshots()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanFromVelocity(
|
||||
new Vector3(0.0f, 30.0f, 0.0f));
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class DerethDateTimeCollection
|
||||
{
|
||||
public const string Name = "DerethDateTime global offset";
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using Xunit;
|
|||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[Collection(DerethDateTimeCollection.Name)]
|
||||
public sealed class DerethDateTimeTests
|
||||
{
|
||||
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Xunit;
|
|||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[Collection(DerethDateTimeCollection.Name)]
|
||||
public sealed class SkyStateTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Xunit;
|
|||
|
||||
namespace AcDream.Core.Tests.World;
|
||||
|
||||
[Collection(DerethDateTimeCollection.Name)]
|
||||
public sealed class WorldTimeDebugTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue