acdream/tests/AcDream.App.Tests/Interaction/ProductionUseApproachWiringTests.cs
Erik 02b735ba4a
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

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

428 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.App.Tests.Interaction;
/// <summary>
/// 2026-08-08 vendor-approach root fix, full PRODUCTION wiring (the live
/// vendor-diag evidence: an out-of-range vendor Use armed its approach and
/// then sat inert — the player never walked, the hourglass lingered, and
/// every attempt resolved <c>natural=False</c> only when manual input
/// cancelled it). Unlike <c>SelectionInteractionControllerTests</c> (fake
/// movement sink, the dead <c>OnNaturalMoveToComplete</c> entry point),
/// these tests drive the REAL chain end to end:
/// <c>SelectionInteractionController.RequestUse</c> → the REAL
/// <c>PlayerInteractionMovementSink</c> → the REAL
/// <c>PlayerMovementController</c>/<c>MovementManager</c>/<c>MoveToManager</c>
/// → the REAL <c>EntityPhysicsHost</c>/<c>TargetManager</c> voyeur
/// round-trip resolved through the REAL
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> seam → the REAL
/// <c>PlayerApproachCompletionState</c> mailbox →
/// <c>SelectionInteractionController.DrainOutbound</c>.
///
/// The root cause lived in the host's <c>getObjectA</c> seam: bound to the
/// exact-installed-host lookup, a never-animated NPC target resolved to
/// null, <c>TargetManager.SetTarget</c>'s <c>add_voyeur</c> never delivered
/// the immediate initial snapshot, and the deferred <c>MoveToObject</c>
/// never queued a node. The fix routes the seam through
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> with the graphical
/// host's canonical resolver bound
/// (<c>SessionPlayerComposition</c> → <c>BindObjectTableHostResolver</c>).
/// </summary>
public sealed class ProductionUseApproachWiringTests
{
private const uint Player = 0x5000_0001u;
private const uint Vendor = 0x7C95_B01Cu;
private const uint OtherVendor = 0x7C95_B01Du;
private const uint Cell = 0x0101_0001u;
private sealed class Query : IWorldSelectionQuery
{
public Dictionary<uint, InteractionApproach> Approaches { get; } = new();
public uint? PickAtCursor(bool includeSelf) => null;
public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null;
public void BeginLightingPulse(uint serverGuid) { }
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
localEntityId = 101u;
return true;
}
public bool IsCurrent(uint serverGuid, uint localEntityId) => true;
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => true;
public bool IsHostileMonster(uint serverGuid) => false;
public bool IsAttackableTarget(uint serverGuid) => false;
public ClosestCombatTarget? FindClosestHostileMonster() => null;
public bool IsUseable(uint serverGuid) => true;
public bool IsPickupable(uint serverGuid) => false;
public bool IsWieldedByPlayer(uint serverGuid) => false;
public bool IsWieldedPositionState(uint serverGuid) => false;
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
=> Approaches.TryGetValue(serverGuid, out approach);
}
private sealed class Transport : IRuntimeInteractionTransport
{
private uint _sequence;
public bool IsInWorld => true;
public List<uint> Uses { get; } = new();
public bool TrySendUse(uint serverGuid, out uint sequence)
{
sequence = ++_sequence;
Uses.Add(serverGuid);
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
sequence = ++_sequence;
return true;
}
}
private sealed class Harness : IDisposable
{
public readonly RuntimeEntityObjectLifetime RuntimeLifetime = new();
public readonly Query Query = new();
public readonly Transport Transport = new();
public readonly PlayerApproachCompletionState Completions = new();
public readonly IPlayerApproachCompletionSink CompletionLifetime;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly InventoryTransactionState Inventory;
public readonly RuntimeInteractionTransactionState Transactions;
public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller;
public readonly PlayerMovementController MovementController;
public readonly MoveToManager MoveTo;
public readonly Dictionary<uint, EntityPhysicsHost> TargetHosts = new();
public Harness(bool bindObjectTableResolver)
{
CompletionLifetime = Completions.BeginControllerLifetime();
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Vendor,
Name = "Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = OtherVendor,
Name = "Other Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
// ── The real local movement graph, wired the same way
// RuntimeLocalPlayerPhysicsPublicationState.Prepare wires the
// production one: MoveToManager seams onto the controller's
// body, target seams onto the player's own EntityPhysicsHost,
// and the host's getObjectA onto the REAL RuntimePhysicsState
// object-table seam under test. ─────────────────────────────
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SeedPlacementForTest(Vector3.Zero, Cell, Vector3.Zero);
MovementController = controller;
EntityPhysicsHost playerHost = null!;
MoveTo = new MoveToManager(
controller.Motion,
stopCompletely: () =>
_ = controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => controller.CellPosition,
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
setHeading: (heading, _) =>
controller.Yaw = MoveToMath.YawFromHeading(heading),
getOwnRadius: static () => 0.48f,
getOwnHeight: static () => 1.835f,
contact: static () => true,
isInterpolating: static () => false,
getVelocity: static () => Vector3.Zero,
getSelfId: static () => Player,
setTarget: (context, target, radius, quantum) =>
playerHost.SetTarget(context, target, radius, quantum),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () =>
playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum =>
playerHost.TargetManager.SetTargetQuantum(quantum));
playerHost = new EntityPhysicsHost(
Player,
getPosition: () => controller.CellPosition,
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.48f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
// THE seam under test — the production publication binding.
getObjectA: RuntimeLifetime.Physics.ResolveObjectTableHost,
handleUpdateTarget: info =>
controller.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () =>
controller.Movement.CancelMoveTo(
WeenieError.ActionCancelled));
MoveTo.StickTo = (target, radius, height) =>
playerHost.PositionManager.StickTo(target, radius, height);
MoveTo.Unstick = playerHost.PositionManager.UnStick;
controller.MoveTo = MoveTo;
// PlayerModeController.BuildControllerAndCamera's exact
// completion bindings (the production mailbox publishers).
MoveTo.MoveToComplete = error =>
{
if (error == WeenieError.None)
CompletionLifetime.PublishNaturalCompletion();
else
CompletionLifetime.PublishCancellation(error);
};
MoveTo.MoveToCancelled = error =>
CompletionLifetime.PublishCancellation(error);
if (bindObjectTableResolver)
{
// SessionPlayerComposition's bind, with the lazy App
// resolver stood in by a per-guid minimal-host table (the
// shape LiveEntityMotionRuntimeController.ResolvePhysicsHost
// produces for a never-animated entity).
RuntimeLifetime.Physics.BindObjectTableHostResolver(
guid => TargetHosts.GetValueOrDefault(guid));
}
Inventory = new InventoryTransactionState(Objects);
Transactions = new RuntimeInteractionTransactionState(Inventory);
SelectionInteractionController? selectionController = null;
Items = new ItemInteractionController(
Objects,
Transactions,
new InteractionState(),
() => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: _ => { },
groundObjectId: () => 0u,
placeInBackpack: (item, container, placement) =>
selectionController!.SendPickup(item, container, placement),
requestUse: (guid, reservation) =>
selectionController!.RequestUse(guid, reservation));
Controller = selectionController = new SelectionInteractionController(
Selection,
Query,
Items,
Transport,
new PlayerInteractionMovementSink(
() => MovementController,
Completions),
toast: null,
Completions);
}
/// <summary>Registers a never-animated target: an approach the
/// selection query reports as genuinely out of range (a real walk)
/// plus the minimal position-only host the canonical object-table
/// resolver would lazily create for it.</summary>
public void AddFarTarget(uint serverGuid, Vector3 position)
{
var entity = new WorldEntity
{
Id = 101u,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = [],
};
Query.Approaches[serverGuid] = new InteractionApproach(
new WorldInteractionTarget(serverGuid, 101u, entity),
new PlayerInteractionPose(Cell, Vector3.Zero),
UseRadius: 3f,
IsCloseRange: false,
CanCharge: true,
TargetRadius: 0.5f,
TargetHeight: 2f);
TargetHosts[serverGuid] = new EntityPhysicsHost(
serverGuid,
getPosition: () => new Position(
Cell, position, Quaternion.Identity),
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.5f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
getObjectA: static _ => null,
handleUpdateTarget: static _ => { },
interruptCurrentMovement: static () => { });
}
public void Dispose() => RuntimeLifetime.Dispose();
}
/// <summary>
/// Far use → approach arms → natural arrival → dispatch, through the
/// complete production chain. The target sits inside the approach's
/// arrival band (cylinder distance 3.5 0.48 0.5 = 2.52 m ≤ the 3 m
/// wire use radius), so retail's deferred MoveToObject completes
/// naturally the moment the first voyeur snapshot arrives — which it
/// can ONLY do when the never-animated target resolves through the
/// bound object-table resolver. Sabotage-verified by
/// <see cref="UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation"/>:
/// the identical drive without the bind dispatches nothing.
/// </summary>
[Fact]
public void FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.RequestUse(Vendor, reservation);
// The deferred object move initialized off the immediate voyeur
// snapshot and completed naturally into the mailbox; nothing has
// dispatched until the production drain runs.
Assert.Empty(h.Transport.Uses);
h.Controller.DrainOutbound();
Assert.Equal(new[] { Vendor }, h.Transport.Uses);
// Dispatch transfers the busy reference to the authoritative
// UseDone (retail's hourglass-until-UseDone), it does not leak.
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// A genuinely cancelled approach (the retail user-input chain's
/// terminal <c>CancelMoveTo</c>) releases the armed use's reservation in
/// the SAME drain that observes the cancellation — the hourglass clears
/// the moment the approach fails, and nothing reaches the wire.
/// </summary>
[Fact]
public void CancelledApproachReleasesTheArmedReservationInTheSameDrain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
// Genuinely walking: the far target initialized a real node plan.
Assert.True(h.MoveTo.IsMovingTo());
Assert.True(h.MoveTo.Initialized);
Assert.NotEmpty(h.MoveTo.PendingActions);
Assert.Equal(1, h.Inventory.BusyCount);
// Retail's input-edge cancel chain terminates here
// (InterruptCurrentMovement → MovementManager.CancelMoveTo).
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
Assert.True(h.Transactions.TryGetPendingUse(out _) == false);
}
/// <summary>
/// A second far use supersedes the first cleanly: the first armed use's
/// reservation releases at the supersede boundary
/// (<c>CancelPendingApproach</c>), the first approach's cancellation
/// no-matches the second's token in the drain, and only the second
/// dispatches on its own natural arrival.
/// </summary>
[Fact]
public void NewFarUseSupersedesThePriorApproachAndReleasesItsReservation()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
h.AddFarTarget(OtherVendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation first =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, first);
Assert.Equal(1, h.Inventory.BusyCount);
ItemUseRequestReservation second =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(2, h.Inventory.BusyCount);
h.Controller.RequestUse(OtherVendor, second);
// The first reservation released at the supersede boundary; the
// second is armed (near target: its natural completion is already
// queued behind the first approach's cancellation).
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Equal(new[] { OtherVendor }, h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// The sabotage control for
/// <see cref="FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain"/>
/// and the permanent pin of the PRE-FIX pathology: without the
/// object-table bind the host's seam answers null for the
/// never-animated target, so the armed MoveToObject never receives its
/// first target update — no nodes, no natural completion, no dispatch,
/// and the reservation (the hourglass) stays held until something
/// cancels the approach; the cancel then still releases it.
/// </summary>
[Fact]
public void UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation()
{
using var h = new Harness(bindObjectTableResolver: false);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
h.Controller.DrainOutbound();
// Armed but inert — the live vendor-diag pathology.
Assert.True(h.MoveTo.IsMovingTo());
Assert.False(h.MoveTo.Initialized);
Assert.Empty(h.MoveTo.PendingActions);
Assert.Empty(h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
}
}