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>
628 lines
26 KiB
C#
628 lines
26 KiB
C#
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->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);
|
||
}
|
||
}
|