Round-3 probe evidence pinpointed it: '[jump] ReportJumpRefusal result=NotGrounded hasCallback=False' — the edge detection, OnWalkable clearing, and refusal dispatch all worked; the callback was null because CommitRuntimeOwnedController (the C3c production publication path) writes _controller directly and never applied _onInterfaceText the way the internal setter does. Two install paths, one wired. Regression test pins the commit path. Runtime tests 1,323/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
512 lines
19 KiB
C#
512 lines
19 KiB
C#
using System.Diagnostics;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Physics;
|
|
|
|
namespace AcDream.Runtime.Gameplay;
|
|
|
|
public interface IRuntimeLocalPlayerControllerSource
|
|
{
|
|
PlayerMovementController? Controller { get; }
|
|
}
|
|
|
|
public interface IRuntimeLocalPlayerMotionSource
|
|
{
|
|
MotionInterpreter? Motion { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c-F1 (2026-08-02): typed outcome of routing a server movement-stat
|
|
/// recompute through the Runtime movement owner. The dropped outcomes are
|
|
/// the J3.6 displaced-callback-rejection pattern — never an exception and
|
|
/// never a silent void: the caller logs them under its existing
|
|
/// diagnostics. A skill write against a dead session is meaningless by
|
|
/// design; the next login re-derives everything from PlayerDescription.
|
|
/// </summary>
|
|
public enum RuntimeMovementStatsApplication
|
|
{
|
|
/// <summary>Applied to the live (published/standalone) controller —
|
|
/// byte-identical to the pre-F1 direct application path.</summary>
|
|
AppliedLive,
|
|
|
|
/// <summary>Applied to the Runtime-owned dormant controller during the
|
|
/// committed-but-not-yet-activated first-entry window. The same
|
|
/// instance goes live at activation, so the values are already current
|
|
/// when movement starts.</summary>
|
|
AppliedDormant,
|
|
|
|
/// <summary>No controller is installed (pre-first-entry, mid-candidate
|
|
/// construction, or after session teardown cleared the owner).</summary>
|
|
DroppedNoController,
|
|
|
|
/// <summary>The skill snapshot has no authoritative run/jump values yet
|
|
/// (PlayerDescription not processed) — same silent skip as the pre-F1
|
|
/// path.</summary>
|
|
DroppedIncompleteSnapshot,
|
|
|
|
/// <summary>The installed controller is terminal (sealed, retired, or
|
|
/// discarded): a displaced post-teardown write, reported instead of
|
|
/// faulting the session.</summary>
|
|
DroppedDisplacedController,
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c-F1 (2026-08-02): typed outcome of routing an inbound server
|
|
/// PhysicsState push through the local movement controller's publication
|
|
/// lifecycle. Same displaced-callback-rejection family as
|
|
/// <see cref="RuntimeMovementStatsApplication"/>, with one deliberate
|
|
/// difference: the dormant window DROPS the push rather than applying it,
|
|
/// because the activation transaction owns the dormant body's physics
|
|
/// state exclusively (it re-reads the canonical record's FinalPhysicsState
|
|
/// through <c>RefreshDormantRuntimePhysicsState</c> at both activation
|
|
/// phases), and the App-side push carries that exact same unchanged record
|
|
/// value while the accepted SetState itself is queued behind the initial
|
|
/// residence — dropping it is value-preserving by construction.
|
|
/// </summary>
|
|
public enum RuntimeServerPhysicsStateApplication
|
|
{
|
|
/// <summary>Applied to the live (published/standalone) controller —
|
|
/// byte-identical to the direct <c>ApplyPhysicsState</c> path.</summary>
|
|
AppliedLive,
|
|
|
|
/// <summary>The controller is Runtime-owned dormant: the activation
|
|
/// pipeline is the sole authority for the dormant body's physics state
|
|
/// and re-reads the canonical value itself.</summary>
|
|
DroppedDormantActivationOwned,
|
|
|
|
/// <summary>The installed controller is terminal — a displaced
|
|
/// post-teardown push.</summary>
|
|
DroppedDisplacedController,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical local movement lifetime and intent owner. Graphical input,
|
|
/// presentation, diagnostics, and future no-window hosts borrow this exact
|
|
/// state; they never mirror the controller or the autorun latch.
|
|
/// </summary>
|
|
public sealed class RuntimeLocalPlayerMovementState
|
|
: IRuntimeLocalPlayerControllerSource,
|
|
IRuntimeLocalPlayerMotionSource,
|
|
IRuntimeMovementView,
|
|
IDisposable
|
|
{
|
|
private PlayerMovementController? _controller;
|
|
private PlayerMovementController? _preparingMotionOwner;
|
|
private RuntimeLocalPlayerPhysicsPublicationState? _physicsPublication;
|
|
private bool _autoRunActive;
|
|
private bool _hasCommandInput;
|
|
private MovementInput _commandInput;
|
|
private bool _disposed;
|
|
private long _revision;
|
|
private Action<string, RetailLogTextType>? _onInterfaceText;
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH2: the retail-faithful text/type router
|
|
/// (<c>RuntimeCommunicationState.AddText</c>) — applied to every
|
|
/// controller this owner installs, past and future, so a client-local
|
|
/// jump refusal (<c>PlayerMovementController</c>'s local
|
|
/// <c>ChargeJump</c>/<c>jump</c> calls, research doc §4.2/§6.4) reaches
|
|
/// the SpewBox through the same chokepoint server-sent WeenieErrors use.
|
|
/// </summary>
|
|
public Action<string, RetailLogTextType>? OnInterfaceText
|
|
{
|
|
get => _onInterfaceText;
|
|
set
|
|
{
|
|
_onInterfaceText = value;
|
|
if (_controller is not null)
|
|
_controller.OnInterfaceText = value;
|
|
}
|
|
}
|
|
|
|
public PlayerMovementController? Controller
|
|
{
|
|
get => _controller;
|
|
// C3c seal: the public write escape hatch is closed. Production
|
|
// controller installation flows only through the publication
|
|
// lifecycle (CommitRuntimeOwnedController via
|
|
// RuntimeLocalPlayerPhysicsPublicationState.Commit) and teardown
|
|
// through ResetSession/Dispose/DiscardActivation. The setter stays
|
|
// reachable for tests via InternalsVisibleTo only.
|
|
internal set
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (ReferenceEquals(_controller, value))
|
|
return;
|
|
_controller?.RetireRuntimePublication();
|
|
_controller = value;
|
|
if (_controller is not null)
|
|
_controller.OnInterfaceText = _onInterfaceText;
|
|
ControllerOwnershipEpoch++;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
public bool AutoRunActive => _autoRunActive;
|
|
public bool HasCommandInput => _hasCommandInput;
|
|
public MovementInput CommandInput => _commandInput;
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
public ulong ControllerOwnershipEpoch { get; private set; }
|
|
public IRuntimeMovementView View => this;
|
|
|
|
internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication =>
|
|
_physicsPublication ?? throw new InvalidOperationException(
|
|
"The Runtime local-player physics publication owner is not bound.");
|
|
|
|
MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion =>
|
|
_preparingMotionOwner?.Motion ?? _controller?.Motion;
|
|
|
|
public RuntimeMovementSnapshot Snapshot
|
|
{
|
|
get
|
|
{
|
|
PlayerMovementController? controller = _controller;
|
|
return controller is null
|
|
? new RuntimeMovementSnapshot(
|
|
false,
|
|
0u,
|
|
default,
|
|
default,
|
|
false,
|
|
0d,
|
|
Revision,
|
|
_autoRunActive,
|
|
_hasCommandInput,
|
|
_commandInput)
|
|
: new RuntimeMovementSnapshot(
|
|
true,
|
|
controller.LocalEntityId,
|
|
controller.CellPosition,
|
|
controller.BodyVelocity,
|
|
controller.IsAirborne,
|
|
controller.SimTimeSeconds,
|
|
Revision,
|
|
_autoRunActive,
|
|
_hasCommandInput,
|
|
_commandInput);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the retail construction seam where a PartArray completion can
|
|
/// reach the candidate MotionInterpreter before the controller is
|
|
/// published to ordinary borrowers.
|
|
/// </summary>
|
|
public IDisposable BeginMotionPreparation(
|
|
PlayerMovementController controller,
|
|
Action? drainPriorAnimationQueue = null)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(controller);
|
|
if (_preparingMotionOwner is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A local player motion owner is already being prepared.");
|
|
}
|
|
|
|
drainPriorAnimationQueue?.Invoke();
|
|
_preparingMotionOwner = controller;
|
|
return new MotionPreparation(this, controller);
|
|
}
|
|
|
|
public bool Execute(RuntimeMovementCommand command)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
switch (command)
|
|
{
|
|
case RuntimeMovementCommand.ToggleRunLock:
|
|
_autoRunActive = !_autoRunActive;
|
|
Interlocked.Increment(ref _revision);
|
|
return true;
|
|
case RuntimeMovementCommand.Stop:
|
|
CancelAutoRun();
|
|
ClearCommandInput();
|
|
return true;
|
|
case RuntimeMovementCommand.Ready:
|
|
case RuntimeMovementCommand.Sit:
|
|
case RuntimeMovementCommand.Crouch:
|
|
case RuntimeMovementCommand.Sleep:
|
|
CancelAutoRun();
|
|
ClearCommandInput();
|
|
uint motion = command switch
|
|
{
|
|
RuntimeMovementCommand.Ready =>
|
|
MotionCommand.Ready,
|
|
RuntimeMovementCommand.Sit =>
|
|
MotionCommand.Sitting,
|
|
RuntimeMovementCommand.Crouch =>
|
|
MotionCommand.Crouch,
|
|
RuntimeMovementCommand.Sleep =>
|
|
MotionCommand.Sleeping,
|
|
_ => throw new UnreachableException(),
|
|
};
|
|
return _controller?.RequestPosture(motion) == true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool CancelAutoRun()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_autoRunActive)
|
|
return false;
|
|
_autoRunActive = false;
|
|
Interlocked.Increment(ref _revision);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs the persistent semantic movement level sampled by either host.
|
|
/// The graphical input adapter and direct host both consume this exact
|
|
/// owner; no bot-only movement latch exists.
|
|
/// </summary>
|
|
public void SetCommandInput(in MovementInput input)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_hasCommandInput && _commandInput == input)
|
|
return;
|
|
_commandInput = input;
|
|
_hasCommandInput = true;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public bool ClearCommandInput()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_hasCommandInput)
|
|
return false;
|
|
_commandInput = default;
|
|
_hasCommandInput = false;
|
|
Interlocked.Increment(ref _revision);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c-F1 (2026-08-02): the ONLY route by which server-authoritative
|
|
/// movement stats (run/jump skill, burden, stamina, PK status — the
|
|
/// exact field set of the deleted
|
|
/// <c>RuntimeMovementSkillProjection.ApplyTo</c>) reach the local
|
|
/// movement controller. App holds no controller reference for stat
|
|
/// application and performs no direct configuration mutation; the
|
|
/// owner's publication lifecycle decides whether the write lands
|
|
/// (live/dormant) or is reported as a typed displaced drop (terminal) —
|
|
/// the fix for the connected-gate post-logout ingest crash at
|
|
/// <c>PlayerMovementController.EnsureConfigurationMutable</c>.
|
|
/// Deliberately tolerant of a disposed owner: a recompute displaced
|
|
/// past teardown observes <see cref="RuntimeMovementStatsApplication.DroppedNoController"/>
|
|
/// instead of faulting the session.
|
|
/// </summary>
|
|
public RuntimeMovementStatsApplication ApplyCharacterMovementStats(
|
|
RuntimeMovementSkillState skills)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(skills);
|
|
if (_controller is not { } controller)
|
|
return RuntimeMovementStatsApplication.DroppedNoController;
|
|
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
|
|
if (!snapshot.IsComplete)
|
|
return RuntimeMovementStatsApplication.DroppedIncompleteSnapshot;
|
|
return controller.ApplyCharacterMovementStats(snapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c-F1: routes the stamina-exhaustion EVENT (retail
|
|
/// <c>CommandInterpreter::HandleExhaustion</c>) through the owner so the
|
|
/// App edge-tracker never touches the gated controller motion surface.
|
|
/// Returns false when no live controller can dispatch it (absent,
|
|
/// dormant, terminal, or disposed owner) — displaced-callback-tolerant
|
|
/// for the same reason as <see cref="ApplyCharacterMovementStats"/>.
|
|
/// </summary>
|
|
public bool ReportExhaustion() =>
|
|
_controller?.ReportExhaustionAtMovementBoundary() == true;
|
|
|
|
/// <summary>
|
|
/// Direct-host projection of the same combat readiness query used by the
|
|
/// graphical attack adapter. A host without a constructed local movement
|
|
/// owner is not ready; Slice K's content host must hydrate that owner
|
|
/// before combat packets can leave.
|
|
/// </summary>
|
|
public bool IsReadyForAttack(CombatMode mode)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_controller is not { } controller)
|
|
return false;
|
|
var motion = controller.Motion.InterpretedState;
|
|
return CombatInputPlanner.PlayerInReadyPositionForAttack(
|
|
mode,
|
|
motion.CurrentStyle,
|
|
motion.ForwardCommand);
|
|
}
|
|
|
|
public bool IsDualWield
|
|
{
|
|
get
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
return _controller?.Motion.InterpretedState.CurrentStyle
|
|
== CombatInputPlanner.DualWieldCombatStyle;
|
|
}
|
|
}
|
|
|
|
public bool PrepareForAttackRequest()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
CancelAutoRun();
|
|
return _controller?.PrepareForAttackRequest() == true;
|
|
}
|
|
|
|
public void ResetInputIntent()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_autoRunActive && !_hasCommandInput)
|
|
return;
|
|
_autoRunActive = false;
|
|
_hasCommandInput = false;
|
|
_commandInput = default;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retires every character-generation movement owner while keeping the
|
|
/// process-scoped Runtime root reusable for the next session.
|
|
/// Graphical hosts release their camera/physics projections first; direct
|
|
/// hosts have no parallel projection to coordinate.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_physicsPublication?.ResetSession();
|
|
bool changed =
|
|
_autoRunActive
|
|
|| _hasCommandInput
|
|
|| _controller is not null
|
|
|| _preparingMotionOwner is not null;
|
|
_autoRunActive = false;
|
|
_hasCommandInput = false;
|
|
_commandInput = default;
|
|
if (_controller is not null)
|
|
{
|
|
_controller.RetireRuntimePublication();
|
|
_controller = null;
|
|
ControllerOwnershipEpoch++;
|
|
}
|
|
_preparingMotionOwner = null;
|
|
if (changed)
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
|
|
new(
|
|
_disposed,
|
|
_controller is not null,
|
|
_preparingMotionOwner is not null,
|
|
_autoRunActive,
|
|
_hasCommandInput,
|
|
Revision,
|
|
ControllerOwnershipEpoch,
|
|
_physicsPublication?.CaptureOwnership() ?? default);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_autoRunActive = false;
|
|
_hasCommandInput = false;
|
|
_commandInput = default;
|
|
_physicsPublication?.Dispose();
|
|
if (_controller is not null)
|
|
{
|
|
_controller.RetireRuntimePublication();
|
|
_controller = null;
|
|
ControllerOwnershipEpoch++;
|
|
}
|
|
_preparingMotionOwner = null;
|
|
Interlocked.Increment(ref _revision);
|
|
_disposed = true;
|
|
}
|
|
|
|
internal void AttachPhysicsPublication(
|
|
RuntimeLocalPlayerPhysicsPublicationState publication)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publication);
|
|
if (_physicsPublication is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The Runtime local-player physics publication owner is already bound.");
|
|
}
|
|
_physicsPublication = publication;
|
|
}
|
|
|
|
internal bool CanCommitRuntimeOwnedController(
|
|
ulong expectedEpoch,
|
|
PlayerMovementController? expectedController) =>
|
|
!_disposed
|
|
&& ControllerOwnershipEpoch == expectedEpoch
|
|
&& ReferenceEquals(_controller, expectedController);
|
|
|
|
internal void CommitRuntimeOwnedController(PlayerMovementController controller)
|
|
{
|
|
_controller?.RetireRuntimePublication();
|
|
_controller = controller;
|
|
// The PRODUCTION install path must carry the same wiring the
|
|
// internal Controller setter applies: this line was missing, so
|
|
// every live graphical controller ran with a null OnInterfaceText
|
|
// and client-local refusals (jump-in-air) silently vanished —
|
|
// proven by the round-3 probe line
|
|
// "[jump] ReportJumpRefusal result=NotGrounded hasCallback=False".
|
|
controller.OnInterfaceText = _onInterfaceText;
|
|
ControllerOwnershipEpoch++;
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
private void EndMotionPreparation(PlayerMovementController controller)
|
|
{
|
|
// Terminal disposal clears the unpublished construction seam. A
|
|
// caller may still unwind the already-issued lease afterward; that
|
|
// unwind is idempotent rather than resurrecting or faulting the
|
|
// retired runtime owner.
|
|
if (_disposed && _preparingMotionOwner is null)
|
|
return;
|
|
|
|
if (!ReferenceEquals(_preparingMotionOwner, controller))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The local player motion preparation owner changed unexpectedly.");
|
|
}
|
|
_preparingMotionOwner = null;
|
|
}
|
|
|
|
private sealed class MotionPreparation(
|
|
RuntimeLocalPlayerMovementState owner,
|
|
PlayerMovementController controller) : IDisposable
|
|
{
|
|
private RuntimeLocalPlayerMovementState? _owner = owner;
|
|
|
|
public void Dispose()
|
|
{
|
|
RuntimeLocalPlayerMovementState? current =
|
|
Interlocked.Exchange(ref _owner, null);
|
|
current?.EndMotionPreparation(controller);
|
|
}
|
|
}
|
|
}
|
|
|
|
public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
|
|
bool IsDisposed,
|
|
bool HasController,
|
|
bool HasPreparingMotionOwner,
|
|
bool AutoRunActive,
|
|
bool HasCommandInput,
|
|
long Revision,
|
|
ulong ControllerOwnershipEpoch,
|
|
RuntimeLocalPlayerPhysicsPublicationOwnershipSnapshot PhysicsPublication)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& !HasController
|
|
&& !HasPreparingMotionOwner
|
|
&& !AutoRunActive
|
|
&& !HasCommandInput
|
|
&& PhysicsPublication.IsConverged;
|
|
}
|