acdream/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Erik 8f8c0c3a07 feat(ui): Campaign AS AS5 — allegiance rank-title table, exam title bar + character panel name line (G9, narrows AP-109)
Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).

Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.

GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.

Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).

Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.

Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).

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

628 lines
26 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]);
}
// ── Campaign AS slice AS5: name-line allegiance rank-title prefix ──────
// Retires register row AP-109. Same GetFullName port + rank-property id
// (props.GetInt(0x1E)) as the examination window's title bar
// (AppraisalUiControllerTests.CharacterResponse_TitleBarPrefixes*).
[Fact]
public void BuildSheet_WithAllegianceRank_PrefixesNameWithRankTitle()
{
var h = new Harness();
var player = h.AddPlayerObject();
player.Properties.Ints[0x1Eu] = 3; // AllegianceRank
player.Properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian
player.Properties.Ints[0x71u] = 2; // Gender: Female
var sheet = h.Provider.BuildSheet();
Assert.Equal("Baroness Testy", sheet.Name);
}
[Fact]
public void BuildSheet_WithoutAllegianceRank_NameStaysPlain()
{
var h = new Harness();
h.AddPlayerObject();
var sheet = h.Provider.BuildSheet();
Assert.Equal("Testy", sheet.Name);
}
[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".
/// <para>
/// CT4 fix round (2026-08-25, SHOULD-FIX 3): drives the wire property
/// through <see cref="ClientObjectTable.UpdateIntProperty"/> — the SAME
/// path the live <c>0x02CE</c>/<c>0x02CD</c> PropertyInt handler uses,
/// which applies <see cref="PlayerKillerStatusBitfield.Apply"/> to
/// <see cref="ClientObject.PublicWeenieBitfield"/> — instead of writing
/// PropertyInt 134 directly into the bundle. Retail's PK line reads the
/// PWD bits (<c>ACCWeenieObject::IsPK</c>/<c>IsPKLite</c>), never
/// PropertyInt 134 itself; the deleted combined-flag case (<c>0x4 |
/// 0x8</c>) asserted a non-retail bitwise-on-property mapping — ACE only
/// ever sends an EXACT <c>PlayerKillerStatus</c> enum value, and
/// <see cref="PlayerKillerStatusBitfield.Apply"/> matches by exact
/// equality, so an unrecognized combined value falls to its "clear all
/// three" default (NPK), not PK.
/// </para>
/// </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
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" };
objects.AddOrUpdate(obj);
objects.UpdateIntProperty(PlayerGuid, 134u, rawStatus);
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" };
objects.AddOrUpdate(obj);
objects.UpdateIntProperty(PlayerGuid, 134u, 0x4); // PK
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);
}
}