acdream/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs
Erik 544f8cb2d7 fix(CT): CT2 fix round — dedupe client-side title add, drop retail-inexact send guard
Opus dual-lens review of CT2 (bcfddc97) found 4 SHOULD-FIX + notes; this
applies the campaign lead's rulings.

F1 (the important one): retail's client-side table add is DEDUPED —
gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks
mTitleList and returns without effect when the id is already present,
only inserting on a miss. The server-side SendNotice_AddCharacterTitle
broadcast is unconditional, but RuntimeCharacterTitleState.ApplyUpdateTitle
models the CLIENT receive side, so TitleAdded now fires only on a genuine
new membership. Inverted the pin:
ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision.

F3: removed the send-side titleId==0 rejection from both command
adapters. Retail's own send path (Event_SetDisplayCharacterTitle
@0x006a5720) packs whatever id it is handed, and ACE accepts id 0
(CharacterTitle.Invalid is a defined enum value) — retail's real
protection is the UI ghost-when-current gate (CT3's job), not a
send-side rejection. No register row: this makes acdream MORE
retail-exact.

A2: ResetSession now publishes TableReplaced unconditionally and
DisplayTitleChanged when the display id was non-zero before the clear,
matching the LocalPlayerState.Clear() precedent (publish every category
even when Clear is repeated, so a failed reset can converge on retry).

A3: RuntimeCharacterState.CaptureOwnership reads the new non-allocating
Titles.Count instead of EarnedTitleIds.Count; EarnedTitleIds now carries
an XML warning that every read allocates.

A4/A5: ReplaceTable/ApplyUpdateTitle now mutate under one _gate hold with
change flags computed inside the lock and events raised after release;
every revision bump is gated on an actual state change (a no-op wire
resend produces zero revision edges), matching the change-gated
RuntimeMovementSkillState precedent. TableReplaced itself still fires
unconditionally per retail's own Refresh() dispatch on 0x0029.

A1/A6/A7/A8: CharacterTitleResolverLiveDatTests honors ACDREAM_DAT_DIR
first (CT1 fix-round pattern); documented the EmitResult
primaryObjectId-as-title-id precedent inline; corrected the "third
consumer" comment (CT1 §5 already records gmAttributeUI::PostInit's
icon-DID lookup — CT5 factors the shared GetDIDByEnum helper); added a
titleId -> resolved-string memo to CharacterTitleResolver, the DAT-static
equivalent of retail's lazy-hash cache on the string buffer.

Appended a "CT3 anchors from the CT2 review" list to the plan doc's CT2
ledger entry for CT3 to consume.

Build green. Runtime (102), Core.Net (12), and App (27 + 3 InstalledDat
pins under ACDREAM_RUN_INSTALLED_DAT_TESTS=1) title-scoped tests pass.
Full hermetic solution suite (Lane exclusions per the release gate) is
green: 0 failures across all 15 test projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:27:07 +02:00

69 lines
2.9 KiB
C#

using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CT slice CT2 (2026-08-24) pin: <see cref="CharacterTitleResolver"/>
/// against the installed DAT set, end to end (EnumMapper 0x22000041 -&gt;
/// compute_str_hash -&gt; StringTable 0x2300000E). CT1's research doc
/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5) verified
/// id 13 == "War Mage" against ACE's <c>CharacterTitle.WarMage</c>; this
/// pin adds several more low-ordinal ids from the same enum
/// (<c>CharacterTitle.cs</c>: Invalid=0, Adventurer=1, Archer=2,
/// Blademaster=3, LifeMage=5, Wayfarer=14) so a DAT revision or resolver
/// regression fails loudly instead of drifting unnoticed into CT3/CT4.
/// Follows the durable-pin pattern of <see cref="ChatStringsLiveDatTests"/>.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class CharacterTitleResolverLiveDatTests
{
private static string DatDirectory =>
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
[InstalledDatFact]
public void Resolve_PinsSeveralTitleIdsToTheirRetailDisplayStrings()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
// Retail's own early-return: id 0 (Invalid) never resolves.
Assert.Null(resolver.Resolve(0u));
Assert.Equal("Adventurer", resolver.Resolve(1u));
Assert.Equal("Archer", resolver.Resolve(2u));
Assert.Equal("Blademaster", resolver.Resolve(3u));
Assert.Equal("Life Mage", resolver.Resolve(5u));
Assert.Equal("War Mage", resolver.Resolve(13u));
Assert.Equal("Wayfarer", resolver.Resolve(14u));
}
[InstalledDatFact]
public void Resolve_UnmappedId_ReturnsNull()
{
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
Assert.Null(resolver.Resolve(0xFFFFFFFEu));
}
[InstalledDatFact]
public void Resolve_CachesTheEnumMapperAcrossCalls()
{
// Not a durable behavioral pin — just confirms the lazy-load path
// used by the assertions above resolves the SAME value on a second
// call (the cached-mapper branch), not only on the first (cold) one.
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats));
string? first = resolver.Resolve(13u);
string? second = resolver.Resolve(13u);
Assert.Equal("War Mage", first);
Assert.Equal(first, second);
}
}