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-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
|
||||
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
|
||||
| AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
|
||||
| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
|
||||
| AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` |
|
||||
| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` |
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
|
||||
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
|
||||
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
|
||||
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, and **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅ are shipped. Build order from here: **finish Wave 3 item policy/router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate.
|
||||
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅, and **Wave 3.2 pure item-use/drop policy** (`ItemInteractionPolicy`; AP-99 retired) ✅ are shipped. Build order from here: **finish Wave 3 activation router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate.
|
||||
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <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 — 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
|
||||
```
|
||||
|
||||
The first capacity bit expression is corrupted into a string address by Binary
|
||||
Ninja. Capacity fields and `IsComponentPack` make the branch intent clear, but the
|
||||
missing bit name/value is **unresolved** and must be recovered before porting.
|
||||
The first capacity bit expression was corrupted into a string address by Binary
|
||||
Ninja. Matching-executable disassembly at `0x005884B6` resolves it exactly as
|
||||
`test PWD._bitfield, 0x00800000`, i.e. `BF_REQUIRES_PACKSLOT`. The complete
|
||||
result-2 predicate is therefore `(!BF_REQUIRES_PACKSLOT && itemsCapacity == 0 &&
|
||||
containersCapacity == 0) || IsComponentPack()` after the loose/viewed-container
|
||||
and wielder gates. This was pinned on 2026-07-11 before the Core policy port.
|
||||
|
||||
`CPlayerSystem::UsingItem` first executes result `2..8`, then independently:
|
||||
|
||||
|
|
@ -772,10 +775,10 @@ The return value means neither strictly "a server message was sent" nor strictly
|
|||
"the item moved": vendor and secure-trade attempts deliberately return false.
|
||||
Callers that use it only as an action-success boolean will diverge.
|
||||
|
||||
**Semantic name uncertain:** the vendor-drop test appears in pseudo-C as a raw
|
||||
bitfield byte test. The branch behavior is exact, but its PWD flag name must be
|
||||
recovered before making a reusable Core policy field; do not substitute a check
|
||||
against `ClientUISystem.vendorID` without that evidence.
|
||||
The vendor-drop test is now pinned. Matching-executable disassembly at
|
||||
`0x0058874C` loads `PWD._bitfield` and `test ah, 0x02`, i.e. overall mask
|
||||
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
|
||||
`ClientUISystem.vendorID`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -343,13 +343,25 @@ passed, 5 skipped.
|
|||
|
||||
### 3.2 Port item-use/drop legality into Core
|
||||
|
||||
- [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and
|
||||
- [x] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and
|
||||
`AttemptPlaceIn3D` to pure/testable Core policy records.
|
||||
- [ ] Include ready/busy, ownership, vendor/trade, PK/rare confirmation, stack,
|
||||
- [x] Include ready/busy, ownership, vendor/trade, PK/rare confirmation, stack,
|
||||
ground/drop, and target-type gates.
|
||||
- [ ] Keep App `ItemInteractionController` as orchestration: ask policy, show modal/
|
||||
- [x] Keep App `ItemInteractionController` as orchestration: ask policy, show modal/
|
||||
toast, send the selected wire action, and manage pending state.
|
||||
|
||||
**Implementation (2026-07-11):** Core `ItemInteractionPolicy` is the pure port
|
||||
of all three named-retail decision trees and returns ordered typed actions (plus
|
||||
the deliberately independent placement return value). Matching-executable x86
|
||||
resolved the two damaged masks as `BF_REQUIRES_PACKSLOT (0x00800000)` and
|
||||
`BF_VENDOR (0x00000200)`. `CreateObject` now retains PWD flags and `CombatUse`;
|
||||
the App snapshot resolves component packs from portal.dat. The controller owns
|
||||
the 200 ms pre-legality throttle, ready/busy lifecycle (released by `UseDone`),
|
||||
target state, policy action dispatch, confirmation/auxiliary action seams, and
|
||||
ground-drop optimistic state. Vendor/trade/salvage/confirmation retained panels
|
||||
consume the typed action seam when those surfaces land; until then the policy
|
||||
refuses unsafe wire sends rather than bypassing their gates.
|
||||
|
||||
### 3.3 Add one item primary-click chokepoint
|
||||
|
||||
- [ ] Define a controller-level activation router: target-mode acquisition first,
|
||||
|
|
|
|||
|
|
@ -140,3 +140,21 @@ Core and interaction modes in App when completing Wave 3.
|
|||
The 2026-07-11 Release gate passed for world/radar/inventory/paperdoll selection
|
||||
synchronization, toolbar/target/radar presentation, PreviousSelection, and the
|
||||
movement/door/armor/window regression tour.
|
||||
|
||||
## 2026-07-11 item-interaction policy boundary
|
||||
|
||||
`AcDream.Core.Items.ItemInteractionPolicy` owns the pure, ordered retail rules
|
||||
from `DetermineUseResult`, `UseObject`, target compatibility, and
|
||||
`AttemptPlaceIn3D`. Do not put vendor/trade/confirmation/stack/ground legality
|
||||
back into widgets or `GameWindow`. `ItemInteractionController` snapshots mutable
|
||||
objects, applies the 200 ms pre-legality throttle, balances busy state against
|
||||
Core.Net `UseDone`, manages target mode, and dispatches typed actions. PWD flags
|
||||
and `CombatUse` are retained from CreateObject; `IsComponentPack` is resolved
|
||||
against portal.dat's `SpellComponentTable`.
|
||||
|
||||
Matching-executable x86 pins the two operands lost by Binary Ninja:
|
||||
`DetermineUseResult @ 0x005884B6` tests `BF_REQUIRES_PACKSLOT (0x00800000)`, and
|
||||
`AttemptPlaceIn3D @ 0x00588752` tests `BF_VENDOR (0x00000200)`. Preserve the
|
||||
retail asymmetry that `UseObject` early-classifies results 2..7 but excludes 8,
|
||||
and preserve placement's action list separately from its boolean return (vendor
|
||||
sale and secure-trade attempts act while returning false).
|
||||
|
|
|
|||
|
|
@ -1984,6 +1984,11 @@ public sealed class GameWindow : IDisposable
|
|||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||
var spellComponentTable =
|
||||
// DBObj file id is 0x0E00000F. Retail's IsComponentPack
|
||||
// passes enum key 0x10000001 to DBObj::GetByEnum; that key is
|
||||
// NOT a portal.dat file id (using it here reads unrelated bytes).
|
||||
_dats!.Get<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
|
||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
|
|
@ -1991,7 +1996,14 @@ public sealed class GameWindow : IDisposable
|
|||
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||
toast: text => _debugVm?.AddToast(text));
|
||||
toast: text => _debugVm?.AddToast(text),
|
||||
readyForInventoryRequest: () => _liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
|
||||
playerOnGround: GetDebugPlayerOnGround,
|
||||
inNonCombatMode: () => Combat.CurrentMode
|
||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
|
||||
placeInBackpack: SendPickUp);
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2561,7 +2573,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
},
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid);
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error => _itemInteractionController?.CompleteUse(error));
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
|
|||
|
|
@ -55,9 +55,18 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
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 long _lastUseMs = long.MinValue / 2;
|
||||
private int _busyCount;
|
||||
private bool _disposed;
|
||||
|
||||
public ItemInteractionController(
|
||||
|
|
@ -69,7 +78,15 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint>? sendDrop,
|
||||
Func<long>? nowMs = 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));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -79,6 +96,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendDrop = sendDrop;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
|
||||
_activeVendorId = activeVendorId ?? (() => 0u);
|
||||
_groundObjectId = groundObjectId ?? (() => 0u);
|
||||
_playerOnGround = playerOnGround ?? (() => true);
|
||||
_inNonCombatMode = inNonCombatMode ?? (() => false);
|
||||
_isComponentPack = isComponentPack ?? (_ => false);
|
||||
_placeInBackpack = placeInBackpack;
|
||||
_auxiliaryAction = auxiliaryAction;
|
||||
_interactionState = interactionState ?? new InteractionState();
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
}
|
||||
|
|
@ -89,6 +114,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public InteractionState InteractionState => _interactionState;
|
||||
|
||||
public int BusyCount => _busyCount;
|
||||
|
||||
/// <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
|
||||
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
||||
? mode.SourceObjectId
|
||||
|
|
@ -105,8 +138,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
||||
var source = _objects.Get(PendingSourceItem);
|
||||
return source?.Useability is { } useability
|
||||
&& TargetCompatible(source, targetGuid, useability);
|
||||
return source?.Useability is not null
|
||||
&& TargetCompatible(source, targetGuid);
|
||||
}
|
||||
|
||||
public bool ActivateItem(uint itemGuid)
|
||||
|
|
@ -122,32 +155,19 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!ConsumeUseThrottle())
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } targetedUse
|
||||
&& ItemUseability.IsTargeted(targetedUse)
|
||||
&& SourceCompatible(item, targetedUse))
|
||||
{
|
||||
EnterTargetMode(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsContainer(item))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryAutoWield(item))
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } directUse
|
||||
&& ItemUseability.IsDirectUseable(directUse)
|
||||
&& SourceCompatible(item, directUse))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
var input = new ItemUsePolicyInput(
|
||||
Snapshot(item),
|
||||
_playerGuid(),
|
||||
_groundObjectId(),
|
||||
_readyForInventoryRequest() && _busyCount == 0,
|
||||
_activeVendorId(),
|
||||
BypassClassification: false,
|
||||
UseCurrentSelection: false,
|
||||
SelectedTarget: null,
|
||||
ConfirmVolatileRareUses: true,
|
||||
InNonCombatMode: _inNonCombatMode());
|
||||
var decision = ItemInteractionPolicy.DecideUse(input);
|
||||
return ExecuteUseActions(decision.Actions);
|
||||
}
|
||||
|
||||
public bool AcquireTarget(uint targetGuid)
|
||||
|
|
@ -161,7 +181,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (source?.Useability is not { } useability)
|
||||
return false;
|
||||
|
||||
bool compatible = TargetCompatible(source, targetGuid, useability);
|
||||
bool compatible = TargetCompatible(source, targetGuid);
|
||||
var target = _objects.Get(targetGuid);
|
||||
Console.WriteLine(
|
||||
$"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
|
||||
|
|
@ -170,7 +190,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!compatible)
|
||||
return false;
|
||||
|
||||
if (!_readyForInventoryRequest() || _busyCount != 0)
|
||||
return false;
|
||||
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||
_busyCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -189,12 +212,106 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
||||
return false;
|
||||
|
||||
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
|
||||
_sendDrop?.Invoke(payload.ObjId);
|
||||
return true;
|
||||
int splitSize = Math.Max(1, item.StackSize);
|
||||
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
||||
Snapshot(item),
|
||||
_playerGuid(),
|
||||
_groundObjectId(),
|
||||
_readyForInventoryRequest() && _busyCount == 0,
|
||||
TargetId: 0,
|
||||
Target: null,
|
||||
AllowGroundFallback: true,
|
||||
MergeAccepted: false,
|
||||
DragOnPlayerOpensSecureTrade: true,
|
||||
PlayerOnGround: _playerOnGround(),
|
||||
SplitSize: splitSize));
|
||||
ExecutePlacementActions(decision.Actions);
|
||||
return decision.ReturnValue;
|
||||
}
|
||||
|
||||
private bool ExecuteUseActions(System.Collections.Generic.IReadOnlyList<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)
|
||||
|
|
@ -210,6 +327,21 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_interactionState.Clear();
|
||||
}
|
||||
|
||||
private static string PolicyActionMessage(ItemPolicyAction action)
|
||||
=> action.Kind switch
|
||||
{
|
||||
ItemPolicyActionKind.ConfirmPlayerKillerSwitch =>
|
||||
"Confirm using this Player Killer altar before continuing.",
|
||||
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch =>
|
||||
"Confirm using this Non-Player Killer altar before continuing.",
|
||||
ItemPolicyActionKind.ConfirmVolatileRare =>
|
||||
"Confirm using this volatile rare before continuing.",
|
||||
ItemPolicyActionKind.OpenSecureTrade or ItemPolicyActionKind.StartSecureTrade =>
|
||||
"Secure trade is not open.",
|
||||
ItemPolicyActionKind.OpenSalvage => "Open the salvage panel to use that item.",
|
||||
_ => "That item action is not available here.",
|
||||
};
|
||||
|
||||
private void OnInteractionModeChanged(InteractionModeTransition _)
|
||||
=> StateChanged?.Invoke();
|
||||
|
||||
|
|
@ -220,6 +352,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
}
|
||||
|
||||
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
||||
public void CompleteUse(uint _)
|
||||
{
|
||||
if (_busyCount == 0) return;
|
||||
_busyCount--;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool ConsumeUseThrottle()
|
||||
{
|
||||
long now = _nowMs();
|
||||
|
|
@ -347,64 +487,52 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private bool SourceCompatible(ClientObject source, uint useability)
|
||||
private ItemPolicyObject Snapshot(ClientObject item)
|
||||
{
|
||||
uint flags = ItemUseability.SourceFlags(useability);
|
||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||
if ((flags & ItemUseability.Viewed) != 0) return true;
|
||||
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
|
||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
|
||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
|
||||
return false;
|
||||
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
|
||||
if (item.ObjectId == _playerGuid())
|
||||
flags |= PublicWeenieFlags.Player;
|
||||
|
||||
bool owned = item.ObjectId == _playerGuid()
|
||||
|| IsCarriedByPlayer(item)
|
||||
|| IsEquippedByPlayer(item);
|
||||
int stackSize = Math.Max(1, item.StackSize);
|
||||
return new ItemPolicyObject(
|
||||
item.ObjectId,
|
||||
item.Type,
|
||||
flags,
|
||||
item.ContainerId,
|
||||
item.WielderId,
|
||||
item.ValidLocations,
|
||||
item.CurrentlyEquippedLocation,
|
||||
item.CombatUse ?? 0,
|
||||
item.ItemsCapacity,
|
||||
item.ContainersCapacity,
|
||||
item.Useability ?? 0u,
|
||||
item.TargetType ?? 0u,
|
||||
owned,
|
||||
IsContainer(item),
|
||||
item.IsComponentPack || _isComponentPack(item.WeenieClassId),
|
||||
item.TradeState,
|
||||
stackSize,
|
||||
stackSize,
|
||||
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
|
||||
&& item.ObjectId != _playerGuid());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Faithful port of <c>ItemHolder::IsTargetCompatibleWithTargetingObject</c>
|
||||
/// (0x00588070) for a player-owned source (ActivateItem's SourceCompatible
|
||||
/// already applied retail's GetLeastLimitedSourceUse arm when target mode was
|
||||
/// armed). Retail shape: (1) a LOCATION constraint applies only when the
|
||||
/// least-limited target-use bit (ItemUses::GetLeastLimitedTargetUse
|
||||
/// 0x004fcd50) is Contained — target must be in our inventory, sole
|
||||
/// exception self when the Self bit is present — or Wielded (refuse
|
||||
/// non-owned); (2) the player as target additionally requires the Self bit
|
||||
/// (ItemUses::IsUseable_SelfTarget 0x004fcd30); (3) EVERY target then passes
|
||||
/// the KIND gate: <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.
|
||||
/// App adapter for Core's pure port of
|
||||
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
|
||||
/// </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);
|
||||
if (target is null)
|
||||
return false; // retail: GetWeenieObject(target) null → incompatible
|
||||
|
||||
if (!(IsCarriedByPlayer(target) || IsEquippedByPlayer(target)))
|
||||
{
|
||||
uint least = ItemUseability.LeastLimitedTargetUse(useability);
|
||||
if ((least & ItemUseability.Contained) != 0)
|
||||
{
|
||||
if (!(targetGuid == player && (flags & ItemUseability.Self) != 0))
|
||||
return false;
|
||||
}
|
||||
else if ((least & ItemUseability.Wielded) != 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetGuid == player && (flags & ItemUseability.Self) == 0)
|
||||
return false;
|
||||
|
||||
return ((source.TargetType ?? 0u) & (uint)target.Type) != 0;
|
||||
return ItemInteractionPolicy.IsTargetCompatible(
|
||||
Snapshot(source), Snapshot(target), _playerGuid());
|
||||
}
|
||||
|
||||
private bool IsWieldedByPlayer(ClientObject item)
|
||||
=> IsEquippedByPlayer(item);
|
||||
|
||||
private bool IsEquippedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ public static class GameEventWiring
|
|||
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
|
||||
// B-Wire: the local player's server guid. When provided, the PD handler upserts
|
||||
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
|
||||
Func<uint>? playerGuid = null)
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint /*weenieError*/>? onUseDone = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -316,6 +317,7 @@ public static class GameEventWiring
|
|||
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
|
||||
if (err is null) return;
|
||||
Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
|
||||
onUseDone?.Invoke(err.Value);
|
||||
if (err.Value != 0)
|
||||
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@ public static class CreateObject
|
|||
// Nullable preserves the wire distinction between an absent flag and
|
||||
// an explicitly transmitted zero (the enum's undefined/default value).
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
byte? RadarBehavior = null,
|
||||
byte? CombatUse = null);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -750,6 +751,7 @@ public static class CreateObject
|
|||
uint? targetType = null;
|
||||
byte? radarBlipColor = null;
|
||||
byte? radarBehavior = null;
|
||||
byte? combatUse = null;
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
|
|
@ -822,7 +824,7 @@ public static class CreateObject
|
|||
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
|
||||
{
|
||||
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
|
||||
pos += 1;
|
||||
combatUse = body[pos]; pos += 1;
|
||||
}
|
||||
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
|
||||
{
|
||||
|
|
@ -966,7 +968,8 @@ public static class CreateObject
|
|||
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
|
||||
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
|
||||
Workmanship: wWorkmanship,
|
||||
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
|
||||
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
|
||||
CombatUse: combatUse);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
|
|
|
|||
|
|
@ -85,5 +85,7 @@ public static class ObjectTableWiring
|
|||
Useability: s.Useability,
|
||||
TargetType: s.TargetType,
|
||||
RadarBlipColor: s.RadarBlipColor,
|
||||
RadarBehavior: s.RadarBehavior);
|
||||
RadarBehavior: s.RadarBehavior,
|
||||
PublicWeenieBitfield: s.ObjectDescriptionFlags,
|
||||
CombatUse: s.CombatUse);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ public sealed class WorldSession : IDisposable
|
|||
// flag was absent; zero means the server explicitly sent the enum's
|
||||
// undefined/default value.
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
byte? RadarBehavior = null,
|
||||
byte? CombatUse = null);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire-level CreateObject result into the stable session
|
||||
|
|
@ -172,7 +173,8 @@ public sealed class WorldSession : IDisposable
|
|||
MovementSequence: parsed.MovementSequence,
|
||||
ServerControlSequence: parsed.ServerControlSequence,
|
||||
RadarBlipColor: parsed.RadarBlipColor,
|
||||
RadarBehavior: parsed.RadarBehavior);
|
||||
RadarBehavior: parsed.RadarBehavior,
|
||||
CombatUse: parsed.CombatUse);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ public sealed class ClientObject
|
|||
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
|
||||
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
|
||||
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
|
||||
/// <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>
|
||||
public byte? RadarBlipColor { get; set; }
|
||||
/// <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? TargetType = null,
|
||||
byte? RadarBlipColor = null,
|
||||
byte? RadarBehavior = null);
|
||||
byte? RadarBehavior = null,
|
||||
uint? PublicWeenieBitfield = null,
|
||||
byte? CombatUse = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
@ -257,6 +267,18 @@ public static class ItemUseability
|
|||
public static uint SourceFlags(uint useability)
|
||||
=> useability & SourceMask;
|
||||
|
||||
/// <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)
|
||||
=> (useability & TargetMask) >> 16;
|
||||
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ public sealed class ClientObjectTable
|
|||
if (d.Priority is { } pr) obj.Priority = pr;
|
||||
if (d.Useability is { } use) obj.Useability = use;
|
||||
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
||||
if (d.PublicWeenieBitfield is { } bitfield) obj.PublicWeenieBitfield = bitfield;
|
||||
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
|
|
|
|||
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.Controller.ActivateItem(0x50000A09u);
|
||||
h.Controller.CompleteUse(0);
|
||||
h.Now += 199;
|
||||
h.Controller.ActivateItem(0x50000A09u);
|
||||
h.Now += 1;
|
||||
|
|
@ -394,4 +395,22 @@ public sealed class ItemInteractionControllerTests
|
|||
|
||||
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseDone_releasesRetailBusyGate()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A0Au, item => item.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
|
||||
h.Now += 200;
|
||||
Assert.False(h.Controller.ActivateItem(0x50000A0Au));
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
|
||||
h.Controller.CompleteUse(0);
|
||||
h.Now += 200; // rejected busy attempt still advanced retail's throttle timestamp
|
||||
|
||||
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
|
||||
Assert.Equal(2, h.Uses.Count);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,6 +449,27 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseDone_releasesAppBusyOwnerThroughCallback()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
uint? completedWith = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
onUseDone: error => completedWith = error);
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1Du);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(0x1Du, completedWith);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -172,6 +172,22 @@ public sealed class CreateObjectTests
|
|||
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_CombatUseFlag_CapturesByte()
|
||||
{
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x5000000Du,
|
||||
name: "Sword",
|
||||
itemType: (uint)ItemType.MeleeWeapon,
|
||||
weenieFlags: 0x00000200u,
|
||||
combatUse: 2);
|
||||
|
||||
var parsed = CreateObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal((byte)2, parsed.Value.CombatUse);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||||
// Absence is distinct from an explicitly transmitted zero because zero is
|
||||
|
|
@ -566,6 +582,7 @@ public sealed class CreateObjectTests
|
|||
float? workmanship = null,
|
||||
byte? radarBlipColor = null,
|
||||
byte? radarBehavior = null,
|
||||
byte? combatUse = null,
|
||||
ushort movementSeq = 0)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
|
|
@ -618,7 +635,7 @@ public sealed class CreateObjectTests
|
|||
}
|
||||
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32
|
||||
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
|
||||
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
|
||||
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(combatUse ?? 0); // CombatUse sbyte/1 byte
|
||||
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16
|
||||
if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16
|
||||
if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ public sealed class ObjectTableWiringTests
|
|||
TargetType = (uint)ItemType.Creature,
|
||||
RadarBlipColor = 4,
|
||||
RadarBehavior = 3,
|
||||
ObjectDescriptionFlags = 0x20800200u,
|
||||
CombatUse = 2,
|
||||
};
|
||||
|
||||
var d = ObjectTableWiring.ToWeenieData(spawn);
|
||||
|
|
@ -104,6 +106,8 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal((uint)ItemType.Creature, d.TargetType);
|
||||
Assert.Equal((byte)4, d.RadarBlipColor);
|
||||
Assert.Equal((byte)3, d.RadarBehavior);
|
||||
Assert.Equal(0x20800200u, d.PublicWeenieBitfield);
|
||||
Assert.Equal((byte)2, d.CombatUse);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
|
||||
[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