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>
225 lines
10 KiB
C#
225 lines
10 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.Tests.UI.Layout;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public sealed class GameplayConfirmationControllerTests
|
|
{
|
|
[Fact]
|
|
public void SkillRequestAppendsContinueAndCloseNoticeSendsServerTuple()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
ImportedLayout? shown = null;
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
shown = FixtureLoader.LoadConfirmationDialog());
|
|
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(type, context, accepted) => responses.Add((type, context, accepted)));
|
|
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(2u, 42u, "Raise this skill?")));
|
|
Assert.Equal(
|
|
"Raise this skill? Continue?",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
|
|
|
Assert.IsType<UiButton>(shown.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
Assert.Equal([(2u, 42u, true)], responses);
|
|
Assert.Equal(0u, controller.ActiveDialogContext);
|
|
}
|
|
|
|
/// <summary>
|
|
/// MUST-FIX 2 (FA4 fix round, 2026-08-12): a fellowship invite
|
|
/// (<c>ConfirmationType.Fellowship</c>, type 4) reaches this generic
|
|
/// controller and opens a dialog exactly like any other type — no
|
|
/// client-side interceptor exists anymore (D6's correction: retail's
|
|
/// client reads neither option bit on the invite path;
|
|
/// <c>RetailUiRuntime.HandleConfirmationRequest</c> now routes every
|
|
/// type, including 4, straight here). Type 4 is NOT in the
|
|
/// 2/3/5/6 " Continue?"-suffix set, so with no composer injected the
|
|
/// message renders verbatim — matching
|
|
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s case-4 arm.
|
|
/// (Production now injects the StringTable template composer — see
|
|
/// <see cref="InjectedComposerWrapsTypes1And4_AndNeverTouchesContinueFamily"/>;
|
|
/// this test remains the null-composer fallback contract.)
|
|
/// </summary>
|
|
[Fact]
|
|
public void FellowshipInviteRequest_Type4_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
ImportedLayout? shown = null;
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
shown = FixtureLoader.LoadConfirmationDialog());
|
|
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(type, context, accepted) => responses.Add((type, context, accepted)));
|
|
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(4u, 7u, "Alice invites you to join their fellowship.")));
|
|
|
|
Assert.Equal(
|
|
"Alice invites you to join their fellowship.",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
|
|
|
Assert.IsType<UiButton>(shown.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
Assert.Equal([(4u, 7u, true)], responses);
|
|
Assert.Equal(0u, controller.ActiveDialogContext);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-13 social gate round 2 (the AD-85 narrowing): with an
|
|
/// injected composer, types 1/4 render the StringTable-composed
|
|
/// sentence instead of ACE's bare name; the 2/3/5/6 " Continue?" family
|
|
/// never consults the composer; and a null compose result falls back to
|
|
/// the bare wire message.
|
|
/// </summary>
|
|
[Fact]
|
|
public void InjectedComposerWrapsTypes1And4_AndNeverTouchesContinueFamily()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
ImportedLayout? shown = null;
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
shown = FixtureLoader.LoadConfirmationDialog());
|
|
var composed = new List<uint>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(_, _, _) => { },
|
|
(type, bareName) =>
|
|
{
|
|
composed.Add(type);
|
|
return type == 4u
|
|
? bareName
|
|
+ " has invited you to join their fellowship. Do you accept?"
|
|
: null;
|
|
});
|
|
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(4u, 7u, "Alice")));
|
|
Assert.Equal(
|
|
"Alice has invited you to join their fellowship. Do you accept?",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
|
.Select(static line => line.Text)));
|
|
Assert.IsType<UiButton>(shown.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
// Null compose result (type 1 here) → the bare wire message.
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(1u, 8u, "Bob")));
|
|
Assert.Equal(
|
|
"Bob",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
|
.Select(static line => line.Text)));
|
|
Assert.IsType<UiButton>(shown.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
// The " Continue?" family bypasses the composer entirely.
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(2u, 9u, "Raise this skill?")));
|
|
Assert.Equal(
|
|
"Raise this skill? Continue?",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
|
.Select(static line => line.Text)));
|
|
Assert.Equal([4u, 1u], composed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA5, item 3: verifies the allegiance-swear
|
|
/// confirmation (<c>ConfirmationType.AllegianceSwear</c>, type 1 —
|
|
/// <c>RecvNotice_SwearAllegianceRequest</c> ->
|
|
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s case 1)
|
|
/// reaches this SAME generic controller, mirroring the type-4 test
|
|
/// above exactly — no allegiance-specific intercept exists (there
|
|
/// never was one to remove; D6's fellowship correction did not touch
|
|
/// type 1 at all, but FA5's own contract calls for this explicit check
|
|
/// since <c>SocialAllegiancePageController</c> is the new panel that
|
|
/// makes this path reachable). Type 1 is NOT in the 2/3/5/6 " Continue?"
|
|
/// suffix set, so with no composer injected the message renders verbatim
|
|
/// — ACE's own type-1 message is the target's BARE name (lane C §6.4:
|
|
/// <c>Player_Allegiance.cs:91</c>/<c>ConfirmationManager.cs:38</c>).
|
|
/// (Production now injects the StringTable template composer that wraps
|
|
/// the name into retail's full sentence — the 2026-08-13 AD-85
|
|
/// narrowing; this test remains the null-composer fallback contract.)
|
|
/// </summary>
|
|
[Fact]
|
|
public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
ImportedLayout? shown = null;
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
shown = FixtureLoader.LoadConfirmationDialog());
|
|
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(type, context, accepted) => responses.Add((type, context, accepted)));
|
|
|
|
Assert.True(controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(1u, 13u, "Bob")));
|
|
|
|
Assert.Equal(
|
|
"Bob",
|
|
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
|
|
|
Assert.IsType<UiButton>(shown.FindElement(
|
|
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
|
|
|
Assert.Equal([(1u, 13u, true)], responses);
|
|
Assert.Equal(0u, controller.ActiveDialogContext);
|
|
}
|
|
|
|
[Fact]
|
|
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
FixtureLoader.LoadConfirmationDialog());
|
|
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(type, context, accepted) => responses.Add((type, context, accepted)));
|
|
controller.HandleRequest(new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
|
|
|
|
Assert.False(controller.HandleDone(
|
|
new GameEvents.CharacterConfirmationDone(7u, 100u)));
|
|
Assert.True(factory.IsOpen);
|
|
Assert.True(controller.HandleDone(
|
|
new GameEvents.CharacterConfirmationDone(7u, 99u)));
|
|
|
|
Assert.False(factory.IsOpen);
|
|
Assert.Equal([(7u, 99u, false)], responses);
|
|
}
|
|
|
|
[Fact]
|
|
public void FactoryReset_CompletesResponseBeforeSessionTupleIsForgotten()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var factory = new RetailDialogFactory(root, _ =>
|
|
FixtureLoader.LoadConfirmationDialog());
|
|
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
|
using var controller = new GameplayConfirmationController(
|
|
factory,
|
|
(type, context, accepted) => responses.Add((type, context, accepted)));
|
|
controller.HandleRequest(
|
|
new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
|
|
|
|
factory.Reset();
|
|
controller.ResetSession();
|
|
|
|
Assert.Equal(0u, controller.ActiveDialogContext);
|
|
Assert.Equal([(7u, 99u, false)], responses);
|
|
Assert.False(factory.IsOpen);
|
|
}
|
|
|
|
}
|