acdream/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Erik ed652ed8ad feat(ui): Campaign CT slice CT4 — header identity block retail-exact
Retires the rest of AP-109's UI half: the character panel's Name/Heritage/
PkStatus/Level header identity block is now live and DAT-faithful on both
Attributes and Skills pages (verified: CharacterStatController.Bind already
scopes Label/LabelAuthoredColor to the ONE physically-visible page container,
so both tabs share the same bound widgets).

- Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime
  colors to the widget's own authored DefaultColor (LabelAuthoredColor) —
  CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed
  all four already carry the correct FontColor (white/white/white/pale-gold
  with Outline); the former "runtime color, dat carries none" comment was
  false.
- PkStatus resolves through StringTable 0x23000001 by key
  (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise
  IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead
  of the prior exact-equality switch, which silently dropped combined-flag
  PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" /
  "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin
  PkStatusKeys_ResolveExpectedAuthoredStrings).
- Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???"
  when absent (CharacterSheet.Level is now int?).
- Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState
  display title through CharacterTitleResolver, refreshing live on both
  TableReplaced (0x0029) and DisplayTitleChanged (0x002B) —
  CharacterSheetProvider's ChangeBinding now subscribes to both.
- Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance
  rank-title prefix (AllegianceData::GetFullName @0x005B6950 ->
  AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function
  heritage x gender table (verbatim decomp literals, e.g.
  GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out
  of reasonable size for this slice. RuntimeAllegianceState already carries
  the local player's own rank; only the string table is missing. Registered,
  not silently omitted.
- Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/
  MaximumLuminance (PropertyInt64 6/7) already flow generically through both
  the PlayerDescription snapshot and the live 0x02CF private-update parsers
  (no wiring gap). The retail show/hide gate (Level >= 200 &&
  MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles
  Visible on both elements every sheet refresh; the exact caption/value text
  could not be recovered this slice (retail's SetText source resolves
  through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a
  DAT string-table sweep found no match), so content stays unbound rather
  than guessed.
- AP-109 narrowed accordingly (register row amended in the same commit).

Tests: CharacterStatControllerTests (heritage composition + live title
update, name stays plain, level int/"???" with authored — not constant —
color across 3 cases, PK line shows resolved text in authored color across
3 statuses, luminance visibility across 5 level/luminance combinations) and
CharacterSheetProviderTests (PK key-by-status resolution including a
combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present,
title resolution + live refresh on both title events + unsubscribe-on-
dispose, luminance Int64 read-through). Full hermetic solution suite green
under Release (0 failures across all 14 test projects); InstalledDat pins
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:02:11 +02:00

587 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Linq;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Tests for <see cref="CharacterSheetProvider"/> — the extracted character-sheet
/// assembly + raise flow (formerly private methods inside GameWindow). Covers the
/// XP-curve math against a known synthetic ExperienceTable and the single-owner
/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire
/// CharacterChanged).
/// </summary>
public sealed class CharacterSheetProviderTests
{
private const uint PlayerGuid = 0x50000001u;
/// <summary>Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250.</summary>
private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new()
{
Levels = new ulong[] { 0, 100, 250, 450 },
Attributes = new uint[] { 0, 10, 30, 60, 100 },
Vitals = new uint[] { 0, 4, 12, 24 },
TrainedSkills = new uint[] { 0, 5, 15, 30 },
SpecializedSkills = new uint[] { 0, 8, 24, 48 },
};
private sealed class Harness
{
public ClientObjectTable Table { get; } = new();
public LocalPlayerState Player { get; } = new();
public CharacterSheetProvider Provider { get; }
public (uint statId, ulong cost)? SentAttribute;
public (uint skillId, uint credits)? SentTrain;
public bool CanSend = true;
public Harness()
{
Provider = new CharacterSheetProvider(
Table, Player,
playerGuid: () => PlayerGuid,
activeToonName: () => "default",
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 },
canSendRaise: () => CanSend,
sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost),
sendRaiseVital: (_, _) => { },
sendRaiseSkill: (_, _) => { },
sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits))
{
ExperienceTable = MakeXpTable(),
};
}
/// <summary>Put the player's ClientObject in the table with live sheet properties.</summary>
public ClientObject AddPlayerObject(long unassignedXp = 1000L)
{
var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
player.Properties.Ints[0x19u] = 1; // level
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
player.Properties.Int64s[2u] = unassignedXp;
player.Properties.Ints[0x18u] = 4; // available skill credits
Table.AddOrUpdate(player);
return player;
}
}
[Fact]
public void BuildSheet_NoLiveData_UsesFallbackSheet()
{
var h = new Harness();
var sheet = h.Provider.BuildSheet();
Assert.Equal(-1, sheet.Level); // fallback marker
Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player"
}
[Fact]
public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 777L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength
var sheet = h.Provider.BuildSheet();
Assert.Equal("Testy", sheet.Name); // live object name beats "Player"
Assert.Equal(1, sheet.Level);
Assert.Equal(150L, sheet.TotalXp);
Assert.Equal(777L, sheet.UnassignedXp);
// Level band 100..250, at 150: 100 XP to next, 1/3 through the band.
Assert.Equal(100L, sheet.XpToNextLevel);
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
Assert.Equal(11, sheet.Strength); // ranks + start
Assert.Equal(4, sheet.SkillCredits);
// Raise x1: Attributes[2] xpSpent = 30 10; x10 clamps at curve end: 100 10.
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
}
[Fact]
public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 0L);
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 1u, 200L));
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 2u, 75L));
var sheet = h.Provider.BuildSheet();
Assert.Equal(200L, sheet.TotalXp);
Assert.Equal(75L, sheet.UnassignedXp);
Assert.Equal(50L, sheet.XpToNextLevel);
Assert.Equal(2f / 3f, sheet.XpFraction, precision: 4);
}
[Fact]
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
{
var h = new Harness();
h.AddPlayerObject();
h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained
h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded
var sheet = h.Provider.BuildSheet();
var skill = Assert.Single(sheet.Skills);
Assert.Equal(6u, skill.Id);
Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name
Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass);
// TrainedSkills curve: x1 = 15 5; x10 clamps at index 3: 30 5.
Assert.Equal(10L, skill.RaiseCost);
Assert.Equal(25L, skill.Raise10Cost);
}
[Fact]
public void HandleRaiseRequest_Attribute_SendsWithoutMutation_AndLatchesOneInFlight()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 1000L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
int tableUpdates = 0;
h.Table.ObjectUpdated += _ => tableUpdates++;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
// CA4 (retired AP-73): retail sends and WAITS — no local mutation of
// ranks or XP; displayed state changes only when the authoritative
// record lands (gmStatManagementUI, pseudocode doc §5).
Assert.Equal((1u, 20ul), h.SentAttribute);
var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength);
Assert.Equal(1u, strength!.Value.Ranks); // unchanged
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // undebited
Assert.Equal(0, tableUpdates);
Assert.True(h.Provider.BuildSheet().AwaitingRaise);
// One request in flight: a second click sends nothing.
h.SentAttribute = null;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.Null(h.SentAttribute);
}
[Fact]
public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 1000L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
h.CanSend = false;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.Null(h.SentAttribute);
Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks);
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u));
}
[Fact]
public void HandleRaiseRequest_TrainSkill_SendsExactDatCostWithoutMutation()
{
var h = new Harness();
var player = h.AddPlayerObject();
player.Properties.Ints[0x18u] = 4;
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
Assert.Equal((6u, 4u), h.SentTrain);
// CA4: no optimistic promotion or credit debit — against ACE a wrong
// TrainSkill cost fails SILENTLY, so an optimistic apply could show
// a trained skill the server refused, forever.
Assert.Equal(1u, h.Player.GetSkill(6u)!.Value.Status); // still untrained
Assert.Equal(4, player.Properties.GetInt(0x18u)); // credits intact
Assert.True(h.Provider.BuildSheet().AwaitingRaise);
}
[Fact]
public void AwaitingRaise_ReleasesOnTheAuthoritativeRecord_AndOnPanelUnmount()
{
// Retail releases the one-in-flight gate on ANY quality-change
// message (ListenToElementMessage @ 0x004EFBE0); the CA2 inbound
// records arriving at LocalPlayerState are our equivalent. The gate
// also dies with the panel binding, matching retail's per-instance
// awaiting flag.
var h = new Harness();
h.AddPlayerObject(unassignedXp: 1000L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
int rebuilds = 0;
using (h.Provider.SubscribeChanged(() => rebuilds++))
{
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.True(h.Provider.BuildSheet().AwaitingRaise);
// The authoritative attribute record releases + refreshes.
h.Player.OnAttributeUpdate(atType: 1u, ranks: 2u, start: 10u, xp: 30u);
Assert.False(h.Provider.BuildSheet().AwaitingRaise);
Assert.True(rebuilds >= 1);
// A vital regen tick outside a raise must NOT rebuild the sheet.
int before = rebuilds;
h.Player.OnVitalCurrent(vitalId: 2u, current: 50u);
Assert.Equal(before, rebuilds);
// But the full vital record answering a RaiseVital releases.
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Vital, StatId: 1u, Cost: 20L, Amount: 1));
Assert.True(h.Provider.BuildSheet().AwaitingRaise);
h.Player.OnVitalUpdate(vitalId: 1u, ranks: 1u, start: 10u, xp: 20u, current: 15u);
Assert.False(h.Provider.BuildSheet().AwaitingRaise);
}
// Panel unmount resets a still-held gate (silent-rejection recovery).
using (h.Provider.SubscribeChanged(() => { }))
{
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.True(h.Provider.BuildSheet().AwaitingRaise);
}
Assert.False(h.Provider.BuildSheet().AwaitingRaise);
}
// ── Issue #267 — vitae/buff-aware skill + attribute values ───────────────
private static SpellMetadata TestSpell(uint spellId) => new(
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
private sealed class VitaeHarness
{
public ClientObjectTable Table { get; } = new();
public Spellbook Book { get; }
public LocalPlayerState Player { get; }
public CharacterSheetProvider Provider { get; }
public VitaeHarness()
{
Book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
Player = new LocalPlayerState(Book);
Provider = new CharacterSheetProvider(
Table, Player,
playerGuid: () => 0u,
activeToonName: () => "default",
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 });
}
}
[Fact]
public void BuildSheet_AttributeBuff_ShowsEffectiveValueAndBasePair()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
var sheet = h.Provider.BuildSheet();
Assert.Equal(220, sheet.Strength); // effective: 200 * 1.1
Assert.Equal(200, sheet.AttributeBaseValues[0]); // base unaffected
}
[Fact]
public void BuildSheet_AttributeUnderVitae_AttributesAreVitaeImmune()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
Assert.Equal(200, sheet.Strength); // unaffected by vitae
Assert.Equal(200, sheet.AttributeBaseValues[0]);
}
[Fact]
public void BuildSheet_SkillUnderVitae_ShowsEffectiveLevelAndVitaeModifier()
{
var h = new VitaeHarness();
h.Player.OnSkillUpdate(skillId: 6u, ranks: 300u, status: 2u, xp: 0u,
init: 3u, resistance: 0u, lastUsed: 0d, formulaBonus: 0u); // base 303
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u)); // 33% vitae
var sheet = h.Provider.BuildSheet();
var skill = Assert.Single(sheet.Skills);
Assert.Equal(303, skill.BaseLevel);
Assert.Equal(203, skill.CurrentLevel); // 303 * 0.67, truncated
Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example
}
[Fact]
public void BuildSheet_SkillAugmentations_UseRetailBeforeAndAfterOrdering()
{
var h = new VitaeHarness();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 3;
properties.Ints[(uint)PropertyInt.AugmentationSkilledMagic] = 1;
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 4;
h.Player.OnProperties(properties);
h.Player.OnSkillUpdate(
skillId: 0x1Fu,
ranks: 100u,
status: 3u,
xp: 0u,
init: 0u,
resistance: 0u,
lastUsed: 0d,
formulaBonus: 0u);
CharacterSkill skill = Assert.Single(h.Provider.BuildSheet().Skills);
Assert.Equal(113, skill.BaseLevel);
Assert.Equal(126, skill.CurrentLevel);
Assert.Equal(0, skill.VitaeModifier);
}
[Fact]
public void BuildSheet_VitalPairsCarryBaseAndVitaeContribution()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(
atType: 2u,
ranks: 100u,
start: 100u,
xp: 0u);
h.Player.OnVitalUpdate(
vitalId: 7u,
ranks: 0u,
start: 100u,
xp: 0u,
current: 150u);
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: -1d,
CasterGuid: 0u,
StatModType: 0u,
StatModKey: 0u,
StatModValue: 0.8f,
Bucket: 4u));
CharacterSheet sheet = h.Provider.BuildSheet();
Assert.Equal(200, sheet.VitalBaseMaxValues[0]);
Assert.Equal(-40, sheet.VitalVitaeModifiers[0]);
Assert.Equal(160, sheet.HealthMax);
}
[Fact]
public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue()
{
var h = new VitaeHarness();
h.Player.OnAttributeUpdate(atType: 1u, ranks: 100u, start: 100u, xp: 0u); // Strength, base 200
int changed = 0;
using IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
Assert.Equal(200, h.Provider.BuildSheet().Strength); // no buff yet
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u, LayerId: 1u, Duration: 60d, CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u, StatModValue: 1.1f, Bucket: 1u));
Assert.True(changed >= 1); // live-refresh notice fired
Assert.Equal(220, h.Provider.BuildSheet().Strength); // and the rebuilt sheet reflects it
}
[Fact]
public void SubscribeChanged_Dispose_UnsubscribesFromEnchantmentsChanged()
{
var h = new VitaeHarness();
int changed = 0;
IDisposable subscription = h.Provider.SubscribeChanged(() => changed++);
subscription.Dispose();
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u, LayerId: 1u, Duration: -1d, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: 0.67f, Bucket: 4u));
Assert.Equal(0, changed);
}
// ── Campaign CT slice CT4: PK status / display title / luminance ───────
/// <summary>
/// <c>gmStatManagementUI::UpdatePKStatus</c> (0x004f00a0): IsPK() tested
/// first, then IsPKLite(), else "neither" resolves NPK. The resolver
/// stub below echoes the KEY it was handed, so the assertion proves
/// which of the three <c>ID_StatManagement_Header_PKStatus_*</c> keys
/// was selected for each ACE <c>PlayerKillerStatus</c> value — the CT4
/// contract's "PK line resolves the three keys by status".
/// </summary>
[Theory]
[InlineData(0x4, "ID_StatManagement_Header_PKStatus_PK")]
[InlineData(0x40, "ID_StatManagement_Header_PKStatus_PKL")]
[InlineData(0x2, "ID_StatManagement_Header_PKStatus_NPK")] // plain NPK bit
[InlineData(0x0, "ID_StatManagement_Header_PKStatus_NPK")] // Undef — still resolves NPK, not omitted
// Bitwise test (not the prior exact-equality switch): PK combined with
// an unrelated flag (Unprotected, 0x08) still resolves PK — IsPK() is
// true regardless of the other bits.
[InlineData(0x4 | 0x8, "ID_StatManagement_Header_PKStatus_PK")]
public void BuildSheet_PkStatus_ResolvesCorrectKeyByStatus(int rawStatus, string expectedKey)
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
string? capturedKey = null;
var provider = new CharacterSheetProvider(
objects, player,
playerGuid: () => PlayerGuid,
resolveUiString: key =>
{
capturedKey = key;
return key; // echo — the test asserts on the KEY, not invented English
});
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Ints[134u] = rawStatus;
objects.AddOrUpdate(obj);
CharacterSheet sheet = provider.BuildSheet();
Assert.Equal(expectedKey, capturedKey);
Assert.Equal(expectedKey, sheet.PkStatus);
}
/// <summary>CT4 contract: "no invented English; if a key fails to
/// resolve, show nothing" — a null resolver (no live DAT session, e.g.
/// the Studio path) must not synthesize any PK text.</summary>
[Fact]
public void BuildSheet_PkStatus_NoResolver_LeavesPkStatusNull()
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Ints[134u] = 0x4; // PK
objects.AddOrUpdate(obj);
Assert.Null(provider.BuildSheet().PkStatus);
}
/// <summary>CT4 contract item 4: Level is null (not 0) when retail
/// InqInt(0x19) is absent, distinguishing "no property yet" from a
/// genuinely-present value.</summary>
[Fact]
public void BuildSheet_Level_NullWhenPropertyAbsent_PresentOtherwise()
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Ints[0x18u] = 1; // some OTHER property present so HasLiveData() is true
objects.AddOrUpdate(obj);
Assert.Null(provider.BuildSheet().Level);
obj.Properties.Ints[0x19u] = 42;
objects.AddOrUpdate(obj);
Assert.Equal(42, provider.BuildSheet().Level);
}
/// <summary>
/// CT4 item 2: the heritage line's appended title comes from CT2's
/// <see cref="RuntimeCharacterTitleState.DisplayTitleId"/> resolved
/// through the DAT id-&gt;string chain — <see cref="CharacterSheet.Title"/>
/// tracks whatever the resolver returns for the CURRENT display id.
/// </summary>
[Fact]
public void BuildSheet_Title_ResolvesDisplayTitleIdThroughResolver()
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
var titles = new RuntimeCharacterTitleState();
var provider = new CharacterSheetProvider(
objects, player,
playerGuid: () => PlayerGuid,
titles: titles,
resolveDisplayTitle: id => id == 13u ? "War Mage" : null);
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Ints[0x19u] = 1; // some property present so HasLiveData() is true
objects.AddOrUpdate(obj);
Assert.Null(provider.BuildSheet().Title); // no display title seeded yet
titles.ReplaceTable(13u, new uint[] { 13u });
Assert.Equal("War Mage", provider.BuildSheet().Title);
}
/// <summary>
/// CT4 contract: the heritage line MUST refresh live on both
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (0x0029) and
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/> (the
/// display half of 0x002B) — both must fire the sheet-changed
/// notification <see cref="CharacterSheetProvider.SubscribeChanged"/>
/// exposes, and both must stop firing after disposal.
/// </summary>
[Fact]
public void SubscribeChanged_FiresOnTitlesTableReplacedAndDisplayTitleChanged_AndUnsubscribesOnDispose()
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
var titles = new RuntimeCharacterTitleState();
var provider = new CharacterSheetProvider(
objects, player, playerGuid: () => PlayerGuid, titles: titles);
int changed = 0;
IDisposable subscription = provider.SubscribeChanged(() => changed++);
titles.ReplaceTable(1u, new uint[] { 1u }); // TableReplaced (+ DisplayTitleChanged, id 0->1)
Assert.True(changed >= 1);
int afterFirst = changed;
titles.ApplyUpdateTitle(2u, setAsDisplay: true); // UpdateTitle → DisplayTitleChanged (1->2)
Assert.True(changed > afterFirst);
subscription.Dispose();
int afterDispose = changed;
titles.ReplaceTable(3u, new uint[] { 3u });
Assert.Equal(afterDispose, changed);
}
/// <summary>CT4 item 5: retail PropertyInt64 6 (AvailableLuminance) / 7
/// (MaximumLuminance) flow into the sheet exactly like TotalXp/
/// UnassignedXp — the same generic, non-whitelisted Int64 property
/// path.</summary>
[Fact]
public void BuildSheet_Luminance_ReadsInt64Properties6And7()
{
var objects = new ClientObjectTable();
var player = new LocalPlayerState();
var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid);
var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
obj.Properties.Int64s[6u] = 1_500_000L;
obj.Properties.Int64s[7u] = 25_000_000L;
objects.AddOrUpdate(obj);
var sheet = provider.BuildSheet();
Assert.Equal(1_500_000L, sheet.AvailableLuminance);
Assert.Equal(25_000_000L, sheet.MaximumLuminance);
}
}