feat(ui) Campaign CA CA4 #431: server-authoritative raises — the optimistic layer is deleted
Some checks failed
CI / linux-portable (push) Successful in 3m30s
CI / windows-gate (push) Failing after 6m32s
CI / release (push) Has been skipped

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>
This commit is contained in:
Erik 2026-08-24 13:56:31 +02:00
parent 5781895977
commit 08b77e20a9
8 changed files with 152 additions and 339 deletions

View file

@ -422,140 +422,6 @@ public sealed class LocalPlayerStateTests
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnSkillUpdate(
skillId: 21u,
ranks: 0u,
status: 1u,
xp: 0u,
init: 5u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 10u);
changed = 0;
Assert.True(s.ApplySkillTraining(21u));
var skill = s.GetSkill(21u);
Assert.NotNull(skill);
Assert.Equal(2u, skill!.Value.Status);
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnSkillUpdate(
skillId: 24u,
ranks: 12u,
status: 2u,
xp: 3456u,
init: 30u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 80u);
Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u));
var run = s.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(22u, run!.Value.Ranks);
Assert.Equal(3956u, run.Value.Xp);
Assert.Equal(132u, run.Value.CurrentLevel);
}
[Fact]
public void ApplyAttributeRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u);
Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u));
var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus);
Assert.NotNull(focus);
Assert.Equal(11u, focus!.Value.Ranks);
Assert.Equal(125u, focus.Value.Xp);
Assert.Equal(21u, focus.Value.Current);
}
[Fact]
public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent()
{
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u);
Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u));
var health = s.Get(LocalPlayerState.VitalKind.Health);
Assert.NotNull(health);
Assert.Equal(6u, health!.Value.Ranks);
Assert.Equal(125u, health.Value.Xp);
Assert.Equal(12u, health.Value.Current);
}
[Fact]
public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Ints[0x18u] = 3;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitIntProperty(0x18u, 2));
Assert.Equal(1, s.Properties.GetInt(0x18u));
Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0
Assert.Equal(0, s.Properties.GetInt(0x18u));
Assert.Equal(2, changed);
}
[Fact]
public void DebitIntProperty_Absent_FalseAndNoEvent()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.False(s.DebitIntProperty(0x18u, 1));
Assert.Equal(0, changed);
}
[Fact]
public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Int64s[2u] = 500L;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitInt64Property(2u, 100L));
Assert.Equal(400L, s.Properties.GetInt64(2u));
Assert.Equal(1, changed);
}
[Fact]
public void DebitInt64Property_ZeroOrAbsent_False()
{
var s = new LocalPlayerState();
Assert.False(s.DebitInt64Property(2u, 100L)); // absent
var props = new PropertyBundle();
props.Int64s[2u] = 0L;
s.OnProperties(props);
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
}
[Fact]
public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState()
{