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>
183 lines
6.2 KiB
C#
183 lines
6.2 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Content;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Lib;
|
|
using DatReaderWriter.Lib.IO;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Conformance for <see cref="DatStringResolver.ResolveTemplate"/> — the
|
|
/// <c>StringTable::GetString @ 0x004300D0</c> fragment/variable interleave
|
|
/// (no-metalanguage branch @ 0x004303B7) that composes the social
|
|
/// confirmation sentences.
|
|
/// </summary>
|
|
public sealed class DatStringResolverTemplateTests
|
|
{
|
|
private const uint TableId = 0x23000001u;
|
|
|
|
[Fact]
|
|
public void PlayerVariableIsTheRetailHash()
|
|
=> Assert.Equal(0x05506DA2u, DatStringResolver.PlayerVariable);
|
|
|
|
[Fact]
|
|
public void ComposesTrailingFragmentTemplate()
|
|
{
|
|
// ID_Allegiance_SwearConfirmation's shape:
|
|
// ["Do you wish to swear to ", "?"] + [PLAYER]
|
|
var resolver = MakeResolver(
|
|
"ID_Allegiance_SwearConfirmation",
|
|
fragments: ["Do you wish to swear to ", "?"],
|
|
variables: [DatStringResolver.PlayerVariable]);
|
|
|
|
Assert.Equal(
|
|
"Do you wish to swear to +Horan?",
|
|
resolver.ResolveTemplate(
|
|
TableId,
|
|
"ID_Allegiance_SwearConfirmation",
|
|
new Dictionary<uint, string>
|
|
{
|
|
[DatStringResolver.PlayerVariable] = "+Horan",
|
|
}));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComposesLeadingVariableTemplate()
|
|
{
|
|
// ID_Fellowship_FellowshipRequest's shape: an EMPTY first fragment,
|
|
// so the player name leads the sentence.
|
|
var resolver = MakeResolver(
|
|
"ID_Fellowship_FellowshipRequest",
|
|
fragments: [
|
|
"",
|
|
" has invited you to join their fellowship. Do you accept?",
|
|
],
|
|
variables: [DatStringResolver.PlayerVariable]);
|
|
|
|
Assert.Equal(
|
|
"+Acdream has invited you to join their fellowship. Do you accept?",
|
|
resolver.ResolveTemplate(
|
|
TableId,
|
|
"ID_Fellowship_FellowshipRequest",
|
|
new Dictionary<uint, string>
|
|
{
|
|
[DatStringResolver.PlayerVariable] = "+Acdream",
|
|
}));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingVariableSubstitutesEmpty()
|
|
{
|
|
var resolver = MakeResolver(
|
|
"ID_Allegiance_SwearConfirmation",
|
|
fragments: ["Do you wish to swear to ", "?"],
|
|
variables: [DatStringResolver.PlayerVariable]);
|
|
|
|
Assert.Equal(
|
|
"Do you wish to swear to ?",
|
|
resolver.ResolveTemplate(
|
|
TableId,
|
|
"ID_Allegiance_SwearConfirmation",
|
|
new Dictionary<uint, string>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownKeyResolvesNull()
|
|
{
|
|
var resolver = MakeResolver(
|
|
"ID_Allegiance_SwearConfirmation",
|
|
fragments: ["Do you wish to swear to ", "?"],
|
|
variables: [DatStringResolver.PlayerVariable]);
|
|
|
|
Assert.Null(resolver.ResolveTemplate(
|
|
TableId, "ID_Not_A_Key", new Dictionary<uint, string>()));
|
|
}
|
|
|
|
private static DatStringResolver MakeResolver(
|
|
string key,
|
|
string[] fragments,
|
|
uint[] variables)
|
|
{
|
|
var entry = new StringTableString();
|
|
foreach (string fragment in fragments)
|
|
entry.Strings.Add(fragment);
|
|
foreach (uint variable in variables)
|
|
entry.Variables.Add(variable);
|
|
|
|
var table = new StringTable { Id = TableId };
|
|
table.Strings[DatStringResolver.ComputeHash(key)] = entry;
|
|
return new DatStringResolver(new SingleTableSource(table));
|
|
}
|
|
|
|
/// <summary>Serves exactly one constructed StringTable through the
|
|
/// production <see cref="IDatReaderWriter"/> seam.</summary>
|
|
private sealed class SingleTableSource : IDatReaderWriter
|
|
{
|
|
private readonly StringTable _table;
|
|
|
|
public SingleTableSource(StringTable table) => _table = table;
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
public IDatDatabase Portal => throw new NotSupportedException();
|
|
public IDatDatabase Cell => throw new NotSupportedException();
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
public IDatDatabase HighRes => throw new NotSupportedException();
|
|
public IDatDatabase Language => throw new NotSupportedException();
|
|
public IDatDatabase Local => throw new NotSupportedException();
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
public int PortalIteration => 0;
|
|
public int CellIteration => 0;
|
|
public int HighResIteration => 0;
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj =>
|
|
fileId == _table.Id && _table is T match ? match : default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
if (fileId == _table.Id && _table is T match)
|
|
{
|
|
value = match;
|
|
return true;
|
|
}
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|