feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
|
|
@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 68 rows
|
## 3. Documented approximation (AP) — 70 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -171,6 +171,8 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
|
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
|
||||||
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
|
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
|
||||||
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
||||||
|
| 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-73 | **Character raises apply optimistically with no pending/rollback ledger** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits unassigned XP / skill credits locally (via eventful store APIs). Retail's local-predict behavior for raises is unverified; item moves in acdream reconcile via RecordPending/Confirm/Rollback but raises do not (**#163**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ApplyLocalRaise`) | The next server property echo (PlayerDescription / private stat updates) is authoritative and overwrites every optimistic value; ACE confirms affordable raises, and the affordability gate runs client-side first (`Cost <= 0` refuses). | A server-rejected raise (XP race, validation) leaves ranks/XP/credits wrong on the sheet until the next full property refresh — retire by porting the ledger (#163) or cdb-tracing retail's raise flow. | unverified — candidate cdb trace: retail raise-button send path |
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 32 rows
|
## 4. Temporary stopgap (TS) — 32 rows
|
||||||
|
|
|
||||||
|
|
@ -659,8 +659,12 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||||||
private AcDream.App.UI.UiHost? _uiHost;
|
private AcDream.App.UI.UiHost? _uiHost;
|
||||||
|
private AcDream.App.UI.Testing.RetailUiAutomationScriptRunner? _uiAutomationRunner;
|
||||||
|
private AcDream.App.UI.CursorFeedbackController? _cursorFeedbackController;
|
||||||
|
private RetailCursorManager? _retailCursorManager;
|
||||||
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
|
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
|
||||||
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
|
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
|
||||||
|
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||||
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
|
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
|
||||||
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
|
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
|
||||||
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
|
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
|
||||||
|
|
@ -764,6 +768,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// and out keeps the right skills.
|
// and out keeps the right skills.
|
||||||
private int _lastSeenRunSkill = -1;
|
private int _lastSeenRunSkill = -1;
|
||||||
private int _lastSeenJumpSkill = -1;
|
private int _lastSeenJumpSkill = -1;
|
||||||
|
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
|
||||||
|
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
||||||
|
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
|
||||||
// K.1b: this field is RESERVED — written when entering / leaving player
|
// K.1b: this field is RESERVED — written when entering / leaving player
|
||||||
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
|
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
|
||||||
// never consumed by MovementInput (mouse never drives character yaw —
|
// never consumed by MovementInput (mouse never drives character yaw —
|
||||||
|
|
@ -1829,6 +1836,28 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||||
|
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => _playerServerGuid,
|
||||||
|
sendUse: g => _liveSession?.SendUse(g),
|
||||||
|
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||||
|
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||||
|
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||||
|
toast: text => _debugVm?.AddToast(text));
|
||||||
|
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController);
|
||||||
|
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
|
||||||
|
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
|
||||||
|
Objects, LocalPlayer,
|
||||||
|
playerGuid: () => _playerServerGuid,
|
||||||
|
activeToonName: () => _activeToonKey,
|
||||||
|
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
||||||
|
canSendRaise: () => _liveSession is not null
|
||||||
|
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
|
||||||
|
sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost),
|
||||||
|
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
|
||||||
|
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
|
||||||
|
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
|
||||||
|
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||||
|
|
||||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||||
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
||||||
|
|
@ -2079,6 +2108,7 @@ public sealed class GameWindow : IDisposable
|
||||||
peaceDigits: toolbarPeaceDigits,
|
peaceDigits: toolbarPeaceDigits,
|
||||||
warDigits: toolbarWarDigits,
|
warDigits: toolbarWarDigits,
|
||||||
emptyDigits: toolbarEmptyDigits,
|
emptyDigits: toolbarEmptyDigits,
|
||||||
|
itemInteraction: _itemInteractionController,
|
||||||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||||||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
||||||
|
|
||||||
|
|
@ -2176,6 +2206,47 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
|
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
|
||||||
|
|
||||||
|
// Phase D.2b-C: live Character window (Attributes / Skills / Titles) from
|
||||||
|
// LayoutDesc 0x2100002E. Starts hidden and is toggled by the status-bar
|
||||||
|
// character button; stats use the same retail-bound sheet shape as the studio
|
||||||
|
// until the live character stat feed is wired.
|
||||||
|
AcDream.App.UI.Layout.ImportedLayout? characterLayout;
|
||||||
|
lock (_datLock)
|
||||||
|
characterLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||||
|
_dats!, 0x2100002Eu, ResolveChrome, vitalsDatFont, ResolveDatFont);
|
||||||
|
if (characterLayout is not null)
|
||||||
|
{
|
||||||
|
AcDream.App.UI.Layout.CharacterStatController.Bind(
|
||||||
|
characterLayout,
|
||||||
|
data: _characterSheetProvider!.BuildSheet,
|
||||||
|
datFont: vitalsDatFont,
|
||||||
|
rowDatFont: ResolveDatFont(0x40000001u) ?? vitalsDatFont,
|
||||||
|
spriteResolve: ResolveChrome,
|
||||||
|
onRaiseRequest: _characterSheetProvider.HandleRaiseRequest,
|
||||||
|
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Character));
|
||||||
|
|
||||||
|
// Retail-authored content is 380px tall but the skill list wants to
|
||||||
|
// grow: bottom-edge-only resize up to 760px, X locked to the dat width.
|
||||||
|
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
|
_uiHost.Root, characterLayout.Root, ResolveChrome,
|
||||||
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
|
{
|
||||||
|
Left = 540f,
|
||||||
|
Top = 18f,
|
||||||
|
MaxHeight = 760f,
|
||||||
|
ResizeX = false,
|
||||||
|
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
||||||
|
Visible = false,
|
||||||
|
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||||
|
| AcDream.App.UI.AnchorEdges.Bottom,
|
||||||
|
ContentClickThrough = false,
|
||||||
|
WindowName = AcDream.App.UI.WindowNames.Character,
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
||||||
|
}
|
||||||
|
else Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found.");
|
||||||
|
|
||||||
// Drain plugin-registered markup panels (buffered before the GL
|
// Drain plugin-registered markup panels (buffered before the GL
|
||||||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||||
// file is isolated — logged + skipped, never crashes the client.
|
// file is isolated — logged + skipped, never crashes the client.
|
||||||
|
|
@ -2294,13 +2365,16 @@ public sealed class GameWindow : IDisposable
|
||||||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
|
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
|
||||||
? (int?)sa.Current : null,
|
? (int?)sa.Current : null,
|
||||||
datFont: vitalsDatFont,
|
datFont: vitalsDatFont,
|
||||||
|
ownerName: _characterSheetProvider!.CharacterName,
|
||||||
contentsEmptySprite: contentsEmpty,
|
contentsEmptySprite: contentsEmpty,
|
||||||
sideBagEmptySprite: sideBagEmpty,
|
sideBagEmptySprite: sideBagEmpty,
|
||||||
mainPackEmptySprite: mainPackEmpty,
|
mainPackEmptySprite: mainPackEmpty,
|
||||||
sendUse: g => _liveSession?.SendUse(g),
|
sendUse: g => _liveSession?.SendUse(g),
|
||||||
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
|
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
|
||||||
sendPutItemInContainer: (item, container, placement) =>
|
sendPutItemInContainer: (item, container, placement) =>
|
||||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||||
|
itemInteraction: _itemInteractionController,
|
||||||
|
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Inventory));
|
||||||
|
|
||||||
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
|
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
|
||||||
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
|
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
|
||||||
|
|
@ -2312,7 +2386,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
||||||
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
||||||
emptySlotSprite: contentsEmpty,
|
emptySlotSprite: contentsEmpty,
|
||||||
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
|
datFont: vitalsDatFont,
|
||||||
|
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
|
||||||
|
|
||||||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||||||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||||||
|
|
@ -2324,6 +2399,25 @@ public sealed class GameWindow : IDisposable
|
||||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||||
}
|
}
|
||||||
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
||||||
|
|
||||||
|
_toolbarController?.BindWindowToggles(
|
||||||
|
toggleInventory: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory),
|
||||||
|
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
|
||||||
|
SyncToolbarWindowButtons();
|
||||||
|
|
||||||
|
if (_options.UiProbeEnabled)
|
||||||
|
{
|
||||||
|
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
|
||||||
|
var probe = new AcDream.App.UI.Testing.RetailUiAutomationProbe(
|
||||||
|
_uiHost.Root,
|
||||||
|
Objects,
|
||||||
|
ProbeLog);
|
||||||
|
_uiAutomationRunner = new AcDream.App.UI.Testing.RetailUiAutomationScriptRunner(
|
||||||
|
probe,
|
||||||
|
_options.UiProbeScript,
|
||||||
|
_options.UiProbeDump,
|
||||||
|
ProbeLog);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||||
|
|
@ -2689,6 +2783,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// and jumps undershot retail by ~30 % at typical
|
// and jumps undershot retail by ~30 % at typical
|
||||||
// attribute levels.
|
// attribute levels.
|
||||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||||
|
if (_characterSheetProvider is not null && _dats is not null)
|
||||||
|
{
|
||||||
|
_characterSheetProvider.SkillTable = skillTable;
|
||||||
|
_characterSheetProvider.ExperienceTable =
|
||||||
|
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||||
|
_dats, Console.WriteLine);
|
||||||
|
}
|
||||||
|
|
||||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
AcDream.Core.Net.GameEventWiring.WireAll(
|
||||||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
||||||
|
|
@ -9498,6 +9599,8 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_options.RetailUi && _uiHost is not null)
|
if (_options.RetailUi && _uiHost is not null)
|
||||||
{
|
{
|
||||||
_uiHost.Tick(deltaSeconds);
|
_uiHost.Tick(deltaSeconds);
|
||||||
|
_uiAutomationRunner?.Tick(deltaSeconds);
|
||||||
|
UpdateRetailCursorFeedback();
|
||||||
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11654,6 +11757,42 @@ public sealed class GameWindow : IDisposable
|
||||||
// first EnterWorld.
|
// first EnterWorld.
|
||||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
|
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
|
||||||
private string _activeToonKey = "default";
|
private string _activeToonKey = "default";
|
||||||
|
|
||||||
|
private bool ToggleRetailWindow(string name)
|
||||||
|
{
|
||||||
|
bool visible = _uiHost?.ToggleWindow(name) ?? false;
|
||||||
|
SyncToolbarWindowButtons();
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseRetailWindow(string name)
|
||||||
|
{
|
||||||
|
_uiHost?.HideWindow(name);
|
||||||
|
SyncToolbarWindowButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncToolbarWindowButtons()
|
||||||
|
{
|
||||||
|
if (_toolbarController is null || _uiHost is null) return;
|
||||||
|
_toolbarController.SetInventoryOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Inventory));
|
||||||
|
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateRetailCursorFeedback()
|
||||||
|
{
|
||||||
|
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var feedback = _cursorFeedbackController.Update(_uiHost.Root);
|
||||||
|
_retailCursorManager.Apply(_input.Mice, feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||||
|
{
|
||||||
|
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||||
|
_itemInteractionController?.DropToWorld(itemPayload);
|
||||||
|
}
|
||||||
|
|
||||||
// L.0 follow-up: persisted-settings cache populated by
|
// L.0 follow-up: persisted-settings cache populated by
|
||||||
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
|
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
|
||||||
// not gated on DevToolsEnabled). The Settings PANEL construction
|
// not gated on DevToolsEnabled). The Settings PANEL construction
|
||||||
|
|
@ -12046,7 +12185,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
|
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
|
||||||
// does not fire while the chat input holds focus. Null _uiHost =
|
// does not fire while the chat input holds focus. Null _uiHost =
|
||||||
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
|
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
|
||||||
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
|
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||||
|
|
@ -12153,7 +12292,9 @@ public sealed class GameWindow : IDisposable
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||||
if (_cameraController?.IsFlyMode == true)
|
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||||
|
_itemInteractionController.CancelTargetMode();
|
||||||
|
else if (_cameraController?.IsFlyMode == true)
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||||
else if (_playerMode)
|
else if (_playerMode)
|
||||||
{
|
{
|
||||||
|
|
@ -12291,6 +12432,12 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
if (picked is uint guid)
|
if (picked is uint guid)
|
||||||
{
|
{
|
||||||
|
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||||
|
{
|
||||||
|
_itemInteractionController.AcquireTarget(guid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SelectedGuid = guid;
|
SelectedGuid = guid;
|
||||||
string label = DescribeLiveEntity(guid);
|
string label = DescribeLiveEntity(guid);
|
||||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||||
|
|
@ -12324,6 +12471,8 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||||
|
return;
|
||||||
_debugVm?.AddToast("Nothing to select");
|
_debugVm?.AddToast("Nothing to select");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/AcDream.App/Rendering/RetailCursorManager.cs
Normal file
142
src/AcDream.App/Rendering/RetailCursorManager.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Core.Textures;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using Silk.NET.Core;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
|
||||||
|
public sealed class RetailCursorManager
|
||||||
|
{
|
||||||
|
private readonly DatCollection _dats;
|
||||||
|
private readonly object _datLock;
|
||||||
|
private readonly RetailCursorResolver _globalCursors;
|
||||||
|
private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
|
||||||
|
private readonly HashSet<uint> _missingSurfaces = new();
|
||||||
|
private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1);
|
||||||
|
private UiCursorMedia _lastCursor;
|
||||||
|
|
||||||
|
public RetailCursorManager(DatCollection dats, object datLock)
|
||||||
|
{
|
||||||
|
_dats = dats;
|
||||||
|
_datLock = datLock;
|
||||||
|
_globalCursors = new RetailCursorResolver(dats, datLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(IEnumerable<IMouse> mice, CursorFeedback feedback)
|
||||||
|
{
|
||||||
|
if (feedback.Cursor.IsValid && TryGetImage(feedback.Cursor.File, out var image))
|
||||||
|
{
|
||||||
|
ApplyCustom(mice, feedback.Cursor, image);
|
||||||
|
_lastKind = feedback.Kind;
|
||||||
|
_lastCursor = feedback.Cursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_globalCursors.TryResolve(feedback.Kind, out var globalCursor)
|
||||||
|
&& TryGetImage(globalCursor.File, out var globalImage))
|
||||||
|
{
|
||||||
|
ApplyCustom(mice, globalCursor, globalImage);
|
||||||
|
_lastKind = feedback.Kind;
|
||||||
|
_lastCursor = globalCursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyStandard(mice, StandardCursorFor(feedback.Kind));
|
||||||
|
_lastKind = feedback.Kind;
|
||||||
|
_lastCursor = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCustom(IEnumerable<IMouse> mice, UiCursorMedia cursorMedia, RawImage image)
|
||||||
|
{
|
||||||
|
if (_lastCursor.Equals(cursorMedia))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var mouse in mice)
|
||||||
|
{
|
||||||
|
var cursor = mouse.Cursor;
|
||||||
|
cursor.Image = image;
|
||||||
|
cursor.HotspotX = cursorMedia.HotspotX;
|
||||||
|
cursor.HotspotY = cursorMedia.HotspotY;
|
||||||
|
if (cursor.Type != CursorType.Custom)
|
||||||
|
cursor.Type = CursorType.Custom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyStandard(IEnumerable<IMouse> mice, StandardCursor desired)
|
||||||
|
{
|
||||||
|
if (_lastCursor.Equals(default(UiCursorMedia)) && _lastKind != (CursorFeedbackKind)(-1)
|
||||||
|
&& StandardCursorFor(_lastKind) == desired)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var mouse in mice)
|
||||||
|
{
|
||||||
|
var cursor = mouse.Cursor;
|
||||||
|
var standard = desired;
|
||||||
|
if (!cursor.IsSupported(standard))
|
||||||
|
standard = StandardCursor.Arrow;
|
||||||
|
if (!cursor.IsSupported(standard))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (cursor.Type != CursorType.Standard)
|
||||||
|
cursor.Type = CursorType.Standard;
|
||||||
|
if (cursor.StandardCursor != standard)
|
||||||
|
cursor.StandardCursor = standard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetImage(uint renderSurfaceId, out RawImage image)
|
||||||
|
{
|
||||||
|
if (_imagesBySurface.TryGetValue(renderSurfaceId, out image))
|
||||||
|
return true;
|
||||||
|
if (_missingSurfaces.Contains(renderSurfaceId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
DecodedTexture? decoded = DecodeCursorSurface(renderSurfaceId);
|
||||||
|
if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0 || decoded.Rgba8.Length == 0)
|
||||||
|
{
|
||||||
|
_missingSurfaces.Add(renderSurfaceId);
|
||||||
|
image = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
image = new RawImage(decoded.Width, decoded.Height, decoded.Rgba8);
|
||||||
|
_imagesBySurface[renderSurfaceId] = image;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DecodedTexture? DecodeCursorSurface(uint renderSurfaceId)
|
||||||
|
{
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
||||||
|
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Palette? palette = rs.DefaultPaletteId != 0
|
||||||
|
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
||||||
|
: null;
|
||||||
|
return SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StandardCursor StandardCursorFor(CursorFeedbackKind kind)
|
||||||
|
=> kind switch
|
||||||
|
{
|
||||||
|
CursorFeedbackKind.Text => StandardCursor.IBeam,
|
||||||
|
CursorFeedbackKind.WindowMove => StandardCursor.ResizeAll,
|
||||||
|
CursorFeedbackKind.ResizeHorizontal => StandardCursor.HResize,
|
||||||
|
CursorFeedbackKind.ResizeVertical => StandardCursor.VResize,
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNwse => StandardCursor.NwseResize,
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNesw => StandardCursor.NeswResize,
|
||||||
|
CursorFeedbackKind.Drag => StandardCursor.Hand,
|
||||||
|
CursorFeedbackKind.DragAccept => StandardCursor.ResizeAll,
|
||||||
|
CursorFeedbackKind.DragReject => StandardCursor.NotAllowed,
|
||||||
|
CursorFeedbackKind.TargetPending => StandardCursor.Crosshair,
|
||||||
|
CursorFeedbackKind.TargetValid => StandardCursor.ResizeAll,
|
||||||
|
CursorFeedbackKind.TargetInvalid => StandardCursor.NotAllowed,
|
||||||
|
_ => StandardCursor.Arrow,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/AcDream.App/Rendering/RetailCursorResolver.cs
Normal file
76
src/AcDream.App/Rendering/RetailCursorResolver.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary>
|
||||||
|
internal sealed class RetailCursorResolver
|
||||||
|
{
|
||||||
|
private readonly DatCollection _dats;
|
||||||
|
private readonly object _datLock;
|
||||||
|
private readonly Dictionary<uint, uint> _didByEnum = new();
|
||||||
|
private readonly HashSet<uint> _missingEnums = new();
|
||||||
|
|
||||||
|
public RetailCursorResolver(DatCollection dats, object datLock)
|
||||||
|
{
|
||||||
|
_dats = dats;
|
||||||
|
_datLock = datLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryResolve(CursorFeedbackKind kind, out UiCursorMedia cursor)
|
||||||
|
{
|
||||||
|
cursor = default;
|
||||||
|
if (!RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return TryResolve(spec, out cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryResolve(RetailCursorSpec spec, out UiCursorMedia cursor)
|
||||||
|
{
|
||||||
|
cursor = default;
|
||||||
|
if (!spec.IsValid)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_didByEnum.TryGetValue(spec.EnumId, out uint cachedDid))
|
||||||
|
{
|
||||||
|
cursor = new UiCursorMedia(cachedDid, spec.HotspotX, spec.HotspotY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_missingEnums.Contains(spec.EnumId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint did = ResolveDidByEnum(spec.EnumId);
|
||||||
|
if (did == 0)
|
||||||
|
{
|
||||||
|
_missingEnums.Add(spec.EnumId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = new UiCursorMedia(did, spec.HotspotX, spec.HotspotY);
|
||||||
|
_didByEnum[spec.EnumId] = did;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ResolveDidByEnum(uint cursorEnum)
|
||||||
|
{
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
|
||||||
|
if (masterDid == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master) || master is null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (!master.ClientEnumToID.TryGetValue(RetailCursorCatalog.CursorEnumTable, out uint cursorMapDid))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (!_dats.Portal.TryGet<EnumIDMap>(cursorMapDid, out var cursorMap) || cursorMap is null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return cursorMap.ClientEnumToID.TryGetValue(cursorEnum, out uint did) ? did : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,9 @@ public sealed record RuntimeOptions(
|
||||||
bool DumpLiveSpawns,
|
bool DumpLiveSpawns,
|
||||||
int? LegacyStreamRadius,
|
int? LegacyStreamRadius,
|
||||||
bool RetailUi,
|
bool RetailUi,
|
||||||
string? AcDir)
|
string? AcDir,
|
||||||
|
bool UiProbeDump,
|
||||||
|
string? UiProbeScript)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build options from the process environment. Used by
|
/// Build options from the process environment. Used by
|
||||||
|
|
@ -85,13 +87,18 @@ public sealed record RuntimeOptions(
|
||||||
// top of the quality preset's radii. Null when unset or invalid.
|
// top of the quality preset's radii. Null when unset or invalid.
|
||||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
|
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
||||||
|
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
||||||
|
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||||
public bool HasLiveCredentials =>
|
public bool HasLiveCredentials =>
|
||||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||||
|
|
||||||
|
/// <summary>True when the opt-in retail UI automation probe should be constructed.</summary>
|
||||||
|
public bool UiProbeEnabled => UiProbeDump || !string.IsNullOrEmpty(UiProbeScript);
|
||||||
|
|
||||||
private static bool IsExactlyOne(string? s)
|
private static bool IsExactlyOne(string? s)
|
||||||
=> string.Equals(s, "1", StringComparison.Ordinal);
|
=> string.Equals(s, "1", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,12 +122,14 @@ public static class SampleData
|
||||||
/// synthetic character. Values are plausible retail-scale numbers so the
|
/// synthetic character. Values are plausible retail-scale numbers so the
|
||||||
/// report renders with well-proportioned text in all sections.
|
/// report renders with well-proportioned text in all sections.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static CharacterSheet SampleCharacter() => new()
|
public static CharacterSheet SampleCharacter() => SampleCharacter(null);
|
||||||
|
|
||||||
|
public static CharacterSheet SampleCharacter(string? name) => new()
|
||||||
{
|
{
|
||||||
Name = "Studio Player",
|
Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name,
|
||||||
Level = 126,
|
Level = 126,
|
||||||
Race = "Aluvian",
|
Gender = "Female",
|
||||||
Heritage = "Aluvian Heritage",
|
Heritage = "Aluvian",
|
||||||
Title = "the Adventurer",
|
Title = "the Adventurer",
|
||||||
BirthDate = "January 5, 2001",
|
BirthDate = "January 5, 2001",
|
||||||
PlayTime = "2 years, 114 days, 4 hours",
|
PlayTime = "2 years, 114 days, 4 hours",
|
||||||
|
|
@ -166,6 +168,7 @@ public static class SampleData
|
||||||
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
|
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
|
||||||
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
||||||
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
||||||
|
AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L },
|
||||||
|
|
||||||
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
|
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
|
||||||
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.
|
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.
|
||||||
|
|
|
||||||
255
src/AcDream.App/UI/CursorFeedbackController.cs
Normal file
255
src/AcDream.App/UI/CursorFeedbackController.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Semantic cursor state for the retail-look UI. Retail stores actual cursor art
|
||||||
|
/// as MediaDescCursor on UI states; this controller owns the behavior decision so
|
||||||
|
/// the visual backend can apply dat cursors without
|
||||||
|
/// changing item/window interaction code.
|
||||||
|
/// </summary>
|
||||||
|
public enum CursorFeedbackKind
|
||||||
|
{
|
||||||
|
Default,
|
||||||
|
Text,
|
||||||
|
WindowMove,
|
||||||
|
ResizeHorizontal,
|
||||||
|
ResizeVertical,
|
||||||
|
ResizeDiagonalNwse,
|
||||||
|
ResizeDiagonalNesw,
|
||||||
|
Drag,
|
||||||
|
DragAccept,
|
||||||
|
DragReject,
|
||||||
|
TargetPending,
|
||||||
|
TargetValid,
|
||||||
|
TargetInvalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default)
|
||||||
|
{
|
||||||
|
public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct CursorFeedbackSnapshot(
|
||||||
|
object? DragPayload = null,
|
||||||
|
UiItemSlot.DragAcceptState DragAccept = UiItemSlot.DragAcceptState.None,
|
||||||
|
ResizeEdges ActiveResizeEdges = ResizeEdges.None,
|
||||||
|
ResizeEdges HoverResizeEdges = ResizeEdges.None,
|
||||||
|
bool WindowMoveActive = false,
|
||||||
|
bool HoverWindowMove = false,
|
||||||
|
bool HoverUi = false,
|
||||||
|
bool HoverTextEdit = false,
|
||||||
|
uint HoverTargetGuid = 0);
|
||||||
|
|
||||||
|
public sealed class CursorFeedbackController
|
||||||
|
{
|
||||||
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
|
||||||
|
public CursorFeedbackController(ItemInteractionController? itemInteraction = null)
|
||||||
|
{
|
||||||
|
_itemInteraction = itemInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
|
||||||
|
|
||||||
|
public CursorFeedback Update(UiRoot root)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(root);
|
||||||
|
|
||||||
|
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
||||||
|
var snapshot = new CursorFeedbackSnapshot(
|
||||||
|
DragPayload: root.DragPayload,
|
||||||
|
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
|
||||||
|
ActiveResizeEdges: root.ActiveResizeEdges,
|
||||||
|
HoverResizeEdges: root.HoverResizeEdges,
|
||||||
|
WindowMoveActive: root.IsWindowMoveActive,
|
||||||
|
HoverWindowMove: root.HoverWindowMove,
|
||||||
|
HoverUi: hover is not null,
|
||||||
|
HoverTextEdit: hover?.IsEditControl == true,
|
||||||
|
HoverTargetGuid: ResolveUseTargetGuid(hover));
|
||||||
|
|
||||||
|
var kind = ResolveKind(snapshot);
|
||||||
|
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
|
||||||
|
return Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CursorFeedback Update(CursorFeedbackSnapshot snapshot)
|
||||||
|
{
|
||||||
|
Current = Resolve(snapshot);
|
||||||
|
return Current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
|
||||||
|
=> new(ResolveKind(snapshot));
|
||||||
|
|
||||||
|
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.DragPayload is not null)
|
||||||
|
{
|
||||||
|
return snapshot.DragAccept switch
|
||||||
|
{
|
||||||
|
UiItemSlot.DragAcceptState.Accept => CursorFeedbackKind.DragAccept,
|
||||||
|
UiItemSlot.DragAcceptState.Reject => CursorFeedbackKind.DragReject,
|
||||||
|
_ => CursorFeedbackKind.Drag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ResizeEdges resizeEdges = snapshot.ActiveResizeEdges != ResizeEdges.None
|
||||||
|
? snapshot.ActiveResizeEdges
|
||||||
|
: snapshot.HoverResizeEdges;
|
||||||
|
if (resizeEdges != ResizeEdges.None)
|
||||||
|
return KindForResize(resizeEdges);
|
||||||
|
|
||||||
|
if (snapshot.WindowMoveActive)
|
||||||
|
return CursorFeedbackKind.WindowMove;
|
||||||
|
|
||||||
|
if (_itemInteraction?.IsTargetModeActive == true)
|
||||||
|
{
|
||||||
|
if (snapshot.HoverTargetGuid != 0)
|
||||||
|
{
|
||||||
|
return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
|
||||||
|
? CursorFeedbackKind.TargetValid
|
||||||
|
: CursorFeedbackKind.TargetInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.HoverUi
|
||||||
|
? CursorFeedbackKind.TargetInvalid
|
||||||
|
: CursorFeedbackKind.TargetPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.HoverWindowMove)
|
||||||
|
return CursorFeedbackKind.WindowMove;
|
||||||
|
|
||||||
|
if (snapshot.HoverTextEdit)
|
||||||
|
return CursorFeedbackKind.Text;
|
||||||
|
|
||||||
|
return CursorFeedbackKind.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
|
||||||
|
{
|
||||||
|
bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0;
|
||||||
|
bool vertical = (edges & (ResizeEdges.Top | ResizeEdges.Bottom)) != 0;
|
||||||
|
|
||||||
|
if (horizontal && vertical)
|
||||||
|
{
|
||||||
|
bool nwse =
|
||||||
|
((edges & ResizeEdges.Left) != 0 && (edges & ResizeEdges.Top) != 0)
|
||||||
|
|| ((edges & ResizeEdges.Right) != 0 && (edges & ResizeEdges.Bottom) != 0);
|
||||||
|
return nwse ? CursorFeedbackKind.ResizeDiagonalNwse : CursorFeedbackKind.ResizeDiagonalNesw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (horizontal) return CursorFeedbackKind.ResizeHorizontal;
|
||||||
|
if (vertical) return CursorFeedbackKind.ResizeVertical;
|
||||||
|
return CursorFeedbackKind.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind)
|
||||||
|
{
|
||||||
|
foreach (var stateName in CursorStateNamesForKind(kind))
|
||||||
|
{
|
||||||
|
var semantic = FindCursorForState(hover, stateName);
|
||||||
|
if (semantic.IsValid)
|
||||||
|
return semantic;
|
||||||
|
}
|
||||||
|
|
||||||
|
var capturedCursor = FindActiveCursor(captured);
|
||||||
|
if (capturedCursor.IsValid)
|
||||||
|
return capturedCursor;
|
||||||
|
|
||||||
|
var hoverCursor = FindActiveCursor(hover);
|
||||||
|
if (hoverCursor.IsValid)
|
||||||
|
return hoverCursor;
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> CursorStateNamesForKind(CursorFeedbackKind kind)
|
||||||
|
=> kind switch
|
||||||
|
{
|
||||||
|
CursorFeedbackKind.DragAccept => new[]
|
||||||
|
{
|
||||||
|
"Drag_rollover_accept",
|
||||||
|
"ItemSlot_DragOver_Accept",
|
||||||
|
"ItemSlot_DragOver_DropIn",
|
||||||
|
},
|
||||||
|
CursorFeedbackKind.TargetValid => new[]
|
||||||
|
{
|
||||||
|
"Drag_rollover_accept",
|
||||||
|
"ItemSlot_DragOver_Accept",
|
||||||
|
"ItemSlot_DragOver_DropIn",
|
||||||
|
"Csm_highlight",
|
||||||
|
},
|
||||||
|
CursorFeedbackKind.DragReject or CursorFeedbackKind.TargetInvalid => new[]
|
||||||
|
{
|
||||||
|
"Drag_rollover_reject",
|
||||||
|
"ItemSlot_DragOver_Reject",
|
||||||
|
"Csm_ghosted",
|
||||||
|
},
|
||||||
|
CursorFeedbackKind.Drag => new[]
|
||||||
|
{
|
||||||
|
"ItemSlot_DragOver_Normal",
|
||||||
|
"Drag_rollover_accept",
|
||||||
|
},
|
||||||
|
CursorFeedbackKind.TargetPending => new[]
|
||||||
|
{
|
||||||
|
"DDDMode",
|
||||||
|
"Csm_normal",
|
||||||
|
},
|
||||||
|
CursorFeedbackKind.WindowMove => new[]
|
||||||
|
{
|
||||||
|
"Csm_highlight",
|
||||||
|
"Csm_normal",
|
||||||
|
},
|
||||||
|
_ => Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static UiCursorMedia FindCursorForState(UiElement? element, string stateName)
|
||||||
|
{
|
||||||
|
while (element is not null)
|
||||||
|
{
|
||||||
|
var cursor = element.CursorForState(stateName, allowFallback: false);
|
||||||
|
if (cursor.IsValid)
|
||||||
|
return cursor;
|
||||||
|
element = element.Parent;
|
||||||
|
}
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiCursorMedia FindActiveCursor(UiElement? element)
|
||||||
|
{
|
||||||
|
while (element is not null)
|
||||||
|
{
|
||||||
|
var cursor = element.ActiveCursor();
|
||||||
|
if (cursor.IsValid)
|
||||||
|
return cursor;
|
||||||
|
element = element.Parent;
|
||||||
|
}
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
|
||||||
|
{
|
||||||
|
while (element is not null)
|
||||||
|
{
|
||||||
|
if (element is UiItemSlot slot)
|
||||||
|
return slot;
|
||||||
|
element = element.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ResolveUseTargetGuid(UiElement? element)
|
||||||
|
{
|
||||||
|
while (element is not null)
|
||||||
|
{
|
||||||
|
uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u;
|
||||||
|
if (provided != 0)
|
||||||
|
return provided;
|
||||||
|
|
||||||
|
if (element is UiItemSlot { ItemId: not 0 } slot)
|
||||||
|
return slot.ItemId;
|
||||||
|
|
||||||
|
element = element.Parent;
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/AcDream.App/UI/ItemEquipRules.cs
Normal file
38
src/AcDream.App/UI/ItemEquipRules.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
internal static class ItemEquipRules
|
||||||
|
{
|
||||||
|
// Retail CPlayerSystem::AutoWear / gmPaperDollUI inventory mask: 0x08007FFF.
|
||||||
|
public const EquipMask AutoWearMask =
|
||||||
|
EquipMask.HeadWear
|
||||||
|
| EquipMask.ChestWear
|
||||||
|
| EquipMask.AbdomenWear
|
||||||
|
| EquipMask.UpperArmWear
|
||||||
|
| EquipMask.LowerArmWear
|
||||||
|
| EquipMask.HandWear
|
||||||
|
| EquipMask.UpperLegWear
|
||||||
|
| EquipMask.LowerLegWear
|
||||||
|
| EquipMask.FootWear
|
||||||
|
| EquipMask.ChestArmor
|
||||||
|
| EquipMask.AbdomenArmor
|
||||||
|
| EquipMask.UpperArmArmor
|
||||||
|
| EquipMask.LowerArmArmor
|
||||||
|
| EquipMask.UpperLegArmor
|
||||||
|
| EquipMask.LowerLegArmor
|
||||||
|
| EquipMask.Cloak;
|
||||||
|
|
||||||
|
public static bool IsAutoWearItem(ClientObject item)
|
||||||
|
=> (item.ValidLocations & AutoWearMask) != EquipMask.None;
|
||||||
|
|
||||||
|
public static EquipMask ResolvePaperdollDropWieldMask(ClientObject item, EquipMask targetMask)
|
||||||
|
{
|
||||||
|
if ((item.ValidLocations & targetMask) == EquipMask.None)
|
||||||
|
return EquipMask.None;
|
||||||
|
|
||||||
|
return IsAutoWearItem(item)
|
||||||
|
? item.ValidLocations
|
||||||
|
: item.ValidLocations & targetMask;
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
using System;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
||||||
|
/// target acquisition, and drag-out drops here instead of duplicating
|
||||||
|
/// ItemHolder::UseObject fragments in each panel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ItemInteractionController
|
||||||
|
{
|
||||||
|
private static readonly EquipMask[] AutoEquipOrder =
|
||||||
|
{
|
||||||
|
EquipMask.HeadWear,
|
||||||
|
EquipMask.ChestWear,
|
||||||
|
EquipMask.AbdomenWear,
|
||||||
|
EquipMask.UpperArmWear,
|
||||||
|
EquipMask.LowerArmWear,
|
||||||
|
EquipMask.HandWear,
|
||||||
|
EquipMask.UpperLegWear,
|
||||||
|
EquipMask.LowerLegWear,
|
||||||
|
EquipMask.FootWear,
|
||||||
|
EquipMask.ChestArmor,
|
||||||
|
EquipMask.AbdomenArmor,
|
||||||
|
EquipMask.UpperArmArmor,
|
||||||
|
EquipMask.LowerArmArmor,
|
||||||
|
EquipMask.UpperLegArmor,
|
||||||
|
EquipMask.LowerLegArmor,
|
||||||
|
EquipMask.NeckWear,
|
||||||
|
EquipMask.WristWearLeft,
|
||||||
|
EquipMask.WristWearRight,
|
||||||
|
EquipMask.FingerWearLeft,
|
||||||
|
EquipMask.FingerWearRight,
|
||||||
|
EquipMask.Shield,
|
||||||
|
EquipMask.MissileAmmo,
|
||||||
|
EquipMask.MeleeWeapon,
|
||||||
|
EquipMask.MissileWeapon,
|
||||||
|
EquipMask.Held,
|
||||||
|
EquipMask.TwoHanded,
|
||||||
|
EquipMask.TrinketOne,
|
||||||
|
EquipMask.Cloak,
|
||||||
|
EquipMask.SigilOne,
|
||||||
|
EquipMask.SigilTwo,
|
||||||
|
EquipMask.SigilThree,
|
||||||
|
};
|
||||||
|
|
||||||
|
private const long RetailUseThrottleMs = 200;
|
||||||
|
|
||||||
|
private readonly ClientObjectTable _objects;
|
||||||
|
private readonly Func<uint> _playerGuid;
|
||||||
|
private readonly Func<long> _nowMs;
|
||||||
|
private readonly Action<uint>? _sendUse;
|
||||||
|
private readonly Action<uint, uint>? _sendUseWithTarget;
|
||||||
|
private readonly Action<uint, uint>? _sendWield;
|
||||||
|
private readonly Action<uint>? _sendDrop;
|
||||||
|
private readonly Action<string>? _toast;
|
||||||
|
|
||||||
|
private long _lastUseMs = long.MinValue / 2;
|
||||||
|
|
||||||
|
public ItemInteractionController(
|
||||||
|
ClientObjectTable objects,
|
||||||
|
Func<uint> playerGuid,
|
||||||
|
Action<uint>? sendUse,
|
||||||
|
Action<uint, uint>? sendUseWithTarget,
|
||||||
|
Action<uint, uint>? sendWield,
|
||||||
|
Action<uint>? sendDrop,
|
||||||
|
Func<long>? nowMs = null,
|
||||||
|
Action<string>? toast = null)
|
||||||
|
{
|
||||||
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
_sendUse = sendUse;
|
||||||
|
_sendUseWithTarget = sendUseWithTarget;
|
||||||
|
_sendWield = sendWield;
|
||||||
|
_sendDrop = sendDrop;
|
||||||
|
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||||
|
_toast = toast;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? StateChanged;
|
||||||
|
|
||||||
|
public uint PlayerGuid => _playerGuid();
|
||||||
|
|
||||||
|
public uint PendingSourceItem { get; private set; }
|
||||||
|
|
||||||
|
public bool IsTargetModeActive => PendingSourceItem != 0;
|
||||||
|
|
||||||
|
public bool IsPendingSource(uint itemGuid)
|
||||||
|
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||||
|
|
||||||
|
public bool IsCurrentTargetCompatible(uint targetGuid)
|
||||||
|
{
|
||||||
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||||
|
|
||||||
|
var source = _objects.Get(PendingSourceItem);
|
||||||
|
return source?.Useability is { } useability
|
||||||
|
&& TargetCompatible(source, targetGuid, useability);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ActivateItem(uint itemGuid)
|
||||||
|
{
|
||||||
|
if (itemGuid == 0) return false;
|
||||||
|
|
||||||
|
if (IsTargetModeActive)
|
||||||
|
return AcquireTarget(itemGuid);
|
||||||
|
|
||||||
|
var item = _objects.Get(itemGuid);
|
||||||
|
if (item is null) return false;
|
||||||
|
|
||||||
|
if (!ConsumeUseThrottle())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (item.Useability is { } targetedUse
|
||||||
|
&& ItemUseability.IsTargeted(targetedUse)
|
||||||
|
&& SourceCompatible(item, targetedUse))
|
||||||
|
{
|
||||||
|
EnterTargetMode(itemGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsContainer(item))
|
||||||
|
{
|
||||||
|
_sendUse?.Invoke(itemGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryAutoWield(item))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (item.Useability is { } directUse
|
||||||
|
&& ItemUseability.IsDirectUseable(directUse)
|
||||||
|
&& SourceCompatible(item, directUse))
|
||||||
|
{
|
||||||
|
_sendUse?.Invoke(itemGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AcquireTarget(uint targetGuid)
|
||||||
|
{
|
||||||
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||||
|
|
||||||
|
uint sourceGuid = PendingSourceItem;
|
||||||
|
ClearTargetMode();
|
||||||
|
|
||||||
|
var source = _objects.Get(sourceGuid);
|
||||||
|
if (source?.Useability is not { } useability)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!TargetCompatible(source, targetGuid, useability))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AcquireSelfTarget()
|
||||||
|
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
||||||
|
|
||||||
|
public void CancelTargetMode()
|
||||||
|
{
|
||||||
|
if (!IsTargetModeActive) return;
|
||||||
|
ClearTargetMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DropToWorld(ItemDragPayload payload)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(payload);
|
||||||
|
|
||||||
|
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||||
|
return false;
|
||||||
|
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
|
||||||
|
_sendDrop?.Invoke(payload.ObjId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterTargetMode(uint sourceGuid)
|
||||||
|
{
|
||||||
|
PendingSourceItem = sourceGuid;
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
var name = _objects.Get(sourceGuid)?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
_toast?.Invoke($"Choose a target for the {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearTargetMode()
|
||||||
|
{
|
||||||
|
PendingSourceItem = 0;
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ConsumeUseThrottle()
|
||||||
|
{
|
||||||
|
long now = _nowMs();
|
||||||
|
if (now - _lastUseMs < RetailUseThrottleMs)
|
||||||
|
return false;
|
||||||
|
_lastUseMs = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryAutoWield(ClientObject item)
|
||||||
|
{
|
||||||
|
if (item.ValidLocations == EquipMask.None
|
||||||
|
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
EquipMask mask = BestAvailableEquipMask(item);
|
||||||
|
if (mask == EquipMask.None)
|
||||||
|
{
|
||||||
|
_toast?.Invoke("That slot is already in use");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||||
|
{
|
||||||
|
if (ItemEquipRules.IsAutoWearItem(item))
|
||||||
|
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||||
|
|
||||||
|
return FirstAvailableEquipMask(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AutoWearIsLegal(ClientObject item)
|
||||||
|
{
|
||||||
|
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
||||||
|
if ((item.Priority & priorityMask) == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
|
||||||
|
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EquipMask FirstAvailableEquipMask(ClientObject item)
|
||||||
|
{
|
||||||
|
foreach (var mask in AutoEquipOrder)
|
||||||
|
{
|
||||||
|
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||||
|
continue;
|
||||||
|
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
return EquipMask.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||||
|
{
|
||||||
|
foreach (var o in _objects.Objects)
|
||||||
|
{
|
||||||
|
if (o.ObjectId == exceptGuid)
|
||||||
|
continue;
|
||||||
|
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
||||||
|
continue;
|
||||||
|
if (IsEquippedByPlayer(o))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||||
|
{
|
||||||
|
uint mask = 0;
|
||||||
|
foreach (var o in _objects.Objects)
|
||||||
|
{
|
||||||
|
if (o.ObjectId == exceptGuid)
|
||||||
|
continue;
|
||||||
|
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||||
|
continue;
|
||||||
|
if (IsEquippedByPlayer(o))
|
||||||
|
mask |= o.Priority;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||||
|
{
|
||||||
|
EquipMask mask = EquipMask.None;
|
||||||
|
foreach (var o in _objects.Objects)
|
||||||
|
{
|
||||||
|
if (o.ObjectId == exceptGuid)
|
||||||
|
continue;
|
||||||
|
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||||
|
continue;
|
||||||
|
if (IsEquippedByPlayer(o))
|
||||||
|
mask |= o.CurrentlyEquippedLocation;
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
||||||
|
{
|
||||||
|
if (locationMask == EquipMask.None)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
foreach (var o in _objects.Objects)
|
||||||
|
{
|
||||||
|
if (o.ObjectId == exceptGuid)
|
||||||
|
continue;
|
||||||
|
if (!IsEquippedByPlayer(o))
|
||||||
|
continue;
|
||||||
|
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||||
|
continue;
|
||||||
|
if ((o.Priority & priority) != 0 || priority == 0)
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsContainer(ClientObject item)
|
||||||
|
=> item.ContainerTypeHint != 0
|
||||||
|
|| item.Type.HasFlag(ItemType.Container)
|
||||||
|
|| item.ItemsCapacity > 0;
|
||||||
|
|
||||||
|
private bool SourceCompatible(ClientObject source, uint useability)
|
||||||
|
{
|
||||||
|
uint flags = ItemUseability.SourceFlags(useability);
|
||||||
|
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||||
|
if ((flags & ItemUseability.Viewed) != 0) return true;
|
||||||
|
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
|
||||||
|
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
|
||||||
|
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
|
||||||
|
{
|
||||||
|
uint flags = ItemUseability.TargetFlags(useability);
|
||||||
|
uint player = _playerGuid();
|
||||||
|
|
||||||
|
if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (targetGuid == player)
|
||||||
|
return (flags & ItemUseability.Self) != 0;
|
||||||
|
|
||||||
|
var target = _objects.Get(targetGuid);
|
||||||
|
if (target is null)
|
||||||
|
return (flags & ItemUseability.Remote) != 0;
|
||||||
|
|
||||||
|
if (source.TargetType is { } targetType && targetType != 0
|
||||||
|
&& ((uint)target.Type & targetType) == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true;
|
||||||
|
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true;
|
||||||
|
if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true;
|
||||||
|
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsWieldedByPlayer(ClientObject item)
|
||||||
|
=> IsEquippedByPlayer(item);
|
||||||
|
|
||||||
|
private bool IsEquippedByPlayer(ClientObject item)
|
||||||
|
{
|
||||||
|
uint player = _playerGuid();
|
||||||
|
return item.CurrentlyEquippedLocation != EquipMask.None
|
||||||
|
&& (item.WielderId == player || item.ContainerId == player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsCarriedByPlayer(ClientObject item)
|
||||||
|
{
|
||||||
|
uint player = _playerGuid();
|
||||||
|
uint container = item.ContainerId;
|
||||||
|
for (int hops = 0; container != 0 && hops < 8; hops++)
|
||||||
|
{
|
||||||
|
if (container == player) return true;
|
||||||
|
container = _objects.Get(container)?.ContainerId ?? 0u;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal file
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail character identity display helpers for gmStatManagementUI.
|
||||||
|
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
|
||||||
|
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
|
||||||
|
/// then appends the current CharacterTitleTable title when one is active.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CharacterIdentityText
|
||||||
|
{
|
||||||
|
public const uint GenderPropertyId = 0x71u;
|
||||||
|
public const uint HeritageGroupPropertyId = 0xBCu;
|
||||||
|
|
||||||
|
public static string StatHeaderLine(CharacterSheet sheet)
|
||||||
|
{
|
||||||
|
string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage)
|
||||||
|
? sheet.Heritage
|
||||||
|
: sheet.Race;
|
||||||
|
|
||||||
|
string? title = StripLeadingArticle(sheet.Title);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(sheet.Gender))
|
||||||
|
return Join(heritage, title);
|
||||||
|
return Join(sheet.Gender, heritage, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GenderDisplayName(int gender) => gender switch
|
||||||
|
{
|
||||||
|
1 => "Male",
|
||||||
|
2 => "Female",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch
|
||||||
|
{
|
||||||
|
1 => "Aluvian",
|
||||||
|
2 => "Gharu'ndim",
|
||||||
|
3 => "Sho",
|
||||||
|
4 => "Viamontian",
|
||||||
|
5 => "Umbraen",
|
||||||
|
6 => "Gearknight",
|
||||||
|
7 => "Tumerok",
|
||||||
|
8 => "Lugian",
|
||||||
|
9 => "Empyrean",
|
||||||
|
10 => "Penumbraen",
|
||||||
|
11 => "Undead",
|
||||||
|
12 => "Olthoi",
|
||||||
|
13 => "Olthoi",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Join(params string?[] parts)
|
||||||
|
{
|
||||||
|
return string.Join(" ", parts
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
|
.Select(p => p!.Trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? StripLeadingArticle(string? title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title)) return null;
|
||||||
|
|
||||||
|
string trimmed = title.Trim();
|
||||||
|
return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
? trimmed[4..]
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,10 +26,13 @@ public sealed class CharacterSheet
|
||||||
/// <summary>Character level.</summary>
|
/// <summary>Character level.</summary>
|
||||||
public int Level { get; init; }
|
public int Level { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
|
||||||
|
public string? Gender { get; init; }
|
||||||
|
|
||||||
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
|
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
|
||||||
public string? Race { get; init; }
|
public string? Race { get; init; }
|
||||||
|
|
||||||
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
|
/// <summary>Heritage group display string, e.g. "Aluvian". Null = omit.</summary>
|
||||||
public string? Heritage { get; init; }
|
public string? Heritage { get; init; }
|
||||||
|
|
||||||
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
|
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
|
||||||
|
|
@ -108,11 +111,11 @@ public sealed class CharacterSheet
|
||||||
public long UnassignedXp { get; init; }
|
public long UnassignedXp { get; init; }
|
||||||
|
|
||||||
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
|
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
|
||||||
// Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value).
|
// Retail formula for x1: ExperienceToAttributeLevel(value + 1) − xpSpent.
|
||||||
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
|
// Retail formula for x10: ExperienceToAttributeLevel(value + min(10, remaining)) − xpSpent.
|
||||||
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
|
// Cost 0 means the attribute is at max or not trainable. Ordered to match AttrRows:
|
||||||
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
|
// Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana.
|
||||||
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
|
// Source: gmAttributeUI::GetCostToRaise/GetCostToRaise10 (0x0049cb80/0x0049cc70).
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
|
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
|
||||||
|
|
@ -122,6 +125,12 @@ public sealed class CharacterSheet
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XP cost to raise each of the 9 attributes/vitals by up to 10 retail steps,
|
||||||
|
/// in the same display order as <see cref="AttributeRaiseCosts"/>.
|
||||||
|
/// </summary>
|
||||||
|
public long[] AttributeRaise10Costs { get; init; } = Array.Empty<long>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
|
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
|
||||||
/// advancement class, then sorts each group alphabetically by skill name.
|
/// advancement class, then sorts each group alphabetically by skill name.
|
||||||
|
|
|
||||||
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
using DatReaderWriter;
|
||||||
|
|
||||||
|
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).
|
||||||
|
///
|
||||||
|
/// <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>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CharacterSheetProvider
|
||||||
|
{
|
||||||
|
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
|
||||||
|
private const uint UnassignedXpPropertyId = 2u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skill-credit debit fallback chain, most-specific first. Retail reads
|
||||||
|
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
|
||||||
|
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
|
||||||
|
/// the live server population varies, so debit whichever is present.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
|
||||||
|
|
||||||
|
private readonly ClientObjectTable _objects;
|
||||||
|
private readonly LocalPlayerState _localPlayer;
|
||||||
|
private readonly Func<uint> _playerGuid;
|
||||||
|
private readonly Func<string?>? _activeToonName;
|
||||||
|
private readonly Func<string, CharacterSheet>? _fallbackSheet;
|
||||||
|
private readonly Func<bool>? _canSendRaise;
|
||||||
|
private readonly Action<uint, ulong>? _sendRaiseAttribute;
|
||||||
|
private readonly Action<uint, ulong>? _sendRaiseVital;
|
||||||
|
private readonly Action<uint, ulong>? _sendRaiseSkill;
|
||||||
|
private readonly Action<uint, uint>? _sendTrainSkill;
|
||||||
|
|
||||||
|
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
|
||||||
|
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
|
||||||
|
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
|
||||||
|
|
||||||
|
public CharacterSheetProvider(
|
||||||
|
ClientObjectTable objects,
|
||||||
|
LocalPlayerState localPlayer,
|
||||||
|
Func<uint> playerGuid,
|
||||||
|
Func<string?>? activeToonName = null,
|
||||||
|
Func<string, CharacterSheet>? fallbackSheet = null,
|
||||||
|
Func<bool>? canSendRaise = null,
|
||||||
|
Action<uint, ulong>? sendRaiseAttribute = null,
|
||||||
|
Action<uint, ulong>? sendRaiseVital = null,
|
||||||
|
Action<uint, ulong>? sendRaiseSkill = null,
|
||||||
|
Action<uint, uint>? sendTrainSkill = null)
|
||||||
|
{
|
||||||
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
||||||
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
_activeToonName = activeToonName;
|
||||||
|
_fallbackSheet = fallbackSheet;
|
||||||
|
_canSendRaise = canSendRaise;
|
||||||
|
_sendRaiseAttribute = sendRaiseAttribute;
|
||||||
|
_sendRaiseVital = sendRaiseVital;
|
||||||
|
_sendRaiseSkill = sendRaiseSkill;
|
||||||
|
_sendTrainSkill = sendTrainSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sheet assembly ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
|
||||||
|
public string CharacterName()
|
||||||
|
{
|
||||||
|
string? toon = _activeToonName?.Invoke();
|
||||||
|
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
|
||||||
|
return toon;
|
||||||
|
uint guid = _playerGuid();
|
||||||
|
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
|
||||||
|
return objectName;
|
||||||
|
return "Player";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the sheet from live state; falls back to the injected sample
|
||||||
|
/// sheet (Studio data) until any live character data has arrived.
|
||||||
|
/// Called per repaint by the Character window's bound widgets.
|
||||||
|
/// </summary>
|
||||||
|
public CharacterSheet BuildSheet()
|
||||||
|
{
|
||||||
|
if (!HasLiveData())
|
||||||
|
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
|
||||||
|
|
||||||
|
var props = CurrentPlayerProperties();
|
||||||
|
int level = props.GetInt(0x19u);
|
||||||
|
long totalXp = props.GetInt64(1u);
|
||||||
|
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||||
|
var xp = ComputeLevelXp(level, totalXp);
|
||||||
|
|
||||||
|
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
|
||||||
|
|
||||||
|
return new CharacterSheet
|
||||||
|
{
|
||||||
|
Name = CharacterName(),
|
||||||
|
Level = level,
|
||||||
|
Gender = CharacterIdentityText.GenderDisplayName(
|
||||||
|
props.GetInt(CharacterIdentityText.GenderPropertyId)),
|
||||||
|
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
|
||||||
|
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
|
||||||
|
PkStatus = PkStatusText(props.GetInt(134u, 0)),
|
||||||
|
TotalXp = totalXp,
|
||||||
|
XpToNextLevel = xp.toNext,
|
||||||
|
XpFraction = xp.fraction,
|
||||||
|
|
||||||
|
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
|
||||||
|
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
|
||||||
|
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
|
||||||
|
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
|
||||||
|
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
|
||||||
|
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
|
||||||
|
|
||||||
|
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
|
||||||
|
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
|
||||||
|
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
|
||||||
|
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
|
||||||
|
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||||
|
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||||
|
|
||||||
|
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
|
||||||
|
SpecializedSkillCredits = props.GetInt(0xC0u),
|
||||||
|
SkillCredits = skillCredits,
|
||||||
|
UnassignedXp = unassignedXp,
|
||||||
|
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||||
|
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
|
||||||
|
Skills = BuildLiveCharacterSkills(),
|
||||||
|
BurdenCurrent = props.GetInt(5u),
|
||||||
|
BurdenMax = props.GetInt(96u),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasLiveData()
|
||||||
|
{
|
||||||
|
var props = CurrentPlayerProperties();
|
||||||
|
return props.Ints.Count > 0
|
||||||
|
|| props.Int64s.Count > 0
|
||||||
|
|| _localPlayer.Skills.Count > 0
|
||||||
|
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|
||||||
|
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The player's canonical bundle: the live ClientObject's when the
|
||||||
|
/// player object has arrived (CreateObject merge-upsert), else the
|
||||||
|
/// PlayerDescription snapshot on LocalPlayerState.</summary>
|
||||||
|
private PropertyBundle CurrentPlayerProperties()
|
||||||
|
{
|
||||||
|
uint guid = _playerGuid();
|
||||||
|
return guid != 0u && _objects.Get(guid) is { } player
|
||||||
|
? player.Properties
|
||||||
|
: _localPlayer.Properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the portal ExperienceTable (0x0E000018), falling back to a
|
||||||
|
/// type scan for older or odd dat collections. Failures are logged —
|
||||||
|
/// never silently swallowed — and leave raise costs unavailable (0).
|
||||||
|
/// </summary>
|
||||||
|
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
|
||||||
|
DatCollection dats, Action<string>? log = null)
|
||||||
|
{
|
||||||
|
if (dats is null) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
|
||||||
|
if (table is not null) return table;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
|
||||||
|
{
|
||||||
|
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
|
||||||
|
if (table is not null) return table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>XP still needed for the next level + fill fraction of the
|
||||||
|
/// current level band (retail (cur−base)/(cap−base); CharacterSheet.XpFraction).</summary>
|
||||||
|
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
|
||||||
|
{
|
||||||
|
var levels = ExperienceTable?.Levels;
|
||||||
|
if (levels is null || level < 0 || level + 1 >= levels.Length)
|
||||||
|
return (0L, 0f);
|
||||||
|
|
||||||
|
long current = ClampToLong(levels[level]);
|
||||||
|
long next = ClampToLong(levels[level + 1]);
|
||||||
|
if (next <= current) return (0L, 0f);
|
||||||
|
|
||||||
|
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
|
||||||
|
long toNext = next - clampedXp;
|
||||||
|
float fraction = (float)(clampedXp - current) / (next - current);
|
||||||
|
return (toNext, fraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-attribute/vital raise costs in retail display order
|
||||||
|
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
|
||||||
|
private long[] BuildAttributeRaiseCosts(int amount)
|
||||||
|
{
|
||||||
|
var xp = ExperienceTable;
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
|
||||||
|
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
|
||||||
|
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
|
||||||
|
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
|
||||||
|
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
|
||||||
|
};
|
||||||
|
|
||||||
|
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
|
||||||
|
{
|
||||||
|
var attr = _localPlayer.GetAttribute(kind);
|
||||||
|
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
|
||||||
|
{
|
||||||
|
var vital = _localPlayer.Get(kind);
|
||||||
|
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
|
||||||
|
{
|
||||||
|
var result = new List<CharacterSkill>();
|
||||||
|
var skillTable = SkillTable;
|
||||||
|
var xp = ExperienceTable;
|
||||||
|
|
||||||
|
foreach (var snapshot in _localPlayer.Skills.Values)
|
||||||
|
{
|
||||||
|
var advancement = AdvancementFromStatus(snapshot.Status);
|
||||||
|
if (advancement == CharacterSkillAdvancementClass.Inactive)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
DatReaderWriter.Types.SkillBase? skillBase = null;
|
||||||
|
if (skillTable?.Skills is not null)
|
||||||
|
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
|
||||||
|
|
||||||
|
string? name = skillBase?.Name.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
name = $"Skill {snapshot.SkillId}";
|
||||||
|
|
||||||
|
uint icon = skillBase?.IconId.DataId ?? 0u;
|
||||||
|
int trainedCost = skillBase?.TrainedCost ?? 0;
|
||||||
|
int specializedCost = skillBase?.SpecializedCost ?? 0;
|
||||||
|
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
|
||||||
|
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
|
||||||
|
|
||||||
|
result.Add(new CharacterSkill(
|
||||||
|
snapshot.SkillId,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
advancement,
|
||||||
|
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
|
||||||
|
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
|
||||||
|
IsUsableUntrained(snapshot.SkillId),
|
||||||
|
trainedCost,
|
||||||
|
specializedCost,
|
||||||
|
raiseCost,
|
||||||
|
raise10Cost));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
|
||||||
|
{
|
||||||
|
1u => CharacterSkillAdvancementClass.Untrained,
|
||||||
|
2u => CharacterSkillAdvancementClass.Trained,
|
||||||
|
3u => CharacterSkillAdvancementClass.Specialized,
|
||||||
|
_ => CharacterSkillAdvancementClass.Inactive,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsUsableUntrained(uint skillId) => skillId switch
|
||||||
|
{
|
||||||
|
18u or 37u or 38u or 39u or 40u => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static long SkillRaiseCost(
|
||||||
|
DatReaderWriter.DBObjs.ExperienceTable? xp,
|
||||||
|
CharacterSkillAdvancementClass advancement,
|
||||||
|
LocalPlayerState.SkillSnapshot skill,
|
||||||
|
int amount)
|
||||||
|
{
|
||||||
|
if (xp is null) return 0L;
|
||||||
|
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
|
||||||
|
? xp.SpecializedSkills
|
||||||
|
: xp.TrainedSkills;
|
||||||
|
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
|
||||||
|
/// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the
|
||||||
|
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
|
||||||
|
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
|
||||||
|
{
|
||||||
|
if (curve is null || amount <= 0) return 0L;
|
||||||
|
long maxIndex = curve.Length - 1L;
|
||||||
|
if (maxIndex <= ranks) return 0L;
|
||||||
|
long targetLong = Math.Min((long)ranks + amount, maxIndex);
|
||||||
|
long targetXp = curve[(int)targetLong];
|
||||||
|
long cost = targetXp - spentXp;
|
||||||
|
return cost > 0 ? cost : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ClampToLong(ulong value) =>
|
||||||
|
value > long.MaxValue ? long.MaxValue : (long)value;
|
||||||
|
|
||||||
|
private static string? PkStatusText(int status) => status switch
|
||||||
|
{
|
||||||
|
0x2 => "Non-Player Killer",
|
||||||
|
0x4 => "Player Killer",
|
||||||
|
0x40 => "Player Killer Lite",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
|
||||||
|
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
|
||||||
|
|
||||||
|
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
|
||||||
|
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
|
||||||
|
|
||||||
|
private int VitalMax(LocalPlayerState.VitalKind kind) =>
|
||||||
|
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
|
||||||
|
|
||||||
|
// ── 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).
|
||||||
|
/// </summary>
|
||||||
|
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
|
||||||
|
{
|
||||||
|
if (request.Cost <= 0) return;
|
||||||
|
if (_canSendRaise is not null && !_canSendRaise()) return;
|
||||||
|
|
||||||
|
bool sent = false;
|
||||||
|
switch (request.Kind)
|
||||||
|
{
|
||||||
|
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||||
|
if (_sendRaiseAttribute is not null)
|
||||||
|
{
|
||||||
|
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CharacterStatController.RaiseTargetKind.Vital:
|
||||||
|
if (_sendRaiseVital is not null)
|
||||||
|
{
|
||||||
|
_sendRaiseVital(request.StatId, (ulong)request.Cost);
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CharacterStatController.RaiseTargetKind.Skill:
|
||||||
|
if (_sendRaiseSkill is not null)
|
||||||
|
{
|
||||||
|
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||||
|
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
|
||||||
|
{
|
||||||
|
_sendTrainSkill(request.StatId, (uint)request.Cost);
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent)
|
||||||
|
ApplyLocalRaise(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
|
||||||
|
{
|
||||||
|
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
|
||||||
|
ulong cost = (ulong)request.Cost;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -209,6 +209,12 @@ public sealed class ChatWindowController
|
||||||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||||
c.Transcript.DatFont = datFont;
|
c.Transcript.DatFont = datFont;
|
||||||
c.Transcript.Font = debugFont;
|
c.Transcript.Font = debugFont;
|
||||||
|
// The imported Type-12 element may inherit HJustify=Center from its dat
|
||||||
|
// prototype, which makes UiText use the static one-line label path. The
|
||||||
|
// chat transcript must always use the scrollable multi-line path so it
|
||||||
|
// caches layout for mouse selection and Ctrl+C.
|
||||||
|
c.Transcript.Centered = false;
|
||||||
|
c.Transcript.RightAligned = false;
|
||||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ public static class DatWidgetFactory
|
||||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
e.DatElementId = info.Id;
|
||||||
|
e.SetStateCursors(info.StateCursors);
|
||||||
|
|
||||||
// Propagate position + size (pixel-exact from the dat).
|
// Propagate position + size (pixel-exact from the dat).
|
||||||
e.Left = info.X;
|
e.Left = info.X;
|
||||||
e.Top = info.Y;
|
e.Top = info.Y;
|
||||||
|
|
@ -151,6 +154,7 @@ public static class DatWidgetFactory
|
||||||
{
|
{
|
||||||
var m = new UiMeter
|
var m = new UiMeter
|
||||||
{
|
{
|
||||||
|
ElementId = info.Id,
|
||||||
SpriteResolve = resolve,
|
SpriteResolve = resolve,
|
||||||
DatFont = datFont,
|
DatFont = datFont,
|
||||||
};
|
};
|
||||||
|
|
@ -290,6 +294,7 @@ public static class DatWidgetFactory
|
||||||
|
|
||||||
var t = new UiText
|
var t = new UiText
|
||||||
{
|
{
|
||||||
|
ElementId = info.Id,
|
||||||
BackgroundSprite = bg,
|
BackgroundSprite = bg,
|
||||||
SpriteResolve = resolve,
|
SpriteResolve = resolve,
|
||||||
Centered = centered,
|
Centered = centered,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,12 @@ public sealed class ElementInfo
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cursor per state: state name to (RenderSurface file id, hotspot).
|
||||||
|
/// The "" key represents DirectState, mirroring <see cref="StateMedia"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, UiCursorMedia> StateCursors = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
|
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
|
||||||
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
|
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
|
||||||
|
|
@ -160,8 +166,8 @@ public static class ElementReader
|
||||||
/// (derived placement, not the base prototype's geometry).
|
/// (derived placement, not the base prototype's geometry).
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
|
/// <see cref="ElementInfo.StateMedia"/> and <see cref="ElementInfo.StateCursors"/>:
|
||||||
/// entries override (or add) per state name key.
|
/// base entries are the default; derived entries override (or add) per state name key.
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||||
|
|
@ -218,6 +224,9 @@ public static class ElementReader
|
||||||
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
||||||
foreach (var kv in derived.StateMedia)
|
foreach (var kv in derived.StateMedia)
|
||||||
m.StateMedia[kv.Key] = kv.Value;
|
m.StateMedia[kv.Key] = kv.Value;
|
||||||
|
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
|
||||||
|
foreach (var kv in derived.StateCursors)
|
||||||
|
m.StateCursors[kv.Key] = kv.Value;
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
|
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
|
||||||
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
|
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
|
||||||
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
|
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
|
||||||
|
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
|
||||||
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
||||||
|
|
||||||
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
|
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
|
||||||
|
|
@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
private readonly Func<uint> _playerGuid;
|
private readonly Func<uint> _playerGuid;
|
||||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||||
private readonly Func<int?> _strength;
|
private readonly Func<int?> _strength;
|
||||||
|
private readonly Func<string>? _ownerName;
|
||||||
|
|
||||||
private readonly UiItemList? _contentsGrid;
|
private readonly UiItemList? _contentsGrid;
|
||||||
private readonly UiItemList? _containerList;
|
private readonly UiItemList? _containerList;
|
||||||
|
|
@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
private readonly Action<uint>? _sendUse;
|
private readonly Action<uint>? _sendUse;
|
||||||
private readonly Action<uint>? _sendNoLongerViewing;
|
private readonly Action<uint>? _sendNoLongerViewing;
|
||||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||||
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
|
||||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||||
|
|
@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
Func<uint> playerGuid,
|
Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||||
Func<int?> strength,
|
Func<int?> strength,
|
||||||
|
Func<string>? ownerName,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
uint contentsEmptySprite,
|
uint contentsEmptySprite,
|
||||||
uint sideBagEmptySprite,
|
uint sideBagEmptySprite,
|
||||||
uint mainPackEmptySprite,
|
uint mainPackEmptySprite,
|
||||||
Action<uint>? sendUse,
|
Action<uint>? sendUse,
|
||||||
Action<uint>? sendNoLongerViewing,
|
Action<uint>? sendNoLongerViewing,
|
||||||
Action<uint, uint, int>? sendPutItemInContainer)
|
Action<uint, uint, int>? sendPutItemInContainer,
|
||||||
|
ItemInteractionController? itemInteraction,
|
||||||
|
Action? onClose)
|
||||||
{
|
{
|
||||||
_objects = objects;
|
_objects = objects;
|
||||||
_playerGuid = playerGuid;
|
_playerGuid = playerGuid;
|
||||||
_iconIds = iconIds;
|
_iconIds = iconIds;
|
||||||
_strength = strength;
|
_strength = strength;
|
||||||
|
_ownerName = ownerName;
|
||||||
_sendUse = sendUse;
|
_sendUse = sendUse;
|
||||||
_sendNoLongerViewing = sendNoLongerViewing;
|
_sendNoLongerViewing = sendNoLongerViewing;
|
||||||
_sendPutItemInContainer = sendPutItemInContainer;
|
_sendPutItemInContainer = sendPutItemInContainer;
|
||||||
|
_itemInteraction = itemInteraction;
|
||||||
|
|
||||||
|
WindowChromeController.BindCloseButton(layout, onClose);
|
||||||
|
|
||||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||||
|
|
@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
// Captions: drive each host UiText directly with the known string (the caption
|
// Captions: drive each host UiText directly with the known string (the caption
|
||||||
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
|
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
|
||||||
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
|
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
|
||||||
|
AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont);
|
||||||
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
|
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
|
||||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||||
|
|
@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
_objects.ObjectMoved += OnObjectMoved;
|
_objects.ObjectMoved += OnObjectMoved;
|
||||||
_objects.ObjectRemoved += OnObjectChanged;
|
_objects.ObjectRemoved += OnObjectChanged;
|
||||||
_objects.ObjectUpdated += OnObjectChanged;
|
_objects.ObjectUpdated += OnObjectChanged;
|
||||||
|
if (_itemInteraction is not null)
|
||||||
|
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||||
|
|
||||||
Populate();
|
Populate();
|
||||||
}
|
}
|
||||||
|
|
@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||||
Func<int?> strength,
|
Func<int?> strength,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
|
Func<string>? ownerName = null,
|
||||||
uint contentsEmptySprite = 0u,
|
uint contentsEmptySprite = 0u,
|
||||||
uint sideBagEmptySprite = 0u,
|
uint sideBagEmptySprite = 0u,
|
||||||
uint mainPackEmptySprite = 0u,
|
uint mainPackEmptySprite = 0u,
|
||||||
Action<uint>? sendUse = null,
|
Action<uint>? sendUse = null,
|
||||||
Action<uint>? sendNoLongerViewing = null,
|
Action<uint>? sendNoLongerViewing = null,
|
||||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
ItemInteractionController? itemInteraction = null,
|
||||||
|
Action? onClose = null)
|
||||||
|
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
|
||||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
|
||||||
|
|
||||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||||
|
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||||
|
|
||||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||||
private bool Concerns(ClientObject o)
|
private bool Concerns(ClientObject o)
|
||||||
|
|
@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
{
|
{
|
||||||
var item = _objects.Get(guid);
|
var item = _objects.Get(guid);
|
||||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
bool isBag = IsBag(item);
|
||||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
{
|
{
|
||||||
var item = _objects.Get(guid);
|
var item = _objects.Get(guid);
|
||||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
bool isBag = IsBag(item);
|
||||||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||||
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
|
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
|
||||||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||||||
main.Clicked = () => OpenContainer(p);
|
main.Clicked = () =>
|
||||||
|
{
|
||||||
|
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||||
|
OpenContainer(p);
|
||||||
|
};
|
||||||
|
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
|
||||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||||
_topContainer.AddItem(main);
|
_topContainer.AddItem(main);
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
|
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
|
||||||
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
|
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
|
||||||
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
|
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
|
||||||
|
private static bool IsBag(ClientObject item) =>
|
||||||
|
item.ContainerTypeHint != 0u
|
||||||
|
|| item.Type.HasFlag(ItemType.Container)
|
||||||
|
|| item.ItemsCapacity > 0;
|
||||||
|
|
||||||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||||
|
|
||||||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||||||
|
|
@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||||
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
||||||
cell.DragRejectSprite = 0x060011F8u; // red circle
|
cell.DragRejectSprite = 0x060011F8u; // red circle
|
||||||
|
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
|
||||||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||||||
else cell.Clicked = () => SelectItem(guid);
|
else cell.Clicked = () => SelectItem(guid);
|
||||||
list.AddItem(cell);
|
list.AddItem(cell);
|
||||||
|
|
@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
{
|
{
|
||||||
var cell = list.GetItem(i);
|
var cell = list.GetItem(i);
|
||||||
if (cell is null) continue;
|
if (cell is null) continue;
|
||||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||||
|
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
|
||||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string OwnerName()
|
||||||
|
{
|
||||||
|
var name = _ownerName?.Invoke();
|
||||||
|
return string.IsNullOrWhiteSpace(name) ? "Player" : name;
|
||||||
|
}
|
||||||
|
|
||||||
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
||||||
{
|
{
|
||||||
if (host is null) return;
|
if (host is null) return;
|
||||||
|
|
@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectChanged;
|
||||||
_objects.ObjectUpdated -= OnObjectChanged;
|
_objects.ObjectUpdated -= OnObjectChanged;
|
||||||
|
if (_itemInteraction is not null)
|
||||||
|
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -394,19 +394,27 @@ public static class LayoutImporter
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
||||||
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
|
/// <c>info.StateMedia[name]</c>, read any <see cref="MediaDescCursor"/> into
|
||||||
|
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
|
||||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||||
{
|
{
|
||||||
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
|
bool imageRead = false;
|
||||||
// are intentionally skipped — cursor behavior is Plan 2.
|
|
||||||
foreach (var m in sd.Media)
|
foreach (var m in sd.Media)
|
||||||
{
|
{
|
||||||
if (m is MediaDescImage img && img.File != 0)
|
if (!imageRead && m is MediaDescImage img && img.File != 0)
|
||||||
{
|
{
|
||||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||||
break;
|
imageRead = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m is MediaDescCursor cursor && cursor.File != 0)
|
||||||
|
{
|
||||||
|
info.StateCursors[name] = new UiCursorMedia(
|
||||||
|
cursor.File,
|
||||||
|
checked((int)cursor.XHotspot),
|
||||||
|
checked((int)cursor.YHotspot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
private readonly Func<uint> _playerGuid;
|
private readonly Func<uint> _playerGuid;
|
||||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||||
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||||
|
|
||||||
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -85,9 +86,10 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
private PaperdollController(
|
private PaperdollController(
|
||||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
||||||
uint emptySlotSprite, UiDatFont? datFont)
|
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
|
||||||
{
|
{
|
||||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||||
|
_itemInteraction = itemInteraction;
|
||||||
|
|
||||||
for (int i = 0; i < SlotMap.Length; i++)
|
for (int i = 0; i < SlotMap.Length; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -97,6 +99,13 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||||
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
||||||
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
||||||
|
list.Cell.UseTargetGuidProvider = _playerGuid;
|
||||||
|
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||||
|
list.Cell.DoubleClicked = () =>
|
||||||
|
{
|
||||||
|
if (list.Cell.ItemId != 0)
|
||||||
|
_itemInteraction?.ActivateItem(list.Cell.ItemId);
|
||||||
|
};
|
||||||
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
|
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
|
||||||
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
|
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
|
||||||
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
|
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
|
||||||
|
|
@ -140,8 +149,9 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
public static PaperdollController Bind(
|
public static PaperdollController Bind(
|
||||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
||||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
|
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
|
ItemInteractionController? itemInteraction = null)
|
||||||
|
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
|
||||||
|
|
||||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||||
|
|
@ -209,7 +219,7 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
{
|
{
|
||||||
var item = _objects.Get(payload.ObjId);
|
var item = _objects.Get(payload.ObjId);
|
||||||
if (item is null) return;
|
if (item is null) return;
|
||||||
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
|
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
|
||||||
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
|
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
|
||||||
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
||||||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||||
|
|
|
||||||
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal file
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mounts an imported retail window's content into the shared 8-piece beveled
|
||||||
|
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
|
||||||
|
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
|
||||||
|
/// retail window follows: frame sized content + 2×border, content inset by
|
||||||
|
/// the border with Draggable/Resizable off (the frame owns move/resize).
|
||||||
|
///
|
||||||
|
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
|
||||||
|
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
|
||||||
|
/// helper instead of copying the block again — see
|
||||||
|
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class RetailWindowFrame
|
||||||
|
{
|
||||||
|
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
|
||||||
|
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
|
||||||
|
public sealed record Options
|
||||||
|
{
|
||||||
|
/// <summary>Initial window position (screen px).</summary>
|
||||||
|
public float Left { get; init; }
|
||||||
|
public float Top { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
|
||||||
|
public float? MinWidth { get; init; }
|
||||||
|
public float? MinHeight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
|
||||||
|
public float? MaxHeight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Allow horizontal resize (frame default: true).</summary>
|
||||||
|
public bool ResizeX { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
|
||||||
|
public ResizeEdges? ResizableEdges { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
|
||||||
|
public float Opacity { get; init; } = 1f;
|
||||||
|
|
||||||
|
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
|
||||||
|
public bool Visible { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>How the content tracks the frame when it resizes.</summary>
|
||||||
|
public AnchorEdges ContentAnchors { get; init; } =
|
||||||
|
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||||||
|
|
||||||
|
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
|
||||||
|
public bool? ContentClickThrough { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Register the frame under this name in the UiRoot window
|
||||||
|
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
|
||||||
|
public string? WindowName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
|
||||||
|
/// chrome, add it to <paramref name="root"/>, and register it when
|
||||||
|
/// <see cref="Options.WindowName"/> is set. Returns the frame.
|
||||||
|
/// </summary>
|
||||||
|
public static UiNineSlicePanel Mount(
|
||||||
|
UiRoot root,
|
||||||
|
UiElement content,
|
||||||
|
Func<uint, (uint handle, int w, int h)> resolveChrome,
|
||||||
|
Options options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(root);
|
||||||
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolveChrome);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
const int border = RetailChromeSprites.Border;
|
||||||
|
float contentW = content.Width;
|
||||||
|
float contentH = content.Height;
|
||||||
|
|
||||||
|
var frame = new UiNineSlicePanel(resolveChrome)
|
||||||
|
{
|
||||||
|
Left = options.Left,
|
||||||
|
Top = options.Top,
|
||||||
|
Width = contentW + 2 * border,
|
||||||
|
Height = contentH + 2 * border,
|
||||||
|
MinWidth = options.MinWidth ?? contentW + 2 * border,
|
||||||
|
MinHeight = options.MinHeight ?? contentH + 2 * border,
|
||||||
|
ResizeX = options.ResizeX,
|
||||||
|
Opacity = options.Opacity,
|
||||||
|
Visible = options.Visible,
|
||||||
|
};
|
||||||
|
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
|
||||||
|
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
|
||||||
|
|
||||||
|
content.Left = border;
|
||||||
|
content.Top = border;
|
||||||
|
content.Width = contentW;
|
||||||
|
content.Height = contentH;
|
||||||
|
content.Anchors = options.ContentAnchors;
|
||||||
|
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
|
||||||
|
content.Draggable = false; // the frame owns window move/resize
|
||||||
|
content.Resizable = false;
|
||||||
|
|
||||||
|
frame.AddChild(content);
|
||||||
|
root.AddChild(frame);
|
||||||
|
if (options.WindowName is { } name)
|
||||||
|
root.RegisterWindow(name, frame);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
private static readonly uint[] CombatIndicatorIds =
|
private static readonly uint[] CombatIndicatorIds =
|
||||||
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||||
|
|
||||||
|
private const uint CharacterButtonId = 0x10000199u;
|
||||||
|
private const uint InventoryButtonId = 0x100001B1u;
|
||||||
|
private const string ButtonClosedState = "Normal";
|
||||||
|
private const string ButtonOpenState = "Highlight";
|
||||||
|
|
||||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||||
|
private readonly UiButton? _characterButton;
|
||||||
|
private readonly UiButton? _inventoryButton;
|
||||||
private readonly ClientObjectTable _repo;
|
private readonly ClientObjectTable _repo;
|
||||||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||||
|
|
@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
private bool _storeLoaded;
|
private bool _storeLoaded;
|
||||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||||
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
|
||||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||||
|
|
@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
uint[]? peaceDigits,
|
uint[]? peaceDigits,
|
||||||
uint[]? warDigits,
|
uint[]? warDigits,
|
||||||
uint[]? emptyDigits,
|
uint[]? emptyDigits,
|
||||||
|
ItemInteractionController? itemInteraction = null,
|
||||||
Action<uint, uint>? sendAddShortcut = null,
|
Action<uint, uint>? sendAddShortcut = null,
|
||||||
Action<uint>? sendRemoveShortcut = null)
|
Action<uint>? sendRemoveShortcut = null)
|
||||||
{
|
{
|
||||||
|
|
@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
_peaceDigits = peaceDigits;
|
_peaceDigits = peaceDigits;
|
||||||
_warDigits = warDigits;
|
_warDigits = warDigits;
|
||||||
_emptyDigits = emptyDigits;
|
_emptyDigits = emptyDigits;
|
||||||
|
_itemInteraction = itemInteraction;
|
||||||
_sendAddShortcut = sendAddShortcut;
|
_sendAddShortcut = sendAddShortcut;
|
||||||
_sendRemoveShortcut = sendRemoveShortcut;
|
_sendRemoveShortcut = sendRemoveShortcut;
|
||||||
|
|
||||||
|
|
@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
for (int i = 0; i < CombatIndicatorIds.Length; i++)
|
for (int i = 0; i < CombatIndicatorIds.Length; i++)
|
||||||
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
|
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
|
||||||
|
|
||||||
|
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
|
||||||
|
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||||||
|
|
||||||
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
||||||
foreach (var id in HiddenIds)
|
foreach (var id in HiddenIds)
|
||||||
if (layout.FindElement(id) is { } e) e.Visible = false;
|
if (layout.FindElement(id) is { } e) e.Visible = false;
|
||||||
|
|
@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
uint[]? peaceDigits = null,
|
uint[]? peaceDigits = null,
|
||||||
uint[]? warDigits = null,
|
uint[]? warDigits = null,
|
||||||
uint[]? emptyDigits = null,
|
uint[]? emptyDigits = null,
|
||||||
|
ItemInteractionController? itemInteraction = null,
|
||||||
Action<uint, uint>? sendAddShortcut = null,
|
Action<uint, uint>? sendAddShortcut = null,
|
||||||
Action<uint>? sendRemoveShortcut = null)
|
Action<uint>? sendRemoveShortcut = null)
|
||||||
{
|
{
|
||||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||||
peaceDigits, warDigits, emptyDigits,
|
peaceDigits, warDigits, emptyDigits, itemInteraction,
|
||||||
sendAddShortcut, sendRemoveShortcut);
|
sendAddShortcut, sendRemoveShortcut);
|
||||||
c.Populate();
|
c.Populate();
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
|
||||||
|
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
|
||||||
|
{
|
||||||
|
if (_inventoryButton is not null)
|
||||||
|
{
|
||||||
|
if (_itemInteraction is not null)
|
||||||
|
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
|
||||||
|
_inventoryButton.OnClick = () =>
|
||||||
|
{
|
||||||
|
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||||
|
toggleInventory?.Invoke();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (_characterButton is not null)
|
||||||
|
_characterButton.OnClick = toggleCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
|
||||||
|
|
||||||
|
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
|
||||||
|
|
||||||
|
private static void SetButtonOpen(UiButton? button, bool open)
|
||||||
|
{
|
||||||
|
if (button is not null)
|
||||||
|
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
|
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
|
||||||
/// each shortcut entry that has a resolved item in the repository.
|
/// each shortcut entry that has a resolved item in the repository.
|
||||||
|
|
@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
list.Cell.Clicked = () =>
|
list.Cell.Clicked = () =>
|
||||||
{
|
{
|
||||||
if (list.Cell.ItemId != 0)
|
if (list.Cell.ItemId != 0)
|
||||||
_useItem(list.Cell.ItemId);
|
{
|
||||||
|
if (_itemInteraction is not null)
|
||||||
|
_itemInteraction.ActivateItem(list.Cell.ItemId);
|
||||||
|
else
|
||||||
|
_useItem(list.Cell.ItemId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ public sealed class UiDatElement : UiElement
|
||||||
/// Falls back to DirectState if the named state is absent.</summary>
|
/// Falls back to DirectState if the named state is absent.</summary>
|
||||||
public string ActiveState { get; set; } = "";
|
public string ActiveState { get; set; } = "";
|
||||||
|
|
||||||
|
public override string ActiveCursorStateName => ActiveState;
|
||||||
|
|
||||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||||
|
|
|
||||||
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal file
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared bindings for imported retail window chrome. The same max/min button id is
|
||||||
|
/// reused by several LayoutDesc windows; per-window controllers decide what the click
|
||||||
|
/// means for their window.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WindowChromeController
|
||||||
|
{
|
||||||
|
public const uint MaxMinButtonId = 0x1000046Fu;
|
||||||
|
public const uint InventoryCloseButtonId = 0x100001D2u;
|
||||||
|
public const uint CharacterCloseButtonId = 0x1000022Au;
|
||||||
|
|
||||||
|
public static int BindCloseButton(ImportedLayout layout, Action? onClose)
|
||||||
|
{
|
||||||
|
if (onClose is null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return BindCloseButton(layout.Root, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int BindCloseButton(UiElement node, Action onClose)
|
||||||
|
{
|
||||||
|
int count = TryBindCloseButton(node, onClose) ? 1 : 0;
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
count += BindCloseButton(child, onClose);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBindCloseButton(UiElement element, Action onClose)
|
||||||
|
{
|
||||||
|
switch (element)
|
||||||
|
{
|
||||||
|
case UiButton button when IsCloseButtonId(button.ElementId):
|
||||||
|
button.OnClick = onClose;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case UiDatElement datElement when IsCloseButtonId(datElement.ElementId):
|
||||||
|
datElement.ClickThrough = false;
|
||||||
|
datElement.CapturesPointerDrag = true;
|
||||||
|
datElement.OnClick = onClose;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCloseButtonId(uint id) =>
|
||||||
|
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
|
||||||
|
}
|
||||||
30
src/AcDream.App/UI/RetailCursorCatalog.cs
Normal file
30
src/AcDream.App/UI/RetailCursorCatalog.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>Retail global cursor enum metadata used outside LayoutDesc widget states.</summary>
|
||||||
|
internal static class RetailCursorCatalog
|
||||||
|
{
|
||||||
|
public const uint CursorEnumTable = 6;
|
||||||
|
|
||||||
|
// ClientUISystem::UpdateCursorState (0x00564630):
|
||||||
|
// TARGET_MODE_USE_TARGET -> enum 0x27 when no SmartBox target is found,
|
||||||
|
// enum 0x28 when ItemHolder::IsTargetCompatibleWithTargetingObject returns true,
|
||||||
|
// enum 0x29 when it returns false. UIElementManager::SetCursor is called
|
||||||
|
// with hotspot (14,14) for all three.
|
||||||
|
public static bool TryGetGlobalCursor(CursorFeedbackKind kind, out RetailCursorSpec spec)
|
||||||
|
{
|
||||||
|
spec = kind switch
|
||||||
|
{
|
||||||
|
CursorFeedbackKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14),
|
||||||
|
CursorFeedbackKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14),
|
||||||
|
CursorFeedbackKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14),
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
|
||||||
|
return spec.IsValid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY)
|
||||||
|
{
|
||||||
|
public bool IsValid => EnumId != 0;
|
||||||
|
}
|
||||||
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal file
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Testing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot row for the live retained-mode retail UI tree. Coordinates are
|
||||||
|
/// absolute root/screen pixels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RetailUiProbeElement(
|
||||||
|
int Index,
|
||||||
|
int Depth,
|
||||||
|
string Path,
|
||||||
|
string TypeName,
|
||||||
|
string? Name,
|
||||||
|
uint EventId,
|
||||||
|
uint DatElementId,
|
||||||
|
float X,
|
||||||
|
float Y,
|
||||||
|
float Width,
|
||||||
|
float Height,
|
||||||
|
bool Visible,
|
||||||
|
bool Enabled,
|
||||||
|
uint ItemId,
|
||||||
|
int SlotIndex,
|
||||||
|
ItemDragSource? SourceKind)
|
||||||
|
{
|
||||||
|
public int CenterX => (int)MathF.Round(X + Width * 0.5f);
|
||||||
|
public int CenterY => (int)MathF.Round(Y + Height * 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RetailUiProbeAssertion(bool Success, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test/diagnostic harness for the retail-style UI. It drives <see cref="UiRoot"/>
|
||||||
|
/// mouse events, never panel controllers directly, so automated checks exercise
|
||||||
|
/// the same click, double-click, and drag-drop path as a player.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RetailUiAutomationProbe
|
||||||
|
{
|
||||||
|
private readonly UiRoot _root;
|
||||||
|
private readonly ClientObjectTable _objects;
|
||||||
|
private readonly Action<string>? _log;
|
||||||
|
private long _nowMs = 1;
|
||||||
|
|
||||||
|
public RetailUiAutomationProbe(
|
||||||
|
UiRoot root,
|
||||||
|
ClientObjectTable objects,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||||
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RetailUiProbeElement> Snapshot(bool visibleOnly = true)
|
||||||
|
{
|
||||||
|
var rows = new List<RetailUiProbeElement>();
|
||||||
|
Walk(_root, "root", depth: 0, ancestorsVisible: true, visibleOnly, rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DumpText(bool visibleOnly = true)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var e in Snapshot(visibleOnly))
|
||||||
|
{
|
||||||
|
sb.Append('[').Append(e.Index.ToString(CultureInfo.InvariantCulture)).Append("] ");
|
||||||
|
sb.Append(new string(' ', Math.Max(0, e.Depth) * 2));
|
||||||
|
sb.Append(e.TypeName);
|
||||||
|
if (e.DatElementId != 0) sb.Append(" dat=0x").Append(e.DatElementId.ToString("X8", CultureInfo.InvariantCulture));
|
||||||
|
if (e.EventId != 0) sb.Append(" event=0x").Append(e.EventId.ToString("X8", CultureInfo.InvariantCulture));
|
||||||
|
if (!string.IsNullOrEmpty(e.Name)) sb.Append(" name=").Append(e.Name);
|
||||||
|
sb.Append(" rect=(").Append(F(e.X)).Append(',').Append(F(e.Y)).Append(',')
|
||||||
|
.Append(F(e.Width)).Append(',').Append(F(e.Height)).Append(')');
|
||||||
|
if (!e.Visible) sb.Append(" hidden");
|
||||||
|
if (!e.Enabled) sb.Append(" disabled");
|
||||||
|
if (e.ItemId != 0)
|
||||||
|
{
|
||||||
|
sb.Append(" item=0x").Append(e.ItemId.ToString("X8", CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(" slot=").Append(e.SlotIndex.ToString(CultureInfo.InvariantCulture));
|
||||||
|
if (e.SourceKind is { } source) sb.Append(" source=").Append(source);
|
||||||
|
}
|
||||||
|
sb.Append(" path=").Append(e.Path);
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RetailUiProbeElement? FindByDatElementId(uint datElementId, bool visibleOnly = true)
|
||||||
|
{
|
||||||
|
foreach (var e in Snapshot(visibleOnly))
|
||||||
|
if (e.DatElementId == datElementId && HasArea(e))
|
||||||
|
return e;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RetailUiProbeElement> FindAllByDatElementId(uint datElementId, bool visibleOnly = true)
|
||||||
|
{
|
||||||
|
var matches = new List<RetailUiProbeElement>();
|
||||||
|
foreach (var e in Snapshot(visibleOnly))
|
||||||
|
if (e.DatElementId == datElementId)
|
||||||
|
matches.Add(e);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RetailUiProbeElement? FindByItemId(
|
||||||
|
uint itemGuid,
|
||||||
|
ItemDragSource? sourceKind = null,
|
||||||
|
bool visibleOnly = true)
|
||||||
|
{
|
||||||
|
if (itemGuid == 0) return null;
|
||||||
|
foreach (var e in Snapshot(visibleOnly))
|
||||||
|
{
|
||||||
|
if (e.ItemId != itemGuid) continue;
|
||||||
|
if (sourceKind is { } source && e.SourceKind != source) continue;
|
||||||
|
if (!HasArea(e)) continue;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ClickElement(uint datElementId)
|
||||||
|
{
|
||||||
|
var target = FindByDatElementId(datElementId);
|
||||||
|
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
|
||||||
|
ClickAt(target.CenterX, target.CenterY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
|
||||||
|
{
|
||||||
|
var target = FindByItemId(itemGuid, sourceKind);
|
||||||
|
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
|
||||||
|
ClickAt(target.CenterX, target.CenterY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DoubleClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
|
||||||
|
{
|
||||||
|
var target = FindByItemId(itemGuid, sourceKind);
|
||||||
|
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
|
||||||
|
ClickAt(target.CenterX, target.CenterY);
|
||||||
|
Advance(80);
|
||||||
|
ClickAt(target.CenterX, target.CenterY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DragItemToElement(uint itemGuid, uint datElementId, ItemDragSource? sourceKind = null)
|
||||||
|
{
|
||||||
|
var source = FindByItemId(itemGuid, sourceKind);
|
||||||
|
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
|
||||||
|
var target = FindByDatElementId(datElementId);
|
||||||
|
if (target is null) return Fail($"drop target element 0x{datElementId:X8} not found");
|
||||||
|
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DragItemToItem(uint sourceGuid, uint targetGuid, ItemDragSource? sourceKind = null)
|
||||||
|
{
|
||||||
|
var source = FindByItemId(sourceGuid, sourceKind);
|
||||||
|
if (source is null) return Fail($"drag source item 0x{sourceGuid:X8} not found");
|
||||||
|
var target = FindByItemId(targetGuid);
|
||||||
|
if (target is null) return Fail($"drop target item 0x{targetGuid:X8} not found");
|
||||||
|
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DragItemOutside(uint itemGuid, int x, int y, ItemDragSource? sourceKind = null)
|
||||||
|
{
|
||||||
|
var source = FindByItemId(itemGuid, sourceKind);
|
||||||
|
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
|
||||||
|
DragAt(source.CenterX, source.CenterY, x, y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RetailUiProbeAssertion AssertItem(
|
||||||
|
uint itemGuid,
|
||||||
|
EquipMask? equippedLocation = null,
|
||||||
|
uint? containerId = null,
|
||||||
|
int? slot = null)
|
||||||
|
{
|
||||||
|
var item = _objects.Get(itemGuid);
|
||||||
|
if (item is null)
|
||||||
|
return Result(false, $"item 0x{itemGuid:X8} missing from object table");
|
||||||
|
|
||||||
|
if (equippedLocation is { } equip && item.CurrentlyEquippedLocation != equip)
|
||||||
|
return Result(false,
|
||||||
|
$"item 0x{itemGuid:X8} equip expected 0x{((uint)equip):X8}, got 0x{((uint)item.CurrentlyEquippedLocation):X8}");
|
||||||
|
|
||||||
|
if (containerId is { } container && item.ContainerId != container)
|
||||||
|
return Result(false,
|
||||||
|
$"item 0x{itemGuid:X8} container expected 0x{container:X8}, got 0x{item.ContainerId:X8}");
|
||||||
|
|
||||||
|
if (slot is { } slotValue && item.ContainerSlot != slotValue)
|
||||||
|
return Result(false,
|
||||||
|
$"item 0x{itemGuid:X8} slot expected {slotValue}, got {item.ContainerSlot}");
|
||||||
|
|
||||||
|
return Result(true, $"item 0x{itemGuid:X8} matched object-table assertion");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Walk(
|
||||||
|
UiElement element,
|
||||||
|
string path,
|
||||||
|
int depth,
|
||||||
|
bool ancestorsVisible,
|
||||||
|
bool visibleOnly,
|
||||||
|
List<RetailUiProbeElement> rows)
|
||||||
|
{
|
||||||
|
bool effectiveVisible = ancestorsVisible && element.Visible;
|
||||||
|
if (visibleOnly && !effectiveVisible) return;
|
||||||
|
|
||||||
|
if (element is UiItemList list)
|
||||||
|
list.LayoutCells();
|
||||||
|
|
||||||
|
var pos = element.ScreenPosition;
|
||||||
|
uint itemId = 0;
|
||||||
|
int slotIndex = -1;
|
||||||
|
ItemDragSource? sourceKind = null;
|
||||||
|
if (element is UiItemSlot slot)
|
||||||
|
{
|
||||||
|
itemId = slot.ItemId;
|
||||||
|
slotIndex = slot.SlotIndex;
|
||||||
|
sourceKind = slot.SourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new RetailUiProbeElement(
|
||||||
|
rows.Count,
|
||||||
|
depth,
|
||||||
|
path,
|
||||||
|
element.GetType().Name,
|
||||||
|
element.Name,
|
||||||
|
element.EventId,
|
||||||
|
element.DatElementId,
|
||||||
|
pos.X,
|
||||||
|
pos.Y,
|
||||||
|
element.Width,
|
||||||
|
element.Height,
|
||||||
|
effectiveVisible,
|
||||||
|
element.Enabled,
|
||||||
|
itemId,
|
||||||
|
slotIndex,
|
||||||
|
sourceKind));
|
||||||
|
|
||||||
|
var children = element.Children;
|
||||||
|
for (int i = 0; i < children.Count; i++)
|
||||||
|
{
|
||||||
|
var child = children[i];
|
||||||
|
var childPath = $"{path}/{child.GetType().Name}[{i}]";
|
||||||
|
if (child.DatElementId != 0)
|
||||||
|
childPath += $"#0x{child.DatElementId:X8}";
|
||||||
|
Walk(child, childPath, depth + 1, effectiveVisible, visibleOnly, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClickAt(int x, int y)
|
||||||
|
{
|
||||||
|
Advance(16);
|
||||||
|
_root.OnMouseMove(x, y);
|
||||||
|
_root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||||
|
Advance(50);
|
||||||
|
_root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||||
|
Advance(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DragAt(int startX, int startY, int endX, int endY)
|
||||||
|
{
|
||||||
|
Advance(16);
|
||||||
|
_root.OnMouseMove(startX, startY);
|
||||||
|
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
||||||
|
Advance(16);
|
||||||
|
_root.OnMouseMove(startX + 5, startY + 5);
|
||||||
|
Advance(16);
|
||||||
|
_root.OnMouseMove(endX, endY);
|
||||||
|
Advance(16);
|
||||||
|
_root.OnMouseUp(UiMouseButton.Left, endX, endY);
|
||||||
|
Advance(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Advance(int ms)
|
||||||
|
{
|
||||||
|
_nowMs += ms;
|
||||||
|
_root.Tick(ms / 1000.0, _nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasArea(RetailUiProbeElement element)
|
||||||
|
=> element.Width > 0f && element.Height > 0f;
|
||||||
|
|
||||||
|
private bool Fail(string message)
|
||||||
|
{
|
||||||
|
_log?.Invoke(message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RetailUiProbeAssertion Result(bool success, string message)
|
||||||
|
{
|
||||||
|
_log?.Invoke(message);
|
||||||
|
return new RetailUiProbeAssertion(success, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string F(float value)
|
||||||
|
=> value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal file
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Testing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||||
|
/// It is intended for local diagnostic launches, not for gameplay. Commands
|
||||||
|
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
|
||||||
|
/// physical mouse input.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RetailUiAutomationScriptRunner
|
||||||
|
{
|
||||||
|
private readonly RetailUiAutomationProbe _probe;
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
private readonly List<ScriptCommand> _commands = new();
|
||||||
|
private readonly bool _dumpOnStart;
|
||||||
|
private readonly string? _loadError;
|
||||||
|
private int _index;
|
||||||
|
private int _activeIndex = -1;
|
||||||
|
private long _elapsedMs;
|
||||||
|
private long _commandStartMs;
|
||||||
|
private bool _started;
|
||||||
|
private bool _completed;
|
||||||
|
|
||||||
|
public RetailUiAutomationScriptRunner(
|
||||||
|
RetailUiAutomationProbe probe,
|
||||||
|
string? scriptPath,
|
||||||
|
bool dumpOnStart,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
|
||||||
|
_log = log ?? (_ => { });
|
||||||
|
_dumpOnStart = dumpOnStart;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(scriptPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int lineNumber = 0;
|
||||||
|
foreach (var raw in File.ReadAllLines(scriptPath))
|
||||||
|
{
|
||||||
|
lineNumber++;
|
||||||
|
var line = StripComment(raw).Trim();
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
_commands.Add(new ScriptCommand(lineNumber, line, Split(line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_loadError = $"failed to load UI probe script '{scriptPath}': {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Completed => _completed;
|
||||||
|
|
||||||
|
public void Tick(double deltaSeconds)
|
||||||
|
{
|
||||||
|
if (_completed) return;
|
||||||
|
|
||||||
|
long addMs = Math.Max(1, (long)Math.Round(deltaSeconds * 1000.0));
|
||||||
|
_elapsedMs += addMs;
|
||||||
|
|
||||||
|
if (!_started)
|
||||||
|
{
|
||||||
|
_started = true;
|
||||||
|
if (_loadError is not null)
|
||||||
|
{
|
||||||
|
_log(_loadError);
|
||||||
|
_completed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_dumpOnStart)
|
||||||
|
_log(_probe.DumpText());
|
||||||
|
|
||||||
|
if (_commands.Count == 0)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log($"running {_commands.Count} UI probe command(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int guard = 0;
|
||||||
|
while (!_completed && _index < _commands.Count && guard++ < 8)
|
||||||
|
{
|
||||||
|
if (_activeIndex != _index)
|
||||||
|
{
|
||||||
|
_activeIndex = _index;
|
||||||
|
_commandStartMs = _elapsedMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = _commands[_index];
|
||||||
|
bool finished = Execute(command);
|
||||||
|
if (!finished) break;
|
||||||
|
_index++;
|
||||||
|
_activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_index >= _commands.Count && !_completed)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
_log("UI probe script complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Execute(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length == 0) return true;
|
||||||
|
|
||||||
|
string verb = p[0].ToLowerInvariant();
|
||||||
|
return verb switch
|
||||||
|
{
|
||||||
|
"dump" => DoDump(),
|
||||||
|
"click" => DoClick(command),
|
||||||
|
"doubleclick" => DoDoubleClick(command),
|
||||||
|
"drag" => DoDrag(command),
|
||||||
|
"wait" => DoWait(command),
|
||||||
|
"sleep" => DoSleep(command),
|
||||||
|
"assert" => DoAssert(command),
|
||||||
|
_ => Stop(command, $"unknown command '{p[0]}'"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoDump()
|
||||||
|
{
|
||||||
|
_log(_probe.DumpText());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoClick(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
||||||
|
string target = p[1].ToLowerInvariant();
|
||||||
|
if (target == "element")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
|
||||||
|
return _probe.ClickElement(datId) || Stop(command, "click element failed");
|
||||||
|
}
|
||||||
|
if (target == "item")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||||
|
return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
|
||||||
|
}
|
||||||
|
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoDoubleClick(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length < 3 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Stop(command, "usage: doubleclick item <guid> [source]");
|
||||||
|
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||||
|
return _probe.DoubleClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "doubleclick item failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoDrag(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y>");
|
||||||
|
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||||
|
|
||||||
|
string target = p[3].ToLowerInvariant();
|
||||||
|
if (target == "element")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[4], out uint datId)) return Stop(command, $"bad element id '{p[4]}'");
|
||||||
|
return _probe.DragItemToElement(sourceGuid, datId, ParseSource(p, 5))
|
||||||
|
|| Stop(command, "drag item to element failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == "item")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[4], out uint targetGuid)) return Stop(command, $"bad target item guid '{p[4]}'");
|
||||||
|
return _probe.DragItemToItem(sourceGuid, targetGuid, ParseSource(p, 5))
|
||||||
|
|| Stop(command, "drag item to item failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == "outside")
|
||||||
|
{
|
||||||
|
if (p.Length < 6) return Stop(command, "usage: drag item <guid> outside <x> <y> [source]");
|
||||||
|
if (!TryParseInt(p[4], out int x)) return Stop(command, $"bad x coordinate '{p[4]}'");
|
||||||
|
if (!TryParseInt(p[5], out int y)) return Stop(command, $"bad y coordinate '{p[5]}'");
|
||||||
|
return _probe.DragItemOutside(sourceGuid, x, y, ParseSource(p, 6))
|
||||||
|
|| Stop(command, "drag item outside failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stop(command, "usage: drag item <guid> element/item/outside ...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoWait(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
|
||||||
|
|
||||||
|
string target = p[1].ToLowerInvariant();
|
||||||
|
if (target == "item")
|
||||||
|
{
|
||||||
|
if (p.Length < 3 || !TryParseUInt(p[2], out uint itemGuid)) return Stop(command, "usage: wait item <guid> [source] [timeoutMs]");
|
||||||
|
if (_probe.FindByItemId(itemGuid, ParseSource(p, 3)) is not null) return true;
|
||||||
|
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"item 0x{itemGuid:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == "element")
|
||||||
|
{
|
||||||
|
if (p.Length < 3 || !TryParseUInt(p[2], out uint datId)) return Stop(command, "usage: wait element <datId> [timeoutMs]");
|
||||||
|
if (_probe.FindByDatElementId(datId) is not null) return true;
|
||||||
|
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"element 0x{datId:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target == "ms")
|
||||||
|
return DoSleep(command);
|
||||||
|
|
||||||
|
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoSleep(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
string value = p.Length >= 3 && string.Equals(p[0], "wait", StringComparison.OrdinalIgnoreCase) ? p[2]
|
||||||
|
: p.Length >= 2 ? p[1] : "";
|
||||||
|
if (!TryParseInt(value, out int ms) || ms < 0)
|
||||||
|
return Stop(command, "usage: sleep <milliseconds> | wait ms <milliseconds>");
|
||||||
|
return _elapsedMs - _commandStartMs >= ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DoAssert(ScriptCommand command)
|
||||||
|
{
|
||||||
|
var p = command.Parts;
|
||||||
|
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
|
||||||
|
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||||
|
|
||||||
|
string kind = p[3].ToLowerInvariant();
|
||||||
|
RetailUiProbeAssertion result;
|
||||||
|
if (kind == "equip")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[4], out uint mask)) return Stop(command, $"bad equip mask '{p[4]}'");
|
||||||
|
result = _probe.AssertItem(itemGuid, equippedLocation: (EquipMask)mask);
|
||||||
|
}
|
||||||
|
else if (kind == "container")
|
||||||
|
{
|
||||||
|
if (!TryParseUInt(p[4], out uint containerId)) return Stop(command, $"bad container id '{p[4]}'");
|
||||||
|
result = _probe.AssertItem(itemGuid, containerId: containerId);
|
||||||
|
}
|
||||||
|
else if (kind == "slot")
|
||||||
|
{
|
||||||
|
if (!TryParseInt(p[4], out int slot)) return Stop(command, $"bad slot '{p[4]}'");
|
||||||
|
result = _probe.AssertItem(itemGuid, slot: slot);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success || Stop(command, result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
|
||||||
|
{
|
||||||
|
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;
|
||||||
|
return Stop(command, $"timed out waiting for {label}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Stop(ScriptCommand command, string message)
|
||||||
|
{
|
||||||
|
_log($"line {command.LineNumber}: {message}; command: {command.Text}");
|
||||||
|
_completed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripComment(string line)
|
||||||
|
{
|
||||||
|
int hash = line.IndexOf('#');
|
||||||
|
int slashes = line.IndexOf("//", StringComparison.Ordinal);
|
||||||
|
int cut = -1;
|
||||||
|
if (hash >= 0) cut = hash;
|
||||||
|
if (slashes >= 0) cut = cut >= 0 ? Math.Min(cut, slashes) : slashes;
|
||||||
|
return cut >= 0 ? line[..cut] : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Split(string line)
|
||||||
|
=> line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
private static ItemDragSource? ParseSource(string[] parts, int start)
|
||||||
|
{
|
||||||
|
for (int i = start; i < parts.Length; i++)
|
||||||
|
if (TryParseSource(parts[i], out var source))
|
||||||
|
return source;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSource(string value, out ItemDragSource source)
|
||||||
|
{
|
||||||
|
switch (value.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "inventory":
|
||||||
|
case "pack":
|
||||||
|
case "backpack":
|
||||||
|
source = ItemDragSource.Inventory;
|
||||||
|
return true;
|
||||||
|
case "shortcut":
|
||||||
|
case "shortcutbar":
|
||||||
|
case "toolbar":
|
||||||
|
source = ItemDragSource.ShortcutBar;
|
||||||
|
return true;
|
||||||
|
case "equipment":
|
||||||
|
case "equip":
|
||||||
|
case "paperdoll":
|
||||||
|
source = ItemDragSource.Equipment;
|
||||||
|
return true;
|
||||||
|
case "ground":
|
||||||
|
source = ItemDragSource.Ground;
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
source = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TimeoutMs(string[] parts, int start, int defaultMs)
|
||||||
|
{
|
||||||
|
for (int i = start; i < parts.Length; i++)
|
||||||
|
if (TryParseInt(parts[i], out int ms) && ms >= 0)
|
||||||
|
return ms;
|
||||||
|
return defaultMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseUInt(string value, out uint parsed)
|
||||||
|
{
|
||||||
|
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return uint.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
return uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseInt(string value, out int parsed)
|
||||||
|
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
|
||||||
|
private readonly record struct ScriptCommand(int LineNumber, string Text, string[] Parts);
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,9 @@ public sealed class UiButton : UiElement
|
||||||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||||
public Action? OnClick { get; set; }
|
public Action? OnClick { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
|
||||||
|
public uint ElementId => _info.Id;
|
||||||
|
|
||||||
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
||||||
public string? Label { get; set; }
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
|
@ -60,6 +63,8 @@ public sealed class UiButton : UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ActiveState { get; set; } = "";
|
public string ActiveState { get; set; } = "";
|
||||||
|
|
||||||
|
public override string ActiveCursorStateName => ActiveState;
|
||||||
|
|
||||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ namespace AcDream.App.UI;
|
||||||
[System.Flags]
|
[System.Flags]
|
||||||
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
||||||
|
|
||||||
|
/// <summary>Retail dat cursor media attached to a UI state.</summary>
|
||||||
|
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
|
||||||
|
{
|
||||||
|
public bool IsValid => File != 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for every UI widget in the retained-mode tree.
|
/// Base class for every UI widget in the retained-mode tree.
|
||||||
///
|
///
|
||||||
|
|
@ -40,9 +46,56 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint EventId { get; internal set; }
|
public uint EventId { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dat LayoutDesc element id for widgets imported from retail UI data.
|
||||||
|
/// Zero for runtime-created helper widgets. This is intentionally separate
|
||||||
|
/// from <see cref="EventId"/>: event ids are runtime-local, while this id is
|
||||||
|
/// stable across retail layout dumps, decomp references, and UI probes.
|
||||||
|
/// </summary>
|
||||||
|
public uint DatElementId { get; internal set; }
|
||||||
|
|
||||||
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
||||||
|
|
||||||
|
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
||||||
|
public IReadOnlyDictionary<string, UiCursorMedia> StateCursors => _stateCursors;
|
||||||
|
|
||||||
|
/// <summary>Active state name used for cursor media. Stateful dat widgets override this.</summary>
|
||||||
|
public virtual string ActiveCursorStateName => "";
|
||||||
|
|
||||||
|
public void SetStateCursors(IReadOnlyDictionary<string, UiCursorMedia> cursors)
|
||||||
|
{
|
||||||
|
_stateCursors.Clear();
|
||||||
|
foreach (var kv in cursors)
|
||||||
|
{
|
||||||
|
if (kv.Value.IsValid)
|
||||||
|
_stateCursors[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiCursorMedia ActiveCursor()
|
||||||
|
=> CursorForState(ActiveCursorStateName, allowFallback: true);
|
||||||
|
|
||||||
|
public UiCursorMedia CursorForState(string stateName, bool allowFallback = true)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(stateName)
|
||||||
|
&& _stateCursors.TryGetValue(stateName, out var named)
|
||||||
|
&& named.IsValid)
|
||||||
|
return named;
|
||||||
|
|
||||||
|
if (!allowFallback)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid)
|
||||||
|
return direct;
|
||||||
|
if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid)
|
||||||
|
return normal;
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Geometry ────────────────────────────────────────────────────────
|
// ── Geometry ────────────────────────────────────────────────────────
|
||||||
/// <summary>X in the parent's local pixel space.</summary>
|
/// <summary>X in the parent's local pixel space.</summary>
|
||||||
public float Left { get; set; }
|
public float Left { get; set; }
|
||||||
|
|
@ -130,6 +183,14 @@ public abstract class UiElement
|
||||||
/// self-driven interior drag such as text selection). Default false.</summary>
|
/// self-driven interior drag such as text selection). Default false.</summary>
|
||||||
public virtual bool HandlesClick => false;
|
public virtual bool HandlesClick => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional game-object target used by item-use cursor/target mode when this UI
|
||||||
|
/// element represents a target that is not its visible item icon. Example:
|
||||||
|
/// the status-bar backpack button and paperdoll both mean "self" for a
|
||||||
|
/// health-kit-style use-with-target action.
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint>? UseTargetGuidProvider { get; set; }
|
||||||
|
|
||||||
/// <summary>Minimum size enforced while resizing.</summary>
|
/// <summary>Minimum size enforced while resizing.</summary>
|
||||||
public float MinWidth { get; set; } = 40f;
|
public float MinWidth { get; set; } = 40f;
|
||||||
public float MinHeight { get; set; } = 40f;
|
public float MinHeight { get; set; } = 40f;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,9 @@ public sealed class UiHost : System.IDisposable
|
||||||
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
|
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
|
||||||
public bool HideWindow(string name) => Root.HideWindow(name);
|
public bool HideWindow(string name) => Root.HideWindow(name);
|
||||||
|
|
||||||
|
/// <summary>Return the current visibility of a registered window.</summary>
|
||||||
|
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
|
||||||
|
|
||||||
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
|
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
|
||||||
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>Cell order for a multi-column <see cref="UiItemList"/>.</summary>
|
||||||
|
public enum UiItemListFlow
|
||||||
|
{
|
||||||
|
/// <summary>Retail listbox bit 0 set: index advances across columns, then down rows.</summary>
|
||||||
|
RowMajor,
|
||||||
|
|
||||||
|
/// <summary>Retail listbox bit 0 clear: index advances down rows, then across columns.</summary>
|
||||||
|
ColumnMajor,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
|
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
|
||||||
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
|
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
|
||||||
|
|
@ -82,9 +92,16 @@ public sealed class UiItemList : UiElement
|
||||||
LayoutCells();
|
LayoutCells();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
|
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
|
||||||
public int Columns { get; set; } = 1;
|
public int Columns { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of
|
||||||
|
/// m_bitField: set = row-major, clear = column-major. Default preserves the
|
||||||
|
/// existing toolbar/grid behavior; panel controllers select retail-specific flow.
|
||||||
|
/// </summary>
|
||||||
|
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
|
||||||
|
|
||||||
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
|
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
|
||||||
/// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid.</summary>
|
/// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid.</summary>
|
||||||
public float CellWidth { get; set; }
|
public float CellWidth { get; set; }
|
||||||
|
|
@ -107,8 +124,25 @@ public sealed class UiItemList : UiElement
|
||||||
return (col * cellW, row * cellH);
|
return (col * cellW, row * cellH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
|
||||||
|
/// row-major when bit 0 is set, column-major when it is clear.
|
||||||
|
/// </summary>
|
||||||
|
internal static (float x, float y) CellOffset(
|
||||||
|
int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH)
|
||||||
|
{
|
||||||
|
int cols = columns < 1 ? 1 : columns;
|
||||||
|
if (flow == UiItemListFlow.ColumnMajor)
|
||||||
|
{
|
||||||
|
int rows = Math.Max(1, RowCount(cellCount, cols));
|
||||||
|
int col = index / rows, row = index % rows;
|
||||||
|
return (col * cellW, row * cellH);
|
||||||
|
}
|
||||||
|
return CellOffset(index, cols, cellW, cellH);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
||||||
/// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position
|
/// cell to the list; grid (CellWidth>0) tiles cells by <see cref="Flow"/>, offset by the scroll position
|
||||||
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
|
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
|
||||||
internal void LayoutCells()
|
internal void LayoutCells()
|
||||||
{
|
{
|
||||||
|
|
@ -137,7 +171,7 @@ public sealed class UiItemList : UiElement
|
||||||
|
|
||||||
for (int i = 0; i < _cells.Count; i++)
|
for (int i = 0; i < _cells.Count; i++)
|
||||||
{
|
{
|
||||||
var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight);
|
var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight);
|
||||||
float top = baseY - scrollY;
|
float top = baseY - scrollY;
|
||||||
var cell = _cells[i];
|
var cell = _cells[i];
|
||||||
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,9 @@ public sealed class UiItemSlot : UiElement
|
||||||
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
||||||
public Action? Clicked { get; set; }
|
public Action? Clicked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Invoked by <see cref="OnEvent"/> when a double-click lands on a bound slot.</summary>
|
||||||
|
public Action? DoubleClicked { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
|
|
@ -184,6 +187,9 @@ public sealed class UiItemSlot : UiElement
|
||||||
case UiEventType.Click:
|
case UiEventType.Click:
|
||||||
Clicked?.Invoke();
|
Clicked?.Invoke();
|
||||||
return true;
|
return true;
|
||||||
|
case UiEventType.DoubleClick:
|
||||||
|
DoubleClicked?.Invoke();
|
||||||
|
return true;
|
||||||
|
|
||||||
case UiEventType.DragBegin:
|
case UiEventType.DragBegin:
|
||||||
// Notify the source list's handler so it can lift (remove + wire) — retail
|
// Notify the source list's handler so it can lift (remove + wire) — retail
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ namespace AcDream.App.UI;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiMeter : UiElement
|
public sealed class UiMeter : UiElement
|
||||||
{
|
{
|
||||||
|
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
||||||
|
public uint ElementId { get; set; }
|
||||||
|
|
||||||
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
||||||
public Func<float?> Fill { get; set; } = () => 0f;
|
public Func<float?> Fill { get; set; } = () => 0f;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ public class UiPanel : UiElement
|
||||||
}
|
}
|
||||||
else if (BackgroundColor.W > 0f)
|
else if (BackgroundColor.W > 0f)
|
||||||
{
|
{
|
||||||
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
|
// Panel fills are backgrounds. Draw them through the sprite/fill
|
||||||
|
// bucket so children that render as sprites/dat-font glyphs stay on
|
||||||
|
// top in painter order; DrawRect flushes after sprites and would
|
||||||
|
// cover text/icons.
|
||||||
|
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BorderColor.W > 0f && BorderThickness > 0f)
|
if (BorderColor.W > 0f && BorderThickness > 0f)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,34 @@ public sealed class UiRoot : UiElement
|
||||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||||
public UiElement? DragSource { get; private set; }
|
public UiElement? DragSource { get; private set; }
|
||||||
public object? DragPayload { get; private set; }
|
public object? DragPayload { get; private set; }
|
||||||
|
public bool IsWindowMoveActive => _windowDragTarget is not null;
|
||||||
|
public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None;
|
||||||
|
public ResizeEdges HoverResizeEdges
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var target = Pick(MouseX, MouseY);
|
||||||
|
var window = FindWindow(target);
|
||||||
|
return window is { Resizable: true }
|
||||||
|
? HitEdges(window, MouseX, MouseY, ResizeGrip)
|
||||||
|
: ResizeEdges.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public bool HoverWindowMove
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var target = Pick(MouseX, MouseY);
|
||||||
|
var window = FindWindow(target);
|
||||||
|
if (target is null || window is not { Draggable: true })
|
||||||
|
return false;
|
||||||
|
if (HoverResizeEdges != ResizeEdges.None)
|
||||||
|
return false;
|
||||||
|
return !target.IsDragSource
|
||||||
|
&& !target.CapturesPointerDrag
|
||||||
|
&& !target.HandlesClick;
|
||||||
|
}
|
||||||
|
}
|
||||||
private (uint tex, int w, int h)? _dragGhost;
|
private (uint tex, int w, int h)? _dragGhost;
|
||||||
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
||||||
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
||||||
|
|
@ -79,12 +107,16 @@ public sealed class UiRoot : UiElement
|
||||||
private bool _dragCandidate;
|
private bool _dragCandidate;
|
||||||
private UiElement? _windowDragTarget;
|
private UiElement? _windowDragTarget;
|
||||||
private int _windowDragOffX, _windowDragOffY;
|
private int _windowDragOffX, _windowDragOffY;
|
||||||
|
private UiElement? _lastClickTarget;
|
||||||
|
private long _lastClickMs;
|
||||||
|
private int _lastClickX, _lastClickY;
|
||||||
private UiElement? _resizeTarget;
|
private UiElement? _resizeTarget;
|
||||||
private ResizeEdges _resizeEdges;
|
private ResizeEdges _resizeEdges;
|
||||||
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
|
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
|
||||||
private int _resizeMouseX, _resizeMouseY;
|
private int _resizeMouseX, _resizeMouseY;
|
||||||
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
|
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
|
||||||
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
||||||
|
private const int DoubleClickDelayMs = 500;
|
||||||
|
|
||||||
// Hover / tooltip tracking.
|
// Hover / tooltip tracking.
|
||||||
private UiElement? _hoverWidget;
|
private UiElement? _hoverWidget;
|
||||||
|
|
@ -106,6 +138,9 @@ public sealed class UiRoot : UiElement
|
||||||
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
|
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
|
||||||
public event Action<int /*dy*/>? WorldScrollFallThrough;
|
public event Action<int /*dy*/>? WorldScrollFallThrough;
|
||||||
|
|
||||||
|
/// <summary>Raised when a drag is released over no UI element.</summary>
|
||||||
|
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
|
||||||
|
|
||||||
private uint _nextEventId = 0x10000001u;
|
private uint _nextEventId = 0x10000001u;
|
||||||
|
|
||||||
public override void AddChild(UiElement child)
|
public override void AddChild(UiElement child)
|
||||||
|
|
@ -371,6 +406,24 @@ public sealed class UiRoot : UiElement
|
||||||
Data0: (int)flags,
|
Data0: (int)flags,
|
||||||
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||||
BubbleEvent(Captured, in click);
|
BubbleEvent(Captured, in click);
|
||||||
|
|
||||||
|
long now = _nowMs != 0 ? _nowMs : Environment.TickCount64;
|
||||||
|
bool isDoubleClick =
|
||||||
|
ReferenceEquals(Captured, _lastClickTarget)
|
||||||
|
&& now - _lastClickMs <= DoubleClickDelayMs
|
||||||
|
&& Math.Abs(x - _lastClickX) <= DragDistanceThreshold
|
||||||
|
&& Math.Abs(y - _lastClickY) <= DragDistanceThreshold;
|
||||||
|
if (isDoubleClick)
|
||||||
|
{
|
||||||
|
var dbl = new UiEvent(Captured.EventId, Captured, UiEventType.DoubleClick,
|
||||||
|
Data0: (int)flags,
|
||||||
|
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||||
|
BubbleEvent(Captured, in dbl);
|
||||||
|
}
|
||||||
|
_lastClickTarget = Captured;
|
||||||
|
_lastClickMs = now;
|
||||||
|
_lastClickX = x;
|
||||||
|
_lastClickY = y;
|
||||||
}
|
}
|
||||||
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
|
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
|
||||||
{
|
{
|
||||||
|
|
@ -504,6 +557,10 @@ public sealed class UiRoot : UiElement
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Return the current visibility of a registered window.</summary>
|
||||||
|
public bool IsWindowVisible(string name)
|
||||||
|
=> _windows.TryGetValue(name, out var w) && w.Visible;
|
||||||
|
|
||||||
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
|
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
|
||||||
/// Returns the new IsVisible state (false for an unknown name).</summary>
|
/// Returns the new IsVisible state (false for an unknown name).</summary>
|
||||||
public bool ToggleWindow(string name)
|
public bool ToggleWindow(string name)
|
||||||
|
|
@ -575,7 +632,10 @@ public sealed class UiRoot : UiElement
|
||||||
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
|
||||||
t.OnEvent(in e);
|
t.OnEvent(in e);
|
||||||
}
|
}
|
||||||
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
|
else if (DragPayload is { } payload)
|
||||||
|
{
|
||||||
|
DragReleasedOutsideUi?.Invoke(payload, x, y);
|
||||||
|
}
|
||||||
DragSource = null;
|
DragSource = null;
|
||||||
DragPayload = null;
|
DragPayload = null;
|
||||||
_dragGhost = null;
|
_dragGhost = null;
|
||||||
|
|
|
||||||
109
src/AcDream.App/UI/UiScrollablePanel.cs
Normal file
109
src/AcDream.App/UI/UiScrollablePanel.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple vertical viewport for controller-built row lists. It shares the same
|
||||||
|
/// pixel scroll model as chat text and item grids, and clips whole rows because
|
||||||
|
/// the UI renderer does not have a scissor stack yet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiScrollablePanel : UiPanel
|
||||||
|
{
|
||||||
|
private readonly Dictionary<UiElement, float> _baseTops = new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
public UiScrollable Scroll { get; } = new();
|
||||||
|
|
||||||
|
public int LineHeight { get; set; } = 16;
|
||||||
|
|
||||||
|
public int ContentHeight { get; private set; }
|
||||||
|
|
||||||
|
public UiScrollablePanel()
|
||||||
|
{
|
||||||
|
BackgroundColor = Vector4.Zero;
|
||||||
|
BorderColor = Vector4.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void AddChild(UiElement child)
|
||||||
|
{
|
||||||
|
base.AddChild(child);
|
||||||
|
Track(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool RemoveChild(UiElement child)
|
||||||
|
{
|
||||||
|
bool removed = base.RemoveChild(child);
|
||||||
|
if (removed)
|
||||||
|
{
|
||||||
|
_baseTops.Remove(child);
|
||||||
|
RecomputeContentHeight();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearContent()
|
||||||
|
{
|
||||||
|
foreach (var child in Children.ToArray())
|
||||||
|
RemoveChild(child);
|
||||||
|
_baseTops.Clear();
|
||||||
|
ContentHeight = 0;
|
||||||
|
Scroll.SetScrollY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void LayoutScrollableChildren()
|
||||||
|
{
|
||||||
|
Scroll.LineHeight = Math.Max(1, LineHeight);
|
||||||
|
Scroll.ContentHeight = ContentHeight;
|
||||||
|
Scroll.ViewHeight = Math.Max(0, (int)MathF.Floor(Height));
|
||||||
|
Scroll.SetScrollY(Scroll.ScrollY);
|
||||||
|
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
if (!_baseTops.TryGetValue(child, out float baseTop))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
float top = baseTop - Scroll.ScrollY;
|
||||||
|
child.Top = top;
|
||||||
|
child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
{
|
||||||
|
if (e.Type == UiEventType.Scroll)
|
||||||
|
{
|
||||||
|
Scroll.ScrollByLines(-e.Data0);
|
||||||
|
LayoutScrollableChildren();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return base.OnEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
LayoutScrollableChildren();
|
||||||
|
base.OnDraw(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Track(UiElement child)
|
||||||
|
{
|
||||||
|
child.Anchors = AnchorEdges.None;
|
||||||
|
_baseTops[child] = child.Top;
|
||||||
|
RecomputeContentHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeContentHeight()
|
||||||
|
{
|
||||||
|
float bottom = 0f;
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
if (!_baseTops.TryGetValue(child, out float top))
|
||||||
|
continue;
|
||||||
|
bottom = MathF.Max(bottom, top + child.Height);
|
||||||
|
}
|
||||||
|
ContentHeight = (int)MathF.Ceiling(bottom);
|
||||||
|
Scroll.SetScrollY(Scroll.ScrollY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ namespace AcDream.App.UI;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiText : UiElement
|
public sealed class UiText : UiElement
|
||||||
{
|
{
|
||||||
|
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
|
||||||
|
public uint ElementId { get; set; }
|
||||||
|
|
||||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||||
public readonly record struct Line(string Text, Vector4 Color);
|
public readonly record struct Line(string Text, Vector4 Color);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,12 @@ public static class GameEventWiring
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
||||||
if (p is null) return;
|
if (p is null) return;
|
||||||
|
uint wielderGuid = p.Value.WielderGuid != 0u
|
||||||
|
? p.Value.WielderGuid
|
||||||
|
: (playerGuid?.Invoke() ?? 0u);
|
||||||
items.MoveItem(
|
items.MoveItem(
|
||||||
p.Value.ItemGuid,
|
p.Value.ItemGuid,
|
||||||
newContainerId: p.Value.WielderGuid,
|
newContainerId: wielderGuid,
|
||||||
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
|
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
|
||||||
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
||||||
});
|
});
|
||||||
|
|
@ -249,7 +252,11 @@ public static class GameEventWiring
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
||||||
if (p is null) return;
|
if (p is null) return;
|
||||||
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
|
items.MoveItem(
|
||||||
|
p.Value.ItemGuid,
|
||||||
|
p.Value.ContainerGuid,
|
||||||
|
newSlot: (int)p.Value.Placement,
|
||||||
|
containerTypeHint: p.Value.ContainerType);
|
||||||
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
|
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -262,9 +269,12 @@ public static class GameEventWiring
|
||||||
{
|
{
|
||||||
var p = GameEvents.ParseViewContents(e.Payload.Span);
|
var p = GameEvents.ParseViewContents(e.Payload.Span);
|
||||||
if (p is null) return;
|
if (p is null) return;
|
||||||
var guids = new uint[p.Value.Items.Count];
|
var entries = new ContainerContentEntry[p.Value.Items.Count];
|
||||||
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
|
for (int i = 0; i < entries.Length; i++)
|
||||||
items.ReplaceContents(p.Value.ContainerGuid, guids);
|
entries[i] = new ContainerContentEntry(
|
||||||
|
p.Value.Items[i].Guid,
|
||||||
|
p.Value.Items[i].ContainerType);
|
||||||
|
items.ReplaceContents(p.Value.ContainerGuid, entries);
|
||||||
});
|
});
|
||||||
|
|
||||||
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
|
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
|
||||||
|
|
@ -273,7 +283,11 @@ public static class GameEventWiring
|
||||||
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
|
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
|
||||||
{
|
{
|
||||||
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
|
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
|
||||||
if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
|
if (guid is not null)
|
||||||
|
{
|
||||||
|
items.MoveItem(guid.Value, newContainerId: 0u);
|
||||||
|
items.ConfirmMove(guid.Value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
|
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
|
||||||
|
|
@ -284,8 +298,12 @@ public static class GameEventWiring
|
||||||
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
|
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
|
||||||
if (p is null) return;
|
if (p is null) return;
|
||||||
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
|
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
|
||||||
if (!items.RollbackMove(p.Value.ItemGuid))
|
var item = items.Get(p.Value.ItemGuid);
|
||||||
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
|
string itemInfo = item is null
|
||||||
|
? "unknown"
|
||||||
|
: $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}";
|
||||||
|
bool rolledBack = items.RollbackMove(p.Value.ItemGuid);
|
||||||
|
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
|
||||||
});
|
});
|
||||||
|
|
||||||
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
|
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
|
||||||
|
|
@ -366,6 +384,8 @@ public static class GameEventWiring
|
||||||
|
|
||||||
if (localPlayer is not null)
|
if (localPlayer is not null)
|
||||||
{
|
{
|
||||||
|
localPlayer.OnProperties(p.Value.Properties);
|
||||||
|
|
||||||
foreach (var attr in p.Value.Attributes)
|
foreach (var attr in p.Value.Attributes)
|
||||||
{
|
{
|
||||||
if (attr.Current is uint cur)
|
if (attr.Current is uint cur)
|
||||||
|
|
@ -408,14 +428,12 @@ public static class GameEventWiring
|
||||||
// component; .Ranks is XP-bought additions. Their sum is
|
// component; .Ranks is XP-bought additions. Their sum is
|
||||||
// the closest we get to ACE's CreatureSkill.Current short
|
// the closest we get to ACE's CreatureSkill.Current short
|
||||||
// of porting the full Aug/Multiplier/Vitae chain.
|
// of porting the full Aug/Multiplier/Vitae chain.
|
||||||
if (onSkillsUpdated is not null)
|
if (localPlayer is not null || onSkillsUpdated is not null)
|
||||||
{
|
{
|
||||||
int runSkill = -1;
|
int runSkill = -1;
|
||||||
int jumpSkill = -1;
|
int jumpSkill = -1;
|
||||||
foreach (var s in p.Value.Skills)
|
foreach (var s in p.Value.Skills)
|
||||||
{
|
{
|
||||||
if (s.SkillId != 22u && s.SkillId != 24u) continue;
|
|
||||||
|
|
||||||
// K-fix13: total = AttributeFormula(skill, attrs)
|
// K-fix13: total = AttributeFormula(skill, attrs)
|
||||||
// + InitLevel (s.Init from wire)
|
// + InitLevel (s.Init from wire)
|
||||||
// + Ranks (s.Ranks from wire)
|
// + Ranks (s.Ranks from wire)
|
||||||
|
|
@ -428,6 +446,19 @@ public static class GameEventWiring
|
||||||
uint formulaBonus = resolveSkillFormulaBonus is not null
|
uint formulaBonus = resolveSkillFormulaBonus is not null
|
||||||
? resolveSkillFormulaBonus(s.SkillId, attrCurrents)
|
? resolveSkillFormulaBonus(s.SkillId, attrCurrents)
|
||||||
: 0u;
|
: 0u;
|
||||||
|
|
||||||
|
localPlayer?.OnSkillUpdate(
|
||||||
|
skillId: s.SkillId,
|
||||||
|
ranks: s.Ranks,
|
||||||
|
status: s.Status,
|
||||||
|
xp: s.Xp,
|
||||||
|
init: s.Init,
|
||||||
|
resistance: s.Resistance,
|
||||||
|
lastUsed: s.LastUsed,
|
||||||
|
formulaBonus: formulaBonus);
|
||||||
|
|
||||||
|
if (s.SkillId != 22u && s.SkillId != 24u) continue;
|
||||||
|
|
||||||
int total = (int)(formulaBonus + s.Init + s.Ranks);
|
int total = (int)(formulaBonus + s.Init + s.Ranks);
|
||||||
if (s.SkillId == 24u) runSkill = total;
|
if (s.SkillId == 24u) runSkill = total;
|
||||||
else if (s.SkillId == 22u) jumpSkill = total;
|
else if (s.SkillId == 22u) jumpSkill = total;
|
||||||
|
|
@ -437,7 +468,7 @@ public static class GameEventWiring
|
||||||
$"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}");
|
$"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}");
|
||||||
}
|
}
|
||||||
if (runSkill >= 0 || jumpSkill >= 0)
|
if (runSkill >= 0 || jumpSkill >= 0)
|
||||||
onSkillsUpdated(runSkill, jumpSkill);
|
onSkillsUpdated?.Invoke(runSkill, jumpSkill);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #7 — enchantment block: feed each entry into the
|
// Issue #7 — enchantment block: feed each entry into the
|
||||||
|
|
@ -463,8 +494,21 @@ public static class GameEventWiring
|
||||||
// actual weenie data via ObjectTableWiring. (Previously this seeded
|
// actual weenie data via ObjectTableWiring. (Previously this seeded
|
||||||
// stubs with WeenieClassId = ContainerType, a misuse — ContainerType
|
// stubs with WeenieClassId = ContainerType, a misuse — ContainerType
|
||||||
// is a 0/1/2 container-kind discriminator, not a weenie class id.)
|
// is a 0/1/2 container-kind discriminator, not a weenie class id.)
|
||||||
foreach (var inv in p.Value.Inventory)
|
uint ownerGuid = playerGuid?.Invoke() ?? 0u;
|
||||||
items.RecordMembership(inv.Guid);
|
if (ownerGuid != 0u)
|
||||||
|
{
|
||||||
|
var entries = new ContainerContentEntry[p.Value.Inventory.Count];
|
||||||
|
for (int i = 0; i < entries.Length; i++)
|
||||||
|
entries[i] = new ContainerContentEntry(
|
||||||
|
p.Value.Inventory[i].Guid,
|
||||||
|
p.Value.Inventory[i].ContainerType);
|
||||||
|
items.ReplaceContents(ownerGuid, entries);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var inv in p.Value.Inventory)
|
||||||
|
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
|
||||||
|
}
|
||||||
foreach (var eq in p.Value.Equipped)
|
foreach (var eq in p.Value.Equipped)
|
||||||
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
|
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ public static class CreateObject
|
||||||
// publish it.
|
// publish it.
|
||||||
uint? Useability = null,
|
uint? Useability = null,
|
||||||
float? UseRadius = null,
|
float? UseRadius = null,
|
||||||
|
uint? TargetType = null,
|
||||||
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
|
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
|
||||||
// WeenieHeader optional tail. IconOverlayId is gated by
|
// WeenieHeader optional tail. IconOverlayId is gated by
|
||||||
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
|
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
|
||||||
|
|
@ -645,6 +646,7 @@ public static class CreateObject
|
||||||
// SAFE because IconComposer early-returns on id==0 per layer.
|
// SAFE because IconComposer early-returns on id==0 per layer.
|
||||||
uint? useability = null;
|
uint? useability = null;
|
||||||
float? useRadius = null;
|
float? useRadius = null;
|
||||||
|
uint? targetType = null;
|
||||||
uint iconOverlayId = 0;
|
uint iconOverlayId = 0;
|
||||||
uint iconUnderlayId = 0;
|
uint iconUnderlayId = 0;
|
||||||
uint uiEffects = 0;
|
uint uiEffects = 0;
|
||||||
|
|
@ -707,7 +709,7 @@ public static class CreateObject
|
||||||
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
|
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
|
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
|
||||||
pos += 4;
|
targetType = ReadU32(body, ref pos);
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
|
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
|
||||||
{
|
{
|
||||||
|
|
@ -850,7 +852,7 @@ public static class CreateObject
|
||||||
physicsState, objectDescriptionFlags,
|
physicsState, objectDescriptionFlags,
|
||||||
friction, elasticity,
|
friction, elasticity,
|
||||||
IconId: iconId,
|
IconId: iconId,
|
||||||
Useability: useability, UseRadius: useRadius,
|
Useability: useability, UseRadius: useRadius, TargetType: targetType,
|
||||||
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
|
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
|
||||||
UiEffects: uiEffects,
|
UiEffects: uiEffects,
|
||||||
WeenieClassId: weenieClassId,
|
WeenieClassId: weenieClassId,
|
||||||
|
|
|
||||||
|
|
@ -336,11 +336,14 @@ public static class GameEvents
|
||||||
|
|
||||||
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
|
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
if (payload.Length < 12) return null;
|
if (payload.Length < 8) return null;
|
||||||
|
uint wielderGuid = payload.Length >= 12
|
||||||
|
? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))
|
||||||
|
: 0u;
|
||||||
return new WieldObject(
|
return new WieldObject(
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
wielderGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
|
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
|
||||||
|
|
|
||||||
|
|
@ -81,5 +81,7 @@ public static class ObjectTableWiring
|
||||||
ContainersCapacity: s.ContainersCapacity,
|
ContainersCapacity: s.ContainersCapacity,
|
||||||
Structure: s.Structure,
|
Structure: s.Structure,
|
||||||
MaxStructure: s.MaxStructure,
|
MaxStructure: s.MaxStructure,
|
||||||
Workmanship: s.Workmanship);
|
Workmanship: s.Workmanship,
|
||||||
|
Useability: s.Useability,
|
||||||
|
TargetType: s.TargetType);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ public sealed class WorldSession : IDisposable
|
||||||
// server publishes it for non-useable display entities.
|
// server publishes it for non-useable display entities.
|
||||||
uint? Useability = null,
|
uint? Useability = null,
|
||||||
float? UseRadius = null,
|
float? UseRadius = null,
|
||||||
|
uint? TargetType = null,
|
||||||
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
|
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
|
||||||
uint IconId = 0,
|
uint IconId = 0,
|
||||||
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
|
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
|
||||||
|
|
@ -807,6 +808,7 @@ public sealed class WorldSession : IDisposable
|
||||||
parsed.Value.Elasticity,
|
parsed.Value.Elasticity,
|
||||||
parsed.Value.Useability,
|
parsed.Value.Useability,
|
||||||
parsed.Value.UseRadius,
|
parsed.Value.UseRadius,
|
||||||
|
parsed.Value.TargetType,
|
||||||
parsed.Value.IconId,
|
parsed.Value.IconId,
|
||||||
parsed.Value.IconOverlayId,
|
parsed.Value.IconOverlayId,
|
||||||
parsed.Value.IconUnderlayId,
|
parsed.Value.IconUnderlayId,
|
||||||
|
|
@ -1209,6 +1211,34 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(body);
|
SendGameAction(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail RaiseAttribute (0x0045).</summary>
|
||||||
|
public void SendRaiseAttribute(uint attrId, ulong xpSpent)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(CharacterActions.BuildRaiseAttribute(seq, attrId, xpSpent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail RaiseVital (0x0044).</summary>
|
||||||
|
public void SendRaiseVital(uint vitalId, ulong xpSpent)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(CharacterActions.BuildRaiseVital(seq, vitalId, xpSpent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail RaiseSkill (0x0046).</summary>
|
||||||
|
public void SendRaiseSkill(uint skillId, ulong xpSpent)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(CharacterActions.BuildRaiseSkill(seq, skillId, xpSpent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send retail TrainSkill (0x0047).</summary>
|
||||||
|
public void SendTrainSkill(uint skillId, uint credits)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
||||||
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
||||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||||
|
|
@ -1255,9 +1285,17 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
|
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
|
/// <summary>Send UseWithTarget (0x0035) - use a source item on an acquired target.
|
||||||
|
/// Retail: CM_Inventory::Event_UseWithTargetEvent.</summary>
|
||||||
|
public void SendUseWithTarget(uint sourceGuid, uint targetGuid)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
|
||||||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||||
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
|
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
||||||
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
||||||
{
|
{
|
||||||
uint seq = NextGameActionSequence();
|
uint seq = NextGameActionSequence();
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,23 @@ public sealed class PropertyBundle
|
||||||
public Dictionary<uint, uint> InstanceIds { get; } = new();
|
public Dictionary<uint, uint> InstanceIds { get; } = new();
|
||||||
|
|
||||||
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
|
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
|
||||||
|
public long GetInt64 (uint k, long def = 0) => Int64s.TryGetValue(k, out var v) ? v : def;
|
||||||
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
|
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
|
||||||
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
|
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
|
||||||
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
|
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
|
||||||
|
|
||||||
|
public PropertyBundle Clone()
|
||||||
|
{
|
||||||
|
var copy = new PropertyBundle();
|
||||||
|
foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value;
|
||||||
|
foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -150,12 +164,20 @@ public sealed class ClientObject
|
||||||
public int Value { get; set; } // pyreals
|
public int Value { get; set; } // pyreals
|
||||||
public uint ContainerId { get; set; } // parent container ObjectId, or 0
|
public uint ContainerId { get; set; } // parent container ObjectId, or 0
|
||||||
public int ContainerSlot { get; set; } = -1;
|
public int ContainerSlot { get; set; } = -1;
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ContentProfile.m_uContainerProperties</c> hint from
|
||||||
|
/// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list.
|
||||||
|
/// Kept separate from <see cref="Type"/> because CreateObject owns real item data.
|
||||||
|
/// </summary>
|
||||||
|
public uint ContainerTypeHint { get; set; }
|
||||||
public bool Attuned { get; set; }
|
public bool Attuned { get; set; }
|
||||||
public bool Bonded { get; set; }
|
public bool Bonded { get; set; }
|
||||||
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
|
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
|
||||||
public int ItemsCapacity { get; set; } // main-pack slots (containers)
|
public int ItemsCapacity { get; set; } // main-pack slots (containers)
|
||||||
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
|
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
|
||||||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||||||
|
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||||||
|
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||||||
public int Structure { get; set; } // charges/uses remaining
|
public int Structure { get; set; } // charges/uses remaining
|
||||||
public int MaxStructure { get; set; }
|
public int MaxStructure { get; set; }
|
||||||
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
|
||||||
|
|
@ -193,7 +215,52 @@ public readonly record struct WeenieData(
|
||||||
int? ContainersCapacity,
|
int? ContainersCapacity,
|
||||||
int? Structure,
|
int? Structure,
|
||||||
int? MaxStructure,
|
int? MaxStructure,
|
||||||
float? Workmanship);
|
float? Workmanship,
|
||||||
|
uint? Useability = null,
|
||||||
|
uint? TargetType = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||||
|
/// Low 16 bits describe where the source may be used from; high 16 bits
|
||||||
|
/// describe where the acquired target may be.
|
||||||
|
/// </summary>
|
||||||
|
public static class ItemUseability
|
||||||
|
{
|
||||||
|
public const uint Undef = 0x0u;
|
||||||
|
public const uint No = 0x1u;
|
||||||
|
public const uint Self = 0x2u;
|
||||||
|
public const uint Wielded = 0x4u;
|
||||||
|
public const uint Contained = 0x8u;
|
||||||
|
public const uint Viewed = 0x10u;
|
||||||
|
public const uint Remote = 0x20u;
|
||||||
|
public const uint NeverWalk = 0x40u;
|
||||||
|
public const uint ObjSelf = 0x80u;
|
||||||
|
|
||||||
|
public const uint SourceMask = 0x0000FFFFu;
|
||||||
|
public const uint TargetMask = 0xFFFF0000u;
|
||||||
|
|
||||||
|
public static bool IsTargeted(uint useability)
|
||||||
|
=> (useability & TargetMask) != 0;
|
||||||
|
|
||||||
|
public static bool AllowsSelfTarget(uint useability)
|
||||||
|
=> ((useability >> 16) & Self) != 0;
|
||||||
|
|
||||||
|
public static bool AllowsObjectSelfTarget(uint useability)
|
||||||
|
=> ((useability >> 16) & ObjSelf) != 0;
|
||||||
|
|
||||||
|
public static uint SourceFlags(uint useability)
|
||||||
|
=> useability & SourceMask;
|
||||||
|
|
||||||
|
public static uint TargetFlags(uint useability)
|
||||||
|
=> (useability & TargetMask) >> 16;
|
||||||
|
|
||||||
|
public static bool IsDirectUseable(uint useability)
|
||||||
|
{
|
||||||
|
uint source = SourceFlags(useability);
|
||||||
|
return !IsTargeted(useability)
|
||||||
|
&& (source & ~(No | NeverWalk)) != Undef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
|
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AcDream.Core.Items;
|
namespace AcDream.Core.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered container snapshot entry from retail ContentProfile:
|
||||||
|
/// guid plus m_uContainerProperties.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
||||||
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
||||||
|
|
@ -69,6 +75,7 @@ public sealed class ClientObjectTable
|
||||||
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
||||||
/// <see cref="ClientObject.Effects"/>.</summary>
|
/// <see cref="ClientObject.Effects"/>.</summary>
|
||||||
public const uint UiEffectsPropertyId = 18u;
|
public const uint UiEffectsPropertyId = 18u;
|
||||||
|
public const uint CurrentWieldedLocationPropertyId = 10u;
|
||||||
|
|
||||||
public int ObjectCount => _objects.Count;
|
public int ObjectCount => _objects.Count;
|
||||||
public int ContainerCount => _containers.Count;
|
public int ContainerCount => _containers.Count;
|
||||||
|
|
@ -124,7 +131,7 @@ public sealed class ClientObjectTable
|
||||||
/// so RollbackMove restores full pre-move state through this method alone.
|
/// so RollbackMove restores full pre-move state through this method alone.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
||||||
EquipMask newEquipLocation = EquipMask.None)
|
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
|
||||||
{
|
{
|
||||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||||
|
|
||||||
|
|
@ -132,6 +139,7 @@ public sealed class ClientObjectTable
|
||||||
item.ContainerId = newContainerId;
|
item.ContainerId = newContainerId;
|
||||||
item.ContainerSlot = newSlot;
|
item.ContainerSlot = newSlot;
|
||||||
item.CurrentlyEquippedLocation = newEquipLocation;
|
item.CurrentlyEquippedLocation = newEquipLocation;
|
||||||
|
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
|
||||||
Reindex(item, oldContainer);
|
Reindex(item, oldContainer);
|
||||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -306,6 +314,23 @@ public sealed class ClientObjectTable
|
||||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||||
item.Properties.Ints[propertyId] = value;
|
item.Properties.Ints[propertyId] = value;
|
||||||
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
||||||
|
if (propertyId == CurrentWieldedLocationPropertyId)
|
||||||
|
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
|
||||||
|
ObjectUpdated?.Invoke(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a single PropertyInt64 update to an object's bundle and fire
|
||||||
|
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
|
||||||
|
/// <see cref="UpdateIntProperty"/> (used by the character sheet's
|
||||||
|
/// optimistic unassigned-XP debit; also the hook for future
|
||||||
|
/// PrivateUpdatePropertyInt64 parsing). False if the object is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public bool UpdateInt64Property(uint itemId, uint propertyId, long value)
|
||||||
|
{
|
||||||
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||||
|
item.Properties.Int64s[propertyId] = value;
|
||||||
ObjectUpdated?.Invoke(item);
|
ObjectUpdated?.Invoke(item);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -359,6 +384,8 @@ public sealed class ClientObjectTable
|
||||||
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
|
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
|
||||||
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
|
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
|
||||||
if (d.Priority is { } pr) obj.Priority = pr;
|
if (d.Priority is { } pr) obj.Priority = pr;
|
||||||
|
if (d.Useability is { } use) obj.Useability = use;
|
||||||
|
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
||||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||||
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
|
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
|
||||||
if (d.Structure is { } st) obj.Structure = st;
|
if (d.Structure is { } st) obj.Structure = st;
|
||||||
|
|
@ -377,7 +404,7 @@ public sealed class ClientObjectTable
|
||||||
/// icon/name/type/effects — that data comes from CreateObject.
|
/// icon/name/type/effects — that data comes from CreateObject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
||||||
EquipMask equip = EquipMask.None)
|
EquipMask equip = EquipMask.None, uint? containerTypeHint = null)
|
||||||
{
|
{
|
||||||
bool existed = _objects.TryGetValue(guid, out var obj);
|
bool existed = _objects.TryGetValue(guid, out var obj);
|
||||||
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
if (!existed || obj is null) // keep: satisfies nullable flow analysis
|
||||||
|
|
@ -386,8 +413,16 @@ public sealed class ClientObjectTable
|
||||||
_objects[guid] = obj;
|
_objects[guid] = obj;
|
||||||
}
|
}
|
||||||
uint oldContainer = obj.ContainerId;
|
uint oldContainer = obj.ContainerId;
|
||||||
if (containerId != 0) obj.ContainerId = containerId;
|
if (containerId != 0)
|
||||||
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
|
{
|
||||||
|
obj.ContainerId = containerId;
|
||||||
|
obj.CurrentlyEquippedLocation = EquipMask.None;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
obj.CurrentlyEquippedLocation = equip;
|
||||||
|
}
|
||||||
|
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
|
||||||
Reindex(obj, oldContainer);
|
Reindex(obj, oldContainer);
|
||||||
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
||||||
return obj;
|
return obj;
|
||||||
|
|
@ -404,12 +439,21 @@ public sealed class ClientObjectTable
|
||||||
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
|
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
|
||||||
_containerIndex[obj.ContainerId] = list = new List<uint>();
|
_containerIndex[obj.ContainerId] = list = new List<uint>();
|
||||||
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
|
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
|
||||||
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
|
var priorOrder = new Dictionary<uint, int>(list.Count);
|
||||||
|
for (int i = 0; i < list.Count; i++)
|
||||||
|
priorOrder[list[i]] = i;
|
||||||
|
list.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
int c = SlotOf(a).CompareTo(SlotOf(b));
|
||||||
|
return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int SlotOf(uint guid) =>
|
private int SlotOf(uint guid) =>
|
||||||
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
|
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
|
||||||
|
? o.ContainerSlot
|
||||||
|
: int.MaxValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
|
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
|
||||||
|
|
@ -433,7 +477,26 @@ public sealed class ClientObjectTable
|
||||||
ArgumentNullException.ThrowIfNull(guids);
|
ArgumentNullException.ThrowIfNull(guids);
|
||||||
if (containerId == 0) return;
|
if (containerId == 0) return;
|
||||||
|
|
||||||
var keep = new HashSet<uint>(guids);
|
var entries = new ContainerContentEntry[guids.Count];
|
||||||
|
for (int i = 0; i < guids.Count; i++)
|
||||||
|
entries[i] = new ContainerContentEntry(guids[i], 0u);
|
||||||
|
ReplaceContents(containerId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
|
||||||
|
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
|
||||||
|
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
|
||||||
|
/// chooses the loose-item list versus the side-pack/container list.
|
||||||
|
/// </summary>
|
||||||
|
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
if (containerId == 0) return;
|
||||||
|
|
||||||
|
var keep = new HashSet<uint>();
|
||||||
|
foreach (var entry in entries)
|
||||||
|
keep.Add(entry.Guid);
|
||||||
|
|
||||||
// Detach prior members no longer present (they left the container server-side). GetContents
|
// Detach prior members no longer present (they left the container server-side). GetContents
|
||||||
// returns a snapshot, so mutating the index inside the loop is safe.
|
// returns a snapshot, so mutating the index inside the loop is safe.
|
||||||
|
|
@ -442,23 +505,26 @@ public sealed class ClientObjectTable
|
||||||
if (keep.Contains(old)) continue;
|
if (keep.Contains(old)) continue;
|
||||||
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
||||||
o.ContainerId = 0;
|
o.ContainerId = 0;
|
||||||
|
o.CurrentlyEquippedLocation = EquipMask.None;
|
||||||
Reindex(o, containerId);
|
Reindex(o, containerId);
|
||||||
ObjectMoved?.Invoke(o, containerId, 0);
|
ObjectMoved?.Invoke(o, containerId, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
||||||
for (int i = 0; i < guids.Count; i++)
|
for (int i = 0; i < entries.Count; i++)
|
||||||
{
|
{
|
||||||
uint g = guids[i];
|
var entry = entries[i];
|
||||||
bool existed = _objects.TryGetValue(g, out var obj);
|
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
|
||||||
if (!existed || obj is null)
|
if (!existed || obj is null)
|
||||||
{
|
{
|
||||||
obj = new ClientObject { ObjectId = g };
|
obj = new ClientObject { ObjectId = entry.Guid };
|
||||||
_objects[g] = obj;
|
_objects[entry.Guid] = obj;
|
||||||
}
|
}
|
||||||
uint oldContainer = obj.ContainerId;
|
uint oldContainer = obj.ContainerId;
|
||||||
obj.ContainerId = containerId;
|
obj.ContainerId = containerId;
|
||||||
obj.ContainerSlot = i;
|
obj.ContainerSlot = i;
|
||||||
|
obj.CurrentlyEquippedLocation = EquipMask.None;
|
||||||
|
obj.ContainerTypeHint = entry.ContainerType;
|
||||||
Reindex(obj, oldContainer);
|
Reindex(obj, oldContainer);
|
||||||
if (!existed) ObjectAdded?.Invoke(obj);
|
if (!existed) ObjectAdded?.Invoke(obj);
|
||||||
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
|
|
||||||
namespace AcDream.Core.Player;
|
namespace AcDream.Core.Player;
|
||||||
|
|
@ -87,10 +88,27 @@ public sealed class LocalPlayerState
|
||||||
/// on primary-attribute state held elsewhere on the cache.</summary>
|
/// on primary-attribute state held elsewhere on the cache.</summary>
|
||||||
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
|
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
|
||||||
|
|
||||||
|
/// <summary>Per-skill snapshot from PlayerDescription's CreatureSkill table.</summary>
|
||||||
|
public readonly record struct SkillSnapshot(
|
||||||
|
uint SkillId,
|
||||||
|
uint Ranks,
|
||||||
|
uint Status,
|
||||||
|
uint Xp,
|
||||||
|
uint Init,
|
||||||
|
uint Resistance,
|
||||||
|
double LastUsed,
|
||||||
|
uint FormulaBonus)
|
||||||
|
{
|
||||||
|
public uint BaseLevel => FormulaBonus + Init + Ranks;
|
||||||
|
public uint CurrentLevel => BaseLevel;
|
||||||
|
}
|
||||||
|
|
||||||
private VitalSnapshot? _health;
|
private VitalSnapshot? _health;
|
||||||
private VitalSnapshot? _stamina;
|
private VitalSnapshot? _stamina;
|
||||||
private VitalSnapshot? _mana;
|
private VitalSnapshot? _mana;
|
||||||
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
|
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
|
||||||
|
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
|
||||||
|
private PropertyBundle _properties = new();
|
||||||
private readonly Spellbook? _spellbook;
|
private readonly Spellbook? _spellbook;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -112,6 +130,9 @@ public sealed class LocalPlayerState
|
||||||
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
|
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
|
||||||
public event System.Action<AttributeKind>? AttributeChanged;
|
public event System.Action<AttributeKind>? AttributeChanged;
|
||||||
|
|
||||||
|
/// <summary>Fires after player properties or skills from PlayerDescription change.</summary>
|
||||||
|
public event System.Action? CharacterChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
|
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
|
|
@ -158,6 +179,16 @@ public sealed class LocalPlayerState
|
||||||
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
|
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
|
||||||
_attrs.TryGetValue(kind, out var a) ? a : null;
|
_attrs.TryGetValue(kind, out var a) ? a : null;
|
||||||
|
|
||||||
|
/// <summary>Snapshot of the local player's current property bundle.</summary>
|
||||||
|
public PropertyBundle Properties => _properties;
|
||||||
|
|
||||||
|
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
|
||||||
|
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
|
||||||
|
|
||||||
|
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
|
||||||
|
public SkillSnapshot? GetSkill(uint skillId) =>
|
||||||
|
_skills.TryGetValue(skillId, out var s) ? s : null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute the buffed max for a vital, using the full retail formula:
|
/// Compute the buffed max for a vital, using the full retail formula:
|
||||||
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
|
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
|
||||||
|
|
@ -273,6 +304,134 @@ public sealed class LocalPlayerState
|
||||||
AttributeChanged?.Invoke(kind);
|
AttributeChanged?.Invoke(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
|
||||||
|
public void OnProperties(PropertyBundle properties)
|
||||||
|
{
|
||||||
|
_properties = properties.Clone();
|
||||||
|
CharacterChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Apply or replace one PlayerDescription skill entry.</summary>
|
||||||
|
public void OnSkillUpdate(
|
||||||
|
uint skillId,
|
||||||
|
uint ranks,
|
||||||
|
uint status,
|
||||||
|
uint xp,
|
||||||
|
uint init,
|
||||||
|
uint resistance,
|
||||||
|
double lastUsed,
|
||||||
|
uint formulaBonus)
|
||||||
|
{
|
||||||
|
_skills[skillId] = new SkillSnapshot(
|
||||||
|
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
|
||||||
|
CharacterChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint SaturatingAdd(uint value, uint delta)
|
||||||
|
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta;
|
||||||
|
|
||||||
|
private static uint SaturatingAdd(uint value, ulong delta)
|
||||||
|
=> delta > uint.MaxValue - value ? uint.MaxValue : value + (uint)delta;
|
||||||
|
|
||||||
// ── Retail attribute contribution ──────────────────────────────────────
|
// ── Retail attribute contribution ──────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +
|
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,20 @@ public class RuntimeOptionsRetailUiTests
|
||||||
Assert.False(opts.RetailUi);
|
Assert.False(opts.RetailUi);
|
||||||
Assert.Null(opts.AcDir);
|
Assert.Null(opts.AcDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ReadsUiProbeOptions()
|
||||||
|
{
|
||||||
|
var env = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ACDREAM_UI_PROBE_DUMP"] = "1",
|
||||||
|
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
|
||||||
|
};
|
||||||
|
|
||||||
|
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
||||||
|
|
||||||
|
Assert.True(opts.UiProbeDump);
|
||||||
|
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
|
||||||
|
Assert.True(opts.UiProbeEnabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ public sealed class RuntimeOptionsTests
|
||||||
Assert.True(opts.RetailCloseDegrades);
|
Assert.True(opts.RetailCloseDegrades);
|
||||||
Assert.False(opts.DumpSceneryZ);
|
Assert.False(opts.DumpSceneryZ);
|
||||||
Assert.Null(opts.LegacyStreamRadius);
|
Assert.Null(opts.LegacyStreamRadius);
|
||||||
|
Assert.False(opts.UiProbeDump);
|
||||||
|
Assert.Null(opts.UiProbeScript);
|
||||||
|
Assert.False(opts.UiProbeEnabled);
|
||||||
Assert.False(opts.HasLiveCredentials);
|
Assert.False(opts.HasLiveCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
155
tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
Normal file
155
tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class CursorFeedbackControllerTests
|
||||||
|
{
|
||||||
|
private const uint Player = 0x50000001u;
|
||||||
|
private const uint Pack = 0x50000010u;
|
||||||
|
private const uint Source = 0x50000020u;
|
||||||
|
private const uint Target = 0x50000021u;
|
||||||
|
private const uint HealthKitUseability = 0x000A0008u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
|
||||||
|
{
|
||||||
|
var c = new CursorFeedbackController();
|
||||||
|
|
||||||
|
var accept = c.Resolve(new CursorFeedbackSnapshot(
|
||||||
|
DragPayload: new object(),
|
||||||
|
DragAccept: UiItemSlot.DragAcceptState.Accept,
|
||||||
|
HoverResizeEdges: ResizeEdges.Right));
|
||||||
|
var reject = c.Resolve(new CursorFeedbackSnapshot(
|
||||||
|
DragPayload: new object(),
|
||||||
|
DragAccept: UiItemSlot.DragAcceptState.Reject,
|
||||||
|
HoverResizeEdges: ResizeEdges.Right));
|
||||||
|
|
||||||
|
Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind);
|
||||||
|
Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
|
||||||
|
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
|
||||||
|
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
|
||||||
|
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
|
||||||
|
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
|
||||||
|
{
|
||||||
|
var c = new CursorFeedbackController();
|
||||||
|
|
||||||
|
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
|
||||||
|
|
||||||
|
Assert.Equal(expected, feedback.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState()
|
||||||
|
{
|
||||||
|
var c = new CursorFeedbackController();
|
||||||
|
|
||||||
|
Assert.Equal(CursorFeedbackKind.WindowMove,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
|
||||||
|
Assert.Equal(CursorFeedbackKind.Text,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TargetMode_reportsValidInvalidAndPendingTargets()
|
||||||
|
{
|
||||||
|
var objects = SeedTargetObjects();
|
||||||
|
var interaction = new ItemInteractionController(
|
||||||
|
objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
sendUse: null,
|
||||||
|
sendUseWithTarget: null,
|
||||||
|
sendWield: null,
|
||||||
|
sendDrop: null,
|
||||||
|
nowMs: () => 1_000);
|
||||||
|
var c = new CursorFeedbackController(interaction);
|
||||||
|
|
||||||
|
Assert.True(interaction.ActivateItem(Source));
|
||||||
|
|
||||||
|
Assert.Equal(CursorFeedbackKind.TargetValid,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind);
|
||||||
|
Assert.Equal(CursorFeedbackKind.TargetInvalid,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind);
|
||||||
|
Assert.Equal(CursorFeedbackKind.TargetInvalid,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
|
||||||
|
Assert.Equal(CursorFeedbackKind.TargetPending,
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot()).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem()
|
||||||
|
{
|
||||||
|
var objects = SeedTargetObjects();
|
||||||
|
var interaction = new ItemInteractionController(
|
||||||
|
objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
sendUse: null,
|
||||||
|
sendUseWithTarget: null,
|
||||||
|
sendWield: null,
|
||||||
|
sendDrop: null,
|
||||||
|
nowMs: () => 1_000);
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var slot = new UiItemSlot
|
||||||
|
{
|
||||||
|
Left = 10,
|
||||||
|
Top = 10,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
UseTargetGuidProvider = () => Player,
|
||||||
|
};
|
||||||
|
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
|
||||||
|
slot.SetStateCursors(new Dictionary<string, UiCursorMedia>
|
||||||
|
{
|
||||||
|
["Drag_rollover_accept"] = acceptCursor,
|
||||||
|
});
|
||||||
|
slot.SetItem(Target, iconTexture: 1u);
|
||||||
|
root.AddChild(slot);
|
||||||
|
root.OnMouseMove(20, 20);
|
||||||
|
var c = new CursorFeedbackController(interaction);
|
||||||
|
|
||||||
|
Assert.True(interaction.ActivateItem(Source));
|
||||||
|
|
||||||
|
var feedback = c.Update(root);
|
||||||
|
Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind);
|
||||||
|
Assert.Equal(acceptCursor, feedback.Cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientObjectTable SeedTargetObjects()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Player,
|
||||||
|
Name = "Player",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Pack,
|
||||||
|
Name = "Backpack",
|
||||||
|
Type = ItemType.Container,
|
||||||
|
ItemsCapacity = 24,
|
||||||
|
});
|
||||||
|
objects.MoveItem(Pack, Player, 0);
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Source,
|
||||||
|
Name = "Health Kit",
|
||||||
|
Type = ItemType.Misc,
|
||||||
|
Useability = HealthKitUseability,
|
||||||
|
});
|
||||||
|
objects.MoveItem(Source, Pack, 0);
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Target,
|
||||||
|
Name = "Target",
|
||||||
|
Type = ItemType.Misc,
|
||||||
|
});
|
||||||
|
objects.MoveItem(Target, Pack, 1);
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class ItemInteractionControllerTests
|
||||||
|
{
|
||||||
|
private const uint Player = 0x50000001u;
|
||||||
|
private const uint Pack = 0x50000010u;
|
||||||
|
private const uint HealthKitUseability = 0x000A0008u;
|
||||||
|
|
||||||
|
private sealed class Harness
|
||||||
|
{
|
||||||
|
public readonly ClientObjectTable Objects = new();
|
||||||
|
public readonly List<uint> Uses = new();
|
||||||
|
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||||
|
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||||
|
public readonly List<uint> Drops = new();
|
||||||
|
public readonly List<string> Toasts = new();
|
||||||
|
public long Now = 1_000;
|
||||||
|
|
||||||
|
public Harness()
|
||||||
|
{
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Player,
|
||||||
|
Name = "Player",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
});
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Pack,
|
||||||
|
Name = "Backpack",
|
||||||
|
Type = ItemType.Container,
|
||||||
|
ItemsCapacity = 24,
|
||||||
|
});
|
||||||
|
Objects.MoveItem(Pack, Player, 0);
|
||||||
|
|
||||||
|
Controller = new ItemInteractionController(
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
sendUse: Uses.Add,
|
||||||
|
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||||
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||||
|
sendDrop: Drops.Add,
|
||||||
|
nowMs: () => Now,
|
||||||
|
toast: Toasts.Add);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ItemInteractionController Controller { get; }
|
||||||
|
|
||||||
|
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
|
||||||
|
{
|
||||||
|
var item = new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = id,
|
||||||
|
Name = $"Item {id:X}",
|
||||||
|
Type = ItemType.Misc,
|
||||||
|
IconId = 0x06001234u,
|
||||||
|
};
|
||||||
|
configure?.Invoke(item);
|
||||||
|
Objects.AddOrUpdate(item);
|
||||||
|
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TargetedItem_entersTargetModeAndMarksPendingSource()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A01u));
|
||||||
|
|
||||||
|
Assert.True(h.Controller.IsTargetModeActive);
|
||||||
|
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
|
||||||
|
Assert.Empty(h.UseWithTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
|
||||||
|
|
||||||
|
h.Controller.ActivateItem(0x50000A01u);
|
||||||
|
Assert.True(h.Controller.AcquireSelfTarget());
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
|
||||||
|
Assert.False(h.Controller.IsTargetModeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u);
|
||||||
|
h.AddContained(0x50000A02u);
|
||||||
|
|
||||||
|
h.Controller.ActivateItem(0x50000A01u);
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A02u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
|
||||||
|
Assert.False(h.Controller.IsTargetModeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectUseItem_sendsUse()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A03u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ContainerItem_sendsUseToOpen()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A04u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Container;
|
||||||
|
item.ItemsCapacity = 12;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A04u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A05u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Clothing;
|
||||||
|
item.ValidLocations = EquipMask.HeadWear;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A05u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
|
||||||
|
var equipped = h.Objects.Get(0x50000A05u)!;
|
||||||
|
Assert.Equal(Player, equipped.ContainerId);
|
||||||
|
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
const EquipMask coatMask =
|
||||||
|
EquipMask.ChestWear
|
||||||
|
| EquipMask.UpperArmWear
|
||||||
|
| EquipMask.LowerArmWear;
|
||||||
|
h.AddContained(0x50000A15u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Clothing;
|
||||||
|
item.ValidLocations = coatMask;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A15u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
|
||||||
|
var equipped = h.Objects.Get(0x50000A15u)!;
|
||||||
|
Assert.Equal(Player, equipped.ContainerId);
|
||||||
|
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x50000AF1u,
|
||||||
|
Type = ItemType.Clothing,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
||||||
|
Priority = 0x00000001u,
|
||||||
|
});
|
||||||
|
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
|
||||||
|
const EquipMask coatMask =
|
||||||
|
EquipMask.ChestWear
|
||||||
|
| EquipMask.UpperArmWear
|
||||||
|
| EquipMask.LowerArmWear;
|
||||||
|
h.AddContained(0x50000A16u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Clothing;
|
||||||
|
item.ValidLocations = coatMask;
|
||||||
|
item.Priority = 0x00000002u;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(h.Controller.ActivateItem(0x50000A16u));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
|
||||||
|
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x50000AF2u,
|
||||||
|
Type = ItemType.Clothing,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
|
||||||
|
Priority = 0x00000004u,
|
||||||
|
});
|
||||||
|
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
|
||||||
|
const EquipMask coatMask =
|
||||||
|
EquipMask.ChestWear
|
||||||
|
| EquipMask.UpperArmWear
|
||||||
|
| EquipMask.LowerArmWear;
|
||||||
|
h.AddContained(0x50000A17u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Clothing;
|
||||||
|
item.ValidLocations = coatMask;
|
||||||
|
item.Priority = 0x00000004u;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(h.Controller.ActivateItem(0x50000A17u));
|
||||||
|
|
||||||
|
Assert.Empty(h.Wields);
|
||||||
|
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EquippableItemWithNoFreeSlot_sendsNothing()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x50000AF0u,
|
||||||
|
Type = ItemType.Armor,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.Shield,
|
||||||
|
});
|
||||||
|
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
|
||||||
|
h.AddContained(0x50000A06u, item =>
|
||||||
|
{
|
||||||
|
item.Type = ItemType.Armor;
|
||||||
|
item.ValidLocations = EquipMask.Shield;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(h.Controller.ActivateItem(0x50000A06u));
|
||||||
|
|
||||||
|
Assert.Empty(h.Wields);
|
||||||
|
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A07u);
|
||||||
|
var payload = new ItemDragPayload(
|
||||||
|
0x50000A07u,
|
||||||
|
ItemDragSource.Inventory,
|
||||||
|
SourceSlot: 0,
|
||||||
|
SourceCell: new UiItemSlot());
|
||||||
|
|
||||||
|
Assert.True(h.Controller.DropToWorld(payload));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
|
||||||
|
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A08u);
|
||||||
|
var payload = new ItemDragPayload(
|
||||||
|
0x50000A08u,
|
||||||
|
ItemDragSource.ShortcutBar,
|
||||||
|
SourceSlot: 0,
|
||||||
|
SourceCell: new UiItemSlot());
|
||||||
|
|
||||||
|
Assert.False(h.Controller.DropToWorld(payload));
|
||||||
|
|
||||||
|
Assert.Empty(h.Drops);
|
||||||
|
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActivateItem_appliesRetailUseThrottle()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
|
||||||
|
|
||||||
|
h.Controller.ActivateItem(0x50000A09u);
|
||||||
|
h.Now += 199;
|
||||||
|
h.Controller.ActivateItem(0x50000A09u);
|
||||||
|
h.Now += 1;
|
||||||
|
h.Controller.ActivateItem(0x50000A09u);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs
Normal file
109
tests/AcDream.App.Tests/UI/Layout/CharacterLayoutImportProbe.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.App.Studio;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
public sealed class CharacterLayoutImportProbe
|
||||||
|
{
|
||||||
|
private const uint CharacterLayout = 0x2100002Eu;
|
||||||
|
|
||||||
|
private static string? DatDir()
|
||||||
|
{
|
||||||
|
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
return Directory.Exists(d) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Selected_attribute_shows_raise_buttons_on_visible_footer_state()
|
||||||
|
{
|
||||||
|
var datDir = DatDir();
|
||||||
|
if (datDir is null) return;
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
|
||||||
|
Assert.NotNull(layout);
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout!, SampleData.SampleCharacter, spriteResolve: _ => (1u, 30, 26));
|
||||||
|
|
||||||
|
var rows = new List<UiClickablePanel>();
|
||||||
|
CollectRows(layout!.Root, rows);
|
||||||
|
Assert.True(rows.Count >= 9, $"expected at least 9 attribute rows, found {rows.Count}");
|
||||||
|
|
||||||
|
rows[4].OnClick!();
|
||||||
|
|
||||||
|
var buttons = new List<UiButton>();
|
||||||
|
CollectButtons(layout.Root, buttons);
|
||||||
|
|
||||||
|
Assert.Contains(buttons, b =>
|
||||||
|
b.ElementId == CharacterStatController.RaiseOneId
|
||||||
|
&& b.ActiveState == "Normal"
|
||||||
|
&& IsEffectivelyVisible(b));
|
||||||
|
Assert.Contains(buttons, b =>
|
||||||
|
b.ElementId == CharacterStatController.RaiseTenId
|
||||||
|
&& b.ActiveState == "Normal"
|
||||||
|
&& IsEffectivelyVisible(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_button_resolves_and_invokes_controller_close_callback()
|
||||||
|
{
|
||||||
|
var datDir = DatDir();
|
||||||
|
if (datDir is null) return;
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
|
||||||
|
Assert.NotNull(layout);
|
||||||
|
|
||||||
|
var close = layout!.FindElement(WindowChromeController.CharacterCloseButtonId);
|
||||||
|
Assert.NotNull(close);
|
||||||
|
Assert.True(close is UiButton or UiDatElement,
|
||||||
|
$"character close button resolved to {close?.GetType().Name ?? "null"}");
|
||||||
|
|
||||||
|
int closes = 0;
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
|
||||||
|
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(1, closes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
|
||||||
|
{
|
||||||
|
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)
|
||||||
|
result.Add(row);
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
CollectRows(child, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectButtons(UiElement node, List<UiButton> result)
|
||||||
|
{
|
||||||
|
if (node is UiButton button
|
||||||
|
&& (button.ElementId == CharacterStatController.RaiseOneId
|
||||||
|
|| button.ElementId == CharacterStatController.RaiseTenId))
|
||||||
|
{
|
||||||
|
result.Add(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
CollectButtons(child, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEffectivelyVisible(UiElement element)
|
||||||
|
{
|
||||||
|
for (UiElement? e = element; e is not null; e = e.Parent)
|
||||||
|
{
|
||||||
|
if (!e.Visible) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
// 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_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_SendsAndDebitsThroughTableEvents()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
[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_DebitsFirstPresentCreditProperty()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
var player = h.AddPlayerObject();
|
||||||
|
player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain
|
||||||
|
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);
|
||||||
|
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
||||||
|
Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
|
||||||
|
{
|
||||||
|
var h = new Harness(); // note: nothing added to the table
|
||||||
|
var props = new PropertyBundle();
|
||||||
|
props.Int64s[2u] = 500L;
|
||||||
|
h.Player.OnProperties(props);
|
||||||
|
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||||
|
int changed = 0;
|
||||||
|
h.Player.CharacterChanged += () => changed++;
|
||||||
|
|
||||||
|
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||||
|
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
|
||||||
|
|
||||||
|
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
|
||||||
|
Assert.True(changed >= 1); // and CharacterChanged fired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,10 +50,114 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
|
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
|
||||||
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
|
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_WindowChromeButton_InvokesCloseCallback()
|
||||||
|
{
|
||||||
|
var close = MakeButton(WindowChromeController.CharacterCloseButtonId);
|
||||||
|
var layout = Fake((WindowChromeController.CharacterCloseButtonId, close));
|
||||||
|
int closes = 0;
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
|
||||||
|
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(1, closes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle()
|
||||||
|
{
|
||||||
|
var sheet = new CharacterSheet
|
||||||
|
{
|
||||||
|
Gender = "Female",
|
||||||
|
Heritage = "Aluvian",
|
||||||
|
Title = "the Adventurer",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, "Male")]
|
||||||
|
[InlineData(2, "Female")]
|
||||||
|
[InlineData(0, null)]
|
||||||
|
public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected)
|
||||||
|
=> Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, "Aluvian")]
|
||||||
|
[InlineData(2, "Gharu'ndim")]
|
||||||
|
[InlineData(5, "Umbraen")]
|
||||||
|
[InlineData(13, "Olthoi")]
|
||||||
|
[InlineData(99, null)]
|
||||||
|
public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected)
|
||||||
|
=> Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated()
|
||||||
|
{
|
||||||
|
var root = new UiPanel { Width = 300, Height = 600 };
|
||||||
|
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||||||
|
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||||||
|
|
||||||
|
var visibleName = new UiText { ElementId = CharacterStatController.NameId };
|
||||||
|
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
|
||||||
|
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||||||
|
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
|
||||||
|
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||||||
|
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
|
||||||
|
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||||||
|
var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
|
||||||
|
var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||||||
|
var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
|
||||||
|
var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||||||
|
var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
|
||||||
|
var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||||||
|
var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
|
||||||
|
visibleMeter.AddChild(visibleXpNext);
|
||||||
|
hiddenMeter.AddChild(hiddenXpNext);
|
||||||
|
|
||||||
|
attrPage.AddChild(visibleName);
|
||||||
|
attrPage.AddChild(visibleHeritage);
|
||||||
|
attrPage.AddChild(visibleLevel);
|
||||||
|
attrPage.AddChild(visibleTotalXpLabel);
|
||||||
|
attrPage.AddChild(visibleTotalXp);
|
||||||
|
attrPage.AddChild(visibleMeter);
|
||||||
|
hiddenPage.AddChild(hiddenName);
|
||||||
|
hiddenPage.AddChild(hiddenHeritage);
|
||||||
|
hiddenPage.AddChild(hiddenLevel);
|
||||||
|
hiddenPage.AddChild(hiddenTotalXpLabel);
|
||||||
|
hiddenPage.AddChild(hiddenTotalXp);
|
||||||
|
hiddenPage.AddChild(hiddenMeter);
|
||||||
|
root.AddChild(attrPage);
|
||||||
|
root.AddChild(hiddenPage);
|
||||||
|
|
||||||
|
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||||
|
{
|
||||||
|
[CharacterStatController.NameId] = hiddenName,
|
||||||
|
[CharacterStatController.HeritageId] = hiddenHeritage,
|
||||||
|
[CharacterStatController.LevelId] = hiddenLevel,
|
||||||
|
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
|
||||||
|
[CharacterStatController.TotalXpId] = hiddenTotalXp,
|
||||||
|
[CharacterStatController.XpMeterId] = hiddenMeter,
|
||||||
|
[CharacterStatController.XpNextValueId] = hiddenXpNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
|
||||||
|
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
|
||||||
|
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
|
||||||
|
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
|
||||||
|
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
|
||||||
|
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
|
||||||
|
Assert.Empty(hiddenName.LinesProvider());
|
||||||
|
Assert.Empty(hiddenXpNext.LinesProvider());
|
||||||
|
}
|
||||||
|
|
||||||
// ── XP meter fill ────────────────────────────────────────────────────────
|
// ── XP meter fill ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -514,6 +618,31 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal("Normal", btn10.ActiveState);
|
Assert.Equal("Normal", btn10.ActiveState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates()
|
||||||
|
{
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var btn10 = MakeButton();
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.RaiseOneId, btn1),
|
||||||
|
(CharacterStatController.RaiseTenId, btn10),
|
||||||
|
(CharacterStatController.ListBoxId, list));
|
||||||
|
var sheet = new CharacterSheet
|
||||||
|
{
|
||||||
|
UnassignedXp = 500L,
|
||||||
|
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||||||
|
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||||||
|
};
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, () => sheet);
|
||||||
|
|
||||||
|
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||||
|
|
||||||
|
Assert.Equal("Normal", btn1.ActiveState);
|
||||||
|
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RaiseButtons_MaxedRow_ShowsGhostedState()
|
public void RaiseButtons_MaxedRow_ShowsGhostedState()
|
||||||
{
|
{
|
||||||
|
|
@ -535,6 +664,95 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal("Ghosted", btn10.ActiveState);
|
Assert.Equal("Ghosted", btn10.ActiveState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest()
|
||||||
|
{
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.RaiseOneId, btn1),
|
||||||
|
(CharacterStatController.ListBoxId, list));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||||
|
btn1.OnClick!();
|
||||||
|
|
||||||
|
var request = Assert.Single(requests);
|
||||||
|
Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind);
|
||||||
|
Assert.Equal(5u, request.StatId);
|
||||||
|
Assert.Equal(110L, request.Cost);
|
||||||
|
Assert.Equal(1, request.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh()
|
||||||
|
{
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.RaiseOneId, btn1),
|
||||||
|
(CharacterStatController.ListBoxId, list));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||||
|
Assert.Equal("Normal", btn1.ActiveState);
|
||||||
|
|
||||||
|
btn1.OnClick!();
|
||||||
|
|
||||||
|
Assert.Single(requests);
|
||||||
|
Assert.Equal("Normal", btn1.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId()
|
||||||
|
{
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.RaiseOneId, btn1),
|
||||||
|
(CharacterStatController.ListBoxId, list));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||||||
|
btn1.OnClick!();
|
||||||
|
|
||||||
|
var request = Assert.Single(requests);
|
||||||
|
Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind);
|
||||||
|
Assert.Equal(1u, request.StatId);
|
||||||
|
Assert.Equal(90L, request.Cost);
|
||||||
|
Assert.Equal(1, request.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest()
|
||||||
|
{
|
||||||
|
var btn10 = MakeButton();
|
||||||
|
var list = new UiPanel();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.RaiseTenId, btn10),
|
||||||
|
(CharacterStatController.ListBoxId, list));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
var sheet = new CharacterSheet
|
||||||
|
{
|
||||||
|
UnassignedXp = 500L,
|
||||||
|
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
|
||||||
|
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||||||
|
};
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||||
|
btn10.OnClick!();
|
||||||
|
|
||||||
|
Assert.Empty(requests);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RaiseButtons_Deselect_HidesButtons()
|
public void RaiseButtons_Deselect_HidesButtons()
|
||||||
{
|
{
|
||||||
|
|
@ -635,10 +853,7 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
ClickTab(layout, left: 92f);
|
ClickTab(layout, left: 92f);
|
||||||
|
|
||||||
var headerPanels = list.Children
|
var headerPanels = SkillHeaders(list);
|
||||||
.Where(c => c is UiPanel and not UiClickablePanel)
|
|
||||||
.Cast<UiPanel>()
|
|
||||||
.ToList();
|
|
||||||
var headers = headerPanels
|
var headers = headerPanels
|
||||||
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
|
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
@ -654,7 +869,7 @@ public class CharacterStatControllerTests
|
||||||
Assert.All(headerPanels, h =>
|
Assert.All(headerPanels, h =>
|
||||||
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
|
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
|
||||||
|
|
||||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
var rows = SkillRows(list);
|
||||||
Assert.Equal(12, rows.Count);
|
Assert.Equal(12, rows.Count);
|
||||||
Assert.All(rows, row =>
|
Assert.All(rows, row =>
|
||||||
{
|
{
|
||||||
|
|
@ -692,7 +907,7 @@ public class CharacterStatControllerTests
|
||||||
spriteResolve: id => (id, 16, 16));
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
|
||||||
ClickTab(layout, left: 92f);
|
ClickTab(layout, left: 92f);
|
||||||
Assert.Equal(12, list.Children.OfType<UiClickablePanel>().Count());
|
Assert.Equal(12, SkillRows(list).Count);
|
||||||
|
|
||||||
ClickTab(layout, left: 0f);
|
ClickTab(layout, left: 0f);
|
||||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
||||||
|
|
@ -704,8 +919,8 @@ public class CharacterStatControllerTests
|
||||||
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
|
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
|
||||||
{
|
{
|
||||||
var root = new UiPanel { Width = 300, Height = 600 };
|
var root = new UiPanel { Width = 300, Height = 600 };
|
||||||
var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
|
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
|
||||||
var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
|
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
|
||||||
var name = new UiText();
|
var name = new UiText();
|
||||||
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||||||
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
|
||||||
|
|
@ -761,7 +976,7 @@ public class CharacterStatControllerTests
|
||||||
spriteResolve: id => (id, 16, 16));
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
|
||||||
ClickTab(layout, left: 92f);
|
ClickTab(layout, left: 92f);
|
||||||
var rows = list.Children.OfType<UiClickablePanel>().ToList();
|
var rows = SkillRows(list);
|
||||||
rows[1].OnClick!();
|
rows[1].OnClick!();
|
||||||
|
|
||||||
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
|
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
|
||||||
|
|
@ -777,6 +992,31 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
|
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest()
|
||||||
|
{
|
||||||
|
var list = new UiPanel { Width = 300 };
|
||||||
|
var btn10 = MakeButton();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.ListBoxId, list),
|
||||||
|
(CharacterStatController.RaiseTenId, btn10));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
|
spriteResolve: id => (id, 16, 16),
|
||||||
|
onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
ClickTab(layout, left: 92f);
|
||||||
|
SkillRows(list)[1].OnClick!();
|
||||||
|
btn10.OnClick!();
|
||||||
|
|
||||||
|
var request = Assert.Single(requests);
|
||||||
|
Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind);
|
||||||
|
Assert.Equal(34u, request.StatId);
|
||||||
|
Assert.Equal(111_000_000L, request.Cost);
|
||||||
|
Assert.Equal(10, request.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
|
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
|
||||||
{
|
{
|
||||||
|
|
@ -800,7 +1040,7 @@ public class CharacterStatControllerTests
|
||||||
spriteResolve: id => (id, 16, 16));
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
|
||||||
ClickTab(layout, left: 92f);
|
ClickTab(layout, left: 92f);
|
||||||
list.Children.OfType<UiClickablePanel>().ToList()[5].OnClick!();
|
SkillRows(list)[5].OnClick!();
|
||||||
|
|
||||||
Assert.Equal("Healing", title.LinesProvider()[0].Text);
|
Assert.Equal("Healing", title.LinesProvider()[0].Text);
|
||||||
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
|
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
|
||||||
|
|
@ -811,6 +1051,133 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal("Normal", btn1.ActiveState);
|
Assert.Equal("Normal", btn1.ActiveState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest()
|
||||||
|
{
|
||||||
|
var list = new UiPanel { Width = 300 };
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.ListBoxId, list),
|
||||||
|
(CharacterStatController.RaiseOneId, btn1));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
|
spriteResolve: id => (id, 16, 16),
|
||||||
|
onRaiseRequest: requests.Add);
|
||||||
|
|
||||||
|
ClickTab(layout, left: 92f);
|
||||||
|
SkillRows(list)[5].OnClick!();
|
||||||
|
btn1.OnClick!();
|
||||||
|
|
||||||
|
var request = Assert.Single(requests);
|
||||||
|
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||||||
|
Assert.Equal(21u, request.StatId);
|
||||||
|
Assert.Equal(6L, request.Cost);
|
||||||
|
Assert.Equal(1, request.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained()
|
||||||
|
{
|
||||||
|
var list = new UiPanel { Width = 300 };
|
||||||
|
var btn1 = MakeButton();
|
||||||
|
var btn10 = MakeButton();
|
||||||
|
CharacterSheet sheet = new()
|
||||||
|
{
|
||||||
|
SkillCredits = 10,
|
||||||
|
UnassignedXp = 1_000,
|
||||||
|
Skills = new[]
|
||||||
|
{
|
||||||
|
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||||||
|
CharacterSkillAdvancementClass.Untrained,
|
||||||
|
BaseLevel: 5,
|
||||||
|
CurrentLevel: 5,
|
||||||
|
UsableUntrained: true,
|
||||||
|
TrainedCost: 4,
|
||||||
|
SpecializedCost: 0,
|
||||||
|
RaiseCost: 0,
|
||||||
|
Raise10Cost: 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var layout = Fake(
|
||||||
|
(CharacterStatController.ListBoxId, list),
|
||||||
|
(CharacterStatController.RaiseOneId, btn1),
|
||||||
|
(CharacterStatController.RaiseTenId, btn10));
|
||||||
|
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, () => sheet,
|
||||||
|
spriteResolve: id => (id, 16, 16),
|
||||||
|
onRaiseRequest: request =>
|
||||||
|
{
|
||||||
|
requests.Add(request);
|
||||||
|
sheet = new CharacterSheet
|
||||||
|
{
|
||||||
|
SkillCredits = 6,
|
||||||
|
UnassignedXp = 1_000,
|
||||||
|
Skills = new[]
|
||||||
|
{
|
||||||
|
new CharacterSkill(100u, "Train Me", 0x06000001u,
|
||||||
|
CharacterSkillAdvancementClass.Trained,
|
||||||
|
BaseLevel: 5,
|
||||||
|
CurrentLevel: 5,
|
||||||
|
UsableUntrained: true,
|
||||||
|
TrainedCost: 4,
|
||||||
|
SpecializedCost: 0,
|
||||||
|
RaiseCost: 10,
|
||||||
|
Raise10Cost: 100),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ClickTab(layout, left: 92f);
|
||||||
|
SkillRows(list).Single().OnClick!();
|
||||||
|
Assert.False(btn10.Visible);
|
||||||
|
|
||||||
|
btn1.OnClick!();
|
||||||
|
|
||||||
|
var request = Assert.Single(requests);
|
||||||
|
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||||||
|
Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
|
||||||
|
Assert.True(btn1.Visible);
|
||||||
|
Assert.True(btn10.Visible);
|
||||||
|
Assert.Equal("Normal", btn1.ActiveState);
|
||||||
|
Assert.Equal("Normal", btn10.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
|
||||||
|
{
|
||||||
|
var root = new UiPanel { Width = 300, Height = 600 };
|
||||||
|
var page = new UiPanel { Width = 300, Height = 600 };
|
||||||
|
var name = new UiText();
|
||||||
|
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80);
|
||||||
|
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80);
|
||||||
|
scrollbarShell.Left = 281;
|
||||||
|
|
||||||
|
page.AddChild(name);
|
||||||
|
page.AddChild(list);
|
||||||
|
page.AddChild(scrollbarShell);
|
||||||
|
root.AddChild(page);
|
||||||
|
|
||||||
|
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||||
|
{
|
||||||
|
[CharacterStatController.NameId] = name,
|
||||||
|
[CharacterStatController.ListBoxId] = list,
|
||||||
|
[CharacterStatController.ListScrollbarId] = scrollbarShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
|
||||||
|
ClickTab(layout, left: 92f);
|
||||||
|
|
||||||
|
var bar = page.Children.OfType<UiScrollbar>().Single();
|
||||||
|
Assert.True(bar.Visible);
|
||||||
|
Assert.NotNull(bar.Model);
|
||||||
|
Assert.Contains(list.Children, c => c is UiScrollablePanel);
|
||||||
|
Assert.False(scrollbarShell.Visible);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetRaiseCost_Index4Focus_Returns110()
|
public void GetRaiseCost_Index4Focus_Returns110()
|
||||||
{
|
{
|
||||||
|
|
@ -818,6 +1185,13 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
|
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetRaiseCost_Amount10Index4Focus_Returns1100()
|
||||||
|
{
|
||||||
|
var sheet = SampleData.SampleCharacter();
|
||||||
|
Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetRaiseCost_Index0Strength_Returns0()
|
public void GetRaiseCost_Index0Strength_Returns0()
|
||||||
{
|
{
|
||||||
|
|
@ -868,10 +1242,22 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal(9, costs.Length);
|
Assert.Equal(9, costs.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SampleCharacter_AttributeRaise10Costs_HasNineEntries()
|
||||||
|
{
|
||||||
|
var costs = SampleData.SampleCharacter().AttributeRaise10Costs;
|
||||||
|
Assert.NotNull(costs);
|
||||||
|
Assert.Equal(9, costs.Length);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
|
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
|
||||||
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
|
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SampleCharacter_AttributeRaise10Costs_FocusAt1100()
|
||||||
|
=> Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
|
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
|
||||||
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
|
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
|
||||||
|
|
@ -1098,12 +1484,32 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
private static string FirstRowName(UiElement list)
|
private static string FirstRowName(UiElement list)
|
||||||
{
|
{
|
||||||
var row = list.Children.OfType<UiClickablePanel>().FirstOrDefault();
|
var row = Descendants(list).OfType<UiClickablePanel>().FirstOrDefault();
|
||||||
if (row is null) return "<no row>";
|
if (row is null) return "<no row>";
|
||||||
var texts = row.Children.OfType<UiText>().ToList();
|
var texts = row.Children.OfType<UiText>().ToList();
|
||||||
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
|
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<UiClickablePanel> SkillRows(UiElement list)
|
||||||
|
=> Descendants(list).OfType<UiClickablePanel>().ToList();
|
||||||
|
|
||||||
|
private static List<UiPanel> SkillHeaders(UiElement list)
|
||||||
|
=> Descendants(list)
|
||||||
|
.Where(c => c is UiPanel and not UiClickablePanel
|
||||||
|
&& c.Children.OfType<UiText>().Any())
|
||||||
|
.Cast<UiPanel>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||||||
|
{
|
||||||
|
foreach (var child in root.Children)
|
||||||
|
{
|
||||||
|
yield return child;
|
||||||
|
foreach (var nested in Descendants(child))
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
|
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
|
||||||
{
|
{
|
||||||
var info = new ElementInfo
|
var info = new ElementInfo
|
||||||
|
|
@ -1123,9 +1529,9 @@ public class CharacterStatControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
|
||||||
private static UiButton MakeButton()
|
private static UiButton MakeButton(uint id = 0u)
|
||||||
{
|
{
|
||||||
var info = new ElementInfo { Id = 0, Type = 1 };
|
var info = new ElementInfo { Id = id, Type = 1 };
|
||||||
return new UiButton(info, static _ => (0u, 0, 0));
|
return new UiButton(info, static _ => (0u, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,19 @@ public class ChatWindowControllerTests
|
||||||
|
|
||||||
// ── Test 3: Input is placed as a child of the input bar ─────────────────
|
// ── Test 3: Input is placed as a child of the input bar ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_Transcript_ForcesScrollableTextMode()
|
||||||
|
{
|
||||||
|
var (rootInfo, layout, vm) = BuildTestTree();
|
||||||
|
var bus = new CaptureBus();
|
||||||
|
|
||||||
|
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
|
||||||
|
|
||||||
|
Assert.NotNull(ctrl);
|
||||||
|
Assert.False(ctrl!.Transcript.Centered);
|
||||||
|
Assert.False(ctrl.Transcript.RightAligned);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_Input_IsChildOfInputBar()
|
public void Bind_Input_IsChildOfInputBar()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,18 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_PropagatesStateCursors()
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Type = 3 };
|
||||||
|
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
|
||||||
|
|
||||||
|
var e = DatWidgetFactory.Create(info, NoTex, null)!;
|
||||||
|
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
|
||||||
|
e.CursorForState("Drag_rollover_accept", allowFallback: false));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,22 @@ public class ElementReaderTests
|
||||||
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
|
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_DerivedStateCursorOverridesBase()
|
||||||
|
{
|
||||||
|
var base_ = new ElementInfo();
|
||||||
|
base_.StateCursors[""] = new UiCursorMedia(0x06003000u, 1, 2);
|
||||||
|
base_.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06003001u, 3, 4);
|
||||||
|
|
||||||
|
var derived = new ElementInfo();
|
||||||
|
derived.StateCursors[""] = new UiCursorMedia(0x06004000u, 5, 6);
|
||||||
|
|
||||||
|
var merged = ElementReader.Merge(base_, derived);
|
||||||
|
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06004000u, 5, 6), merged.StateCursors[""]);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06003001u, 3, 4), merged.StateCursors["Drag_rollover_accept"]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Merge_ChildrenComeFromDerived()
|
public void Merge_ChildrenComeFromDerived()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public class InventoryControllerTests
|
||||||
private const uint BurdenText = 0x100001D8u;
|
private const uint BurdenText = 0x100001D8u;
|
||||||
private const uint BurdenCaption = 0x100001D7u;
|
private const uint BurdenCaption = 0x100001D7u;
|
||||||
private const uint ContentsCaption = 0x100001C5u;
|
private const uint ContentsCaption = 0x100001C5u;
|
||||||
|
private const uint TitleText = 0x100001D3u;
|
||||||
private const uint ContentsScrollbar = 0x100001C7u;
|
private const uint ContentsScrollbar = 0x100001C7u;
|
||||||
|
|
||||||
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
||||||
|
|
@ -35,16 +36,17 @@ public class InventoryControllerTests
|
||||||
var burdenText = new TestElement { Width = 36, Height = 15 };
|
var burdenText = new TestElement { Width = 36, Height = 15 };
|
||||||
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
||||||
var contentsCap = new TestElement { Width = 192, Height = 15 };
|
var contentsCap = new TestElement { Width = 192, Height = 15 };
|
||||||
|
var titleText = new TestElement { Width = 276, Height = 25 };
|
||||||
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
||||||
var root = new TestElement { Width = 300, Height = 362 };
|
var root = new TestElement { Width = 300, Height = 362 };
|
||||||
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
||||||
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
||||||
root.AddChild(contentsCap); root.AddChild(scrollbar);
|
root.AddChild(contentsCap); root.AddChild(titleText); root.AddChild(scrollbar);
|
||||||
var byId = new Dictionary<uint, UiElement>
|
var byId = new Dictionary<uint, UiElement>
|
||||||
{
|
{
|
||||||
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
||||||
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
||||||
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
|
[ContentsCaption] = contentsCap, [TitleText] = titleText, [ContentsScrollbar] = scrollbar,
|
||||||
};
|
};
|
||||||
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
||||||
burdenText, burdenCap, contentsCap);
|
burdenText, burdenCap, contentsCap);
|
||||||
|
|
@ -52,13 +54,23 @@ public class InventoryControllerTests
|
||||||
|
|
||||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||||
List<(uint item, uint container, int placement)>? puts = null)
|
List<(uint item, uint container, int placement)>? puts = null,
|
||||||
|
string? ownerName = null,
|
||||||
|
Action? onClose = null)
|
||||||
=> InventoryController.Bind(layout, objects, () => Player,
|
=> InventoryController.Bind(layout, objects, () => Player,
|
||||||
iconIds: (_, _, _, _, _) => 0u,
|
iconIds: (_, _, _, _, _) => 0u,
|
||||||
strength: () => strength, datFont: null,
|
strength: () => strength, datFont: null,
|
||||||
|
ownerName: ownerName is null ? null : () => ownerName,
|
||||||
sendUse: uses is null ? null : g => uses.Add(g),
|
sendUse: uses is null ? null : g => uses.Add(g),
|
||||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||||
|
onClose: onClose);
|
||||||
|
|
||||||
|
private static UiButton MakeButton(uint id)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Id = id, Type = 1 };
|
||||||
|
return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 };
|
||||||
|
}
|
||||||
|
|
||||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||||
|
|
@ -100,6 +112,23 @@ public class InventoryControllerTests
|
||||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Populate_uses_manifest_container_hint_before_create_object_details()
|
||||||
|
{
|
||||||
|
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.ReplaceContents(Player, new[]
|
||||||
|
{
|
||||||
|
new ContainerContentEntry(0xC, 1u),
|
||||||
|
new ContainerContentEntry(0xA, 0u),
|
||||||
|
});
|
||||||
|
|
||||||
|
Bind(layout, objects);
|
||||||
|
|
||||||
|
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||||
|
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Equipped_items_are_excluded_from_the_grid_and_selector()
|
public void Equipped_items_are_excluded_from_the_grid_and_selector()
|
||||||
{
|
{
|
||||||
|
|
@ -125,6 +154,7 @@ public class InventoryControllerTests
|
||||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
Bind(layout, new ClientObjectTable());
|
Bind(layout, new ClientObjectTable());
|
||||||
Assert.Equal(6, grid.Columns);
|
Assert.Equal(6, grid.Columns);
|
||||||
|
Assert.Equal(UiItemListFlow.RowMajor, grid.Flow);
|
||||||
Assert.Equal(32f, grid.CellWidth);
|
Assert.Equal(32f, grid.CellWidth);
|
||||||
Assert.Equal(32f, grid.CellHeight);
|
Assert.Equal(32f, grid.CellHeight);
|
||||||
}
|
}
|
||||||
|
|
@ -164,11 +194,27 @@ public class InventoryControllerTests
|
||||||
public void Captions_render_known_strings()
|
public void Captions_render_known_strings()
|
||||||
{
|
{
|
||||||
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
||||||
Bind(layout, new ClientObjectTable());
|
var title = layout.FindElement(TitleText)!;
|
||||||
|
Bind(layout, new ClientObjectTable(), ownerName: "Horan");
|
||||||
|
Assert.Contains("Inventory of Horan", CaptionText(title));
|
||||||
Assert.Contains("Burden", CaptionText(burdenCap));
|
Assert.Contains("Burden", CaptionText(burdenCap));
|
||||||
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Window_chrome_button_invokes_close_callback()
|
||||||
|
{
|
||||||
|
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var close = MakeButton(WindowChromeController.InventoryCloseButtonId);
|
||||||
|
layout.Root.AddChild(close);
|
||||||
|
int closes = 0;
|
||||||
|
|
||||||
|
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
|
||||||
|
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(1, closes);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
|
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
|
||||||
{
|
{
|
||||||
|
|
@ -354,6 +400,42 @@ public class InventoryControllerTests
|
||||||
Assert.Empty(closes);
|
Assert.Empty(closes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TargetMode_suppressesSelectedSquare_onPendingSource()
|
||||||
|
{
|
||||||
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Player,
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
ItemsCapacity = 102,
|
||||||
|
});
|
||||||
|
SeedContained(objects, 0xA, Player, slot: 0);
|
||||||
|
objects.Get(0xA)!.Useability = 0x000A0008u;
|
||||||
|
var interaction = new ItemInteractionController(
|
||||||
|
objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
sendUse: null,
|
||||||
|
sendUseWithTarget: null,
|
||||||
|
sendWield: null,
|
||||||
|
sendDrop: null,
|
||||||
|
nowMs: () => 1_000);
|
||||||
|
|
||||||
|
InventoryController.Bind(layout, objects, () => Player,
|
||||||
|
iconIds: (_, _, _, _, _) => 0u,
|
||||||
|
strength: () => 100,
|
||||||
|
datFont: null,
|
||||||
|
itemInteraction: interaction);
|
||||||
|
grid.GetItem(0)!.Clicked!();
|
||||||
|
Assert.True(grid.GetItem(0)!.Selected);
|
||||||
|
|
||||||
|
Assert.True(interaction.ActivateItem(0xAu));
|
||||||
|
|
||||||
|
Assert.True(interaction.IsTargetModeActive);
|
||||||
|
Assert.False(grid.GetItem(0)!.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Default_mainPackCell_isOpenContainer()
|
public void Default_mainPackCell_isOpenContainer()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Items;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -86,4 +87,34 @@ public class InventoryFrameImportProbe
|
||||||
"(else the Alphablend backdrop overpaints/washes out the panel content)");
|
"(else the Alphablend backdrop overpaints/washes out the panel content)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_button_resolves_and_invokes_controller_close_callback()
|
||||||
|
{
|
||||||
|
var datDir = DatDir();
|
||||||
|
if (datDir is null) return; // CI: no live dat - skip
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||||
|
Assert.NotNull(layout);
|
||||||
|
|
||||||
|
var close = layout!.FindElement(WindowChromeController.InventoryCloseButtonId);
|
||||||
|
Assert.NotNull(close);
|
||||||
|
Assert.True(close is UiButton or UiDatElement,
|
||||||
|
$"inventory close button resolved to {close?.GetType().Name ?? "null"}");
|
||||||
|
|
||||||
|
int closes = 0;
|
||||||
|
InventoryController.Bind(
|
||||||
|
layout,
|
||||||
|
new ClientObjectTable(),
|
||||||
|
playerGuid: static () => 0u,
|
||||||
|
iconIds: static (_, _, _, _, _) => 0u,
|
||||||
|
strength: static () => 100,
|
||||||
|
datFont: null,
|
||||||
|
onClose: () => closes++);
|
||||||
|
|
||||||
|
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(1, closes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,22 @@ public class LayoutImporterTests
|
||||||
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
||||||
/// the Type-3 must also be present.
|
/// the Type-3 must also be present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildFromInfos_StampsDatElementId_OnRootAndItemList()
|
||||||
|
{
|
||||||
|
const uint RootId = 0x100005F9u;
|
||||||
|
const uint ListId = 0x100001C6u;
|
||||||
|
|
||||||
|
var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 };
|
||||||
|
var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 };
|
||||||
|
|
||||||
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null);
|
||||||
|
|
||||||
|
Assert.Equal(RootId, tree.Root.DatElementId);
|
||||||
|
var found = Assert.IsType<UiItemList>(tree.FindElement(ListId));
|
||||||
|
Assert.Equal(ListId, found.DatElementId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ public class PaperdollControllerTests
|
||||||
private const uint Player = 0x50000001u;
|
private const uint Player = 0x50000001u;
|
||||||
private const uint Pack = 0x40000005u;
|
private const uint Pack = 0x40000005u;
|
||||||
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
||||||
|
private const uint ChestSlot = 0x100001E2u; // ChestWear 0x2
|
||||||
|
private const uint ChestArmorSlot = 0x100005ACu; // ChestArmor 0x200
|
||||||
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
||||||
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
||||||
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
||||||
|
|
@ -19,7 +21,7 @@ public class PaperdollControllerTests
|
||||||
|
|
||||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
||||||
{
|
{
|
||||||
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||||
var lists = new Dictionary<uint, UiItemList>();
|
var lists = new Dictionary<uint, UiItemList>();
|
||||||
var byId = new Dictionary<uint, UiElement>();
|
var byId = new Dictionary<uint, UiElement>();
|
||||||
var root = new RootElement { Width = 224, Height = 214 };
|
var root = new RootElement { Width = 224, Height = 214 };
|
||||||
|
|
@ -112,6 +114,47 @@ public class PaperdollControllerTests
|
||||||
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleDropRelease_onAutoWearSlot_sendsFullValidLocations()
|
||||||
|
{
|
||||||
|
var (layout, lists) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
const EquipMask coatMask =
|
||||||
|
EquipMask.ChestWear
|
||||||
|
| EquipMask.UpperArmWear
|
||||||
|
| EquipMask.LowerArmWear;
|
||||||
|
SeedPackItem(objects, 0xE02u, coatMask);
|
||||||
|
var wields = new List<(uint item, uint mask)>();
|
||||||
|
var ctrl = Bind(layout, objects, wields);
|
||||||
|
var payload = new ItemDragPayload(0xE02u, ItemDragSource.Inventory, 0, lists[ChestSlot].Cell);
|
||||||
|
|
||||||
|
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
|
||||||
|
|
||||||
|
Assert.Equal((uint)coatMask, wields[0].mask);
|
||||||
|
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandleDropRelease_onArmorAutoWearSlot_sendsFullValidLocations()
|
||||||
|
{
|
||||||
|
var (layout, lists) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
const EquipMask hauberkMask =
|
||||||
|
EquipMask.ChestArmor
|
||||||
|
| EquipMask.AbdomenArmor
|
||||||
|
| EquipMask.UpperArmArmor
|
||||||
|
| EquipMask.LowerArmArmor;
|
||||||
|
SeedPackItem(objects, 0xE03u, hauberkMask);
|
||||||
|
var wields = new List<(uint item, uint mask)>();
|
||||||
|
var ctrl = Bind(layout, objects, wields);
|
||||||
|
var payload = new ItemDragPayload(0xE03u, ItemDragSource.Inventory, 0, lists[ChestArmorSlot].Cell);
|
||||||
|
|
||||||
|
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
|
||||||
|
|
||||||
|
Assert.Equal((uint)hauberkMask, wields[0].mask);
|
||||||
|
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
|
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ public class ToolbarControllerTests
|
||||||
|
|
||||||
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
|
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
|
||||||
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||||
|
private const uint CharacterButtonId = 0x10000199u;
|
||||||
|
private const uint InventoryButtonId = 0x100001B1u;
|
||||||
|
|
||||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
|
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
|
||||||
Dictionary<uint, UiElement> indicators) FakeToolbar()
|
Dictionary<uint, UiElement> indicators) FakeToolbar()
|
||||||
|
|
@ -34,6 +36,8 @@ public class ToolbarControllerTests
|
||||||
var e = new UiPanel { Visible = true };
|
var e = new UiPanel { Visible = true };
|
||||||
dict[id] = e; indicators[id] = e; root.AddChild(e);
|
dict[id] = e; indicators[id] = e; root.AddChild(e);
|
||||||
}
|
}
|
||||||
|
AddButton(CharacterButtonId);
|
||||||
|
AddButton(InventoryButtonId);
|
||||||
return (new ImportedLayout(root, dict), slots, indicators);
|
return (new ImportedLayout(root, dict), slots, indicators);
|
||||||
|
|
||||||
void AddSlot(uint id)
|
void AddSlot(uint id)
|
||||||
|
|
@ -41,6 +45,23 @@ public class ToolbarControllerTests
|
||||||
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||||
dict[id] = list; slots[id] = list; root.AddChild(list);
|
dict[id] = list; slots[id] = list; root.AddChild(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddButton(uint id)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Type = 1,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
DefaultStateName = "Normal",
|
||||||
|
};
|
||||||
|
info.StateMedia["Normal"] = (0x1u, 1);
|
||||||
|
info.StateMedia["Highlight"] = (0x2u, 1);
|
||||||
|
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||||
|
dict[id] = button;
|
||||||
|
root.AddChild(button);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -95,6 +116,93 @@ public class ToolbarControllerTests
|
||||||
Assert.Equal(0x5001u, used);
|
Assert.Equal(0x5001u, used);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowToggleButtons_clickCallbacks_fire()
|
||||||
|
{
|
||||||
|
var (layout, _, _) = FakeToolbar();
|
||||||
|
var repo = new ClientObjectTable();
|
||||||
|
int inventoryClicks = 0;
|
||||||
|
int characterClicks = 0;
|
||||||
|
|
||||||
|
var ctrl = ToolbarController.Bind(layout, repo,
|
||||||
|
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||||
|
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||||
|
ctrl.BindWindowToggles(
|
||||||
|
toggleInventory: () => inventoryClicks++,
|
||||||
|
toggleCharacter: () => characterClicks++);
|
||||||
|
|
||||||
|
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||||
|
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(1, inventoryClicks);
|
||||||
|
Assert.Equal(1, characterClicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
|
||||||
|
{
|
||||||
|
const uint player = 0x50000001u;
|
||||||
|
const uint pack = 0x50000010u;
|
||||||
|
const uint kit = 0x50000A01u;
|
||||||
|
var (layout, _, _) = FakeToolbar();
|
||||||
|
var repo = new ClientObjectTable();
|
||||||
|
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||||||
|
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
|
||||||
|
repo.MoveItem(pack, player, 0);
|
||||||
|
repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u });
|
||||||
|
repo.MoveItem(kit, pack, 0);
|
||||||
|
var useWithTarget = new List<(uint Source, uint Target)>();
|
||||||
|
var interaction = new ItemInteractionController(
|
||||||
|
repo,
|
||||||
|
playerGuid: () => player,
|
||||||
|
sendUse: null,
|
||||||
|
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
|
||||||
|
sendWield: null,
|
||||||
|
sendDrop: null,
|
||||||
|
nowMs: () => 1_000);
|
||||||
|
int inventoryClicks = 0;
|
||||||
|
|
||||||
|
var ctrl = ToolbarController.Bind(layout, repo,
|
||||||
|
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||||
|
iconIds: (_,_,_,_,_) => 0u,
|
||||||
|
useItem: _ => { },
|
||||||
|
itemInteraction: interaction);
|
||||||
|
ctrl.BindWindowToggles(
|
||||||
|
toggleInventory: () => inventoryClicks++,
|
||||||
|
toggleCharacter: () => { });
|
||||||
|
interaction.ActivateItem(kit);
|
||||||
|
|
||||||
|
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||||
|
|
||||||
|
Assert.Equal(0, inventoryClicks);
|
||||||
|
Assert.Equal(new[] { (kit, player) }, useWithTarget);
|
||||||
|
Assert.False(interaction.IsTargetModeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowButtonState_highlightsWhenOpen()
|
||||||
|
{
|
||||||
|
var (layout, _, _) = FakeToolbar();
|
||||||
|
var repo = new ClientObjectTable();
|
||||||
|
var ctrl = ToolbarController.Bind(layout, repo,
|
||||||
|
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||||
|
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||||
|
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||||
|
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
|
||||||
|
|
||||||
|
ctrl.SetInventoryOpen(true);
|
||||||
|
ctrl.SetCharacterOpen(true);
|
||||||
|
|
||||||
|
Assert.Equal("Highlight", inventoryButton.ActiveState);
|
||||||
|
Assert.Equal("Highlight", characterButton.ActiveState);
|
||||||
|
|
||||||
|
ctrl.SetInventoryOpen(false);
|
||||||
|
ctrl.SetCharacterOpen(false);
|
||||||
|
|
||||||
|
Assert.Equal("Normal", inventoryButton.ActiveState);
|
||||||
|
Assert.Equal("Normal", characterButton.ActiveState);
|
||||||
|
}
|
||||||
|
|
||||||
// ── C1: combat-mode indicator tests ─────────────────────────────────────
|
// ── C1: combat-mode indicator tests ─────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
61
tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs
Normal file
61
tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using SysEnv = System.Environment;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class RetailCursorCatalogTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)]
|
||||||
|
[InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)]
|
||||||
|
[InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)]
|
||||||
|
public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState(
|
||||||
|
CursorFeedbackKind kind,
|
||||||
|
uint expectedEnum,
|
||||||
|
int expectedHotspotX,
|
||||||
|
int expectedHotspotY)
|
||||||
|
{
|
||||||
|
Assert.Equal(6u, RetailCursorCatalog.CursorEnumTable);
|
||||||
|
|
||||||
|
Assert.True(RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec));
|
||||||
|
|
||||||
|
Assert.Equal(expectedEnum, spec.EnumId);
|
||||||
|
Assert.Equal(expectedHotspotX, spec.HotspotX);
|
||||||
|
Assert.Equal(expectedHotspotY, spec.HotspotY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TargetUseCursorSpecs_resolveGoldenDatSurfaces()
|
||||||
|
{
|
||||||
|
string? datDir = ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var resolver = new RetailCursorResolver(dats, new object());
|
||||||
|
|
||||||
|
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending));
|
||||||
|
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid));
|
||||||
|
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid));
|
||||||
|
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveDatDir()
|
||||||
|
{
|
||||||
|
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||||
|
return fromEnv;
|
||||||
|
|
||||||
|
string defaultDir = Path.Combine(
|
||||||
|
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
|
||||||
|
"Documents",
|
||||||
|
"Asheron's Call");
|
||||||
|
return Directory.Exists(defaultDir) ? defaultDir : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
Normal file
179
tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Testing;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class RetailUiAutomationProbeTests
|
||||||
|
{
|
||||||
|
private sealed class SpyHandler : IItemListDragHandler
|
||||||
|
{
|
||||||
|
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||||
|
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||||
|
|
||||||
|
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||||
|
=> LastLift = (sourceList, sourceCell, payload);
|
||||||
|
|
||||||
|
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||||
|
=> true;
|
||||||
|
|
||||||
|
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||||
|
=> LastDrop = (targetList, targetCell, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
|
||||||
|
RootWithTwoItemLists()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 240, Height = 120 };
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
|
||||||
|
var source = new UiItemList(_ => (1u, 1, 1))
|
||||||
|
{
|
||||||
|
DatElementId = 0x10000010u,
|
||||||
|
Left = 10,
|
||||||
|
Top = 10,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
};
|
||||||
|
source.Cell.SlotIndex = 0;
|
||||||
|
source.Cell.SourceKind = ItemDragSource.Inventory;
|
||||||
|
source.Cell.SetItem(0x5001u, 0x99u);
|
||||||
|
|
||||||
|
var target = new UiItemList(_ => (1u, 1, 1))
|
||||||
|
{
|
||||||
|
DatElementId = 0x10000020u,
|
||||||
|
Left = 70,
|
||||||
|
Top = 10,
|
||||||
|
Width = 32,
|
||||||
|
Height = 32,
|
||||||
|
};
|
||||||
|
target.Cell.SlotIndex = 1;
|
||||||
|
|
||||||
|
var handler = new SpyHandler();
|
||||||
|
target.RegisterDragHandler(handler);
|
||||||
|
|
||||||
|
root.AddChild(source);
|
||||||
|
root.AddChild(target);
|
||||||
|
return (root, source, target, handler, objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_listsDatElementsAndItemSlots()
|
||||||
|
{
|
||||||
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
|
||||||
|
var rows = probe.Snapshot();
|
||||||
|
|
||||||
|
Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList));
|
||||||
|
var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u);
|
||||||
|
Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind);
|
||||||
|
Assert.Equal(0, itemRow.SlotIndex);
|
||||||
|
Assert.True(itemRow.Width > 0f);
|
||||||
|
Assert.True(itemRow.Height > 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClickItem_routesThroughUiRootDoubleClick()
|
||||||
|
{
|
||||||
|
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
bool doubleClicked = false;
|
||||||
|
source.Cell.DoubleClicked = () => doubleClicked = true;
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
|
||||||
|
Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
Assert.True(doubleClicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DragItemToElement_deliversDropReleaseToTargetCell()
|
||||||
|
{
|
||||||
|
var (root, _, target, handler, objects) = RootWithTwoItemLists();
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
|
||||||
|
Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastDrop);
|
||||||
|
Assert.Same(target, handler.LastDrop!.Value.list);
|
||||||
|
Assert.Same(target.Cell, handler.LastDrop.Value.cell);
|
||||||
|
Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DragItemOutside_raisesRootOutsideUiEvent()
|
||||||
|
{
|
||||||
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
object? payload = null;
|
||||||
|
(int x, int y) release = default;
|
||||||
|
root.DragReleasedOutsideUi += (p, x, y) =>
|
||||||
|
{
|
||||||
|
payload = p;
|
||||||
|
release = (x, y);
|
||||||
|
};
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
|
||||||
|
Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
var itemPayload = Assert.IsType<ItemDragPayload>(payload);
|
||||||
|
Assert.Equal(0x5001u, itemPayload.ObjId);
|
||||||
|
Assert.Equal((220, 100), release);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AssertItem_checksObjectTableState()
|
||||||
|
{
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x5001u,
|
||||||
|
ContainerId = 0x7001u,
|
||||||
|
ContainerSlot = 3,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
||||||
|
});
|
||||||
|
var probe = new RetailUiAutomationProbe(new UiRoot(), objects);
|
||||||
|
|
||||||
|
var ok = probe.AssertItem(
|
||||||
|
0x5001u,
|
||||||
|
equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor,
|
||||||
|
containerId: 0x7001u,
|
||||||
|
slot: 3);
|
||||||
|
var bad = probe.AssertItem(0x5001u, slot: 4);
|
||||||
|
|
||||||
|
Assert.True(ok.Success);
|
||||||
|
Assert.False(bad.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe()
|
||||||
|
{
|
||||||
|
var (root, source, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
bool doubleClicked = false;
|
||||||
|
source.Cell.DoubleClicked = () => doubleClicked = true;
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
var logs = new List<string>();
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
||||||
|
File.WriteAllLines(path, new[]
|
||||||
|
{
|
||||||
|
"wait item 0x5001 inventory 100",
|
||||||
|
"doubleclick item 0x5001 inventory",
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add);
|
||||||
|
|
||||||
|
runner.Tick(0.016);
|
||||||
|
|
||||||
|
Assert.True(doubleClicked);
|
||||||
|
Assert.True(runner.Completed);
|
||||||
|
Assert.Contains(logs, line => line.Contains("UI probe script complete"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.App.UI.Testing;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class RetailUiInteractionFlowTests
|
||||||
|
{
|
||||||
|
private const uint Player = 0x50000001u;
|
||||||
|
private const uint Hauberk = 0x50001001u;
|
||||||
|
private const uint HealthKit = 0x50001002u;
|
||||||
|
private const uint SlotsButtonId = 0x100005BEu;
|
||||||
|
private const uint ChestArmorSlotId = 0x100005ACu;
|
||||||
|
private const uint HealthKitUseability = 0x000A0008u;
|
||||||
|
|
||||||
|
private const EquipMask HauberkMask =
|
||||||
|
EquipMask.ChestArmor
|
||||||
|
| EquipMask.AbdomenArmor
|
||||||
|
| EquipMask.UpperArmArmor
|
||||||
|
| EquipMask.LowerArmArmor;
|
||||||
|
|
||||||
|
private sealed class TestElement : UiElement { }
|
||||||
|
|
||||||
|
private sealed class Harness
|
||||||
|
{
|
||||||
|
public readonly UiRoot Root = new() { Width = 800, Height = 600 };
|
||||||
|
public readonly ClientObjectTable Objects = new();
|
||||||
|
public readonly ImportedLayout Layout;
|
||||||
|
public readonly List<uint> Uses = new();
|
||||||
|
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||||
|
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||||
|
public readonly List<uint> Drops = new();
|
||||||
|
public long Now = 10_000;
|
||||||
|
|
||||||
|
public Harness()
|
||||||
|
{
|
||||||
|
var layoutRoot = new TestElement { Width = 360, Height = 180 };
|
||||||
|
var grid = ItemList(
|
||||||
|
InventoryController.ContentsGridId,
|
||||||
|
left: 10,
|
||||||
|
top: 10,
|
||||||
|
width: 192,
|
||||||
|
height: 96);
|
||||||
|
var sideBags = ItemList(
|
||||||
|
InventoryController.ContainerListId,
|
||||||
|
left: 210,
|
||||||
|
top: 10,
|
||||||
|
width: 36,
|
||||||
|
height: 96);
|
||||||
|
var mainPack = ItemList(
|
||||||
|
InventoryController.TopContainerId,
|
||||||
|
left: 210,
|
||||||
|
top: 112,
|
||||||
|
width: 36,
|
||||||
|
height: 36);
|
||||||
|
var chestArmor = ItemList(
|
||||||
|
ChestArmorSlotId,
|
||||||
|
left: 280,
|
||||||
|
top: 10,
|
||||||
|
width: 32,
|
||||||
|
height: 32);
|
||||||
|
var slotsButton = new UiButton(
|
||||||
|
new ElementInfo { Id = SlotsButtonId, Type = 1 },
|
||||||
|
static _ => (0u, 0, 0))
|
||||||
|
{
|
||||||
|
DatElementId = SlotsButtonId,
|
||||||
|
Left = 270,
|
||||||
|
Top = 56,
|
||||||
|
Width = 56,
|
||||||
|
Height = 20,
|
||||||
|
};
|
||||||
|
var meter = new UiMeter
|
||||||
|
{
|
||||||
|
DatElementId = InventoryController.BurdenMeterId,
|
||||||
|
Left = 248,
|
||||||
|
Top = 10,
|
||||||
|
Width = 11,
|
||||||
|
Height = 58,
|
||||||
|
};
|
||||||
|
var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14);
|
||||||
|
var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14);
|
||||||
|
var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14);
|
||||||
|
var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14);
|
||||||
|
var scrollbar = new UiScrollbar
|
||||||
|
{
|
||||||
|
DatElementId = InventoryController.ContentsScrollbarId,
|
||||||
|
Left = 192,
|
||||||
|
Top = 10,
|
||||||
|
Width = 16,
|
||||||
|
Height = 96,
|
||||||
|
};
|
||||||
|
|
||||||
|
var byId = new Dictionary<uint, UiElement>
|
||||||
|
{
|
||||||
|
[InventoryController.ContentsGridId] = grid,
|
||||||
|
[InventoryController.ContainerListId] = sideBags,
|
||||||
|
[InventoryController.TopContainerId] = mainPack,
|
||||||
|
[InventoryController.BurdenMeterId] = meter,
|
||||||
|
[InventoryController.TitleTextId] = titleText,
|
||||||
|
[InventoryController.BurdenTextId] = burdenText,
|
||||||
|
[InventoryController.BurdenCaptionId] = burdenCaption,
|
||||||
|
[InventoryController.ContentsCaptionId] = contentsCaption,
|
||||||
|
[InventoryController.ContentsScrollbarId] = scrollbar,
|
||||||
|
[ChestArmorSlotId] = chestArmor,
|
||||||
|
[SlotsButtonId] = slotsButton,
|
||||||
|
};
|
||||||
|
|
||||||
|
layoutRoot.AddChild(grid);
|
||||||
|
layoutRoot.AddChild(sideBags);
|
||||||
|
layoutRoot.AddChild(mainPack);
|
||||||
|
layoutRoot.AddChild(chestArmor);
|
||||||
|
layoutRoot.AddChild(slotsButton);
|
||||||
|
layoutRoot.AddChild(meter);
|
||||||
|
layoutRoot.AddChild(titleText);
|
||||||
|
layoutRoot.AddChild(burdenText);
|
||||||
|
layoutRoot.AddChild(burdenCaption);
|
||||||
|
layoutRoot.AddChild(contentsCaption);
|
||||||
|
layoutRoot.AddChild(scrollbar);
|
||||||
|
Layout = new ImportedLayout(layoutRoot, byId);
|
||||||
|
Root.AddChild(layoutRoot);
|
||||||
|
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Player,
|
||||||
|
Name = "Player",
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
ItemsCapacity = 102,
|
||||||
|
ContainersCapacity = 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ItemInteractionController BindInventoryInteraction()
|
||||||
|
{
|
||||||
|
var interaction = new ItemInteractionController(
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
sendUse: Uses.Add,
|
||||||
|
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
|
||||||
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||||
|
sendDrop: Drops.Add,
|
||||||
|
nowMs: () => Now);
|
||||||
|
|
||||||
|
InventoryController.Bind(
|
||||||
|
Layout,
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||||
|
strength: () => 100,
|
||||||
|
datFont: null,
|
||||||
|
itemInteraction: interaction);
|
||||||
|
|
||||||
|
Root.DragReleasedOutsideUi += (payload, _, _) =>
|
||||||
|
{
|
||||||
|
if (payload is ItemDragPayload itemPayload)
|
||||||
|
interaction.DropToWorld(itemPayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return interaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BindPaperdoll()
|
||||||
|
=> PaperdollController.Bind(
|
||||||
|
Layout,
|
||||||
|
Objects,
|
||||||
|
playerGuid: () => Player,
|
||||||
|
iconIds: static (_, _, _, _, _) => 0x1234u,
|
||||||
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||||
|
emptySlotSprite: 0x06004D20u);
|
||||||
|
|
||||||
|
public RetailUiAutomationProbe Probe()
|
||||||
|
=> new(Root, Objects);
|
||||||
|
|
||||||
|
public void SeedHauberk(int slot = 0)
|
||||||
|
{
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = Hauberk,
|
||||||
|
Name = "Hauberk",
|
||||||
|
Type = ItemType.Armor,
|
||||||
|
ValidLocations = HauberkMask,
|
||||||
|
IconId = 0x06001234u,
|
||||||
|
});
|
||||||
|
Objects.MoveItem(Hauberk, Player, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SeedHealthKit(int slot = 0)
|
||||||
|
{
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = HealthKit,
|
||||||
|
Name = "Health Kit",
|
||||||
|
Type = ItemType.Misc,
|
||||||
|
IconId = 0x06001235u,
|
||||||
|
Useability = HealthKitUseability,
|
||||||
|
});
|
||||||
|
Objects.MoveItem(HealthKit, Player, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiItemList ItemList(uint id, float left, float top, float width, float height)
|
||||||
|
=> new(static _ => (1u, 32, 32))
|
||||||
|
{
|
||||||
|
DatElementId = id,
|
||||||
|
Left = left,
|
||||||
|
Top = top,
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static UiElement TextHost(uint id, float left, float top, float width, float height)
|
||||||
|
=> new TestElement
|
||||||
|
{
|
||||||
|
DatElementId = id,
|
||||||
|
Left = left,
|
||||||
|
Top = top,
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
ClickThrough = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.SeedHauberk();
|
||||||
|
h.BindInventoryInteraction();
|
||||||
|
var probe = h.Probe();
|
||||||
|
|
||||||
|
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
|
||||||
|
var state = probe.AssertItem(
|
||||||
|
Hauberk,
|
||||||
|
equippedLocation: HauberkMask,
|
||||||
|
containerId: Player);
|
||||||
|
Assert.True(state.Success, state.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.SeedHealthKit();
|
||||||
|
var interaction = h.BindInventoryInteraction();
|
||||||
|
var probe = h.Probe();
|
||||||
|
|
||||||
|
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
|
||||||
|
Assert.True(interaction.IsTargetModeActive);
|
||||||
|
Assert.True(probe.ClickElement(InventoryController.TopContainerId));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
|
||||||
|
Assert.False(interaction.IsTargetModeActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.SeedHauberk();
|
||||||
|
h.BindInventoryInteraction();
|
||||||
|
var probe = h.Probe();
|
||||||
|
|
||||||
|
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { Hauberk }, h.Drops);
|
||||||
|
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
|
||||||
|
Assert.True(state.Success, state.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.SeedHauberk();
|
||||||
|
h.BindInventoryInteraction();
|
||||||
|
h.BindPaperdoll();
|
||||||
|
var probe = h.Probe();
|
||||||
|
|
||||||
|
Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view.
|
||||||
|
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
|
||||||
|
var state = probe.AssertItem(
|
||||||
|
Hauberk,
|
||||||
|
equippedLocation: HauberkMask,
|
||||||
|
containerId: Player);
|
||||||
|
Assert.True(state.Success, state.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,14 @@ public class UiItemListGridTests
|
||||||
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CellOffset_ColumnMajor_UsesLogicalRows()
|
||||||
|
{
|
||||||
|
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||||
|
Assert.Equal((0f, 36f), UiItemList.CellOffset(1, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||||
|
Assert.Equal((36f, 0f), UiItemList.CellOffset(3, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GridMode_PositionsCellsInColumns()
|
public void GridMode_PositionsCellsInColumns()
|
||||||
{
|
{
|
||||||
|
|
@ -26,6 +34,25 @@ public class UiItemListGridTests
|
||||||
Assert.Equal(36f, c4.Height);
|
Assert.Equal(36f, c4.Height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GridMode_ColumnMajor_PositionsCellsDownRowsFirst()
|
||||||
|
{
|
||||||
|
var list = new UiItemList
|
||||||
|
{
|
||||||
|
Columns = 3,
|
||||||
|
Flow = UiItemListFlow.ColumnMajor,
|
||||||
|
CellWidth = 36,
|
||||||
|
CellHeight = 36,
|
||||||
|
};
|
||||||
|
list.Flush();
|
||||||
|
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
|
||||||
|
|
||||||
|
var c3 = list.GetItem(3)!;
|
||||||
|
Assert.Equal(36f, c3.Left);
|
||||||
|
Assert.Equal(0f, c3.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FillMode_SizesSingleCellToList()
|
public void FillMode_SizesSingleCellToList()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,18 @@ public class UiItemSlotTests
|
||||||
Assert.Equal(0u, s.IconTexture);
|
Assert.Equal(0u, s.IconTexture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClick_invokesDoubleClicked()
|
||||||
|
{
|
||||||
|
var s = new UiItemSlot();
|
||||||
|
bool fired = false;
|
||||||
|
s.DoubleClicked = () => fired = true;
|
||||||
|
|
||||||
|
s.OnEvent(new UiEvent(0u, s, UiEventType.DoubleClick));
|
||||||
|
|
||||||
|
Assert.True(fired);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shortcut number tests ────────────────────────────────────────────────
|
// ── Shortcut number tests ────────────────────────────────────────────────
|
||||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,14 @@ public class UiRootInputTests
|
||||||
{
|
{
|
||||||
private readonly bool _handlesClick;
|
private readonly bool _handlesClick;
|
||||||
public bool Clicked;
|
public bool Clicked;
|
||||||
|
public int Clicks;
|
||||||
|
public int DoubleClicks;
|
||||||
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
|
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
|
||||||
public override bool HandlesClick => _handlesClick;
|
public override bool HandlesClick => _handlesClick;
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
|
if (e.Type == UiEventType.Click) { Clicked = true; Clicks++; return true; }
|
||||||
|
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +48,51 @@ public class UiRootInputTests
|
||||||
Assert.True(btn.Clicked);
|
Assert.True(btn.Clicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClick_EmitsRealDoubleClickEvent()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
||||||
|
root.AddChild(btn);
|
||||||
|
|
||||||
|
root.Tick(0, nowMs: 1_000);
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
||||||
|
root.Tick(0, nowMs: 1_300);
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, btn.Clicks);
|
||||||
|
Assert.Equal(1, btn.DoubleClicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||||
|
cell.SetItem(0x50000A01u, 0x99u);
|
||||||
|
root.AddChild(cell);
|
||||||
|
object? payload = null;
|
||||||
|
int releaseX = 0;
|
||||||
|
int releaseY = 0;
|
||||||
|
root.DragReleasedOutsideUi += (p, x, y) =>
|
||||||
|
{
|
||||||
|
payload = p;
|
||||||
|
releaseX = x;
|
||||||
|
releaseY = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 5, 5);
|
||||||
|
root.OnMouseMove(20, 20);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 200, 200);
|
||||||
|
|
||||||
|
var drag = Assert.IsType<ItemDragPayload>(payload);
|
||||||
|
Assert.Equal(0x50000A01u, drag.ObjId);
|
||||||
|
Assert.Equal(200, releaseX);
|
||||||
|
Assert.Equal(200, releaseY);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
|
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
|
||||||
{
|
{
|
||||||
|
|
@ -327,14 +375,18 @@ public class UiRootInputTests
|
||||||
root.AddChild(win);
|
root.AddChild(win);
|
||||||
root.RegisterWindow("inventory", win);
|
root.RegisterWindow("inventory", win);
|
||||||
|
|
||||||
|
Assert.False(root.IsWindowVisible("inventory"));
|
||||||
Assert.True(root.ShowWindow("inventory"));
|
Assert.True(root.ShowWindow("inventory"));
|
||||||
Assert.True(win.Visible);
|
Assert.True(win.Visible);
|
||||||
|
Assert.True(root.IsWindowVisible("inventory"));
|
||||||
Assert.True(root.HideWindow("inventory"));
|
Assert.True(root.HideWindow("inventory"));
|
||||||
Assert.False(win.Visible);
|
Assert.False(win.Visible);
|
||||||
|
Assert.False(root.IsWindowVisible("inventory"));
|
||||||
|
|
||||||
Assert.False(root.ShowWindow("nope"));
|
Assert.False(root.ShowWindow("nope"));
|
||||||
Assert.False(root.HideWindow("nope"));
|
Assert.False(root.HideWindow("nope"));
|
||||||
Assert.False(root.ToggleWindow("nope"));
|
Assert.False(root.ToggleWindow("nope"));
|
||||||
|
Assert.False(root.IsWindowVisible("nope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
39
tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs
Normal file
39
tests/AcDream.App.Tests/UI/UiScrollablePanelTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class UiScrollablePanelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LayoutScrollableChildren_ClipsRowsOutsideViewport()
|
||||||
|
{
|
||||||
|
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
|
||||||
|
|
||||||
|
panel.LayoutScrollableChildren();
|
||||||
|
Assert.True(panel.Children[0].Visible);
|
||||||
|
Assert.True(panel.Children[1].Visible);
|
||||||
|
Assert.False(panel.Children[2].Visible);
|
||||||
|
|
||||||
|
panel.Scroll.SetScrollY(20);
|
||||||
|
panel.LayoutScrollableChildren();
|
||||||
|
Assert.False(panel.Children[0].Visible);
|
||||||
|
Assert.True(panel.Children[1].Visible);
|
||||||
|
Assert.True(panel.Children[2].Visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScrollEvent_MovesByLineHeight()
|
||||||
|
{
|
||||||
|
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
|
||||||
|
panel.LayoutScrollableChildren();
|
||||||
|
|
||||||
|
var e = new UiEvent(0u, panel, UiEventType.Scroll, Data0: -1);
|
||||||
|
Assert.True(panel.OnEvent(in e));
|
||||||
|
|
||||||
|
Assert.Equal(20, panel.Scroll.ScrollY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,14 +37,14 @@ public sealed class GameEventWiringTests
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll()
|
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func<uint>? playerGuid = null)
|
||||||
{
|
{
|
||||||
var dispatcher = new GameEventDispatcher();
|
var dispatcher = new GameEventDispatcher();
|
||||||
var items = new ClientObjectTable();
|
var items = new ClientObjectTable();
|
||||||
var combat = new CombatState();
|
var combat = new CombatState();
|
||||||
var spellbook = new Spellbook();
|
var spellbook = new Spellbook();
|
||||||
var chat = new ChatLog();
|
var chat = new ChatLog();
|
||||||
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid);
|
||||||
return (dispatcher, items, combat, spellbook, chat);
|
return (dispatcher, items, combat, spellbook, chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,20 +123,20 @@ public sealed class GameEventWiringTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WireAll_WieldObject_ConfirmsOptimisticWield()
|
public void WireAll_WieldObject_ConfirmsOptimisticWield()
|
||||||
{
|
{
|
||||||
var (d, items, _, _, _) = MakeAll();
|
|
||||||
const uint player = 0x2000u;
|
const uint player = 0x2000u;
|
||||||
|
var (d, items, _, _, _) = MakeAll(() => player);
|
||||||
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
|
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
|
||||||
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
|
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
|
||||||
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
|
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
|
||||||
|
|
||||||
byte[] payload = new byte[12];
|
byte[] payload = new byte[8];
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
|
|
||||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
||||||
d.Dispatch(env!.Value); // server confirms the wield
|
d.Dispatch(env!.Value); // server confirms the wield
|
||||||
|
|
||||||
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
|
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
|
||||||
|
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -215,6 +215,59 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
|
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills()
|
||||||
|
{
|
||||||
|
var dispatcher = new GameEventDispatcher();
|
||||||
|
var items = new ClientObjectTable();
|
||||||
|
var combat = new CombatState();
|
||||||
|
var spellbook = new Spellbook();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
var local = new LocalPlayerState();
|
||||||
|
int callbackRun = -1;
|
||||||
|
|
||||||
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local,
|
||||||
|
onSkillsUpdated: (run, _) => callbackRun = run,
|
||||||
|
resolveSkillFormulaBonus: (skillId, attrs) =>
|
||||||
|
{
|
||||||
|
Assert.Equal(24u, skillId);
|
||||||
|
Assert.Equal(50u, attrs[1u]);
|
||||||
|
return 80u;
|
||||||
|
});
|
||||||
|
|
||||||
|
var sb = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(sb);
|
||||||
|
w.Write(0u); // propertyFlags
|
||||||
|
w.Write(0x52u); // weenieType
|
||||||
|
w.Write(0x03u); // vectorFlags = Attribute | Skill
|
||||||
|
w.Write(0u); // has_health = false
|
||||||
|
w.Write(0x01u); // attribute_flags = Strength only
|
||||||
|
w.Write(0u); // Strength ranks
|
||||||
|
w.Write(50u); // Strength start
|
||||||
|
w.Write(0u); // Strength xp
|
||||||
|
|
||||||
|
w.Write((ushort)1); // skill count
|
||||||
|
w.Write((ushort)8); // buckets
|
||||||
|
w.Write(24u); // Run
|
||||||
|
w.Write((ushort)12);
|
||||||
|
w.Write((ushort)1); // const_one
|
||||||
|
w.Write(2u); // trained
|
||||||
|
w.Write(3456u); // xp
|
||||||
|
w.Write(30u); // init
|
||||||
|
w.Write(0u); // resistance
|
||||||
|
w.Write(1.5); // last used
|
||||||
|
|
||||||
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
||||||
|
dispatcher.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
var run = local.GetSkill(24u);
|
||||||
|
Assert.NotNull(run);
|
||||||
|
Assert.Equal(122u, run!.Value.CurrentLevel);
|
||||||
|
Assert.Equal(2u, run.Value.Status);
|
||||||
|
Assert.Equal(3456u, run.Value.Xp);
|
||||||
|
Assert.Equal(122, callbackRun);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WireAll_PlayerDescription_FeedsSpellbook()
|
public void WireAll_PlayerDescription_FeedsSpellbook()
|
||||||
{
|
{
|
||||||
|
|
@ -392,6 +445,48 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(2, items.ObjectCount);
|
Assert.Equal(2, items.ObjectCount);
|
||||||
Assert.NotNull(items.Get(0x50000A01u));
|
Assert.NotNull(items.Get(0x50000A01u));
|
||||||
Assert.NotNull(items.Get(0x50000A02u));
|
Assert.NotNull(items.Get(0x50000A02u));
|
||||||
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
||||||
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
var dispatcher = new GameEventDispatcher();
|
||||||
|
var items = new ClientObjectTable();
|
||||||
|
var combat = new CombatState();
|
||||||
|
var spellbook = new Spellbook();
|
||||||
|
var chat = new ChatLog();
|
||||||
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
|
||||||
|
playerGuid: () => playerGuid);
|
||||||
|
|
||||||
|
var sb = new MemoryStream();
|
||||||
|
using var w = new BinaryWriter(sb);
|
||||||
|
w.Write(0u); // propertyFlags = 0
|
||||||
|
w.Write(0x52u); // weenieType
|
||||||
|
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
|
||||||
|
w.Write(1u); // has_health
|
||||||
|
w.Write(0u); // attribute_flags = 0 (no attrs)
|
||||||
|
w.Write(0u); // enchantment_mask = 0
|
||||||
|
|
||||||
|
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path)
|
||||||
|
w.Write(0u); // options1
|
||||||
|
w.Write(0u); // legacy hotbar list count = 0
|
||||||
|
w.Write(0u); // spellbook_filters
|
||||||
|
|
||||||
|
w.Write(2u);
|
||||||
|
w.Write(0x50000A02u); w.Write(1u);
|
||||||
|
w.Write(0x50000A01u); w.Write(0u);
|
||||||
|
w.Write(0u); // equipped count
|
||||||
|
|
||||||
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
||||||
|
dispatcher.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid));
|
||||||
|
Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId);
|
||||||
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||||
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -441,6 +536,7 @@ public sealed class GameEventWiringTests
|
||||||
Assert.NotNull(items.Get(0x700u));
|
Assert.NotNull(items.Get(0x700u));
|
||||||
// (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone
|
// (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone
|
||||||
Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId);
|
Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId);
|
||||||
|
Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint);
|
||||||
// (c) equipped guid has its equip slot set
|
// (c) equipped guid has its equip slot set
|
||||||
Assert.NotNull(items.Get(0x701u));
|
Assert.NotNull(items.Get(0x701u));
|
||||||
Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation);
|
Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation);
|
||||||
|
|
@ -573,6 +669,9 @@ public sealed class GameEventWiringTests
|
||||||
|
|
||||||
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
|
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
|
||||||
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
|
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
|
||||||
|
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
|
||||||
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
|
||||||
|
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -638,13 +737,14 @@ public sealed class GameEventWiringTests
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
|
||||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
|
||||||
d.Dispatch(env!.Value);
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
|
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
|
||||||
Assert.False(items.RollbackMove(0x50000B02u));
|
Assert.False(items.RollbackMove(0x50000B02u));
|
||||||
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
|
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
|
||||||
|
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -661,4 +761,21 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop()
|
||||||
|
{
|
||||||
|
var (d, items, _, _, _) = MakeAll();
|
||||||
|
items.RecordMembership(0x50000A03u, containerId: 0x500000C9u);
|
||||||
|
items.MoveItem(0x50000A03u, 0x500000C9u, 4);
|
||||||
|
items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1);
|
||||||
|
|
||||||
|
byte[] payload = new byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u);
|
||||||
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
|
||||||
|
d.Dispatch(env!.Value);
|
||||||
|
|
||||||
|
Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId);
|
||||||
|
Assert.False(items.RollbackMove(0x50000A03u));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,22 @@ public sealed class CreateObjectTests
|
||||||
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
|
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WeenieFlagsTargetType_ReadsTargetType()
|
||||||
|
{
|
||||||
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||||
|
guid: 0x50000009u,
|
||||||
|
name: "Healing Kit",
|
||||||
|
itemType: (uint)ItemType.Misc,
|
||||||
|
weenieFlags: 0x00080000u,
|
||||||
|
targetType: (uint)ItemType.Creature);
|
||||||
|
|
||||||
|
var parsed = CreateObject.TryParse(body);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
|
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
|
||||||
// action bar / equipment UI can read icon dat ids from spawn messages.
|
// action bar / equipment UI can read icon dat ids from spawn messages.
|
||||||
|
|
@ -451,6 +467,7 @@ public sealed class CreateObjectTests
|
||||||
uint? value = null,
|
uint? value = null,
|
||||||
uint? useability = null,
|
uint? useability = null,
|
||||||
float? useRadius = null,
|
float? useRadius = null,
|
||||||
|
uint? targetType = null,
|
||||||
uint iconOverlayId = 0,
|
uint iconOverlayId = 0,
|
||||||
uint iconUnderlayId = 0,
|
uint iconUnderlayId = 0,
|
||||||
// intermediate fields for cursor-arithmetic test
|
// intermediate fields for cursor-arithmetic test
|
||||||
|
|
@ -516,7 +533,7 @@ public sealed class CreateObjectTests
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
|
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
|
||||||
bytes.AddRange(tmp.ToArray());
|
bytes.AddRange(tmp.ToArray());
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32
|
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32
|
||||||
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
|
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
|
||||||
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
|
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
|
||||||
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16
|
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -176,6 +177,21 @@ public sealed class GameEventDispatcherTests
|
||||||
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
|
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWieldObject_acceptsAceEightBytePayload()
|
||||||
|
{
|
||||||
|
byte[] payload = new byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.ChestArmor);
|
||||||
|
|
||||||
|
var parsed = GameEvents.ParseWieldObject(payload);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
|
||||||
|
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
|
||||||
|
Assert.Equal(0u, parsed.Value.WielderGuid);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseWeenieError_RoundTrip()
|
public void ParseWeenieError_RoundTrip()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ public sealed class ObjectTableWiringTests
|
||||||
Structure = 80,
|
Structure = 80,
|
||||||
MaxStructure = 100,
|
MaxStructure = 100,
|
||||||
Workmanship = 4.5f,
|
Workmanship = 4.5f,
|
||||||
|
Useability = 0x000A0008u,
|
||||||
|
TargetType = (uint)ItemType.Creature,
|
||||||
};
|
};
|
||||||
|
|
||||||
var d = ObjectTableWiring.ToWeenieData(spawn);
|
var d = ObjectTableWiring.ToWeenieData(spawn);
|
||||||
|
|
@ -96,6 +98,8 @@ public sealed class ObjectTableWiringTests
|
||||||
Assert.Equal(80, d.Structure);
|
Assert.Equal(80, d.Structure);
|
||||||
Assert.Equal(100, d.MaxStructure);
|
Assert.Equal(100, d.MaxStructure);
|
||||||
Assert.Equal(4.5f, d.Workmanship);
|
Assert.Equal(4.5f, d.Workmanship);
|
||||||
|
Assert.Equal(0x000A0008u, d.Useability);
|
||||||
|
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,58 @@ public sealed class WorldSessionCombatTests
|
||||||
CharacterActions.CombatMode.Magic), captured);
|
CharacterActions.CombatMode.Magic), captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendRaiseAttribute_UsesRetailBuilder()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendRaiseAttribute(5u, 110u);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(CharacterActions.BuildRaiseAttribute(1, 5u, 110u), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendRaiseVital_UsesRetailBuilder()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendRaiseVital(1u, 90u);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(CharacterActions.BuildRaiseVital(1, 1u, 90u), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendRaiseSkill_UsesRetailBuilder()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendRaiseSkill(34u, 111_000_000u);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(CharacterActions.BuildRaiseSkill(1, 34u, 111_000_000u), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendTrainSkill_UsesRetailBuilder()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendTrainSkill(21u, 6u);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(CharacterActions.BuildTrainSkill(1, 21u, 6u), captured);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SendMeleeAttack_UsesRetailMeleeBuilder()
|
public void SendMeleeAttack_UsesRetailMeleeBuilder()
|
||||||
{
|
{
|
||||||
|
|
@ -88,4 +140,19 @@ public sealed class WorldSessionCombatTests
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured);
|
Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendUseWithTarget_UsesRetailBuilder()
|
||||||
|
{
|
||||||
|
using var session = NewSession();
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendUseWithTarget(0x50000A01u, 0x50000001u);
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(
|
||||||
|
InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u),
|
||||||
|
captured);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,18 +152,38 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects);
|
Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateIntProperty_currentWieldedLocation_updatesTypedEquipLocation()
|
||||||
|
{
|
||||||
|
var repo = new ClientObjectTable();
|
||||||
|
repo.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x500000ADu,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.ChestArmor,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(repo.UpdateIntProperty(
|
||||||
|
0x500000ADu,
|
||||||
|
ClientObjectTable.CurrentWieldedLocationPropertyId,
|
||||||
|
value: 0));
|
||||||
|
|
||||||
|
Assert.Equal(EquipMask.None, repo.Get(0x500000ADu)!.CurrentlyEquippedLocation);
|
||||||
|
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClientObject_NewFields_DefaultAndSettable()
|
public void ClientObject_NewFields_DefaultAndSettable()
|
||||||
{
|
{
|
||||||
var o = new ClientObject
|
var o = new ClientObject
|
||||||
{
|
{
|
||||||
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
|
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
|
||||||
Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
|
ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
|
||||||
};
|
};
|
||||||
o.WeenieClassId = 0xABCDu; // now settable
|
o.WeenieClassId = 0xABCDu; // now settable
|
||||||
Assert.Equal(0x42u, o.WielderId);
|
Assert.Equal(0x42u, o.WielderId);
|
||||||
Assert.Equal(24, o.ItemsCapacity);
|
Assert.Equal(24, o.ItemsCapacity);
|
||||||
Assert.Equal(7, o.ContainersCapacity);
|
Assert.Equal(7, o.ContainersCapacity);
|
||||||
|
Assert.Equal(1u, o.ContainerTypeHint);
|
||||||
Assert.Equal(8u, o.Priority);
|
Assert.Equal(8u, o.Priority);
|
||||||
Assert.Equal(5, o.Structure);
|
Assert.Equal(5, o.Structure);
|
||||||
Assert.Equal(10, o.MaxStructure);
|
Assert.Equal(10, o.MaxStructure);
|
||||||
|
|
@ -239,6 +259,24 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
|
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ingest_UseabilityAndTargetType_AssignsTypedFields()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
var data = FullWeenie(0x500000B7u) with
|
||||||
|
{
|
||||||
|
Useability = 0x000A0008u,
|
||||||
|
TargetType = (uint)ItemType.Creature,
|
||||||
|
};
|
||||||
|
|
||||||
|
table.Ingest(data);
|
||||||
|
|
||||||
|
var item = table.Get(0x500000B7u);
|
||||||
|
Assert.NotNull(item);
|
||||||
|
Assert.Equal(0x000A0008u, item!.Useability);
|
||||||
|
Assert.Equal((uint)ItemType.Creature, item.TargetType);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
||||||
{
|
{
|
||||||
|
|
@ -271,6 +309,21 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation);
|
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordMembership_inventoryEntryClearsStaleEquipLocation()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x500000B8u,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
||||||
|
});
|
||||||
|
|
||||||
|
table.RecordMembership(0x500000B8u);
|
||||||
|
|
||||||
|
Assert.Equal(EquipMask.None, table.Get(0x500000B8u)!.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ContainerIndex_IngestThenContents_OrderedBySlot()
|
public void ContainerIndex_IngestThenContents_OrderedBySlot()
|
||||||
{
|
{
|
||||||
|
|
@ -340,6 +393,40 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0xA10u,
|
||||||
|
ContainerId = 0x50000001u,
|
||||||
|
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
||||||
|
});
|
||||||
|
|
||||||
|
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
|
||||||
|
|
||||||
|
var item = table.Get(0xA10u)!;
|
||||||
|
Assert.Equal(0x50000001u, item.ContainerId);
|
||||||
|
Assert.Equal(0, item.ContainerSlot);
|
||||||
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
|
||||||
|
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
|
||||||
|
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
|
||||||
|
|
||||||
|
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
|
||||||
|
|
||||||
|
var item = table.Get(0xA11u)!;
|
||||||
|
Assert.Equal(0u, item.ContainerId);
|
||||||
|
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReplaceContents_ReordersToNewListOrder()
|
public void ReplaceContents_ReordersToNewListOrder()
|
||||||
{
|
{
|
||||||
|
|
@ -349,6 +436,54 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceContents_RecordsContainerTypeHintAndOrder()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.ReplaceContents(0xC9u, new[]
|
||||||
|
{
|
||||||
|
new ContainerContentEntry(0xA02u, 1u),
|
||||||
|
new ContainerContentEntry(0xA01u, 0u),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||||
|
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
||||||
|
Assert.Equal(0u, table.Get(0xA01u)!.ContainerTypeHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceContents_ManifestOrderSurvivesLaterCreateObjects()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
const uint player = 0x50000001u;
|
||||||
|
|
||||||
|
table.ReplaceContents(player, new[]
|
||||||
|
{
|
||||||
|
new ContainerContentEntry(0xA01u, 0u),
|
||||||
|
new ContainerContentEntry(0xA02u, 1u),
|
||||||
|
});
|
||||||
|
table.Ingest(FullWeenie(0xA02u, container: player, type: ItemType.Container));
|
||||||
|
table.Ingest(FullWeenie(0xA01u, container: player));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
|
||||||
|
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
|
||||||
|
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
|
||||||
|
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
const uint pack = 0x500000C9u;
|
||||||
|
|
||||||
|
table.ReplaceContents(pack, new uint[] { 0xA01u, 0xA02u });
|
||||||
|
table.Ingest(FullWeenie(0xA03u, container: pack));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(pack));
|
||||||
|
Assert.Equal(-1, table.Get(0xA03u)!.ContainerSlot);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReplaceContents_ZeroContainer_NoOp()
|
public void ReplaceContents_ZeroContainer_NoOp()
|
||||||
{
|
{
|
||||||
|
|
@ -516,4 +651,22 @@ public sealed class ClientObjectTableTests
|
||||||
Assert.Equal(2, o.ContainerSlot); // and slot
|
Assert.Equal(2, o.ContainerSlot); // and slot
|
||||||
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
|
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateInt64Property_setsValueAndFiresUpdated()
|
||||||
|
{
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.AddOrUpdate(new ClientObject { ObjectId = 0x960u });
|
||||||
|
int updates = 0;
|
||||||
|
table.ObjectUpdated += _ => updates++;
|
||||||
|
|
||||||
|
Assert.True(table.UpdateInt64Property(0x960u, 2u, 12_345L));
|
||||||
|
|
||||||
|
Assert.Equal(12_345L, table.Get(0x960u)!.Properties.Int64s[2u]);
|
||||||
|
Assert.Equal(1, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateInt64Property_unknownObject_false()
|
||||||
|
=> Assert.False(new ClientObjectTable().UpdateInt64Property(0xDEADu, 2u, 1L));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs
Normal file
30
tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Items;
|
||||||
|
|
||||||
|
public sealed class ItemUseabilityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TargetedUse_hasHighTargetBits()
|
||||||
|
{
|
||||||
|
const uint healthKit = 0x000A0008u; // source contained, target self or contained
|
||||||
|
|
||||||
|
Assert.True(ItemUseability.IsTargeted(healthKit));
|
||||||
|
Assert.True(ItemUseability.AllowsSelfTarget(healthKit));
|
||||||
|
Assert.Equal(ItemUseability.Contained, ItemUseability.SourceFlags(healthKit));
|
||||||
|
Assert.Equal(ItemUseability.Self | ItemUseability.Contained, ItemUseability.TargetFlags(healthKit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectContainedUse_isNotTargeted()
|
||||||
|
{
|
||||||
|
Assert.False(ItemUseability.IsTargeted(ItemUseability.Contained));
|
||||||
|
Assert.True(ItemUseability.IsDirectUseable(ItemUseability.Contained));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseableNo_isNotDirectUseable()
|
||||||
|
{
|
||||||
|
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Player;
|
using AcDream.Core.Player;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Player;
|
namespace AcDream.Core.Tests.Player;
|
||||||
|
|
@ -244,4 +245,179 @@ public sealed class LocalPlayerStateTests
|
||||||
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
|
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
|
||||||
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
|
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnProperties_ClonesBundleAndExposesInt64()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
var props = new PropertyBundle();
|
||||||
|
props.Ints[0x19u] = 126;
|
||||||
|
props.Int64s[1u] = 1_234_567_890L;
|
||||||
|
|
||||||
|
s.OnProperties(props);
|
||||||
|
props.Ints[0x19u] = 1;
|
||||||
|
props.Int64s[1u] = 2L;
|
||||||
|
|
||||||
|
Assert.Equal(126, s.Properties.GetInt(0x19u));
|
||||||
|
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
int changed = 0;
|
||||||
|
s.CharacterChanged += () => changed++;
|
||||||
|
|
||||||
|
s.OnSkillUpdate(
|
||||||
|
skillId: 24u,
|
||||||
|
ranks: 12u,
|
||||||
|
status: 2u,
|
||||||
|
xp: 3456u,
|
||||||
|
init: 30u,
|
||||||
|
resistance: 0u,
|
||||||
|
lastUsed: 1.5,
|
||||||
|
formulaBonus: 80u);
|
||||||
|
|
||||||
|
var run = s.GetSkill(24u);
|
||||||
|
Assert.NotNull(run);
|
||||||
|
Assert.Equal(122u, run!.Value.CurrentLevel);
|
||||||
|
Assert.Equal(2u, run.Value.Status);
|
||||||
|
Assert.Equal(3456u, run.Value.Xp);
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue