feat(ui): port retail item interaction policy
This commit is contained in:
parent
0cf780478a
commit
a8da4fd05a
20 changed files with 1110 additions and 104 deletions
|
|
@ -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-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-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-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-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-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` |
|
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.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`.
|
- **✓ 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 <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` 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 — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` 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 — 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.
|
- **✓ 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.
|
||||||
|
|
|
||||||
|
|
@ -561,9 +561,12 @@ DetermineUseResult(object): // 0x00588460
|
||||||
return 0
|
return 0
|
||||||
```
|
```
|
||||||
|
|
||||||
The first capacity bit expression is corrupted into a string address by Binary
|
The first capacity bit expression was corrupted into a string address by Binary
|
||||||
Ninja. Capacity fields and `IsComponentPack` make the branch intent clear, but the
|
Ninja. Matching-executable disassembly at `0x005884B6` resolves it exactly as
|
||||||
missing bit name/value is **unresolved** and must be recovered before porting.
|
`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:
|
`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.
|
"the item moved": vendor and secure-trade attempts deliberately return false.
|
||||||
Callers that use it only as an action-success boolean will diverge.
|
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
|
The vendor-drop test is now pinned. Matching-executable disassembly at
|
||||||
bitfield byte test. The branch behavior is exact, but its PWD flag name must be
|
`0x0058874C` loads `PWD._bitfield` and `test ah, 0x02`, i.e. overall mask
|
||||||
recovered before making a reusable Core policy field; do not substitute a check
|
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
|
||||||
against `ClientUISystem.vendorID` without that evidence.
|
`ClientUISystem.vendorID`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,13 +343,25 @@ passed, 5 skipped.
|
||||||
|
|
||||||
### 3.2 Port item-use/drop legality into Core
|
### 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.
|
`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.
|
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.
|
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
|
### 3.3 Add one item primary-click chokepoint
|
||||||
|
|
||||||
- [ ] Define a controller-level activation router: target-mode acquisition first,
|
- [ ] Define a controller-level activation router: target-mode acquisition first,
|
||||||
|
|
|
||||||
|
|
@ -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
|
The 2026-07-11 Release gate passed for world/radar/inventory/paperdoll selection
|
||||||
synchronization, toolbar/target/radar presentation, PreviousSelection, and the
|
synchronization, toolbar/target/radar presentation, PreviousSelection, and the
|
||||||
movement/door/armor/window regression tour.
|
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).
|
||||||
|
|
|
||||||
|
|
@ -1984,6 +1984,11 @@ 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);
|
||||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
_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<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
|
||||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||||
Objects,
|
Objects,
|
||||||
playerGuid: () => _playerServerGuid,
|
playerGuid: () => _playerServerGuid,
|
||||||
|
|
@ -1991,7 +1996,14 @@ public sealed class GameWindow : IDisposable
|
||||||
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
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(
|
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||||
_itemInteractionController,
|
_itemInteractionController,
|
||||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||||
|
|
@ -2561,7 +2573,8 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShortcuts: list => Shortcuts = list,
|
onShortcuts: list => Shortcuts = list,
|
||||||
playerGuid: () => _playerServerGuid);
|
playerGuid: () => _playerServerGuid,
|
||||||
|
onUseDone: error => _itemInteractionController?.CompleteUse(error));
|
||||||
|
|
||||||
// Phase I.7: subscribe to CombatState events and emit
|
// Phase I.7: subscribe to CombatState events and emit
|
||||||
// retail-faithful "You hit X for Y damage" chat lines into
|
// retail-faithful "You hit X for Y damage" chat lines into
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,18 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
private readonly Action<uint, uint>? _sendWield;
|
private readonly Action<uint, uint>? _sendWield;
|
||||||
private readonly Action<uint>? _sendDrop;
|
private readonly Action<uint>? _sendDrop;
|
||||||
private readonly Action<string>? _toast;
|
private readonly Action<string>? _toast;
|
||||||
|
private readonly Func<bool> _readyForInventoryRequest;
|
||||||
|
private readonly Func<uint> _activeVendorId;
|
||||||
|
private readonly Func<uint> _groundObjectId;
|
||||||
|
private readonly Func<bool> _playerOnGround;
|
||||||
|
private readonly Func<bool> _inNonCombatMode;
|
||||||
|
private readonly Func<uint, bool> _isComponentPack;
|
||||||
|
private readonly Action<uint>? _placeInBackpack;
|
||||||
|
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
||||||
private readonly InteractionState _interactionState;
|
private readonly InteractionState _interactionState;
|
||||||
|
|
||||||
private long _lastUseMs = long.MinValue / 2;
|
private long _lastUseMs = long.MinValue / 2;
|
||||||
|
private int _busyCount;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public ItemInteractionController(
|
public ItemInteractionController(
|
||||||
|
|
@ -69,7 +78,15 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
Action<uint>? sendDrop,
|
Action<uint>? sendDrop,
|
||||||
Func<long>? nowMs = null,
|
Func<long>? nowMs = null,
|
||||||
Action<string>? toast = null,
|
Action<string>? toast = null,
|
||||||
InteractionState? interactionState = null)
|
InteractionState? interactionState = null,
|
||||||
|
Func<bool>? readyForInventoryRequest = null,
|
||||||
|
Func<uint>? activeVendorId = null,
|
||||||
|
Func<uint>? groundObjectId = null,
|
||||||
|
Func<bool>? playerOnGround = null,
|
||||||
|
Func<bool>? inNonCombatMode = null,
|
||||||
|
Func<uint, bool>? isComponentPack = null,
|
||||||
|
Action<uint>? placeInBackpack = null,
|
||||||
|
Action<ItemPolicyAction>? auxiliaryAction = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
|
@ -79,6 +96,14 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_sendDrop = sendDrop;
|
_sendDrop = sendDrop;
|
||||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||||
_toast = toast;
|
_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 = interactionState ?? new InteractionState();
|
||||||
_interactionState.Changed += OnInteractionModeChanged;
|
_interactionState.Changed += OnInteractionModeChanged;
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +114,14 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
|
|
||||||
public InteractionState InteractionState => _interactionState;
|
public InteractionState InteractionState => _interactionState;
|
||||||
|
|
||||||
|
public int BusyCount => _busyCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised for retail confirmation/auxiliary actions whose retained panel is
|
||||||
|
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
|
||||||
|
/// </summary>
|
||||||
|
public event Action<ItemPolicyAction>? PolicyActionRequested;
|
||||||
|
|
||||||
public uint PendingSourceItem
|
public uint PendingSourceItem
|
||||||
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
||||||
? mode.SourceObjectId
|
? mode.SourceObjectId
|
||||||
|
|
@ -105,8 +138,8 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||||
|
|
||||||
var source = _objects.Get(PendingSourceItem);
|
var source = _objects.Get(PendingSourceItem);
|
||||||
return source?.Useability is { } useability
|
return source?.Useability is not null
|
||||||
&& TargetCompatible(source, targetGuid, useability);
|
&& TargetCompatible(source, targetGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ActivateItem(uint itemGuid)
|
public bool ActivateItem(uint itemGuid)
|
||||||
|
|
@ -122,32 +155,19 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
if (!ConsumeUseThrottle())
|
if (!ConsumeUseThrottle())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (item.Useability is { } targetedUse
|
var input = new ItemUsePolicyInput(
|
||||||
&& ItemUseability.IsTargeted(targetedUse)
|
Snapshot(item),
|
||||||
&& SourceCompatible(item, targetedUse))
|
_playerGuid(),
|
||||||
{
|
_groundObjectId(),
|
||||||
EnterTargetMode(itemGuid);
|
_readyForInventoryRequest() && _busyCount == 0,
|
||||||
return true;
|
_activeVendorId(),
|
||||||
}
|
BypassClassification: false,
|
||||||
|
UseCurrentSelection: false,
|
||||||
if (IsContainer(item))
|
SelectedTarget: null,
|
||||||
{
|
ConfirmVolatileRareUses: true,
|
||||||
_sendUse?.Invoke(itemGuid);
|
InNonCombatMode: _inNonCombatMode());
|
||||||
return true;
|
var decision = ItemInteractionPolicy.DecideUse(input);
|
||||||
}
|
return ExecuteUseActions(decision.Actions);
|
||||||
|
|
||||||
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)
|
public bool AcquireTarget(uint targetGuid)
|
||||||
|
|
@ -161,7 +181,7 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
if (source?.Useability is not { } useability)
|
if (source?.Useability is not { } useability)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
bool compatible = TargetCompatible(source, targetGuid, useability);
|
bool compatible = TargetCompatible(source, targetGuid);
|
||||||
var target = _objects.Get(targetGuid);
|
var target = _objects.Get(targetGuid);
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
|
$"[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)
|
if (!compatible)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (!_readyForInventoryRequest() || _busyCount != 0)
|
||||||
|
return false;
|
||||||
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||||
|
_busyCount++;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,12 +212,106 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
|
|
||||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||||
return false;
|
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;
|
return false;
|
||||||
|
|
||||||
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
|
int splitSize = Math.Max(1, item.StackSize);
|
||||||
_sendDrop?.Invoke(payload.ObjId);
|
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
||||||
return true;
|
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<ItemPolicyAction> 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<ItemPolicyAction> 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)
|
private void EnterTargetMode(uint sourceGuid)
|
||||||
|
|
@ -210,6 +327,21 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_interactionState.Clear();
|
_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 _)
|
private void OnInteractionModeChanged(InteractionModeTransition _)
|
||||||
=> StateChanged?.Invoke();
|
=> StateChanged?.Invoke();
|
||||||
|
|
||||||
|
|
@ -220,6 +352,14 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_interactionState.Changed -= OnInteractionModeChanged;
|
_interactionState.Changed -= OnInteractionModeChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
||||||
|
public void CompleteUse(uint _)
|
||||||
|
{
|
||||||
|
if (_busyCount == 0) return;
|
||||||
|
_busyCount--;
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
private bool ConsumeUseThrottle()
|
private bool ConsumeUseThrottle()
|
||||||
{
|
{
|
||||||
long now = _nowMs();
|
long now = _nowMs();
|
||||||
|
|
@ -347,63 +487,51 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
|| item.Type.HasFlag(ItemType.Container)
|
|| item.Type.HasFlag(ItemType.Container)
|
||||||
|| item.ItemsCapacity > 0;
|
|| item.ItemsCapacity > 0;
|
||||||
|
|
||||||
private bool SourceCompatible(ClientObject source, uint useability)
|
private ItemPolicyObject Snapshot(ClientObject item)
|
||||||
{
|
{
|
||||||
uint flags = ItemUseability.SourceFlags(useability);
|
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
|
||||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
if (item.ObjectId == _playerGuid())
|
||||||
if ((flags & ItemUseability.Viewed) != 0) return true;
|
flags |= PublicWeenieFlags.Player;
|
||||||
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
|
|
||||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
|
bool owned = item.ObjectId == _playerGuid()
|
||||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
|
|| IsCarriedByPlayer(item)
|
||||||
return false;
|
|| 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Faithful port of <c>ItemHolder::IsTargetCompatibleWithTargetingObject</c>
|
/// App adapter for Core's pure port of
|
||||||
/// (0x00588070) for a player-owned source (ActivateItem's SourceCompatible
|
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
|
||||||
/// 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: <c>source._targetType & target->InqType()</c>. 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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);
|
var target = _objects.Get(targetGuid);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return false; // retail: GetWeenieObject(target) null → incompatible
|
return false; // retail: GetWeenieObject(target) null → incompatible
|
||||||
|
return ItemInteractionPolicy.IsTargetCompatible(
|
||||||
if (!(IsCarriedByPlayer(target) || IsEquippedByPlayer(target)))
|
Snapshot(source), Snapshot(target), _playerGuid());
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsWieldedByPlayer(ClientObject item)
|
|
||||||
=> IsEquippedByPlayer(item);
|
|
||||||
|
|
||||||
private bool IsEquippedByPlayer(ClientObject item)
|
private bool IsEquippedByPlayer(ClientObject item)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,8 @@ public static class GameEventWiring
|
||||||
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
|
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
|
||||||
// B-Wire: the local player's server guid. When provided, the PD handler upserts
|
// 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.
|
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
|
||||||
Func<uint>? playerGuid = null)
|
Func<uint>? playerGuid = null,
|
||||||
|
Action<uint /*weenieError*/>? onUseDone = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
@ -316,6 +317,7 @@ public static class GameEventWiring
|
||||||
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
|
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
|
||||||
if (err is null) return;
|
if (err is null) return;
|
||||||
Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
|
Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
|
||||||
|
onUseDone?.Invoke(err.Value);
|
||||||
if (err.Value != 0)
|
if (err.Value != 0)
|
||||||
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
|
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,8 @@ public static class CreateObject
|
||||||
// Nullable preserves the wire distinction between an absent flag and
|
// Nullable preserves the wire distinction between an absent flag and
|
||||||
// an explicitly transmitted zero (the enum's undefined/default value).
|
// an explicitly transmitted zero (the enum's undefined/default value).
|
||||||
byte? RadarBlipColor = null,
|
byte? RadarBlipColor = null,
|
||||||
byte? RadarBehavior = null);
|
byte? RadarBehavior = null,
|
||||||
|
byte? CombatUse = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||||
|
|
@ -750,6 +751,7 @@ public static class CreateObject
|
||||||
uint? targetType = null;
|
uint? targetType = null;
|
||||||
byte? radarBlipColor = null;
|
byte? radarBlipColor = null;
|
||||||
byte? radarBehavior = null;
|
byte? radarBehavior = null;
|
||||||
|
byte? combatUse = null;
|
||||||
uint iconOverlayId = 0;
|
uint iconOverlayId = 0;
|
||||||
uint iconUnderlayId = 0;
|
uint iconUnderlayId = 0;
|
||||||
uint uiEffects = 0;
|
uint uiEffects = 0;
|
||||||
|
|
@ -822,7 +824,7 @@ public static class CreateObject
|
||||||
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
|
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
|
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
|
||||||
pos += 1;
|
combatUse = body[pos]; pos += 1;
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
|
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
|
||||||
{
|
{
|
||||||
|
|
@ -966,7 +968,8 @@ public static class CreateObject
|
||||||
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
||||||
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
||||||
Workmanship: wWorkmanship,
|
Workmanship: wWorkmanship,
|
||||||
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
|
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
|
||||||
|
CombatUse: combatUse);
|
||||||
|
|
||||||
// Local helper: if we ran out of fields past PhysicsData, still
|
// Local helper: if we ran out of fields past PhysicsData, still
|
||||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||||
|
|
|
||||||
|
|
@ -85,5 +85,7 @@ public static class ObjectTableWiring
|
||||||
Useability: s.Useability,
|
Useability: s.Useability,
|
||||||
TargetType: s.TargetType,
|
TargetType: s.TargetType,
|
||||||
RadarBlipColor: s.RadarBlipColor,
|
RadarBlipColor: s.RadarBlipColor,
|
||||||
RadarBehavior: s.RadarBehavior);
|
RadarBehavior: s.RadarBehavior,
|
||||||
|
PublicWeenieBitfield: s.ObjectDescriptionFlags,
|
||||||
|
CombatUse: s.CombatUse);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,8 @@ public sealed class WorldSession : IDisposable
|
||||||
// flag was absent; zero means the server explicitly sent the enum's
|
// flag was absent; zero means the server explicitly sent the enum's
|
||||||
// undefined/default value.
|
// undefined/default value.
|
||||||
byte? RadarBlipColor = null,
|
byte? RadarBlipColor = null,
|
||||||
byte? RadarBehavior = null);
|
byte? RadarBehavior = null,
|
||||||
|
byte? CombatUse = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects the wire-level CreateObject result into the stable session
|
/// Projects the wire-level CreateObject result into the stable session
|
||||||
|
|
@ -172,7 +173,8 @@ public sealed class WorldSession : IDisposable
|
||||||
MovementSequence: parsed.MovementSequence,
|
MovementSequence: parsed.MovementSequence,
|
||||||
ServerControlSequence: parsed.ServerControlSequence,
|
ServerControlSequence: parsed.ServerControlSequence,
|
||||||
RadarBlipColor: parsed.RadarBlipColor,
|
RadarBlipColor: parsed.RadarBlipColor,
|
||||||
RadarBehavior: parsed.RadarBehavior);
|
RadarBehavior: parsed.RadarBehavior,
|
||||||
|
CombatUse: parsed.CombatUse);
|
||||||
|
|
||||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||||
public event Action<EntitySpawn>? EntitySpawned;
|
public event Action<EntitySpawn>? EntitySpawned;
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,14 @@ public sealed class ClientObject
|
||||||
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? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||||||
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||||||
|
/// <summary>Retail <c>PublicWeenieDesc._bitfield</c>; null until CreateObject supplies it.</summary>
|
||||||
|
public uint? PublicWeenieBitfield { get; set; }
|
||||||
|
/// <summary>Retail <c>PublicWeenieDesc._combatUse</c>; null when its header flag was absent.</summary>
|
||||||
|
public byte? CombatUse { get; set; }
|
||||||
|
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
|
||||||
|
public int TradeState { get; set; }
|
||||||
|
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
|
||||||
|
public bool IsComponentPack { get; set; }
|
||||||
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
|
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
|
||||||
public byte? RadarBlipColor { get; set; }
|
public byte? RadarBlipColor { get; set; }
|
||||||
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
|
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
|
||||||
|
|
@ -223,7 +231,9 @@ public readonly record struct WeenieData(
|
||||||
uint? Useability = null,
|
uint? Useability = null,
|
||||||
uint? TargetType = null,
|
uint? TargetType = null,
|
||||||
byte? RadarBlipColor = null,
|
byte? RadarBlipColor = null,
|
||||||
byte? RadarBehavior = null);
|
byte? RadarBehavior = null,
|
||||||
|
uint? PublicWeenieBitfield = null,
|
||||||
|
byte? CombatUse = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||||
|
|
@ -257,6 +267,18 @@ public static class ItemUseability
|
||||||
public static uint SourceFlags(uint useability)
|
public static uint SourceFlags(uint useability)
|
||||||
=> useability & SourceMask;
|
=> useability & SourceMask;
|
||||||
|
|
||||||
|
/// <summary>Retail <c>ItemUses::GetLeastLimitedSourceUse</c>.</summary>
|
||||||
|
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)
|
public static uint TargetFlags(uint useability)
|
||||||
=> (useability & TargetMask) >> 16;
|
=> (useability & TargetMask) >> 16;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,8 @@ public sealed class ClientObjectTable
|
||||||
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.Useability is { } use) obj.Useability = use;
|
||||||
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
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.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||||
|
|
|
||||||
437
src/AcDream.Core/Items/ItemInteractionPolicy.cs
Normal file
437
src/AcDream.Core/Items/ItemInteractionPolicy.cs
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>PublicWeenieDesc::BitfieldIndex</c> values used by item interaction.
|
||||||
|
/// Names and values are from named-retail <c>acclient.h:6431</c>; the two damaged
|
||||||
|
/// decompiler expressions were verified in the matching v11.4186 executable at
|
||||||
|
/// <c>0x005884B6</c> and <c>0x00588752</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Return values of retail <c>ItemHolder::DetermineUseResult</c>.</summary>
|
||||||
|
public enum ItemPrimaryUseResult : ushort
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
ItemUse = 1,
|
||||||
|
PlaceInBackpack = 2,
|
||||||
|
WieldRight = 3,
|
||||||
|
AutoSort = 4,
|
||||||
|
OpenSecureTrade = 5,
|
||||||
|
OpenSalvage = 6,
|
||||||
|
BeginGame = 7,
|
||||||
|
WieldLeft = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ItemPolicyAction> 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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's placement return is deliberately separate from its action list:
|
||||||
|
/// vendor sale and secure-trade attempts return false even though they act.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ItemPlacementPolicyDecision(
|
||||||
|
bool ReturnValue,
|
||||||
|
IReadOnlyList<ItemPolicyAction> Actions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure ports of <c>ItemHolder::DetermineUseResult @ 0x00588460</c>,
|
||||||
|
/// <c>UseObject @ 0x00588A80</c>, and <c>AttemptPlaceIn3D @ 0x00588600</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<ItemPolicyAction>
|
||||||
|
{
|
||||||
|
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<ItemPolicyAction>
|
||||||
|
{
|
||||||
|
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<ItemPolicyAction>());
|
||||||
|
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<ItemPolicyAction>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ItemPolicyAction> BuildUsingItemActions(
|
||||||
|
in ItemPolicyObject item,
|
||||||
|
uint playerId,
|
||||||
|
uint groundObjectId,
|
||||||
|
ItemPrimaryUseResult primary)
|
||||||
|
{
|
||||||
|
var actions = new List<ItemPolicyAction>(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<ItemPolicyAction> 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);
|
||||||
|
}
|
||||||
|
|
@ -387,6 +387,7 @@ public sealed class ItemInteractionControllerTests
|
||||||
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
|
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
|
||||||
|
|
||||||
h.Controller.ActivateItem(0x50000A09u);
|
h.Controller.ActivateItem(0x50000A09u);
|
||||||
|
h.Controller.CompleteUse(0);
|
||||||
h.Now += 199;
|
h.Now += 199;
|
||||||
h.Controller.ActivateItem(0x50000A09u);
|
h.Controller.ActivateItem(0x50000A09u);
|
||||||
h.Now += 1;
|
h.Now += 1;
|
||||||
|
|
@ -394,4 +395,22 @@ public sealed class ItemInteractionControllerTests
|
||||||
|
|
||||||
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,27 @@ public sealed class GameEventWiringTests
|
||||||
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
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]
|
[Fact]
|
||||||
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
|
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,22 @@ public sealed class CreateObjectTests
|
||||||
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
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.
|
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||||||
// Absence is distinct from an explicitly transmitted zero because zero is
|
// Absence is distinct from an explicitly transmitted zero because zero is
|
||||||
|
|
@ -566,6 +582,7 @@ public sealed class CreateObjectTests
|
||||||
float? workmanship = null,
|
float? workmanship = null,
|
||||||
byte? radarBlipColor = null,
|
byte? radarBlipColor = null,
|
||||||
byte? radarBehavior = null,
|
byte? radarBehavior = null,
|
||||||
|
byte? combatUse = null,
|
||||||
ushort movementSeq = 0)
|
ushort movementSeq = 0)
|
||||||
{
|
{
|
||||||
var bytes = new List<byte>();
|
var bytes = new List<byte>();
|
||||||
|
|
@ -618,7 +635,7 @@ public sealed class CreateObjectTests
|
||||||
}
|
}
|
||||||
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // 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(combatUse ?? 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
|
||||||
if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16
|
if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16
|
||||||
if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16
|
if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ public sealed class ObjectTableWiringTests
|
||||||
TargetType = (uint)ItemType.Creature,
|
TargetType = (uint)ItemType.Creature,
|
||||||
RadarBlipColor = 4,
|
RadarBlipColor = 4,
|
||||||
RadarBehavior = 3,
|
RadarBehavior = 3,
|
||||||
|
ObjectDescriptionFlags = 0x20800200u,
|
||||||
|
CombatUse = 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
var d = ObjectTableWiring.ToWeenieData(spawn);
|
var d = ObjectTableWiring.ToWeenieData(spawn);
|
||||||
|
|
@ -104,6 +106,8 @@ public sealed class ObjectTableWiringTests
|
||||||
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
||||||
Assert.Equal((byte)4, d.RadarBlipColor);
|
Assert.Equal((byte)4, d.RadarBlipColor);
|
||||||
Assert.Equal((byte)3, d.RadarBehavior);
|
Assert.Equal((byte)3, d.RadarBehavior);
|
||||||
|
Assert.Equal(0x20800200u, d.PublicWeenieBitfield);
|
||||||
|
Assert.Equal((byte)2, d.CombatUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
289
tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs
Normal file
289
tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -27,4 +27,16 @@ public sealed class ItemUseabilityTests
|
||||||
{
|
{
|
||||||
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue