diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8ad8619e..d20d3363 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance | | ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | | ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior | -| AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | +| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` | | AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2a1db2b5..99324a8f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -481,7 +481,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. -- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, and **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅ are shipped. Build order from here: **finish Wave 3 item policy/router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate. +- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅, and **Wave 3.2 pure item-use/drop policy** (`ItemInteractionPolicy`; AP-99 retired) ✅ are shipped. Build order from here: **finish Wave 3 activation router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. diff --git a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md index 57261aec..96829647 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -561,9 +561,12 @@ DetermineUseResult(object): // 0x00588460 return 0 ``` -The first capacity bit expression is corrupted into a string address by Binary -Ninja. Capacity fields and `IsComponentPack` make the branch intent clear, but the -missing bit name/value is **unresolved** and must be recovered before porting. +The first capacity bit expression was corrupted into a string address by Binary +Ninja. Matching-executable disassembly at `0x005884B6` resolves it exactly as +`test PWD._bitfield, 0x00800000`, i.e. `BF_REQUIRES_PACKSLOT`. The complete +result-2 predicate is therefore `(!BF_REQUIRES_PACKSLOT && itemsCapacity == 0 && +containersCapacity == 0) || IsComponentPack()` after the loose/viewed-container +and wielder gates. This was pinned on 2026-07-11 before the Core policy port. `CPlayerSystem::UsingItem` first executes result `2..8`, then independently: @@ -772,10 +775,10 @@ The return value means neither strictly "a server message was sent" nor strictly "the item moved": vendor and secure-trade attempts deliberately return false. Callers that use it only as an action-success boolean will diverge. -**Semantic name uncertain:** the vendor-drop test appears in pseudo-C as a raw -bitfield byte test. The branch behavior is exact, but its PWD flag name must be -recovered before making a reusable Core policy field; do not substitute a check -against `ClientUISystem.vendorID` without that evidence. +The vendor-drop test is now pinned. Matching-executable disassembly at +`0x0058874C` loads `PWD._bitfield` and `test ah, 0x02`, i.e. overall mask +`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from +`ClientUISystem.vendorID`. --- diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index 36aa7bae..6e848913 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -343,13 +343,25 @@ passed, 5 skipped. ### 3.2 Port item-use/drop legality into Core -- [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and +- [x] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and `AttemptPlaceIn3D` to pure/testable Core policy records. -- [ ] Include ready/busy, ownership, vendor/trade, PK/rare confirmation, stack, +- [x] Include ready/busy, ownership, vendor/trade, PK/rare confirmation, stack, ground/drop, and target-type gates. -- [ ] Keep App `ItemInteractionController` as orchestration: ask policy, show modal/ +- [x] Keep App `ItemInteractionController` as orchestration: ask policy, show modal/ toast, send the selected wire action, and manage pending state. +**Implementation (2026-07-11):** Core `ItemInteractionPolicy` is the pure port +of all three named-retail decision trees and returns ordered typed actions (plus +the deliberately independent placement return value). Matching-executable x86 +resolved the two damaged masks as `BF_REQUIRES_PACKSLOT (0x00800000)` and +`BF_VENDOR (0x00000200)`. `CreateObject` now retains PWD flags and `CombatUse`; +the App snapshot resolves component packs from portal.dat. The controller owns +the 200 ms pre-legality throttle, ready/busy lifecycle (released by `UseDone`), +target state, policy action dispatch, confirmation/auxiliary action seams, and +ground-drop optimistic state. Vendor/trade/salvage/confirmation retained panels +consume the typed action seam when those surfaces land; until then the policy +refuses unsafe wire sends rather than bypassing their gates. + ### 3.3 Add one item primary-click chokepoint - [ ] Define a controller-level activation router: target-mode acquisition first, diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 252f021b..8a14090c 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -140,3 +140,21 @@ Core and interaction modes in App when completing Wave 3. The 2026-07-11 Release gate passed for world/radar/inventory/paperdoll selection synchronization, toolbar/target/radar presentation, PreviousSelection, and the movement/door/armor/window regression tour. + +## 2026-07-11 item-interaction policy boundary + +`AcDream.Core.Items.ItemInteractionPolicy` owns the pure, ordered retail rules +from `DetermineUseResult`, `UseObject`, target compatibility, and +`AttemptPlaceIn3D`. Do not put vendor/trade/confirmation/stack/ground legality +back into widgets or `GameWindow`. `ItemInteractionController` snapshots mutable +objects, applies the 200 ms pre-legality throttle, balances busy state against +Core.Net `UseDone`, manages target mode, and dispatches typed actions. PWD flags +and `CombatUse` are retained from CreateObject; `IsComponentPack` is resolved +against portal.dat's `SpellComponentTable`. + +Matching-executable x86 pins the two operands lost by Binary Ninja: +`DetermineUseResult @ 0x005884B6` tests `BF_REQUIRES_PACKSLOT (0x00800000)`, and +`AttemptPlaceIn3D @ 0x00588752` tests `BF_VENDOR (0x00000200)`. Preserve the +retail asymmetry that `UseObject` early-classifies results 2..7 but excludes 8, +and preserve placement's action list separately from its boolean return (vendor +sale and secure-trade attempts act while returning false). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3c2f8e0d..03135cd0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1984,6 +1984,11 @@ public sealed class GameWindow : IDisposable _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost.Root.UiLocked = _persistedGameplay.LockUI; + var spellComponentTable = + // DBObj file id is 0x0E00000F. Retail's IsComponentPack + // passes enum key 0x10000001 to DBObj::GetByEnum; that key is + // NOT a portal.dat file id (using it here reads unrelated bytes). + _dats!.Get(0x0E00000Fu); _itemInteractionController = new AcDream.App.UI.ItemInteractionController( Objects, playerGuid: () => _playerServerGuid, @@ -1991,7 +1996,14 @@ public sealed class GameWindow : IDisposable sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), sendDrop: item => _liveSession?.SendDropItem(item), - toast: text => _debugVm?.AddToast(text)); + toast: text => _debugVm?.AddToast(text), + readyForInventoryRequest: () => _liveSession is not null + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + playerOnGround: GetDebugPlayerOnGround, + inNonCombatMode: () => Combat.CurrentMode + == AcDream.Core.Combat.CombatMode.NonCombat, + isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true, + placeInBackpack: SendPickUp); var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -2561,7 +2573,8 @@ public sealed class GameWindow : IDisposable } }, onShortcuts: list => Shortcuts = list, - playerGuid: () => _playerServerGuid); + playerGuid: () => _playerServerGuid, + onUseDone: error => _itemInteractionController?.CompleteUse(error)); // Phase I.7: subscribe to CombatState events and emit // retail-faithful "You hit X for Y damage" chat lines into diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 6ad1cb1b..1d5f7053 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -55,9 +55,18 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendWield; private readonly Action? _sendDrop; private readonly Action? _toast; + private readonly Func _readyForInventoryRequest; + private readonly Func _activeVendorId; + private readonly Func _groundObjectId; + private readonly Func _playerOnGround; + private readonly Func _inNonCombatMode; + private readonly Func _isComponentPack; + private readonly Action? _placeInBackpack; + private readonly Action? _auxiliaryAction; private readonly InteractionState _interactionState; private long _lastUseMs = long.MinValue / 2; + private int _busyCount; private bool _disposed; public ItemInteractionController( @@ -69,7 +78,15 @@ public sealed class ItemInteractionController : IDisposable Action? sendDrop, Func? nowMs = null, Action? toast = null, - InteractionState? interactionState = null) + InteractionState? interactionState = null, + Func? readyForInventoryRequest = null, + Func? activeVendorId = null, + Func? groundObjectId = null, + Func? playerOnGround = null, + Func? inNonCombatMode = null, + Func? isComponentPack = null, + Action? placeInBackpack = null, + Action? auxiliaryAction = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -79,6 +96,14 @@ public sealed class ItemInteractionController : IDisposable _sendDrop = sendDrop; _nowMs = nowMs ?? (() => Environment.TickCount64); _toast = toast; + _readyForInventoryRequest = readyForInventoryRequest ?? (() => true); + _activeVendorId = activeVendorId ?? (() => 0u); + _groundObjectId = groundObjectId ?? (() => 0u); + _playerOnGround = playerOnGround ?? (() => true); + _inNonCombatMode = inNonCombatMode ?? (() => false); + _isComponentPack = isComponentPack ?? (_ => false); + _placeInBackpack = placeInBackpack; + _auxiliaryAction = auxiliaryAction; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; } @@ -89,6 +114,14 @@ public sealed class ItemInteractionController : IDisposable public InteractionState InteractionState => _interactionState; + public int BusyCount => _busyCount; + + /// + /// Raised for retail confirmation/auxiliary actions whose retained panel is + /// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage). + /// + public event Action? PolicyActionRequested; + public uint PendingSourceItem => _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode ? mode.SourceObjectId @@ -105,8 +138,8 @@ public sealed class ItemInteractionController : IDisposable if (!IsTargetModeActive || targetGuid == 0) return false; var source = _objects.Get(PendingSourceItem); - return source?.Useability is { } useability - && TargetCompatible(source, targetGuid, useability); + return source?.Useability is not null + && TargetCompatible(source, targetGuid); } public bool ActivateItem(uint itemGuid) @@ -122,32 +155,19 @@ public sealed class ItemInteractionController : IDisposable 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; + var input = new ItemUsePolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + _readyForInventoryRequest() && _busyCount == 0, + _activeVendorId(), + BypassClassification: false, + UseCurrentSelection: false, + SelectedTarget: null, + ConfirmVolatileRareUses: true, + InNonCombatMode: _inNonCombatMode()); + var decision = ItemInteractionPolicy.DecideUse(input); + return ExecuteUseActions(decision.Actions); } public bool AcquireTarget(uint targetGuid) @@ -161,7 +181,7 @@ public sealed class ItemInteractionController : IDisposable if (source?.Useability is not { } useability) return false; - bool compatible = TargetCompatible(source, targetGuid, useability); + bool compatible = TargetCompatible(source, targetGuid); var target = _objects.Get(targetGuid); Console.WriteLine( $"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}" @@ -170,7 +190,10 @@ public sealed class ItemInteractionController : IDisposable if (!compatible) return false; + if (!_readyForInventoryRequest() || _busyCount != 0) + return false; _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); + _busyCount++; return true; } @@ -189,12 +212,106 @@ public sealed class ItemInteractionController : IDisposable if (payload.SourceKind == ItemDragSource.ShortcutBar) return false; - if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null) + if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) return false; - _objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1); - _sendDrop?.Invoke(payload.ObjId); - return true; + int splitSize = Math.Max(1, item.StackSize); + var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + _readyForInventoryRequest() && _busyCount == 0, + TargetId: 0, + Target: null, + AllowGroundFallback: true, + MergeAccepted: false, + DragOnPlayerOpensSecureTrade: true, + PlayerOnGround: _playerOnGround(), + SplitSize: splitSize)); + ExecutePlacementActions(decision.Actions); + return decision.ReturnValue; + } + + private bool ExecuteUseActions(System.Collections.Generic.IReadOnlyList actions) + { + uint openedOrUsed = 0; + bool acted = false; + foreach (var action in actions) + { + switch (action.Kind) + { + case ItemPolicyActionKind.PlaceInBackpack: + _placeInBackpack?.Invoke(action.ObjectId); + acted |= _placeInBackpack is not null; + break; + case ItemPolicyActionKind.WieldRight: + case ItemPolicyActionKind.WieldLeft: + case ItemPolicyActionKind.AutoSort: + if (_objects.Get(action.ObjectId) is { } item) + acted |= TryAutoWield(item); + break; + case ItemPolicyActionKind.OpenContainedContainer: + case ItemPolicyActionKind.SendUse: + // Retail's wire Use and local OpenContainedContainer notice are + // distinct. acdream currently represents both with SendUse, so + // coalesce the pair to one packet. + if (openedOrUsed != action.ObjectId) + { + _sendUse?.Invoke(action.ObjectId); + openedOrUsed = action.ObjectId; + acted |= _sendUse is not null; + } + break; + case ItemPolicyActionKind.SendUseWithTarget: + _sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId); + acted |= _sendUseWithTarget is not null; + break; + case ItemPolicyActionKind.EnterTargetMode: + EnterTargetMode(action.ObjectId); + acted = true; + break; + case ItemPolicyActionKind.IncrementBusy: + _busyCount++; + break; + case ItemPolicyActionKind.Reject: + if (!string.IsNullOrWhiteSpace(action.Message)) + _toast?.Invoke(action.Message); + break; + default: + _auxiliaryAction?.Invoke(action); + PolicyActionRequested?.Invoke(action); + bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null; + if (!handled) + _toast?.Invoke(PolicyActionMessage(action)); + acted |= handled || _toast is not null; + break; + } + } + return acted; + } + + private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList actions) + { + foreach (var action in actions) + { + switch (action.Kind) + { + case ItemPolicyActionKind.DropToWorld: + _objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1); + _sendDrop?.Invoke(action.ObjectId); + break; + case ItemPolicyActionKind.Reject: + if (!string.IsNullOrWhiteSpace(action.Message)) + _toast?.Invoke(action.Message); + break; + default: + _auxiliaryAction?.Invoke(action); + PolicyActionRequested?.Invoke(action); + if (_auxiliaryAction is null && PolicyActionRequested is null) + _toast?.Invoke(PolicyActionMessage(action)); + break; + } + } } private void EnterTargetMode(uint sourceGuid) @@ -210,6 +327,21 @@ public sealed class ItemInteractionController : IDisposable _interactionState.Clear(); } + private static string PolicyActionMessage(ItemPolicyAction action) + => action.Kind switch + { + ItemPolicyActionKind.ConfirmPlayerKillerSwitch => + "Confirm using this Player Killer altar before continuing.", + ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch => + "Confirm using this Non-Player Killer altar before continuing.", + ItemPolicyActionKind.ConfirmVolatileRare => + "Confirm using this volatile rare before continuing.", + ItemPolicyActionKind.OpenSecureTrade or ItemPolicyActionKind.StartSecureTrade => + "Secure trade is not open.", + ItemPolicyActionKind.OpenSalvage => "Open the salvage panel to use that item.", + _ => "That item action is not available here.", + }; + private void OnInteractionModeChanged(InteractionModeTransition _) => StateChanged?.Invoke(); @@ -220,6 +352,14 @@ public sealed class ItemInteractionController : IDisposable _interactionState.Changed -= OnInteractionModeChanged; } + /// Retail UseDone (0x01C7) releases one UI busy reference. + public void CompleteUse(uint _) + { + if (_busyCount == 0) return; + _busyCount--; + StateChanged?.Invoke(); + } + private bool ConsumeUseThrottle() { long now = _nowMs(); @@ -347,64 +487,52 @@ public sealed class ItemInteractionController : IDisposable || item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - private bool SourceCompatible(ClientObject source, uint useability) + private ItemPolicyObject Snapshot(ClientObject item) { - 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; + var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u); + if (item.ObjectId == _playerGuid()) + flags |= PublicWeenieFlags.Player; + + bool owned = item.ObjectId == _playerGuid() + || IsCarriedByPlayer(item) + || IsEquippedByPlayer(item); + int stackSize = Math.Max(1, item.StackSize); + return new ItemPolicyObject( + item.ObjectId, + item.Type, + flags, + item.ContainerId, + item.WielderId, + item.ValidLocations, + item.CurrentlyEquippedLocation, + item.CombatUse ?? 0, + item.ItemsCapacity, + item.ContainersCapacity, + item.Useability ?? 0u, + item.TargetType ?? 0u, + owned, + IsContainer(item), + item.IsComponentPack || _isComponentPack(item.WeenieClassId), + item.TradeState, + stackSize, + stackSize, + IsIn3DView: item.ContainerId == 0 && item.WielderId == 0 + && item.ObjectId != _playerGuid()); } /// - /// Faithful port of ItemHolder::IsTargetCompatibleWithTargetingObject - /// (0x00588070) for a player-owned source (ActivateItem's SourceCompatible - /// already applied retail's GetLeastLimitedSourceUse arm when target mode was - /// armed). Retail shape: (1) a LOCATION constraint applies only when the - /// least-limited target-use bit (ItemUses::GetLeastLimitedTargetUse - /// 0x004fcd50) is Contained — target must be in our inventory, sole - /// exception self when the Self bit is present — or Wielded (refuse - /// non-owned); (2) the player as target additionally requires the Self bit - /// (ItemUses::IsUseable_SelfTarget 0x004fcd30); (3) EVERY target then passes - /// the KIND gate: source._targetType & target->InqType(). A - /// missing/zero TargetType mask therefore matches nothing, exactly like - /// retail. Retail's tradeState!=1 refusal is skipped — acdream has no trade - /// state yet. The previous per-bit arms (Remote ⇒ accept-anything, invented - /// Viewed/Contained accepts, self path skipping the kind gate) were the - /// 2026-07-03 "yellow bullseye over a coat" bug. + /// App adapter for Core's pure port of + /// ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070. /// - private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability) + private bool TargetCompatible(ClientObject source, uint targetGuid) { - uint flags = ItemUseability.TargetFlags(useability); - uint player = _playerGuid(); - var target = _objects.Get(targetGuid); if (target is null) return false; // retail: GetWeenieObject(target) null → incompatible - - if (!(IsCarriedByPlayer(target) || IsEquippedByPlayer(target))) - { - uint least = ItemUseability.LeastLimitedTargetUse(useability); - if ((least & ItemUseability.Contained) != 0) - { - if (!(targetGuid == player && (flags & ItemUseability.Self) != 0)) - return false; - } - else if ((least & ItemUseability.Wielded) != 0) - return false; - } - - if (targetGuid == player && (flags & ItemUseability.Self) == 0) - return false; - - return ((source.TargetType ?? 0u) & (uint)target.Type) != 0; + return ItemInteractionPolicy.IsTargetCompatible( + Snapshot(source), Snapshot(target), _playerGuid()); } - private bool IsWieldedByPlayer(ClientObject item) - => IsEquippedByPlayer(item); - private bool IsEquippedByPlayer(ClientObject item) { uint player = _playerGuid(); diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 1185284b..962295c4 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -68,7 +68,8 @@ public static class GameEventWiring Action>? onShortcuts = null, // B-Wire: the local player's server guid. When provided, the PD handler upserts // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. - Func? playerGuid = null) + Func? playerGuid = null, + Action? onUseDone = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -316,6 +317,7 @@ public static class GameEventWiring uint? err = GameEvents.ParseUseDone(e.Payload.Span); if (err is null) return; Console.WriteLine($"[use-done] err=0x{err.Value:X4}"); + onUseDone?.Invoke(err.Value); if (err.Value != 0) chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0); }); diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 42312015..45bceb6e 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -193,7 +193,8 @@ public static class CreateObject // Nullable preserves the wire distinction between an absent flag and // an explicitly transmitted zero (the enum's undefined/default value). byte? RadarBlipColor = null, - byte? RadarBehavior = null); + byte? RadarBehavior = null, + byte? CombatUse = null); /// /// The relevant subset of the server-sent MovementData / @@ -750,6 +751,7 @@ public static class CreateObject uint? targetType = null; byte? radarBlipColor = null; byte? radarBehavior = null; + byte? combatUse = null; uint iconOverlayId = 0; uint iconUnderlayId = 0; uint uiEffects = 0; @@ -822,7 +824,7 @@ public static class CreateObject if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte) { if (body.Length - pos < 1) throw new FormatException("trunc CombatUse"); - pos += 1; + combatUse = body[pos]; pos += 1; } if ((weenieFlags & 0x00000400u) != 0) // Structure u16 { @@ -966,7 +968,8 @@ public static class CreateObject ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation, Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure, Workmanship: wWorkmanship, - RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior); + RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior, + CombatUse: combatUse); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index 222b4377..18403f89 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -85,5 +85,7 @@ public static class ObjectTableWiring Useability: s.Useability, TargetType: s.TargetType, RadarBlipColor: s.RadarBlipColor, - RadarBehavior: s.RadarBehavior); + RadarBehavior: s.RadarBehavior, + PublicWeenieBitfield: s.ObjectDescriptionFlags, + CombatUse: s.CombatUse); } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 26118783..4446c09e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -121,7 +121,8 @@ public sealed class WorldSession : IDisposable // flag was absent; zero means the server explicitly sent the enum's // undefined/default value. byte? RadarBlipColor = null, - byte? RadarBehavior = null); + byte? RadarBehavior = null, + byte? CombatUse = null); /// /// Projects the wire-level CreateObject result into the stable session @@ -172,7 +173,8 @@ public sealed class WorldSession : IDisposable MovementSequence: parsed.MovementSequence, ServerControlSequence: parsed.ServerControlSequence, RadarBlipColor: parsed.RadarBlipColor, - RadarBehavior: parsed.RadarBehavior); + RadarBehavior: parsed.RadarBehavior, + CombatUse: parsed.CombatUse); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 23c55483..807933c3 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -178,6 +178,14 @@ public sealed class ClientObject 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 + /// Retail PublicWeenieDesc._bitfield; null until CreateObject supplies it. + public uint? PublicWeenieBitfield { get; set; } + /// Retail PublicWeenieDesc._combatUse; null when its header flag was absent. + public byte? CombatUse { get; set; } + /// Client-side trade state used by ItemHolder legality gates. + public int TradeState { get; set; } + /// Resolved membership in SpellComponentTable (retail IsComponentPack). + public bool IsComponentPack { get; set; } /// Retail PublicWeenieDesc _blipColor; null until supplied by CreateObject. public byte? RadarBlipColor { get; set; } /// Retail PublicWeenieDesc _radar_enum; null until supplied by CreateObject. @@ -223,7 +231,9 @@ public readonly record struct WeenieData( uint? Useability = null, uint? TargetType = null, byte? RadarBlipColor = null, - byte? RadarBehavior = null); + byte? RadarBehavior = null, + uint? PublicWeenieBitfield = null, + byte? CombatUse = null); /// /// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). @@ -257,6 +267,18 @@ public static class ItemUseability public static uint SourceFlags(uint useability) => useability & SourceMask; + /// Retail ItemUses::GetLeastLimitedSourceUse. + public static uint LeastLimitedSourceUse(uint useability) + { + uint s = SourceFlags(useability); + if ((s & Remote) != 0) return Remote; + if ((s & Viewed) != 0) return Viewed; + if ((s & Contained) != 0) return Contained; + if ((s & Wielded) != 0) return Wielded; + if ((s & Self) != 0) return Self; + return Undef; + } + public static uint TargetFlags(uint useability) => (useability & TargetMask) >> 16; diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 67af6f31..3dcc560e 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -386,6 +386,8 @@ public sealed class ClientObjectTable 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.PublicWeenieBitfield is { } bitfield) obj.PublicWeenieBitfield = bitfield; + if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse; if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor; if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior; if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs new file mode 100644 index 00000000..0486d5fc --- /dev/null +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// +/// Retail PublicWeenieDesc::BitfieldIndex values used by item interaction. +/// Names and values are from named-retail acclient.h:6431; the two damaged +/// decompiler expressions were verified in the matching v11.4186 executable at +/// 0x005884B6 and 0x00588752. +/// +[Flags] +public enum PublicWeenieFlags : uint +{ + None = 0, + Openable = 0x00000001, + Stuck = 0x00000004, + Player = 0x00000008, + Attackable = 0x00000010, + Vendor = 0x00000200, + PlayerKillerSwitch = 0x00000400, + NonPlayerKillerSwitch = 0x00000800, + Door = 0x00001000, + RequiresPackSlot = 0x00800000, + VolatileRare = 0x10000000, + WieldOnUse = 0x20000000, + WieldLeft = 0x40000000, +} + +/// Return values of retail ItemHolder::DetermineUseResult. +public enum ItemPrimaryUseResult : ushort +{ + None = 0, + ItemUse = 1, + PlaceInBackpack = 2, + WieldRight = 3, + AutoSort = 4, + OpenSecureTrade = 5, + OpenSalvage = 6, + BeginGame = 7, + WieldLeft = 8, +} + +/// +/// Immutable data needed by the retail item rules. App builds this snapshot from +/// the mutable object table; Core never reaches into UI, networking, or physics. +/// +public readonly record struct ItemPolicyObject( + uint Id, + ItemType Type, + PublicWeenieFlags Flags, + uint ContainerId, + uint WielderId, + EquipMask ValidLocations, + EquipMask CurrentLocation, + byte CombatUse, + int ItemsCapacity, + int ContainersCapacity, + uint Useability, + uint TargetType, + bool OwnedByPlayer, + bool IsContainer, + bool IsComponentPack, + int TradeState, + int StackSize, + int MaxSplitSize, + bool IsIn3DView) +{ + public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0; +} + +public enum ItemPolicyActionKind +{ + PlaceInBackpack, + WieldRight, + AutoSort, + OpenSecureTrade, + OpenSalvage, + BeginGame, + WieldLeft, + OpenContainedContainer, + SetGroundObject, + EnterTargetMode, + SendUse, + SendUseWithTarget, + IncrementBusy, + ConfirmPlayerKillerSwitch, + ConfirmNonPlayerKillerSwitch, + ConfirmVolatileRare, + MergeStack, + SellToVendor, + StartSecureTrade, + GiveToTarget, + PlaceInContainer, + SplitToWorld, + DropToWorld, + Reject, +} + +public readonly record struct ItemPolicyAction( + ItemPolicyActionKind Kind, + uint ObjectId = 0, + uint TargetId = 0, + int Amount = 0, + string? Message = null); + +public readonly record struct ItemUsePolicyInput( + ItemPolicyObject Source, + uint PlayerId, + uint GroundObjectId, + bool ReadyForInventoryRequest, + uint ActiveVendorId, + bool BypassClassification, + bool UseCurrentSelection, + ItemPolicyObject? SelectedTarget, + bool ConfirmVolatileRareUses, + bool InNonCombatMode); + +public readonly record struct ItemUsePolicyDecision( + bool Consumed, + IReadOnlyList Actions); + +public readonly record struct ItemPlacementPolicyInput( + ItemPolicyObject Item, + uint PlayerId, + uint GroundObjectId, + bool ReadyForInventoryRequest, + uint TargetId, + ItemPolicyObject? Target, + bool AllowGroundFallback, + bool MergeAccepted, + bool DragOnPlayerOpensSecureTrade, + bool PlayerOnGround, + int SplitSize); + +/// +/// Retail's placement return is deliberately separate from its action list: +/// vendor sale and secure-trade attempts return false even though they act. +/// +public readonly record struct ItemPlacementPolicyDecision( + bool ReturnValue, + IReadOnlyList Actions); + +/// +/// Pure ports of ItemHolder::DetermineUseResult @ 0x00588460, +/// UseObject @ 0x00588A80, and AttemptPlaceIn3D @ 0x00588600. +/// +public static class ItemInteractionPolicy +{ + private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u; + private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu; + private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u; + + public static ItemPrimaryUseResult DetermineUseResult( + in ItemPolicyObject item, + uint playerId, + uint groundObjectId) + { + bool looseOrViewed = (item.ContainerId == 0 + && (item.Flags & PublicWeenieFlags.Stuck) == 0) + || (groundObjectId != 0 && item.ContainerId == groundObjectId); + + if (looseOrViewed && (item.WielderId == 0 || item.WielderId == playerId)) + { + bool ordinaryLooseItem = (item.Flags & PublicWeenieFlags.RequiresPackSlot) == 0 + && item.ItemsCapacity == 0 + && item.ContainersCapacity == 0; + if (ordinaryLooseItem || item.IsComponentPack) + return ItemPrimaryUseResult.PlaceInBackpack; + } + + if (item.OwnedByPlayer) + { + bool wieldOnPrimaryUse = item.CombatUse != 0 + || (item.Type & ItemType.Caster) != 0 + || (item.Flags & PublicWeenieFlags.WieldOnUse) != 0; + if (wieldOnPrimaryUse && item.WielderId != playerId) + return (item.Flags & PublicWeenieFlags.WieldLeft) != 0 + ? ItemPrimaryUseResult.WieldLeft + : ItemPrimaryUseResult.WieldRight; + + if (HasUnusedLocation(item.ValidLocations, item.CurrentLocation)) + return ItemPrimaryUseResult.AutoSort; + + if ((item.Type & ItemType.TinkeringTool) != 0) + return ItemPrimaryUseResult.OpenSalvage; + } + else if ((item.Type & ItemType.Gameboard) != 0) + { + return ItemPrimaryUseResult.BeginGame; + } + + if (IsUseable(item.Useability)) + return ItemPrimaryUseResult.ItemUse; + + if (item.IsPlayer && item.Id != playerId) + return ItemPrimaryUseResult.OpenSecureTrade; + + return ItemPrimaryUseResult.None; + } + + public static ItemUsePolicyDecision DecideUse(in ItemUsePolicyInput input) + { + var source = input.Source; + if (!input.ReadyForInventoryRequest) + return Consumed(); + if (input.ActiveVendorId != 0 && source.ContainerId == input.ActiveVendorId) + return Consumed(); + + if (!input.BypassClassification) + { + var primary = DetermineUseResult(source, input.PlayerId, input.GroundObjectId); + // Exact retail bound: UseObject classifies 2..7, deliberately excluding 8. + if (primary is >= ItemPrimaryUseResult.PlaceInBackpack + and <= ItemPrimaryUseResult.BeginGame) + return Consumed(BuildUsingItemActions(source, input.PlayerId, + input.GroundObjectId, primary)); + } + + if (source.TradeState == 1) + return Reject("You cannot use an item while it is being traded."); + + if (source.CurrentLocation == EquipMask.None + && ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded) + return Reject("You must wield that item before you can use it."); + + if (ItemUseability.IsTargeted(source.Useability)) + { + if (!input.UseCurrentSelection) + return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id)); + + if (input.SelectedTarget is not { } target) + return Reject("Select a target for this item first."); + if (!IsTargetCompatible(source, target, input.PlayerId)) + return Reject("That is not a valid target for this item."); + + var actions = new List + { + new(ItemPolicyActionKind.SendUseWithTarget, source.Id, target.Id), + new(ItemPolicyActionKind.IncrementBusy), + }; + actions.AddRange(BuildUsingItemActions(source, input.PlayerId, + input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId))); + return Consumed(actions); + } + + if (IsUseable(source.Useability)) + { + if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0) + return Consumed(new ItemPolicyAction( + ItemPolicyActionKind.ConfirmPlayerKillerSwitch, source.Id)); + if ((source.Flags & PublicWeenieFlags.NonPlayerKillerSwitch) != 0) + return Consumed(new ItemPolicyAction( + ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch, source.Id)); + if ((source.Flags & PublicWeenieFlags.VolatileRare) != 0 + && input.ConfirmVolatileRareUses) + return Consumed(new ItemPolicyAction( + ItemPolicyActionKind.ConfirmVolatileRare, source.Id)); + + var actions = new List + { + new(ItemPolicyActionKind.SendUse, source.Id), + new(ItemPolicyActionKind.IncrementBusy), + }; + actions.AddRange(BuildUsingItemActions(source, input.PlayerId, + input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId))); + return Consumed(actions); + } + + var fallback = BuildUsingItemActions(source, input.PlayerId, + input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId)); + if (fallback.Count != 0) + return Consumed(fallback); + if (source.Id == input.PlayerId) + return new ItemUsePolicyDecision(false, Array.Empty()); + if ((source.Flags & PublicWeenieFlags.Door) != 0) + return Reject("You cannot open or close that object right now."); + if ((source.Flags & PublicWeenieFlags.Attackable) != 0 + && input.InNonCombatMode) + return Reject("You must switch to a combat mode before attacking that target."); + if ((source.Flags & PublicWeenieFlags.Attackable) == 0 + || input.InNonCombatMode) + return Reject("That object cannot be used."); + return new ItemUsePolicyDecision(false, Array.Empty()); + } + + public static bool IsTargetCompatible( + in ItemPolicyObject source, + in ItemPolicyObject target, + uint playerId) + { + if (source.TradeState == 1) + return false; + + uint flags = ItemUseability.TargetFlags(source.Useability); + if (!target.OwnedByPlayer) + { + uint least = ItemUseability.LeastLimitedTargetUse(source.Useability); + if ((least & ItemUseability.Contained) != 0) + { + if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0)) + return false; + } + else if ((least & ItemUseability.Wielded) != 0) + { + return false; + } + } + + if (target.Id == playerId && (flags & ItemUseability.Self) == 0) + return false; + + return (source.TargetType & (uint)target.Type) != 0; + } + + public static ItemPlacementPolicyDecision DecidePlacement( + in ItemPlacementPolicyInput input) + { + if (!input.ReadyForInventoryRequest) + return Placement(false); + + if (input.TargetId == input.PlayerId) + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id)); + if (!input.Item.OwnedByPlayer) + return Placement(false, RejectAction("You must first pick up that item.")); + if (input.Item.TradeState != 0) + return Placement(false, RejectAction("You cannot move an item while it is being traded.")); + + if (input.TargetId == 0) + return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); + if (input.MergeAccepted) + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.MergeStack, + input.Item.Id, input.TargetId, input.SplitSize)); + if (input.Target is not { } target) + return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); + + if ((target.Flags & PublicWeenieFlags.Vendor) != 0) + { + if (input.SplitSize >= input.Item.MaxSplitSize) + return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor, + input.Item.Id, target.Id, input.SplitSize)); + return Placement(false, RejectAction("Split the stack before selling part of it.")); + } + + if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer) + return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.StartSecureTrade, + input.Item.Id, target.Id, input.SplitSize)); + + if (target.Type == ItemType.Creature) + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.GiveToTarget, + input.Item.Id, target.Id, input.SplitSize)); + + if (target.IsContainer) + { + if ((target.Flags & PublicWeenieFlags.Openable) == 0) + return Placement(false, RejectAction("That container is locked.")); + if (target.Id != input.GroundObjectId) + return Placement(false, RejectAction("You must open that container first.")); + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer, + input.Item.Id, target.Id, input.SplitSize)); + } + + if (input.AllowGroundFallback) + return PlaceOnGround(input); + return Placement(false, RejectAction("You cannot give that item to this target.")); + } + + private static IReadOnlyList BuildUsingItemActions( + in ItemPolicyObject item, + uint playerId, + uint groundObjectId, + ItemPrimaryUseResult primary) + { + var actions = new List(3); + ItemPolicyActionKind? primaryAction = primary switch + { + ItemPrimaryUseResult.PlaceInBackpack => ItemPolicyActionKind.PlaceInBackpack, + ItemPrimaryUseResult.WieldRight => ItemPolicyActionKind.WieldRight, + ItemPrimaryUseResult.AutoSort => ItemPolicyActionKind.AutoSort, + ItemPrimaryUseResult.OpenSecureTrade => ItemPolicyActionKind.OpenSecureTrade, + ItemPrimaryUseResult.OpenSalvage => ItemPolicyActionKind.OpenSalvage, + ItemPrimaryUseResult.BeginGame => ItemPolicyActionKind.BeginGame, + ItemPrimaryUseResult.WieldLeft => ItemPolicyActionKind.WieldLeft, + _ => null, + }; + if (primaryAction is { } action) + actions.Add(new ItemPolicyAction(action, item.Id)); + + if (item.OwnedByPlayer && item.IsContainer) + actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id)); + if (!item.OwnedByPlayer && item.IsContainer + && IsUseable(item.Useability) + && !ItemUseability.IsTargeted(item.Useability)) + actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id, + groundObjectId)); + return actions; + } + + private static bool HasUnusedLocation(EquipMask valid, EquipMask current) + => ((valid & BodyLocations) != 0 && (current & BodyLocations) == 0) + || ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0) + || ((valid & HeldLocations) != 0 && (current & HeldLocations) == 0); + + private static bool IsUseable(uint useability) + => (ItemUseability.SourceFlags(useability) + & ~(ItemUseability.No | ItemUseability.NeverWalk)) != 0; + + private static ItemPlacementPolicyDecision PlaceOnGround( + in ItemPlacementPolicyInput input) + { + if (!input.PlayerOnGround) + return Placement(false, RejectAction("You cannot do that in mid air.")); + if (input.SplitSize < input.Item.MaxSplitSize) + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld, + input.Item.Id, Amount: input.SplitSize)); + if (!input.Item.IsIn3DView) + return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id)); + return Placement(false, RejectAction("Move cancelled.")); + } + + private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions) + => new(true, actions); + + private static ItemUsePolicyDecision Consumed(IReadOnlyList actions) + => new(true, actions); + + private static ItemUsePolicyDecision Reject(string message) + => Consumed(RejectAction(message)); + + private static ItemPolicyAction RejectAction(string message) + => new(ItemPolicyActionKind.Reject, Message: message); + + private static ItemPlacementPolicyDecision Placement( + bool returnValue, + params ItemPolicyAction[] actions) + => new(returnValue, actions); +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 7b50c1e0..81635251 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -387,6 +387,7 @@ public sealed class ItemInteractionControllerTests h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained); h.Controller.ActivateItem(0x50000A09u); + h.Controller.CompleteUse(0); h.Now += 199; h.Controller.ActivateItem(0x50000A09u); h.Now += 1; @@ -394,4 +395,22 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses); } + + [Fact] + public void UseDone_releasesRetailBusyGate() + { + var h = new Harness(); + h.AddContained(0x50000A0Au, item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.ActivateItem(0x50000A0Au)); + h.Now += 200; + Assert.False(h.Controller.ActivateItem(0x50000A0Au)); + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.CompleteUse(0); + h.Now += 200; // rejected busy attempt still advanced retail's throttle timestamp + + Assert.True(h.Controller.ActivateItem(0x50000A0Au)); + Assert.Equal(2, h.Uses.Count); + } } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index f1c4d52e..455b6c4d 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -449,6 +449,27 @@ public sealed class GameEventWiringTests Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } + [Fact] + public void UseDone_releasesAppBusyOwnerThroughCallback() + { + var dispatcher = new GameEventDispatcher(); + uint? completedWith = null; + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + new Spellbook(), + new ChatLog(), + onUseDone: error => completedWith = error); + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1Du); + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload)); + dispatcher.Dispatch(env!.Value); + + Assert.Equal(0x1Du, completedWith); + } + [Fact] public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index feb66664..33135723 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -172,6 +172,22 @@ public sealed class CreateObjectTests Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType); } + [Fact] + public void TryParse_CombatUseFlag_CapturesByte() + { + byte[] body = BuildMinimalCreateObjectWithWeenieHeader( + guid: 0x5000000Du, + name: "Sword", + itemType: (uint)ItemType.MeleeWeapon, + weenieFlags: 0x00000200u, + combatUse: 2); + + var parsed = CreateObject.TryParse(body); + + Assert.NotNull(parsed); + Assert.Equal((byte)2, parsed.Value.CombatUse); + } + // ----------------------------------------------------------------------- // Retail radar: PublicWeenieDesc carries two independently gated bytes. // Absence is distinct from an explicitly transmitted zero because zero is @@ -566,6 +582,7 @@ public sealed class CreateObjectTests float? workmanship = null, byte? radarBlipColor = null, byte? radarBehavior = null, + byte? combatUse = null, ushort movementSeq = 0) { var bytes = new List(); @@ -618,7 +635,7 @@ public sealed class CreateObjectTests } if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType 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(combatUse ?? 0); // CombatUse sbyte/1 byte if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16 if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16 if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16 diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 367260a6..e3aa9393 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -61,6 +61,8 @@ public sealed class ObjectTableWiringTests TargetType = (uint)ItemType.Creature, RadarBlipColor = 4, RadarBehavior = 3, + ObjectDescriptionFlags = 0x20800200u, + CombatUse = 2, }; var d = ObjectTableWiring.ToWeenieData(spawn); @@ -104,6 +106,8 @@ public sealed class ObjectTableWiringTests Assert.Equal((uint)ItemType.Creature, d.TargetType); Assert.Equal((byte)4, d.RadarBlipColor); Assert.Equal((byte)3, d.RadarBehavior); + Assert.Equal(0x20800200u, d.PublicWeenieBitfield); + Assert.Equal((byte)2, d.CombatUse); } // ------------------------------------------------------------------------- diff --git a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs new file mode 100644 index 00000000..e1f70d91 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs @@ -0,0 +1,289 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +public sealed class ItemInteractionPolicyTests +{ + private const uint Player = 0x50000001u; + private const uint GroundContainer = 0x70000001u; + + [Fact] + public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch() + { + Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack, + Determine(Obj(0x6001))); + + var requiresSlot = Obj(0x6002) with + { + Flags = PublicWeenieFlags.RequiresPackSlot, + ItemsCapacity = 12, + }; + Assert.Equal(ItemPrimaryUseResult.None, Determine(requiresSlot)); + Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack, + Determine(requiresSlot with { IsComponentPack = true })); + } + + [Fact] + public void DetermineUseResult_onlyAllowsContainedPickupFromViewedGroundContainer() + { + var item = Obj(0x6001) with { ContainerId = GroundContainer }; + + Assert.Equal(ItemPrimaryUseResult.None, + ItemInteractionPolicy.DetermineUseResult(item, Player, 0)); + Assert.Equal(ItemPrimaryUseResult.PlaceInBackpack, + ItemInteractionPolicy.DetermineUseResult(item, Player, GroundContainer)); + } + + [Fact] + public void DetermineUseResult_portsAllOwnedPrimaryClassifications() + { + var owned = Obj(0x6001) with + { + ContainerId = Player, + OwnedByPlayer = true, + Flags = PublicWeenieFlags.RequiresPackSlot, + }; + + Assert.Equal(ItemPrimaryUseResult.WieldRight, + Determine(owned with { CombatUse = 1 })); + Assert.Equal(ItemPrimaryUseResult.WieldLeft, + Determine(owned with { CombatUse = 1, Flags = owned.Flags | PublicWeenieFlags.WieldLeft })); + Assert.Equal(ItemPrimaryUseResult.AutoSort, + Determine(owned with { ValidLocations = EquipMask.HeadWear })); + Assert.Equal(ItemPrimaryUseResult.OpenSalvage, + Determine(owned with { Type = ItemType.TinkeringTool })); + Assert.Equal(ItemPrimaryUseResult.ItemUse, + Determine(owned with { Useability = ItemUseability.Contained })); + } + + [Fact] + public void DetermineUseResult_nonOwnedGameAndPlayerPaths_areDistinct() + { + Assert.Equal(ItemPrimaryUseResult.BeginGame, + Determine(Obj(0x6001) with + { + Type = ItemType.Gameboard, + Flags = PublicWeenieFlags.RequiresPackSlot, + })); + Assert.Equal(ItemPrimaryUseResult.OpenSecureTrade, + Determine(Obj(0x6002) with + { + Flags = PublicWeenieFlags.Player | PublicWeenieFlags.RequiresPackSlot, + })); + } + + [Fact] + public void UseObject_excludesResultEightFromEarlyClassifiedBound_thenStillWieldsLeft() + { + var source = Obj(0x6001) with + { + ContainerId = Player, + OwnedByPlayer = true, + CombatUse = 1, + Flags = PublicWeenieFlags.RequiresPackSlot | PublicWeenieFlags.WieldLeft, + }; + + var decision = ItemInteractionPolicy.DecideUse(Use(source)); + + Assert.Equal(new[] { ItemPolicyActionKind.WieldLeft }, + decision.Actions.Select(a => a.Kind)); + } + + [Fact] + public void UseObject_readyVendorTradeAndWieldedSourceGates_areOrdered() + { + var direct = OwnedDirect(); + + Assert.Empty(ItemInteractionPolicy.DecideUse( + Use(direct) with { ReadyForInventoryRequest = false }).Actions); + Assert.Empty(ItemInteractionPolicy.DecideUse( + Use(direct) with { ActiveVendorId = 0x7001, Source = direct with { ContainerId = 0x7001 } }).Actions); + Assert.Equal(ItemPolicyActionKind.Reject, + Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { TradeState = 1 })).Actions).Kind); + Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UseObject_targetedUse_appliesKindAndLocationGate_thenSendsAndMarksBusy() + { + var source = OwnedDirect() with + { + Useability = 0x00080008u, + TargetType = (uint)ItemType.Misc, + }; + var target = Obj(0x6002) with + { + ContainerId = Player, + OwnedByPlayer = true, + Type = ItemType.Misc, + }; + + var decision = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + SelectedTarget = target, + }); + + Assert.Equal(new[] + { + ItemPolicyActionKind.SendUseWithTarget, + ItemPolicyActionKind.IncrementBusy, + }, decision.Actions.Select(a => a.Kind)); + } + + [Theory] + [InlineData(PublicWeenieFlags.PlayerKillerSwitch, ItemPolicyActionKind.ConfirmPlayerKillerSwitch)] + [InlineData(PublicWeenieFlags.NonPlayerKillerSwitch, ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch)] + [InlineData(PublicWeenieFlags.VolatileRare, ItemPolicyActionKind.ConfirmVolatileRare)] + public void UseObject_confirmationFlags_blockWireUse( + PublicWeenieFlags flag, + ItemPolicyActionKind expected) + { + var decision = ItemInteractionPolicy.DecideUse( + Use(OwnedDirect() with { Flags = flag | PublicWeenieFlags.RequiresPackSlot })); + + Assert.Equal(expected, Assert.Single(decision.Actions).Kind); + } + + [Fact] + public void Placement_vendorAndSecureTrade_actButPreserveRetailFalseReturn() + { + var item = OwnedDirect() with { StackSize = 10, MaxSplitSize = 10 }; + var vendor = Obj(0x7001) with { Flags = PublicWeenieFlags.Vendor }; + var sale = ItemInteractionPolicy.DecidePlacement(Place(item, vendor)); + Assert.False(sale.ReturnValue); + Assert.Equal(ItemPolicyActionKind.SellToVendor, Assert.Single(sale.Actions).Kind); + + var player = Obj(0x7002) with { Flags = PublicWeenieFlags.Player }; + var trade = ItemInteractionPolicy.DecidePlacement(Place(item, player)); + Assert.False(trade.ReturnValue); + Assert.Equal(ItemPolicyActionKind.StartSecureTrade, Assert.Single(trade.Actions).Kind); + } + + [Fact] + public void Placement_groundStackAndAirborneMatrix_matchesRetail() + { + var item = OwnedDirect() with { StackSize = 10, MaxSplitSize = 10 }; + + var airborne = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with + { + PlayerOnGround = false, + }); + Assert.False(airborne.ReturnValue); + Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind); + + var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 }); + Assert.True(split.ReturnValue); + Assert.Equal(ItemPolicyActionKind.SplitToWorld, Assert.Single(split.Actions).Kind); + + var full = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item)); + Assert.True(full.ReturnValue); + Assert.Equal(ItemPolicyActionKind.DropToWorld, Assert.Single(full.Actions).Kind); + + var alreadyWorld = ItemInteractionPolicy.DecidePlacement( + PlaceOnGround(item with { IsIn3DView = true })); + Assert.False(alreadyWorld.ReturnValue); + Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Placement_containerRequiresOpenableAndCurrentGroundObject() + { + var item = OwnedDirect(); + var container = Obj(GroundContainer) with { IsContainer = true }; + + var locked = ItemInteractionPolicy.DecidePlacement(Place(item, container) with + { + GroundObjectId = GroundContainer, + }); + Assert.False(locked.ReturnValue); + + var open = container with { Flags = PublicWeenieFlags.Openable }; + var closedView = ItemInteractionPolicy.DecidePlacement(Place(item, open)); + Assert.False(closedView.ReturnValue); + + var placed = ItemInteractionPolicy.DecidePlacement(Place(item, open) with + { + GroundObjectId = GroundContainer, + }); + Assert.True(placed.ReturnValue); + Assert.Equal(ItemPolicyActionKind.PlaceInContainer, Assert.Single(placed.Actions).Kind); + } + + private static ItemPrimaryUseResult Determine(ItemPolicyObject item) + => ItemInteractionPolicy.DetermineUseResult(item, Player, 0); + + private static ItemPolicyObject OwnedDirect() + => Obj(0x6001) with + { + ContainerId = Player, + OwnedByPlayer = true, + Flags = PublicWeenieFlags.RequiresPackSlot, + Useability = ItemUseability.Contained, + }; + + private static ItemPolicyObject Obj(uint id) => new( + id, + ItemType.Misc, + PublicWeenieFlags.None, + ContainerId: 0, + WielderId: 0, + ValidLocations: EquipMask.None, + CurrentLocation: EquipMask.None, + CombatUse: 0, + ItemsCapacity: 0, + ContainersCapacity: 0, + Useability: 0, + TargetType: 0, + OwnedByPlayer: false, + IsContainer: false, + IsComponentPack: false, + TradeState: 0, + StackSize: 1, + MaxSplitSize: 1, + IsIn3DView: false); + + private static ItemUsePolicyInput Use(ItemPolicyObject source) => new( + source, + Player, + GroundObjectId: 0, + ReadyForInventoryRequest: true, + ActiveVendorId: 0, + BypassClassification: false, + UseCurrentSelection: false, + SelectedTarget: null, + ConfirmVolatileRareUses: true, + InNonCombatMode: false); + + private static ItemPlacementPolicyInput Place( + ItemPolicyObject item, + ItemPolicyObject target) => new( + item, + Player, + GroundObjectId: 0, + ReadyForInventoryRequest: true, + TargetId: target.Id, + Target: target, + AllowGroundFallback: false, + MergeAccepted: false, + DragOnPlayerOpensSecureTrade: true, + PlayerOnGround: true, + SplitSize: item.StackSize); + + private static ItemPlacementPolicyInput PlaceOnGround(ItemPolicyObject item) => new( + item, + Player, + GroundObjectId: 0, + ReadyForInventoryRequest: true, + TargetId: 0, + Target: null, + AllowGroundFallback: true, + MergeAccepted: false, + DragOnPlayerOpensSecureTrade: true, + PlayerOnGround: true, + SplitSize: item.StackSize); +} diff --git a/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs index 38ce3b92..452d9c58 100644 --- a/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemUseabilityTests.cs @@ -27,4 +27,16 @@ public sealed class ItemUseabilityTests { Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No)); } + + [Fact] + public void LeastLimitedSourceUse_matchesRetailPriorityAndIgnoresObjSelf() + { + Assert.Equal(ItemUseability.Remote, + ItemUseability.LeastLimitedSourceUse( + ItemUseability.Remote | ItemUseability.Contained | ItemUseability.Wielded)); + Assert.Equal(ItemUseability.Wielded, + ItemUseability.LeastLimitedSourceUse(ItemUseability.Wielded | ItemUseability.Self)); + Assert.Equal(ItemUseability.Undef, + ItemUseability.LeastLimitedSourceUse(ItemUseability.ObjSelf)); + } }