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>
This commit is contained in:
parent
5781895977
commit
08b77e20a9
8 changed files with 152 additions and 339 deletions
|
|
@ -142,7 +142,7 @@ public sealed class CharacterSheetProviderTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents()
|
||||
public void HandleRaiseRequest_Attribute_SendsWithoutMutation_AndLatchesOneInFlight()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 1000L);
|
||||
|
|
@ -153,11 +153,21 @@ public sealed class CharacterSheetProviderTests
|
|||
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(2u, strength!.Value.Ranks); // optimistic rank apply
|
||||
Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited
|
||||
Assert.True(tableUpdates >= 1); // via the eventful API
|
||||
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]
|
||||
|
|
@ -177,7 +187,7 @@ public sealed class CharacterSheetProviderTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_TrainSkill_DebitsRetailSkillCreditProperty()
|
||||
public void HandleRaiseRequest_TrainSkill_SendsExactDatCostWithoutMutation()
|
||||
{
|
||||
var h = new Harness();
|
||||
var player = h.AddPlayerObject();
|
||||
|
|
@ -189,26 +199,58 @@ public sealed class CharacterSheetProviderTests
|
|||
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
|
||||
|
||||
Assert.Equal((6u, 4u), h.SentTrain);
|
||||
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
||||
Assert.Equal(0, player.Properties.GetInt(0x18u)); // credits debited
|
||||
// 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 SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
|
||||
public void AwaitingRaise_ReleasesOnTheAuthoritativeRecord_AndOnPanelUnmount()
|
||||
{
|
||||
var h = new Harness(); // note: nothing added to the table
|
||||
var props = new PropertyBundle();
|
||||
props.Int64s[2u] = 500L;
|
||||
h.Player.OnProperties(props);
|
||||
// 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 changed = 0;
|
||||
h.Player.CharacterChanged += () => changed++;
|
||||
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);
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
|
||||
// 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);
|
||||
|
||||
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
|
||||
Assert.True(changed >= 1); // and CharacterChanged fired
|
||||
// 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 ───────────────
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue