acdream/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Erik 08b77e20a9
Some checks failed
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Failing after 6m32s
CI / release (push) Has been skipped
feat(ui) Campaign CA CA4 #431: server-authoritative raises — the optimistic layer is deleted
Retail sends a raise and WAITS: one request in flight, the raise
controls ghost, and displayed state changes only when the authoritative
quality-change record lands (gmStatManagementUI @ 0x004F03F0 family,
pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
§5, whose own conclusion names ApplyLocalRaise as the thing to remove).
The optimistic layer predates the inbound parsers — it existed so the
panel showed anything at all — and with CA2 delivering server truth it
became strictly harmful: against ACE, a wrong TrainSkill cost fails
SILENTLY, so the optimistic promote-and-debit could show a trained
skill the server refused with nothing to ever correct it.

Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers,
and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise,
ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty,
DebitInt64Property) with their tests. Added: the one-in-flight latch in
HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise
controls, and gate release on every authoritative quality signal
(attribute/character/player-property events unconditionally; vital
events only release-and-refresh while a raise is in flight, so regen
ticks stay out of the sheet-rebuild path). Panel unmount resets the
gate — retail's awaiting flag lives on the panel instance.

AP-73 NARROWS rather than retires: retail's release on a rejection that
produces NO quality change is statically unverifiable, and ACE sends
chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until
the CA5 live check, a silently-rejected request leaves the controls
ghosted until panel reopen — recorded with its observable symptom.

Also verified for CA4: the train button sends the DAT-exact TrainedCost
(ACE's silent exact-match rule), and there is correctly NO panel
specialize send — retail/ACE specialize only via the SkillAlterationDevice
item-use + confirmation round-trip, whose client seams
(SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat
routing) already exist. Provider tests now pin the retail contract:
send-without-mutation, one-in-flight, release-on-record, release-on-
unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327
passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:56:31 +02:00

423 lines
18 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 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);
}
}