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>
This commit is contained in:
parent
fc62cb6397
commit
67fe754dd6
15 changed files with 1133 additions and 50 deletions
|
|
@ -354,6 +354,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
stackSplitQuantity: d.StackSplitQuantity,
|
||||
systemMessage:
|
||||
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
||||
// ServerSaysAttemptFailed / HandleFailureEvent refusal lines
|
||||
// (0x00A0) — typed so per-code routing (SpewBox vs chat) follows
|
||||
// WeenieErrorMessages' resolved destination.
|
||||
interfaceText: (text, type) => d.Communication.AddText(text, type),
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
session.CurrentSession?.SendPutItemInContainer(
|
||||
item,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public sealed class GameplayConfirmationController : IDisposable
|
|||
{
|
||||
private readonly RetailDialogFactory _dialogs;
|
||||
private readonly Action<uint, uint, bool> _sendResponse;
|
||||
private readonly Func<uint, string, string?>? _composeMessage;
|
||||
private uint _dialogContext;
|
||||
private uint _serverType;
|
||||
private uint _serverContext;
|
||||
|
|
@ -20,10 +21,12 @@ public sealed class GameplayConfirmationController : IDisposable
|
|||
|
||||
public GameplayConfirmationController(
|
||||
RetailDialogFactory dialogs,
|
||||
Action<uint, uint, bool> sendResponse)
|
||||
Action<uint, uint, bool> sendResponse,
|
||||
Func<uint, string, string?>? composeMessage = null)
|
||||
{
|
||||
_dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
|
||||
_sendResponse = sendResponse ?? throw new ArgumentNullException(nameof(sendResponse));
|
||||
_composeMessage = composeMessage;
|
||||
_dialogs.DialogClosed += OnDialogClosed;
|
||||
}
|
||||
|
||||
|
|
@ -46,9 +49,15 @@ public sealed class GameplayConfirmationController : IDisposable
|
|||
if (_dialogContext != 0u)
|
||||
return false;
|
||||
|
||||
// Types 1/4 arrive as ACE's bare player name; retail's own client
|
||||
// wraps it through the StringInfo template mechanism
|
||||
// (RecvNotice_SwearAllegiance / gmFellowshipUI's FellowshipRequest —
|
||||
// the injected composer owns the resolve). A null compose falls back
|
||||
// to the bare wire message rather than invented English.
|
||||
string message = request.Type is 2u or 3u or 5u or 6u
|
||||
? request.Message + " Continue?"
|
||||
: request.Message;
|
||||
: _composeMessage?.Invoke(request.Type, request.Message)
|
||||
?? request.Message;
|
||||
var data = RetailDialogData.Confirmation(message)
|
||||
.Set(RetailDialogProperty.ElementAttribute40, true);
|
||||
_dialogContext = _dialogs.MakeDialog(data);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
|
@ -59,6 +60,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
private readonly Action<string, RetailLogTextType>? _interfaceText;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
|
||||
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true
|
||||
|
|
@ -115,7 +117,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint, ItemUseRequestReservation>? requestUse = null,
|
||||
Func<uint, uint, int, uint, bool>? sendBuy = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null)
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
||||
Action<string, RetailLogTextType>? interfaceText = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -144,6 +147,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
|
||||
_systemMessage = systemMessage;
|
||||
_interfaceText = interfaceText;
|
||||
_requestUse = requestUse;
|
||||
_sendBuy = sendBuy;
|
||||
_sendBuyAll = sendBuyAll;
|
||||
|
|
@ -171,7 +175,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_transactions.StateChanged += OnTransactionStateChanged;
|
||||
_transactions.RequestCompleted += OnInventoryRequestCompleted;
|
||||
_transactions.RequestFailed += OnInventoryRequestFailed;
|
||||
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
|
||||
_objects.MoveRequestFailed += OnMoveRequestFailedNotice;
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
|
@ -1338,12 +1344,70 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_pendingBackpackPlacement = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c>: the
|
||||
/// server rejected the latched inventory request — compose
|
||||
/// "The <item> can't be <verb>" and route it as LogTextType
|
||||
/// 0x1A (ClientLocal, the SpewBox-only channel). No resolvable item or no
|
||||
/// latched kind → no text, exactly retail's silence.
|
||||
/// </summary>
|
||||
private void OnInventoryRequestFailed(
|
||||
PendingInventoryRequest request,
|
||||
uint weenieError)
|
||||
{
|
||||
if (_interfaceText is null)
|
||||
return;
|
||||
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
|
||||
if (item is null)
|
||||
return;
|
||||
// NAME_PLURAL for merge/split, NAME_APPROPRIATE otherwise (the
|
||||
// ServerSaysAttemptFailed name-style column); a wire-omitted plural
|
||||
// falls back to the appropriate form.
|
||||
bool plural = request.Kind
|
||||
is InventoryRequestKind.Merge
|
||||
or InventoryRequestKind.SplitToContainer
|
||||
or InventoryRequestKind.SplitToWorld;
|
||||
string name = plural && !string.IsNullOrEmpty(item.PluralName)
|
||||
? item.PluralName
|
||||
: item.GetAppropriateName();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return;
|
||||
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
|
||||
is { } text)
|
||||
{
|
||||
_interfaceText(text, RetailLogTextType.ClientLocal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 0x00A0 dispatcher's second leg (<c>case 0xA0 @ 0x0055B342</c>):
|
||||
/// unless the error is in the exclusion set, the generic
|
||||
/// <c>HandleFailureEvent</c> table also runs — independent of whether a
|
||||
/// request was latched. Codes without a table row (0x426 AttunedItem
|
||||
/// among them) resolve to null text and stay silent.
|
||||
/// </summary>
|
||||
private void OnMoveRequestFailedNotice(MoveRequestFailure failure)
|
||||
{
|
||||
if (_interfaceText is null
|
||||
|| failure.WeenieError == 0u
|
||||
|| InventoryFailureMessages.SuppressesGenericFailureText(
|
||||
failure.WeenieError))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var (text, type) = WeenieErrorMessages.Resolve(failure.WeenieError, null);
|
||||
if (text is not null)
|
||||
_interfaceText(text, type);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
_objects.MoveRequestFailed -= OnMoveRequestFailedNotice;
|
||||
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
|
||||
_transactions.RequestFailed -= OnInventoryRequestFailed;
|
||||
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
||||
_transactions.StateChanged -= OnTransactionStateChanged;
|
||||
WorldDropDispatched = null;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,60 @@ public sealed class DatStringResolver
|
|||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Retail's variable-name hash for <c>PLAYER</c> (0x05506DA2) —
|
||||
/// the single substitution slot every social confirmation template uses.</summary>
|
||||
public static readonly uint PlayerVariable = ComputeHash("PLAYER");
|
||||
|
||||
/// <summary>
|
||||
/// Composes a templated StringTable entry: N (or N+1) literal fragments
|
||||
/// interleaved with N named variables, keyed by the entry-key hash.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exact port of <c>StringTable::GetString @ 0x004300D0</c>'s
|
||||
/// no-metalanguage branch <c>@ 0x004303B7</c>: each fragment is appended,
|
||||
/// then the variable in the same slot (resolved through
|
||||
/// <paramref name="variables"/>, keyed by <see cref="ComputeHash"/> of the
|
||||
/// authored variable name; a missing variable substitutes the empty
|
||||
/// string, as retail does). This is NOT a
|
||||
/// <c>StringTableMetaLanguage::RenderString</c> port — callers own
|
||||
/// keeping it to token-free templates (register row AD-81's scope note).
|
||||
/// </remarks>
|
||||
public string? ResolveTemplate(
|
||||
uint tableId,
|
||||
string key,
|
||||
IReadOnlyDictionary<uint, string> variables)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(variables);
|
||||
if (tableId == 0u)
|
||||
return null;
|
||||
|
||||
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
||||
{
|
||||
table = _dats.Get<StringTable>(tableId);
|
||||
_tables[tableId] = table;
|
||||
}
|
||||
|
||||
if (table is null
|
||||
|| !table.Strings.TryGetValue(ComputeHash(key), out var entry)
|
||||
|| entry.Strings.Count == 0)
|
||||
return null;
|
||||
|
||||
var composed = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < entry.Strings.Count; i++)
|
||||
{
|
||||
composed.Append(entry.Strings[i].Value);
|
||||
// Variables are stored as the pre-computed name hashes (the same
|
||||
// compute_str_hash space PlayerVariable lives in).
|
||||
if (i < entry.Variables.Count
|
||||
&& variables.TryGetValue(entry.Variables[i], out string? value))
|
||||
{
|
||||
composed.Append(value);
|
||||
}
|
||||
}
|
||||
return composed.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact retail ELF-style string hash used for StringInfo keys.
|
||||
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
||||
|
|
|
|||
|
|
@ -183,7 +183,12 @@ public sealed class SocialAllegiancePageController
|
|||
Func<uint, uint, UiElement?> TemplateResolver,
|
||||
Func<uint, uint, string?> ResolveString,
|
||||
Func<uint, string?> ResolveWorldObjectName,
|
||||
Func<string, Action<bool>, uint> ShowConfirmation);
|
||||
Func<string, Action<bool>, uint> ShowConfirmation,
|
||||
// (templateKey, playerName) -> the composed confirmation sentence via
|
||||
// StringTable::GetString's fragment/PLAYER-variable interleave
|
||||
// (DatStringResolver.ResolveTemplate). Null delegate or null result →
|
||||
// the caller falls back to the bare name, never invented English.
|
||||
Func<string, string, string?>? ResolvePlayerTemplate = null);
|
||||
|
||||
private readonly record struct VassalRowWidgets(
|
||||
UiText? Name,
|
||||
|
|
@ -216,12 +221,13 @@ public sealed class SocialAllegiancePageController
|
|||
/// <summary>Bind-time-resolved (never per-tick — same discipline every
|
||||
/// other <c>DatStringResolver</c> consumer in this codebase follows).
|
||||
/// Null when resolution failed — the affected widget then keeps its
|
||||
/// import-time text/caption rather than showing invented English.</summary>
|
||||
/// import-time text/caption rather than showing invented English.
|
||||
/// The swear/break/kick confirmation SENTENCES are not latched here:
|
||||
/// they need the target's name, so they compose at click time through
|
||||
/// <see cref="Bindings.ResolvePlayerTemplate"/> (retail's own
|
||||
/// <c>MakeXxxConfirmationDialog</c> shape — 2026-08-13 AD-85 fix).</summary>
|
||||
private readonly string? _monarchLabelCaption;
|
||||
private readonly string? _patronSlashMonarchLabelCaption;
|
||||
private readonly string? _swearConfirmationTemplate;
|
||||
private readonly string? _breakConfirmationTemplate;
|
||||
private readonly string? _kickConfirmationTemplate;
|
||||
|
||||
private readonly Dictionary<uint, VassalRowWidgets> _rows = new();
|
||||
private readonly HashSet<uint> _vassalGuids = new();
|
||||
|
|
@ -262,10 +268,7 @@ public sealed class SocialAllegiancePageController
|
|||
UiButton? breakButton,
|
||||
UiButton? kickButton,
|
||||
string? monarchLabelCaption,
|
||||
string? patronSlashMonarchLabelCaption,
|
||||
string? swearConfirmationTemplate,
|
||||
string? breakConfirmationTemplate,
|
||||
string? kickConfirmationTemplate)
|
||||
string? patronSlashMonarchLabelCaption)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_selfName = selfName;
|
||||
|
|
@ -287,9 +290,6 @@ public sealed class SocialAllegiancePageController
|
|||
_kickButton = kickButton;
|
||||
_monarchLabelCaption = monarchLabelCaption;
|
||||
_patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption;
|
||||
_swearConfirmationTemplate = swearConfirmationTemplate;
|
||||
_breakConfirmationTemplate = breakConfirmationTemplate;
|
||||
_kickConfirmationTemplate = kickConfirmationTemplate;
|
||||
}
|
||||
|
||||
public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
|
||||
|
|
@ -359,15 +359,6 @@ public sealed class SocialAllegiancePageController
|
|||
string? patronSlashMonarchLabelCaption = bindings.ResolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_PatronSlashMonarchLabel"));
|
||||
|
||||
// AD-85: unsubstituted retail template text, used verbatim (never
|
||||
// blended with an invented sentence) — see class doc.
|
||||
string? swearConfirmationTemplate = bindings.ResolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_SwearConfirmation"));
|
||||
string? breakConfirmationTemplate = bindings.ResolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_BreakConfirmation"));
|
||||
string? kickConfirmationTemplate = bindings.ResolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_KickConfirmation"));
|
||||
|
||||
var controller = new SocialAllegiancePageController(
|
||||
bindings,
|
||||
selfName, selfFollowers, selfRank,
|
||||
|
|
@ -375,8 +366,7 @@ public sealed class SocialAllegiancePageController
|
|||
monarchIsPatronSubBlock, monarchExperiencePassedUp,
|
||||
patronField, patronName, patronExperiencePassedUp,
|
||||
vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton,
|
||||
monarchLabelCaption, patronSlashMonarchLabelCaption,
|
||||
swearConfirmationTemplate, breakConfirmationTemplate, kickConfirmationTemplate);
|
||||
monarchLabelCaption, patronSlashMonarchLabelCaption);
|
||||
|
||||
controller.WireButtons();
|
||||
controller.WireCheckbox();
|
||||
|
|
@ -450,7 +440,12 @@ public sealed class SocialAllegiancePageController
|
|||
string? name = _bindings.ResolveWorldObjectName(targetGuid);
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
|
||||
string message = _swearConfirmationTemplate ?? name;
|
||||
// "Do you wish to swear to <target>?" — StringTable template
|
||||
// ID_Allegiance_SwearConfirmation with the PLAYER slot filled at
|
||||
// click time (retail's MakeSwearConfirmationDialog). Null resolve →
|
||||
// the bare name, never invented English.
|
||||
string message = _bindings.ResolvePlayerTemplate?.Invoke(
|
||||
"ID_Allegiance_SwearConfirmation", name) ?? name;
|
||||
_bindings.ShowConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted) _bindings.Swear(targetGuid);
|
||||
|
|
@ -465,7 +460,8 @@ public sealed class SocialAllegiancePageController
|
|||
uint selfGuid = _bindings.LocalPlayerGuid();
|
||||
if (_bindings.Patron(selfGuid) is not { } patron) return;
|
||||
|
||||
string message = _breakConfirmationTemplate ?? patron.Name;
|
||||
string message = _bindings.ResolvePlayerTemplate?.Invoke(
|
||||
"ID_Allegiance_BreakConfirmation", patron.Name) ?? patron.Name;
|
||||
_bindings.ShowConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted) _bindings.Break(patron.CharacterId);
|
||||
|
|
@ -483,7 +479,8 @@ public sealed class SocialAllegiancePageController
|
|||
if (_bindings.Member(_selectedVassalGuid) is not { } vassal) return;
|
||||
|
||||
uint vassalGuid = _selectedVassalGuid;
|
||||
string message = _kickConfirmationTemplate ?? vassal.Name;
|
||||
string message = _bindings.ResolvePlayerTemplate?.Invoke(
|
||||
"ID_Allegiance_KickConfirmation", vassal.Name) ?? vassal.Name;
|
||||
_bindings.ShowConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted) _bindings.Kick(vassalGuid);
|
||||
|
|
|
|||
|
|
@ -2858,7 +2858,24 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// an allegiance member, so the allegiance-profile accessors
|
||||
// above cannot name them).
|
||||
ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(),
|
||||
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
|
||||
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed),
|
||||
// Swear/Break/Kick confirmation sentences: the 0x23000001
|
||||
// templates' PLAYER slot filled at click time (2026-08-13
|
||||
// confirm/weenie-error research §1.4 — the AD-85 dangling
|
||||
// "Do you wish to swear to " fix).
|
||||
ResolvePlayerTemplate: (key, playerName) =>
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return fellowshipStrings.ResolveTemplate(
|
||||
0x23000001u,
|
||||
key,
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[Layout.DatStringResolver.PlayerVariable] = playerName,
|
||||
});
|
||||
}
|
||||
}),
|
||||
Friends: _bindings.Social.Friends,
|
||||
Squelch: _bindings.Social.Squelch,
|
||||
TemplateResolver: TemplateResolver,
|
||||
|
|
@ -2978,9 +2995,37 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
|
||||
DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout);
|
||||
// Types 1/4 carry ACE's bare player name; retail's client wraps it
|
||||
// via the local StringTable templates (single PLAYER variable,
|
||||
// token-free — 2026-08-13 confirm/weenie-error research §1).
|
||||
var confirmationStrings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
string? ComposeConfirmation(uint type, string bareName)
|
||||
{
|
||||
string? key = type switch
|
||||
{
|
||||
// gmAllegianceUI::RecvNotice_SwearAllegiance
|
||||
1u => "ID_Allegiance_AcceptSwearConfirmation",
|
||||
// gmFellowshipUI's incoming FellowshipRequest
|
||||
4u => "ID_Fellowship_FellowshipRequest",
|
||||
_ => null,
|
||||
};
|
||||
if (key is null)
|
||||
return null;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return confirmationStrings.ResolveTemplate(
|
||||
0x23000001u,
|
||||
key,
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = bareName,
|
||||
});
|
||||
}
|
||||
}
|
||||
_gameplayConfirmationController = new GameplayConfirmationController(
|
||||
DialogFactory,
|
||||
_bindings.Confirmations.SendResponse);
|
||||
_bindings.Confirmations.SendResponse,
|
||||
ComposeConfirmation);
|
||||
_itemConfirmationController = new RetailItemConfirmationController(
|
||||
DialogFactory,
|
||||
ItemInteraction);
|
||||
|
|
|
|||
81
src/AcDream.Core/Chat/InventoryFailureMessages.cs
Normal file
81
src/AcDream.Core/Chat/InventoryFailureMessages.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the client-side "The <item> can't be <verb>" refusal
|
||||
/// line retail shows when the server rejects an inventory request
|
||||
/// (InventoryServerSaveFailed 0x00A0).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exact port of <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c>:
|
||||
/// the base text switches on the latched request kind
|
||||
/// (<c>ACCWeenieObject::prevRequest</c>, enum <c>InventoryRequest</c>,
|
||||
/// <c>acclient.h:6812-6825</c>), then an error-code suffix is appended, and
|
||||
/// the result is displayed via <c>ECM_UI::SendNotice_DisplayStringInfo(0x1A)</c>
|
||||
/// — LogTextType 0x1A (<see cref="RetailLogTextType.ClientLocal"/>), the
|
||||
/// SpewBox-only channel. The event dispatcher (<c>case 0xA0 @ 0x0055B342</c>)
|
||||
/// additionally routes most errors through
|
||||
/// <c>ClientCommunicationSystem::HandleFailureEvent</c>; the exclusion set it
|
||||
/// checks first lives in <see cref="SuppressesGenericFailureText"/>.
|
||||
/// </remarks>
|
||||
public static class InventoryFailureMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// The refusal line for a failed inventory request, or null when the
|
||||
/// request kind has no retail verb (retail shows nothing rather than an
|
||||
/// invented sentence). <paramref name="itemName"/> must already be the
|
||||
/// retail-appropriate display name (plural for merge/split —
|
||||
/// <c>NAME_PLURAL</c>; appropriate otherwise).
|
||||
/// </summary>
|
||||
public static string? Compose(
|
||||
InventoryRequestKind kind,
|
||||
string itemName,
|
||||
uint weenieError)
|
||||
{
|
||||
// ServerSaysAttemptFailed's verb switch. acdream has no latched kind
|
||||
// for retail's IR_MOVE ("moved") or IR_WIELD ("wielded") today —
|
||||
// wields ride AutoWieldController without the single-request gate —
|
||||
// so those rows are absent rather than guessed onto a wrong kind.
|
||||
string? verb = kind switch
|
||||
{
|
||||
InventoryRequestKind.Merge => "merged",
|
||||
InventoryRequestKind.SplitToContainer => "split",
|
||||
InventoryRequestKind.SplitToWorld => "split",
|
||||
InventoryRequestKind.Pickup => "picked up",
|
||||
InventoryRequestKind.PutInContainer => "put in the container",
|
||||
InventoryRequestKind.DropToWorld => "dropped",
|
||||
InventoryRequestKind.Give => "given",
|
||||
_ => null,
|
||||
};
|
||||
if (verb is null)
|
||||
return null;
|
||||
|
||||
return $"The {itemName} can't be {verb}{Suffix(weenieError)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ServerSaysAttemptFailed's error suffix map; every other code —
|
||||
/// including 0x426 AttunedItem — gets no suffix.
|
||||
/// </summary>
|
||||
private static string Suffix(uint weenieError) => weenieError switch
|
||||
{
|
||||
0x1Du => " - you're too busy",
|
||||
0x20u => " - you must control both objects",
|
||||
0x28u => " - the item is under someone else's control",
|
||||
0x2Au => " - you are too encumbered",
|
||||
0x36u => " - action cancelled",
|
||||
0x37u or 0x38u or 0x39u => " - unable to move to object",
|
||||
0x3EEu => " - the container is closed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The 0x00A0 dispatcher's exclusion set (<c>@ 0x0055B342</c>): these
|
||||
/// errors skip the <c>HandleFailureEvent</c> leg entirely (their text, if
|
||||
/// any, is owned elsewhere).
|
||||
/// </summary>
|
||||
public static bool SuppressesGenericFailureText(uint weenieError)
|
||||
=> weenieError is 0x1Eu or 0x2Bu or 0x3EFu or 0x43Eu or 0x4CEu
|
||||
or 0x4CFu or 0x46Au;
|
||||
}
|
||||
|
|
@ -77,6 +77,18 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
public event Action<PendingInventoryRequest>? RequestCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the pending request is cleared by an
|
||||
/// InventoryServerSaveFailed (0x00A0) response, carrying the request plus
|
||||
/// the wire WeenieError. This is the seam retail's
|
||||
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c> consumes:
|
||||
/// it reads <c>prevRequest</c> to pick the "can't be <verb>" text
|
||||
/// before <c>RecordResponse</c> clears the latch. Fires after
|
||||
/// <see cref="RequestCompleted"/> for the same request.
|
||||
/// </summary>
|
||||
public event Action<PendingInventoryRequest, uint>? RequestFailed;
|
||||
|
||||
public event Action? ObjectTableCleared;
|
||||
|
||||
public ClientObjectTable Objects => _objects;
|
||||
|
|
@ -278,6 +290,7 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
_busyCount = 0;
|
||||
StateChanged = null;
|
||||
RequestCompleted = null;
|
||||
RequestFailed = null;
|
||||
ObjectTableCleared = null;
|
||||
}
|
||||
|
||||
|
|
@ -310,10 +323,18 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
CompleteInventoryResponse(move.ItemId, move.Item);
|
||||
}
|
||||
|
||||
private void OnMoveFailed(MoveRequestFailure failure) =>
|
||||
CompleteInventoryResponse(
|
||||
failure.ItemId,
|
||||
_objects.Get(failure.ItemId));
|
||||
private void OnMoveFailed(MoveRequestFailure failure)
|
||||
{
|
||||
// Match by the wire guid. Retail's dispatcher (case 0xA0 @ 0x0055B342)
|
||||
// PREFERS the latched prevRequestObjectID over the wire guid, but ACE
|
||||
// always sends the item guid, so requiring the match is equivalent —
|
||||
// and it protects a stale latch from mislabeling an unrelated failure.
|
||||
if (CompleteInventoryResponse(failure.ItemId, _objects.Get(failure.ItemId))
|
||||
is { } failed)
|
||||
{
|
||||
Dispatch(RequestFailed, failed, failure.WeenieError);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnObjectRemoved(ClientObject item) =>
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
|
|
@ -321,7 +342,7 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
private void OnStackSizeUpdated(ClientObject item) =>
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
|
||||
private void CompleteInventoryResponse(
|
||||
private PendingInventoryRequest? CompleteInventoryResponse(
|
||||
uint itemId,
|
||||
ClientObject? identity)
|
||||
{
|
||||
|
|
@ -329,7 +350,7 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
|| request.ItemId != itemId
|
||||
|| !MatchesIdentity(request.ItemIdentity, itemId, identity))
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// RecordResponse clears prevRequest before ItemList receives the
|
||||
|
|
@ -338,6 +359,7 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
_pendingRequest = null;
|
||||
Dispatch(RequestCompleted, request);
|
||||
DispatchStateChanged();
|
||||
return request;
|
||||
}
|
||||
|
||||
private bool MatchesIdentity(
|
||||
|
|
@ -398,6 +420,23 @@ public sealed class InventoryTransactionState : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void Dispatch<T1, T2>(Action<T1, T2>? listeners, T1 first, T2 second)
|
||||
{
|
||||
if (listeners is null)
|
||||
return;
|
||||
foreach (Action<T1, T2> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try
|
||||
{
|
||||
listener(first, second);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecordDispatchFailure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordDispatchFailure(Exception error)
|
||||
{
|
||||
Interlocked.Increment(ref _dispatchFailureCount);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue