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
|
|
@ -327,7 +327,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-129 | **NARROWED 2026-07-30 (P4 Opus review fix) — `CanMoveInto`/`IsAllowedIn` are now ported and fed; two narrow gaps remain.** `ObjectInfo.CheckEntryRestrictions` resolves the cell's `RestrictionObj` via `PhysicsEngine.Objects` (a `ClientObjectTable`, acdream's `GetObjectA` equivalent) and evaluates the real owner IID / `HouseRestrictionRecord` (open flag, allegiance monarch, guest table) fed from CreateObject's `HouseOwner`/`HouseRestrictions`/`Monarch` PWD-tail fields and live `House_UpdateRestrictions (0x0248)` refreshes — see `RestrictionObjPrevalenceInspectionTests` (103,766 of 729,888 installed EnvCells, 1,293 landblocks, carry a baked `RestrictionObj`; this is the whole housing estate, not a rare case, which is why the OLD unconditional-fail-closed row was upgraded to FIX-FIRST rather than shipped). Remaining gaps: (1) `House_UpdateRestrictions`'s `Sequence` byte is parsed but not used for staleness/reordering rejection — a lost-then-late UDP delivery could transiently apply an older restriction snapshot over a newer one (low-probability; the next full CreateObject or another update self-corrects). (2) Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table) remains entirely unported — unaffected by this fix, since the gate only reads the indoor/EnvCell `CellPhysics.RestrictionObj` field. `HouseData (0x0225)`/`HouseStatus (0x0226)` and the guest-management opcode family (`House_AddPermanentGuest`, `House_UpdateHAR`, etc.) remain unparsed but are NOT consulted by this entry gate (they carry rent/ownership-transfer UI data, not the owner-iid/guest-list pair `CanMoveInto` needs) — noted for future house-UI work, not a residual of this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`Objects`); `src/AcDream.Core/Items/{ClientObject,ClientObjectTable,HouseRestrictions}.cs`; `src/AcDream.Core.Net/{Messages/CreateObject.cs,Messages/GameEvents.cs,GameEventWiring.cs}`; `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (production wiring) | A reordered `House_UpdateRestrictions` pair could transiently apply the older snapshot; self-corrects on the next update or CreateObject. An outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40 (pc:407982-408056); `RestrictionDB::IsAllowedIn` 0x005ae8f0 (pc:444493-444516); `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs`; `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventHouseUpdateRestrictions.cs` |
|
||||
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
||||
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
|
||||
| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
| AP-73 | **NARROWED 2026-08-24 (Campaign CA CA4)** — the optimistic mutation this row filed is DELETED: `ApplyLocalRaise` and the six LocalPlayerState optimistic apply/debit methods are gone, and the raise flow now matches the pinned retail mechanism (one request in flight, raise controls ghost while awaiting, displayed state changes only when the authoritative quality records land — which CA2's inbound 0x02E3/0x02DD parsers now deliver; release on any quality-change event mirrors `gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0`). REMAINING OPEN POINT (the reason this row narrows instead of retiring): retail's release behavior on a rejection that produces NO quality change is statically unverifiable (the pseudocode doc's own §5 caveat), and ACE sends chat-only for a failed Raise* and NOTHING for a rejected RaiseSkill/TrainSkill — acdream holds the gate until the panel remounts (retail's per-instance flag lifetime), which may differ from retail's live behavior. Verify at the CA5 connected gate (deliberately provoke the vital raise-10-with-1-affordable client bug ACE's own comment documents). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ReleaseAwaitingRaise`); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (AwaitingRaise ghosting) | A silently-rejected request leaves the raise controls ghosted until the panel is reopened | Raise buttons stuck ghosted after a failed raise until panel close/reopen — visible only on server-rejected requests | `gmStatManagementUI @ 0x004F03F0`; `gmAttributeUI::RaiseSelection @ 0x0049D020`; `gmSkillUI::RaiseSelection @ 0x0049C8C0`; `InfoRegion::OnQualityChanged @ 0x004F0EB0`; `ListenToElementMessage @ 0x004EFBE0`; pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` §5 |
|
||||
---
|
||||
|
||||
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
|
||||
|
|
|
|||
|
|
@ -122,5 +122,5 @@ neighborhood).
|
|||
| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly |
|
||||
| CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) |
|
||||
| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests |
|
||||
| CA4 | — | |
|
||||
| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten |
|
||||
| CA5 | — | |
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ public sealed class CharacterSheet
|
|||
/// </summary>
|
||||
public int SkillCredits { get; init; }
|
||||
|
||||
/// <summary>Campaign CA CA4 (retired AP-73): true while a raise/train
|
||||
/// request is awaiting its authoritative server record — retail permits
|
||||
/// one in flight and ghosts the raise controls until the quality-change
|
||||
/// message lands (gmStatManagementUI's awaiting flag).</summary>
|
||||
public bool AwaitingRaise { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unassigned (banked) experience points.
|
||||
/// Retail InqInt64(2) — shown in footer line-2 in State-A display.
|
||||
|
|
|
|||
|
|
@ -10,22 +10,21 @@ namespace AcDream.App.UI.Layout;
|
|||
|
||||
/// <summary>
|
||||
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
|
||||
/// window and owns the raise-request flow (wire send + optimistic local
|
||||
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
|
||||
/// assembly + XP-curve math is feature logic, not wiring).
|
||||
/// window and owns the raise-request flow. Extracted from
|
||||
/// <c>GameWindow</c> (Code Structure Rule 1: sheet assembly + XP-curve math
|
||||
/// is feature logic, not wiring).
|
||||
///
|
||||
/// <para>Retail property ids and the decomp anchors for every field are
|
||||
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
|
||||
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
|
||||
///
|
||||
/// <para><b>State ownership:</b> optimistic debits go through the owning
|
||||
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
|
||||
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
|
||||
/// when the player object is in the table, else
|
||||
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
|
||||
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
|
||||
/// Never write the raw property dictionaries from UI code. The next server
|
||||
/// snapshot remains authoritative over every optimistic value.</para>
|
||||
/// <para><b>Raise flow (Campaign CA CA4, retired AP-73):</b>
|
||||
/// server-authoritative, exactly retail's
|
||||
/// (<c>gmStatManagementUI</c> — one request in flight, controls ghost while
|
||||
/// awaiting, no local mutation). Displayed state changes only when the
|
||||
/// authoritative quality records land (<c>PrivateUpdateAttribute /
|
||||
/// PrivateUpdateVital / PrivateUpdateSkill</c> plus the XP/credit property
|
||||
/// updates), which also release the in-flight gate.</para>
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProvider
|
||||
{
|
||||
|
|
@ -177,6 +176,7 @@ public sealed class CharacterSheetProvider
|
|||
: null,
|
||||
Deaths = props.GetInt(0x2Bu),
|
||||
SkillCredits = skillCredits,
|
||||
AwaitingRaise = _awaitingRaise,
|
||||
UnassignedXp = unassignedXp,
|
||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
|
||||
|
|
@ -233,6 +233,10 @@ public sealed class CharacterSheetProvider
|
|||
owner._objects.Cleared += OnCleared;
|
||||
owner._localPlayer.AttributeChanged += OnAttributeChanged;
|
||||
owner._localPlayer.CharacterChanged += OnCharacterChanged;
|
||||
// CA4: vital events participate ONLY in the raise-gate release
|
||||
// (see OnVitalChanged) — regen ticks fire this constantly and
|
||||
// must not rebuild the sheet outside an awaited raise.
|
||||
owner._localPlayer.Changed += OnVitalChanged;
|
||||
// Issue #267: skills/attributes are now vitae + buff aware, so the
|
||||
// sheet must refresh whenever the active-enchantment set changes
|
||||
// (vitae applied/removed on death/lifestone, a buff cast/expired),
|
||||
|
|
@ -245,15 +249,41 @@ public sealed class CharacterSheetProvider
|
|||
{
|
||||
CharacterSheetProvider? owner = _owner;
|
||||
if (owner is not null && value.ObjectId == owner._playerGuid())
|
||||
{
|
||||
// An authoritative player-property record (XP, credits, …)
|
||||
// is a quality change — retail releases the raise gate on
|
||||
// any of them (ListenToElementMessage @ 0x004EFBE0).
|
||||
owner.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCleared() => _changed();
|
||||
|
||||
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
|
||||
private void OnAttributeChanged(LocalPlayerState.AttributeKind _)
|
||||
{
|
||||
_owner?.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private void OnCharacterChanged() => _changed();
|
||||
private void OnCharacterChanged()
|
||||
{
|
||||
_owner?.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private void OnVitalChanged(LocalPlayerState.VitalKind _)
|
||||
{
|
||||
// Release-and-refresh only while a raise is in flight: the full
|
||||
// vital record answering a RaiseVital (or the Endurance/Self
|
||||
// side-push) must un-ghost the controls, but ordinary regen
|
||||
// ticks outside a raise stay out of the sheet-rebuild path.
|
||||
CharacterSheetProvider? owner = _owner;
|
||||
if (owner is null || !owner._awaitingRaise)
|
||||
return;
|
||||
owner.ReleaseAwaitingRaise();
|
||||
_changed();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
@ -267,8 +297,12 @@ public sealed class CharacterSheetProvider
|
|||
owner._objects.Cleared -= OnCleared;
|
||||
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
|
||||
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
|
||||
owner._localPlayer.Changed -= OnVitalChanged;
|
||||
if (owner._localPlayer.Spellbook is { } spellbook)
|
||||
spellbook.EnchantmentsChanged -= OnCleared;
|
||||
// Panel unmount resets the one-in-flight raise gate — retail's
|
||||
// awaiting flag lives on the panel instance and dies with it.
|
||||
owner.ReleaseAwaitingRaise();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -497,14 +531,22 @@ public sealed class CharacterSheetProvider
|
|||
// ── Raise-request flow ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Send a raise/train action to the server and, when a send delegate
|
||||
/// fired, optimistically apply the local effect so the sheet stays
|
||||
/// current during the round trip. The next server snapshot remains
|
||||
/// authoritative (a rejected raise is corrected by the property echo —
|
||||
/// pending/rollback ledger tracked as a follow-up issue).
|
||||
/// Campaign CA CA4 (#431, retires AP-73): send a raise/train action and
|
||||
/// wait for the server's authoritative record — retail's exact flow
|
||||
/// (<c>gmAttributeUI::RaiseSelection @ 0x0049D020</c> /
|
||||
/// <c>gmSkillUI::RaiseSelection @ 0x0049C8C0</c>, pinned in
|
||||
/// docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5):
|
||||
/// one request in flight, the raise controls ghost while awaiting, and
|
||||
/// NO local mutation of ranks/XP/credits — displayed state changes only
|
||||
/// when the quality-change record lands (which CA2's inbound parsers
|
||||
/// now deliver). The former optimistic <c>ApplyLocalRaise</c> layer is
|
||||
/// deleted: against ACE, a wrong TrainSkill cost fails SILENTLY, so an
|
||||
/// optimistic apply could show a trained skill the server refused with
|
||||
/// nothing to ever correct it.
|
||||
/// </summary>
|
||||
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
if (_awaitingRaise) return;
|
||||
if (request.Cost <= 0) return;
|
||||
if (_canSendRaise is not null && !_canSendRaise()) return;
|
||||
|
||||
|
|
@ -542,71 +584,25 @@ public sealed class CharacterSheetProvider
|
|||
}
|
||||
|
||||
if (sent)
|
||||
ApplyLocalRaise(request);
|
||||
_awaitingRaise = true;
|
||||
}
|
||||
|
||||
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
|
||||
ulong cost = (ulong)request.Cost;
|
||||
/// <summary>
|
||||
/// Retail releases the one-in-flight raise gate on ANY authoritative
|
||||
/// quality-change element message
|
||||
/// (<c>gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0</c>) —
|
||||
/// the LocalPlayerState change events are our equivalent. Rejections
|
||||
/// that produce NO quality change (ACE sends chat-only for a failed
|
||||
/// Raise*, and nothing at all for a rejected RaiseSkill/TrainSkill)
|
||||
/// leave the gate held exactly as the static retail evidence leaves it
|
||||
/// unverified (the pseudocode doc's own caveat); panel remount resets
|
||||
/// it, matching retail's per-instance field lifetime. Verify live at
|
||||
/// CA5 before hardening further — see the narrowed AP-73 row.
|
||||
/// </summary>
|
||||
internal void ReleaseAwaitingRaise() => _awaitingRaise = false;
|
||||
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_localPlayer.ApplySkillTraining(request.StatId))
|
||||
SpendSkillCredits(request.Cost);
|
||||
break;
|
||||
}
|
||||
}
|
||||
/// <summary>One raise/train request in flight — retail permits exactly
|
||||
/// one (gmStatManagementUI's awaiting flag).</summary>
|
||||
private bool _awaitingRaise;
|
||||
|
||||
private void SpendUnassignedExperience(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
|
||||
if (current <= 0) return;
|
||||
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
|
||||
current > cost ? current - cost : 0L);
|
||||
return;
|
||||
}
|
||||
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
|
||||
}
|
||||
|
||||
private void SpendSkillCredits(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
|
||||
continue;
|
||||
_objects.UpdateIntProperty(guid, propertyId,
|
||||
current > debit ? current - debit : 0);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (_localPlayer.DebitIntProperty(propertyId, debit))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1112,8 +1112,10 @@ public static class CharacterStatController
|
|||
var sheet = data();
|
||||
long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1);
|
||||
long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10);
|
||||
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
// CA4 (retired AP-73): while a raise awaits its authoritative
|
||||
// record, retail ghosts the raise controls (one request in flight).
|
||||
bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
|
|
@ -1146,9 +1148,9 @@ public static class CharacterStatController
|
|||
|
||||
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
|
||||
long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost;
|
||||
bool affordable = trained
|
||||
bool affordable = !sheet.AwaitingRaise && (trained
|
||||
? cost > 0 && sheet.UnassignedXp >= cost
|
||||
: cost > 0 && sheet.SkillCredits >= cost;
|
||||
: cost > 0 && sheet.SkillCredits >= cost);
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
|
|
@ -1163,7 +1165,7 @@ public static class CharacterStatController
|
|||
if (trained)
|
||||
{
|
||||
long cost10 = selectedSkill.Raise10Cost;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
b.TrySetRetailState(affordable10
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
|
|
|
|||
|
|
@ -623,105 +623,6 @@ public sealed class LocalPlayerState
|
|||
return (run, jump);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimistically apply a successful local attribute-raise action.
|
||||
/// The next server snapshot remains authoritative; this keeps UI state current
|
||||
/// during the round trip after sending the retail raise action.
|
||||
/// </summary>
|
||||
public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent)
|
||||
{
|
||||
if (AttributeIdToKind(atType) is not AttributeKind kind) return false;
|
||||
if (!_attrs.TryGetValue(kind, out var prev)) return false;
|
||||
|
||||
_attrs[kind] = prev with
|
||||
{
|
||||
Ranks = SaturatingAdd(prev.Ranks, amount),
|
||||
Xp = SaturatingAdd(prev.Xp, xpSpent),
|
||||
};
|
||||
AttributeChanged?.Invoke(kind);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Optimistically apply a successful local max-vital raise action.</summary>
|
||||
public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent)
|
||||
{
|
||||
if (VitalIdToKind(vitalId) is not VitalKind kind) return false;
|
||||
VitalSnapshot? existing = Get(kind);
|
||||
if (existing is not VitalSnapshot prev) return false;
|
||||
|
||||
var snap = prev with
|
||||
{
|
||||
Ranks = SaturatingAdd(prev.Ranks, amount),
|
||||
Xp = SaturatingAdd(prev.Xp, xpSpent),
|
||||
};
|
||||
switch (kind)
|
||||
{
|
||||
case VitalKind.Health: _health = snap; break;
|
||||
case VitalKind.Stamina: _stamina = snap; break;
|
||||
case VitalKind.Mana: _mana = snap; break;
|
||||
}
|
||||
Changed?.Invoke(kind);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Optimistically promote an untrained skill after a successful TrainSkill action.</summary>
|
||||
public bool ApplySkillTraining(uint skillId)
|
||||
{
|
||||
if (!_skills.TryGetValue(skillId, out var prev)) return false;
|
||||
if (prev.Status >= 2u) return false;
|
||||
|
||||
_skills[skillId] = prev with { Status = 2u };
|
||||
CharacterChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Optimistically apply a successful local skill-raise action.</summary>
|
||||
public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent)
|
||||
{
|
||||
if (!_skills.TryGetValue(skillId, out var prev)) return false;
|
||||
if (prev.Status < 2u) return false;
|
||||
|
||||
_skills[skillId] = prev with
|
||||
{
|
||||
Ranks = SaturatingAdd(prev.Ranks, amount),
|
||||
Xp = SaturatingAdd(prev.Xp, xpSpent),
|
||||
};
|
||||
CharacterChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimistically debit an int property in the player's property bundle
|
||||
/// (clamped at 0), firing <see cref="CharacterChanged"/> so bound UI
|
||||
/// refreshes. False if the property is absent — callers walk their
|
||||
/// fallback id chain. The next server snapshot remains authoritative.
|
||||
/// All local-player property writes go through eventful APIs like this
|
||||
/// one; writing <see cref="Properties"/> dictionaries directly skips the
|
||||
/// change event and is a single-owner-state violation.
|
||||
/// </summary>
|
||||
public bool DebitIntProperty(uint propertyId, int amount)
|
||||
{
|
||||
if (!_properties.Ints.TryGetValue(propertyId, out int current))
|
||||
return false;
|
||||
_properties.Ints[propertyId] = current > amount ? current - amount : 0;
|
||||
CharacterChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimistically debit an int64 property (clamped at 0), firing
|
||||
/// <see cref="CharacterChanged"/>. False if the property is absent or
|
||||
/// already ≤ 0. The next server snapshot remains authoritative.
|
||||
/// </summary>
|
||||
public bool DebitInt64Property(uint propertyId, long amount)
|
||||
{
|
||||
if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0)
|
||||
return false;
|
||||
_properties.Int64s[propertyId] = current > amount ? current - amount : 0L;
|
||||
CharacterChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the character snapshot to its pre-login state. The object itself
|
||||
/// is process-lived because UI view models subscribe to it once; session
|
||||
|
|
|
|||
|
|
@ -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