Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's template-list mechanism and OP3's OptionPage model: 6 authored group headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK death messages", AP-193) in research doc §2's authored order, each row resolved by PlayerOption id through CharacterOptionTable, seeded from live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable. ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption @0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm the directive was followed), labels/tooltips resolved by name from string table 0x23000003 (never hard-coded English), and registered with OptionsPanelController.CharacterPage. Apply/Reset/Defaults (0x100001FC/FD/FE) are now wired per-page via a scoped subtree search (UiElement.FindDescendant, promoted from UiTabPanel) since Character/ Chat/Config each author their own physical instance under the SAME element ids. Consumers: Group A (29 ids) wire+store only via the existing SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display Timestamps prefixes new transcript lines (RuntimeCommunicationState. DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off (WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as Default Movement inverts the walk-mode modifier's default (RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget (CharacterOptionCombatSettingsSource), VividTargetingIndicator/ CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local GameplaySettings record to the canonical server bit — closing two previously-unfiled divergences where AutoRepeatAttack and AcceptCorpseLootingPermissions never reached the wire despite being retail auto-save ids. TS-73 narrowed to its two still-open cases; TS-75..TS-80 file the genuine gaps (no day/night force, no weather- particle/profanity-filter/salvage/housing/pickup-preference subsystem, fellowship-create's unaudited client-sourced field) rather than inventing stand-ins. Conformance: CharacterOptionsPageControllerTests pins all 50 rows against CharacterOptionTable in both directions (an invented or dropped row fails the build), the authored group/order row-by-row, and the build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against the committed fixture. 52 new tests; full solution suite 13,008 passed / 4 skipped / 0 failed (was 12,956/4/0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
604 lines
23 KiB
C#
604 lines
23 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_CommitRuntimeOwnedController_CarriesTheCallback()
|
|
{
|
|
// The PRODUCTION install path (CommitRuntimeOwnedController via the
|
|
// publication lifecycle) bypasses the internal Controller setter.
|
|
// It shipped WITHOUT the OnInterfaceText application, so every live
|
|
// graphical controller ran with a null callback and client-local
|
|
// refusals silently vanished — round-3 probe evidence:
|
|
// "[jump] ReportJumpRefusal result=NotGrounded hasCallback=False".
|
|
using var movement = new RuntimeLocalPlayerMovementState();
|
|
var received = new List<string>();
|
|
movement.OnInterfaceText = (text, _) => received.Add(text);
|
|
|
|
var controller = new PlayerMovementController(new PhysicsEngine());
|
|
movement.CommitRuntimeOwnedController(controller);
|
|
|
|
Assert.NotNull(controller.OnInterfaceText);
|
|
controller.OnInterfaceText!(
|
|
"commit-path line", AcDream.Core.Chat.RetailLogTextType.ClientLocal);
|
|
Assert.Equal(["commit-path line"], received);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── OP4 (Campaign OP, 2026-08-11): RunAsDefaultMovementSource —
|
|
// PlayerOption RunAsDefaultMovement, polled.
|
|
|
|
[Fact]
|
|
public void RunAsDefaultMovement_Unbound_DefaultsToTrue()
|
|
{
|
|
var movement = new RuntimeLocalPlayerMovementState();
|
|
|
|
Assert.Null(movement.RunAsDefaultMovementSource);
|
|
Assert.True(movement.RunAsDefaultMovement);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void RunAsDefaultMovement_ReflectsBoundSourceLive(bool value)
|
|
{
|
|
var movement = new RuntimeLocalPlayerMovementState();
|
|
bool current = value;
|
|
movement.RunAsDefaultMovementSource = () => current;
|
|
|
|
Assert.Equal(value, movement.RunAsDefaultMovement);
|
|
|
|
current = !value;
|
|
Assert.Equal(!value, movement.RunAsDefaultMovement); // polled, not cached
|
|
}
|
|
}
|