acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00

555 lines
21 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeLocalPlayerMovementStateTests
{
[Fact]
public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
{
var controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 0x50000001u,
};
controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Assert.Same(movement, movement.View);
Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
RuntimeMovementSnapshot snapshot = movement.View.Snapshot;
Assert.True(snapshot.HasController);
Assert.Equal(0x50000001u, snapshot.LocalEntityId);
Assert.Equal(controller.CellPosition, snapshot.Position);
Assert.Equal(controller.BodyVelocity, snapshot.Velocity);
Assert.Equal(controller.IsAirborne, snapshot.IsAirborne);
Assert.Equal(controller.SimTimeSeconds, snapshot.SimulationTimeSeconds);
Assert.True(snapshot.AutoRunActive);
Assert.Equal(movement.Revision, snapshot.Revision);
}
[Fact]
public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
{
using var movement = new RuntimeLocalPlayerMovementState();
var input = new MovementInput(
Forward: true,
Run: true);
Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
movement.SetCommandInput(input);
Assert.True(movement.AutoRunActive);
Assert.True(movement.View.Snapshot.AutoRunActive);
Assert.True(movement.HasCommandInput);
Assert.Equal(input, movement.CommandInput);
Assert.Equal(input, movement.View.Snapshot.CommandInput);
Assert.True(movement.Execute(RuntimeMovementCommand.Stop));
Assert.False(movement.AutoRunActive);
Assert.False(movement.View.Snapshot.AutoRunActive);
Assert.False(movement.HasCommandInput);
Assert.False(movement.View.Snapshot.HasCommandInput);
}
[Fact]
public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
{
using var movement = new RuntimeLocalPlayerMovementState();
var input = new MovementInput(
StrafeLeft: true,
Run: true);
movement.SetCommandInput(input);
long revision = movement.Revision;
movement.SetCommandInput(input);
Assert.Equal(revision, movement.Revision);
Assert.True(movement.Snapshot.HasCommandInput);
movement.ResetInputIntent();
Assert.False(movement.HasCommandInput);
Assert.Equal(default, movement.CommandInput);
Assert.Equal(revision + 1, movement.Revision);
}
[Theory]
[InlineData(RuntimeMovementCommand.Ready, MotionCommand.Ready)]
[InlineData(RuntimeMovementCommand.Crouch, MotionCommand.Crouch)]
[InlineData(RuntimeMovementCommand.Sit, MotionCommand.Sitting)]
[InlineData(RuntimeMovementCommand.Sleep, MotionCommand.Sleeping)]
public void StanceCommandsUseCanonicalControllerAndEmitOneMovementEdge(
RuntimeMovementCommand command,
uint expectedMotion)
{
var controller = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
movement.SetCommandInput(
new MovementInput(Forward: true, Run: true));
Assert.True(movement.Execute(command));
Assert.False(movement.HasCommandInput);
Assert.Equal(
expectedMotion,
controller.Motion.RawState.ForwardCommand);
MovementResult first = controller.Update(
1f / 60f,
default);
MovementResult second = controller.Update(
1f / 60f,
default);
Assert.True(first.ShouldSendMovementEvent);
Assert.False(second.ShouldSendMovementEvent);
if (command == RuntimeMovementCommand.Ready)
{
Assert.Null(first.ForwardCommand);
}
else
{
Assert.Equal(expectedMotion, first.ForwardCommand);
}
}
[Fact]
public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
{
using var first = new RuntimeLocalPlayerMovementState();
using var second = new RuntimeLocalPlayerMovementState();
var firstController = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 1u,
};
var secondController = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 2u,
};
first.Controller = firstController;
second.Controller = secondController;
first.Execute(RuntimeMovementCommand.ToggleRunLock);
firstController.SeedPlacementForTest(Vector3.One, 0xA9B40001u, Vector3.One);
secondController.SeedPlacementForTest(
new Vector3(2f),
0xA9B50001u,
new Vector3(2f));
Assert.True(first.Snapshot.AutoRunActive);
Assert.False(second.Snapshot.AutoRunActive);
Assert.Equal(1u, first.Snapshot.LocalEntityId);
Assert.Equal(2u, second.Snapshot.LocalEntityId);
Assert.NotEqual(first.Snapshot.Position, second.Snapshot.Position);
}
// ── Campaign CH slice CH2: OnInterfaceText propagation ──────────────
[Fact]
public void OnInterfaceText_SetBeforeControllerInstall_AppliesToTheInstalledController()
{
using var movement = new RuntimeLocalPlayerMovementState();
var received = new List<(string Text, AcDream.Core.Chat.RetailLogTextType Type)>();
movement.OnInterfaceText = (text, type) => received.Add((text, type));
var controller = new PlayerMovementController(new PhysicsEngine());
movement.Controller = controller;
controller.OnInterfaceText!("test line", AcDream.Core.Chat.RetailLogTextType.ClientLocal);
Assert.Single(received);
Assert.Equal("test line", received[0].Text);
Assert.Equal(AcDream.Core.Chat.RetailLogTextType.ClientLocal, received[0].Type);
}
[Fact]
public void OnInterfaceText_SetAfterControllerInstall_StillAppliesToTheCurrentController()
{
using var movement = new RuntimeLocalPlayerMovementState();
var controller = new PlayerMovementController(new PhysicsEngine());
movement.Controller = controller;
var received = new List<string>();
movement.OnInterfaceText = (text, _) => received.Add(text);
controller.OnInterfaceText!("late-bound line", AcDream.Core.Chat.RetailLogTextType.ClientLocal);
Assert.Equal(["late-bound line"], received);
}
[Fact]
public void OnInterfaceText_SwappingController_AppliesToTheReplacement()
{
using var movement = new RuntimeLocalPlayerMovementState();
var received = new List<string>();
movement.OnInterfaceText = (text, _) => received.Add(text);
var first = new PlayerMovementController(new PhysicsEngine());
movement.Controller = first;
var second = new PlayerMovementController(new PhysicsEngine());
movement.Controller = second;
second.OnInterfaceText!("from replacement", AcDream.Core.Chat.RetailLogTextType.ClientLocal);
Assert.Equal(["from replacement"], received);
}
[Fact]
public void MotionPreparationPublishesOnlyTheConstructionSeam()
{
using var movement = new RuntimeLocalPlayerMovementState();
IRuntimeLocalPlayerMotionSource source = movement;
var committed = new PlayerMovementController(new PhysicsEngine());
var candidate = new PlayerMovementController(new PhysicsEngine());
movement.Controller = committed;
bool drained = false;
using (movement.BeginMotionPreparation(
candidate,
() =>
{
drained = true;
Assert.Same(committed.Motion, source.Motion);
}))
{
Assert.True(drained);
Assert.Same(committed, movement.Controller);
Assert.Same(candidate.Motion, source.Motion);
}
Assert.Same(committed.Motion, source.Motion);
}
[Fact]
public void TerminalDisposalConvergesWhenPreparationLeaseUnwindsLater()
{
var movement = new RuntimeLocalPlayerMovementState();
var controller = new PlayerMovementController(new PhysicsEngine());
IDisposable preparation = movement.BeginMotionPreparation(controller);
movement.Controller = controller;
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
movement.Dispose();
preparation.Dispose();
RuntimeLocalMovementOwnershipSnapshot ownership =
movement.CaptureOwnership();
Assert.True(ownership.IsConverged);
Assert.Throws<ObjectDisposedException>(
() => movement.Execute(RuntimeMovementCommand.ToggleRunLock));
}
[Fact]
public void TypedSkillOptionsPreserveCompleteValuesAndFallbackPerField()
{
Assert.Equal(
new PlayerMovementConstructionOptions(321, 654),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(321, 654, 1)));
Assert.Equal(
new PlayerMovementConstructionOptions(
PlayerMovementConstructionOptions.FallbackRunSkill,
654),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(-1, 654, 1)));
Assert.Equal(
new PlayerMovementConstructionOptions(
321,
PlayerMovementConstructionOptions.FallbackJumpSkill),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(321, -1, 1)));
}
[Fact]
public void WarmMovementSnapshotsAndOwnershipCaptureAllocateNothing()
{
using var movement = new RuntimeLocalPlayerMovementState();
movement.Controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 7u,
};
for (int i = 0; i < 1_000; i++)
{
_ = movement.Snapshot;
_ = movement.CaptureOwnership();
}
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100_000; i++)
{
_ = movement.Snapshot;
_ = movement.CaptureOwnership();
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0L, allocated);
}
// ── C3c-F1 (2026-08-02): movement-stats application seam ──────────────
//
// The typed seam is the ONLY route by which server stat recomputes
// reach the controller; the publication lifecycle decides whether the
// write lands (live/dormant) or is a typed displaced drop (terminal).
private static RuntimeMovementSkillState CompleteSkillState()
{
var skills = new RuntimeMovementSkillState();
skills.Update(runSkill: 240, jumpSkill: 180);
skills.UpdateBurden(0.5f);
skills.UpdateStamina(37);
// BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) → ObjectInfoState.IsPK.
skills.UpdateOwnPwdBitfield(0x28u);
skills.UpdatePlayerKillerStatus(1, 123.5f);
return skills;
}
private static PlayerMovementController NewDormantRuntimeController()
{
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
controller.SealPublicationCandidate();
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
return controller;
}
private static (float RunRate, float JumpVz, bool CanJump, int JumpCost,
ObjectInfoState PvpFlags) CaptureStatObservables(
PlayerMovementController controller)
{
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float runRate));
Assert.True(weenie.InqJumpVelocity(1.0f, out float jumpVz));
bool canJump = weenie.CanJump(1.0f);
Assert.True(((PlayerWeenie)weenie).JumpStaminaCost(1.0f, out int cost));
return (runRate, jumpVz, canJump, cost, controller.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_LiveApplicationIsByteIdenticalToTheRetiredDirectPath()
{
RuntimeMovementSkillState skills = CompleteSkillState();
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
// Old direct path (the deleted RuntimeMovementSkillProjection.ApplyTo
// body), applied through the public gated setters.
var direct = new PlayerMovementController(new PhysicsEngine());
direct.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
direct.SetCharacterBurden(snapshot.Burden);
direct.SetCharacterStamina(snapshot.CurrentStamina);
direct.OwnPvpFlags = EntityCollisionFlagsExt
.FromPwdBitfield(snapshot.OwnPwdBitfield)
.ToMoverState();
direct.SetCharacterPkStatus(
snapshot.PlayerKillerStatus,
snapshot.LastPkAttackTimestamp);
var routed = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = routed,
};
Assert.Equal(
RuntimeMovementStatsApplication.AppliedLive,
movement.ApplyCharacterMovementStats(skills));
Assert.Equal(
CaptureStatObservables(direct),
CaptureStatObservables(routed));
Assert.Equal(ObjectInfoState.IsPK, routed.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_DormantWindowWriteLandsOnTheControllerThatGoesLive()
{
RuntimeMovementSkillState skills = CompleteSkillState();
PlayerMovementController controller = NewDormantRuntimeController();
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
// The committed-but-unactivated first-entry window: the dormant
// controller is installed in the movement owner while activation
// waits on cell streaming, and inbound recomputes keep pumping.
Assert.True(controller.IsRuntimeOwnedDormant);
Assert.Throws<InvalidOperationException>(
() => controller.SetCharacterSkills(1, 1));
Assert.Equal(
RuntimeMovementStatsApplication.AppliedDormant,
movement.ApplyCharacterMovementStats(skills));
// Activation flips the SAME instance live — the applied values are
// already current when movement starts.
controller.ActivateRuntimePublication();
Assert.True(controller.IsRuntimePublished);
(float runRate, _, _, _, ObjectInfoState pvp) =
CaptureStatObservables(controller);
Assert.Equal(
PlayerWeenie.GetRunRate(0.5f, 240),
runRate,
precision: 5);
Assert.Equal(ObjectInfoState.IsPK, pvp);
}
[Fact]
public void ApplyCharacterMovementStats_TerminalControllerReportsTypedDisplacedDrop()
{
RuntimeMovementSkillState skills = CompleteSkillState();
PlayerMovementController controller = NewDormantRuntimeController();
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
controller.ActivateRuntimePublication();
var before = CaptureStatObservables(controller);
// The Motion surface is gated once terminal; capture the weenie
// reference while the controller is still published.
IWeenieObject weenie = controller.Motion.WeenieObj!;
// Post-teardown transient truth: the controller retired while still
// reachable through a displaced recompute callback. The public
// setter still throws (the seal working); the seam reports the
// typed drop instead and mutates nothing.
controller.RetireRuntimePublication();
Assert.Throws<InvalidOperationException>(
() => controller.SetCharacterSkills(1, 1));
Assert.Equal(
RuntimeMovementStatsApplication.DroppedDisplacedController,
movement.ApplyCharacterMovementStats(skills));
Assert.True(weenie.InqRunRate(out float runRate));
Assert.Equal(before.RunRate, runRate);
Assert.Equal(before.PvpFlags, controller.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_SealedCandidateIsATypedDisplacedDrop()
{
// Defensive lifecycle-matrix row: a sealed candidate is frozen for
// the Prepare→Commit validation handshake and never installed into
// the movement owner in production (Prepare requires the owner
// empty; Commit installs it already-dormant within the same
// synchronous conductor step), so only a direct controller-level
// call can observe this row.
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
controller.SealPublicationCandidate();
Assert.Equal(
RuntimeMovementStatsApplication.DroppedDisplacedController,
controller.ApplyCharacterMovementStats(
CompleteSkillState().Snapshot));
}
[Fact]
public void ApplyCharacterMovementStats_AbsentControllerAndIncompleteSnapshotDropSilently()
{
var skills = new RuntimeMovementSkillState();
using var movement = new RuntimeLocalPlayerMovementState();
Assert.Equal(
RuntimeMovementStatsApplication.DroppedNoController,
movement.ApplyCharacterMovementStats(skills));
movement.Controller = new PlayerMovementController(new PhysicsEngine());
Assert.Equal(
RuntimeMovementStatsApplication.DroppedIncompleteSnapshot,
movement.ApplyCharacterMovementStats(skills));
}
[Fact]
public void ApplyCharacterMovementStats_DisposedOwnerToleratesTheDisplacedCallback()
{
var movement = new RuntimeLocalPlayerMovementState
{
Controller = new PlayerMovementController(new PhysicsEngine()),
};
movement.Dispose();
// The displaced-callback-rejection pattern: a recompute landing
// after terminal disposal observes a typed drop, never a fault.
Assert.Equal(
RuntimeMovementStatsApplication.DroppedNoController,
movement.ApplyCharacterMovementStats(CompleteSkillState()));
Assert.False(movement.ReportExhaustion());
}
[Fact]
public void ReportExhaustion_DispatchesOnlyThroughALiveController()
{
using var movement = new RuntimeLocalPlayerMovementState();
Assert.False(movement.ReportExhaustion());
PlayerMovementController controller = NewDormantRuntimeController();
movement.Controller = controller;
Assert.False(movement.ReportExhaustion());
controller.ActivateRuntimePublication();
Assert.True(movement.ReportExhaustion());
controller.RetireRuntimePublication();
Assert.False(movement.ReportExhaustion());
}
[Fact]
public void ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly()
{
// The second connected-gate crash chain
// (logs/connected-world-gate-20260802-125907): an inbound local-
// player SetState pushed ApplyPhysicsState at the dormant
// first-entry controller. The typed entry decides by lifecycle.
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
PhysicsBody body = controller.PhysicsBody;
PhysicsStateFlags initial = body.State;
PhysicsStateFlags pushed =
PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Ethereal;
Assert.NotEqual(initial, pushed);
controller.SealPublicationCandidate();
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
// Dormant: the activation transaction owns the dormant body's
// physics state; the push is dropped and the body is untouched.
Assert.Equal(
RuntimeServerPhysicsStateApplication.DroppedDormantActivationOwned,
controller.ApplyServerPhysicsState(pushed));
Assert.Equal(initial, body.State);
// Published: byte-identical to the direct ApplyPhysicsState body.
controller.ActivateRuntimePublication();
Assert.Equal(
RuntimeServerPhysicsStateApplication.AppliedLive,
controller.ApplyServerPhysicsState(pushed));
Assert.Equal(pushed, body.State);
// Terminal: a displaced push, typed instead of a fault, mutating
// nothing.
controller.RetireRuntimePublication();
Assert.Throws<InvalidOperationException>(
() => controller.ApplyPhysicsState(initial));
Assert.Equal(
RuntimeServerPhysicsStateApplication.DroppedDisplacedController,
controller.ApplyServerPhysicsState(initial));
Assert.Equal(pushed, body.State);
}
}