acdream/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs
Erik 67fe754dd6 fix: social gate round 2, part 2 - confirmation-dialog sentences + the
refused-drop yellow notice

Item 4 (confirmation dialogs missing text + names): the missing retail
mechanism was StringTable template substitution - an entry is N+1 literal
fragments interleaved with N named variables, composed by
StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7).
ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps
it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2,
the exact compute_str_hash space; Chorizite stores the variable hashes
directly):

- Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 ->
  ID_Allegiance_AcceptSwearConfirmation, injected into
  GameplayConfirmationController; null resolve falls back to the bare
  wire message, never invented English. The 2/3/5/6 " Continue?" family
  never consults the composer.
- Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed
  the dangling "Do you wish to swear to ") is replaced by click-time
  ResolveTemplate with the target's name.

All five templates verified token-free in the installed DAT - this is
NOT a StringTableMetaLanguage port (AD-81's engine caveat stands).

Item 5 (refused drop shows nothing; retail shows yellow top-center
text): the prevRequest latch was ALREADY ported (InventoryTransactionState);
what was missing was the consumer. InventoryTransactionState now raises
RequestFailed(request, weenieError) when a 0x00A0 clears the latch;
ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's
"The <item> can't be <verb>" (verb table + suffix map ported verbatim in
Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes
it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow
top-center line. The dispatcher's second leg (@0x0055B342) also runs:
outside the 7-code exclusion set, WeenieErrorMessages resolves per-code
text/destination; 0x426 AttunedItem has no row in either place beyond
the verb line - faithful single-line output.

Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the
token-free interleave is now ported; meta-token engine + FormatName
remain), AD-93 filed (wire-guid-match vs retail's latched-guid
preference; no Move/Wield latch kinds).

Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate
(constructed StringTable fixtures), +1 composer injection, +1 end-to-end
refused-drop line. Core 4,697/1 skip, App 4,983/3 skips.

Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:10:42 +02:00

306 lines
10 KiB
C#

using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class InventoryTransactionStateTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000002u;
private const uint First = 0x60000001u;
private const uint Second = 0x60000002u;
[Fact]
public void ReserveClosesReentrantDispatchWindowAndSuccessfulSendCommits()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
bool secondDispatched = true;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
() =>
{
secondDispatched = state.TryDispatch(
InventoryRequestKind.PutInContainer,
Second,
static () => true);
return true;
}));
Assert.False(secondDispatched);
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
Assert.Equal(First, pending.ItemId);
Assert.True(pending.Dispatched);
}
[Fact]
public void DispatchFailureOrExceptionReleasesExactProvisionalOwner()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.False(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => false));
Assert.False(state.HasPendingRequest);
Assert.Throws<InvalidOperationException>(() => state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => throw new InvalidOperationException("transport")));
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void FailedPrePublicationCallbackRollsBackOnlyItsReservation()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.Throws<InvalidOperationException>(() =>
state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _,
static _ => throw new InvalidOperationException("projection")));
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void AuthoritativeResponseClearsBeforeReentrantCompletionObserver()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
bool replacementDispatched = false;
state.RequestCompleted += request =>
{
Assert.Equal(First, request.ItemId);
Assert.False(state.HasPendingRequest);
replacementDispatched = state.TryDispatch(
InventoryRequestKind.PutInContainer,
Second,
static () => true);
};
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.True(replacementDispatched);
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
Assert.Equal(Second, pending.ItemId);
}
[Fact]
public void OptimisticMoveAndUnrelatedResponsesDoNotCompleteRequest()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
() => objects.MoveItemOptimistic(First, Player, 0)));
Assert.True(objects.ApplyConfirmedServerMove(Second, Player, 1u, 1));
Assert.True(state.HasPendingRequest);
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.False(state.HasPendingRequest);
}
[Fact]
public void ReusedGuidResponseCannotCompletePriorObjectIdentity()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
ClientObject original = objects.Get(First)!;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
var replacement = new ClientObject
{
ObjectId = First,
Name = "replacement",
Type = ItemType.Misc,
};
objects.AddOrUpdate(replacement);
objects.MoveItem(First, Pack, 0);
Assert.NotSame(original, replacement);
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.True(state.HasPendingRequest);
}
[Fact]
public void UseReservationTransfersToUseDoneOrCancelsBeforeDispatch()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
ItemUseRequestReservation cancelled = state.BeginUseRequestReservation();
Assert.Equal(1, state.BusyCount);
cancelled.CancelBeforeDispatch();
cancelled.CancelBeforeDispatch();
Assert.Equal(0, state.BusyCount);
ItemUseRequestReservation dispatched = state.BeginUseRequestReservation();
dispatched.MarkDispatched();
dispatched.CancelBeforeDispatch();
Assert.Equal(1, state.BusyCount);
state.CompleteUse(0u);
Assert.Equal(0, state.BusyCount);
}
[Fact]
public void ResetInvalidatesLateUseReservationAndClearsPendingRequest()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.True(state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _));
ItemUseRequestReservation stale = state.BeginUseRequestReservation();
state.ResetSession();
stale.CancelBeforeDispatch();
Assert.Equal(0, state.BusyCount);
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void ObserverFailureIsIsolatedAndRecorded()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
int delivered = 0;
state.StateChanged += () =>
throw new InvalidOperationException("observer");
state.StateChanged += () => delivered++;
state.IncrementBusyCount();
Assert.Equal(1, delivered);
Assert.Equal(1, state.DispatchFailureCount);
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
}
[Fact]
public void ObjectTableClearReleasesRequestAndNotifiesProjectionOwner()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
int cleared = 0;
state.ObjectTableCleared += () => cleared++;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
objects.Clear();
Assert.Equal(1, cleared);
Assert.False(state.HasPendingRequest);
}
[Fact]
public void DisposeDetachesFromBorrowedObjectTable()
{
var objects = CreateTable();
var state = new InventoryTransactionState(objects);
int completed = 0;
state.RequestCompleted += _ => completed++;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
state.Dispose();
state.Dispose();
objects.ApplyConfirmedServerMove(First, Player, 0u, 0);
Assert.True(state.IsDisposed);
Assert.Equal(0, completed);
Assert.Throws<ObjectDisposedException>(() =>
state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _));
}
[Fact]
public void RejectMoveFiresRequestFailedWithLatchedKindAndWireError()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
var failures = new List<(PendingInventoryRequest Request, uint Error)>();
state.RequestFailed += (request, error) => failures.Add((request, error));
Assert.True(state.TryDispatch(
InventoryRequestKind.DropToWorld, First, static () => true));
objects.RejectMove(First, 0x426u);
(PendingInventoryRequest failed, uint error) = Assert.Single(failures);
Assert.Equal(InventoryRequestKind.DropToWorld, failed.Kind);
Assert.Equal(First, failed.ItemId);
Assert.Equal(0x426u, error);
Assert.False(state.HasPendingRequest);
}
[Fact]
public void RequestFailedRequiresTheLatchedGuidAndAnActivePending()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
int failures = 0;
state.RequestFailed += (_, _) => failures++;
Assert.True(state.TryDispatch(
InventoryRequestKind.DropToWorld, First, static () => true));
// A failure for a DIFFERENT item must not consume (or mislabel) the
// latch — the stale-latch guard on retail's latched-guid preference.
objects.RejectMove(Second, 0x426u);
Assert.Equal(0, failures);
Assert.True(state.HasPendingRequest);
objects.RejectMove(First, 0x426u);
Assert.Equal(1, failures);
// RecordResponse cleared the latch; a repeat failure shows nothing.
objects.RejectMove(First, 0x1Du);
Assert.Equal(1, failures);
}
private static ClientObjectTable CreateTable()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = First,
Name = "first",
Type = ItemType.Misc,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Second,
Name = "second",
Type = ItemType.Misc,
});
objects.MoveItem(First, Pack, 0);
objects.MoveItem(Second, Pack, 1);
return objects;
}
}