feat(ui): port retail item interaction policy

This commit is contained in:
Erik 2026-07-11 01:23:31 +02:00
parent 0cf780478a
commit a8da4fd05a
20 changed files with 1110 additions and 104 deletions

View file

@ -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 100360 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` |

View file

@ -481,7 +481,7 @@ behavior. Estimated 1726 days focused work, 35 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 19** (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.

View file

@ -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`.
---

View file

@ -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,

View file

@ -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).

View file

@ -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

View file

@ -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 &amp; target-&gt;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();

View file

@ -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);
});

View file

@ -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).

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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);
}

View file

@ -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);
}
}

View file

@ -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()
{

View file

@ -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

View file

@ -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);
}
// -------------------------------------------------------------------------

View 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);
}

View file

@ -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));
}
}