feat(ui): complete retail item drop branches
Give retained buttons a reusable item-drop seam, wire the toolbar backpack target, and port retail stack-merge legality, capacity clamping, wire dispatch, destination selection, and immediate shortcut rekey notice. Record the live ACE merge gate and keep split quantity under AP-101. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
3281e0acb4
commit
dc1649c493
19 changed files with 479 additions and 28 deletions
|
|
@ -489,6 +489,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
|
||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ All rows are `outbound` direction (GameActions are client→server only).
|
|||
| 0x0048 | outbound | CastUntargetedSpell | `CM_Magic::Event_CastUntargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
|
||||
| 0x004A | outbound | CastTargetedSpell | `CM_Magic::Event_CastTargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
|
||||
| 0x0053 | outbound | ChangeCombatMode | `CM_Combat::Event_ChangeCombatMode` | W | H | W | B+W | Wired in WorldSession.SendChangeCombatMode |
|
||||
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
|
||||
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | W | B+W | Wired through `StackMergePlanner` → inventory drop → `WorldSession.SendStackableMerge` |
|
||||
| 0x0055 | outbound | StackableSplitToContainer | `CM_Inventory::Event_StackableSplitToContainer` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
|
||||
| 0x0056 | outbound | StackableSplitTo3D | `CM_Inventory::Event_StackableSplitTo3D` | – | H | B | B+W | InventoryActions builder dead [^a-1] |
|
||||
| 0x0058 | outbound | ModifyCharacterSquelch | `CM_Communication::Event_ModifyCharacterSquelch` | – | H | – | B | Mute one player |
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs`
|
|||
| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) |
|
||||
| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed** — `InteractRequests.BuildUseWithTarget` |
|
||||
| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed** — `InteractRequests.BuildUse` |
|
||||
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableMerge` |
|
||||
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **wired** — `StackMergePlanner` → `InventoryController` → `WorldSession.SendStackableMerge` |
|
||||
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToContainer` |
|
||||
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitTo3D` |
|
||||
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToWield` |
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ an occupied slot sends the displaced item to the first empty slot cyclically to
|
|||
the right of the target. Only a dragged shortcut alias attempts to restore the
|
||||
displaced item to `m_lastShortcutNumDragged`.
|
||||
|
||||
### 4.6 Full-stack merge rekey
|
||||
### 4.6 Stack-merge attempt rekey
|
||||
|
||||
```text
|
||||
RecvNotice_FullMergingItem(oldId, newId): // 0x004BE9B0
|
||||
|
|
@ -435,6 +435,39 @@ This body updates the first matching shortcut returned by `RemoveShortcut`.
|
|||
Whether duplicate object shortcuts can exist through external player-description
|
||||
data is unresolved; `CreateShortcutToItem` normally prevents duplicates.
|
||||
|
||||
The notice name is misleading: `ItemHolder::AttemptMerge @ 0x005878F0` broadcasts
|
||||
`SendNotice_FullMergingItem(oldId, newId)` immediately after `UIAttemptMerge`, for
|
||||
every legal merge request. It is not an ACE confirmation and is not conditional on
|
||||
the source stack being exhausted. `CreateShortcutToItem` also removes any existing
|
||||
`newId` shortcut before adding it at the old source shortcut's slot.
|
||||
|
||||
```text
|
||||
IsMergeAttemptLegal(sourceId, targetId, quiet): // 0x00586F30
|
||||
if player not ready for inventory request: return false
|
||||
if sourceId == targetId: return false
|
||||
source = lookup sourceId; target = lookup targetId
|
||||
if either missing: return false
|
||||
if source.maxStackSize <= 1 or target.maxStackSize <= 1: return false
|
||||
if source.tradeState == 1 or target.tradeState == 1: return false
|
||||
if source.wcid != target.wcid: return false
|
||||
return target.stackSize < target.maxStackSize
|
||||
|
||||
AttemptMerge(sourceId, targetId, quiet): // 0x005878F0
|
||||
if not IsMergeAttemptLegal(...): return false
|
||||
requested = sourceId == selectedId ? splitSize : max(1, source.stackSize)
|
||||
available = target.maxStackSize - max(1, target.stackSize)
|
||||
amount = min(requested, available)
|
||||
source.UIAttemptMerge(targetId, amount) // sends 0x0054
|
||||
SendNotice_FullMergingItem(sourceId, targetId) // immediate local notice
|
||||
Select(targetId)
|
||||
return true
|
||||
```
|
||||
|
||||
acdream's selected-object stack entry/slider is still absent under AP-101, so the
|
||||
current inventory merge path requests the whole source stack and lets the same
|
||||
retail capacity clamp limit the transfer. Once the shared split-quantity owner
|
||||
lands, it supplies `requested` without changing legality or wire ordering.
|
||||
|
||||
---
|
||||
|
||||
## 5. Selected-object health, mana, and stack behavior
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ same slot after a clean reconnect.
|
|||
|
||||
### 4.3 Port every drag/drop branch
|
||||
|
||||
- [ ] Add a pure `ShortcutDropPlanner` keyed by source kind: toolbar reorder,
|
||||
- [x] Add a pure `ShortcutDropPlanner` keyed by source kind: toolbar reorder,
|
||||
inventory fresh add, occupied target, inventory-button target,
|
||||
off-bar removal, and full-stack merge replacement.
|
||||
- [x] For an inventory-to-occupied-slot drop, place the displaced shortcut at
|
||||
|
|
@ -479,7 +479,7 @@ same slot after a clean reconnect.
|
|||
- [x] Validate the complete mutation before changing state, apply it atomically, and
|
||||
emit exact Remove/Add wire ordering. Use pending/confirm/rollback only where the
|
||||
protocol actually exposes rejection; never leave a partially mutated bar.
|
||||
- [ ] On full-stack merge, rekey every matching object shortcut and preserve the
|
||||
- [x] On full-stack merge, rekey the matching object shortcut and preserve the
|
||||
non-object fields of its raw `ShortCutData` entry.
|
||||
|
||||
**Wave 4.3 partial implementation (2026-07-11):** drag-over feedback is now an
|
||||
|
|
@ -499,9 +499,18 @@ records, exact wire order, and raw full-stack rekeying have conformance fixtures
|
|||
`ToolbarController` applies the validated local transaction atomically, then emits
|
||||
its ordered Remove/Add events. AP-102 is retired; 4,646 tests pass. The live
|
||||
inventory-to-occupied displacement plus toolbar-reorder gate passed 2026-07-11.
|
||||
Inventory-button placement and
|
||||
the upstream full-stack-merge notice owner remain part of the unchecked umbrella
|
||||
items above rather than being implied complete by this slice.
|
||||
Wave 4.3c completes the umbrella: retained buttons can own item-drop callbacks;
|
||||
the toolbar inventory button uses retail's fresh-item gate and exact green-arrow
|
||||
overlay, while aliases stay neutral. Inventory now tries the pure named-retail
|
||||
stack-merge legality/amount planner before ordinary insertion, sends `0x0054`,
|
||||
broadcasts `FullMergingItem` immediately from the attempt owner, rekeys the toolbar
|
||||
with existing-destination removal, and selects the target. The notice is not a
|
||||
server confirmation despite its name. AP-101 still covers the absent shared split
|
||||
entry/slider; until it lands, merge requests use the whole source stack before the
|
||||
retail destination-capacity clamp. Release build is warning-free and 4,660 tests
|
||||
pass. The compatible-stack live gate passed 2026-07-11; it exposed the expected
|
||||
Wave 4.4/AP-101 gap that the selected strip still displays one instead of the
|
||||
stack count and has no split slider. The inventory-button live gate remains pending.
|
||||
|
||||
### 4.4 Finish toolbar controls
|
||||
|
||||
|
|
|
|||
|
|
@ -2124,7 +2124,10 @@ public sealed class GameWindow : IDisposable
|
|||
Combat.GetHealthPercent,
|
||||
Combat.HasHealth,
|
||||
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
|
||||
guid => _liveSession?.SendQueryHealth(guid)),
|
||||
guid => _liveSession?.SendQueryHealth(guid),
|
||||
() => _playerServerGuid,
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement)),
|
||||
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
|
||||
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
|
||||
Objects,
|
||||
|
|
@ -2137,6 +2140,8 @@ public sealed class GameWindow : IDisposable
|
|||
guid => _liveSession?.SendNoLongerViewingContents(guid),
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
(source, target, amount) =>
|
||||
_liveSession?.SendStackableMerge(source, target, amount),
|
||||
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
_itemInteractionController,
|
||||
_selection),
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public int BusyCount => _busyCount;
|
||||
|
||||
public bool CanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Raised for retail confirmation/auxiliary actions whose retained panel is
|
||||
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
private readonly Action<uint, uint>? _notifyMergeAttempt;
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -80,6 +82,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<uint, uint, uint>? sendStackableMerge,
|
||||
Action<uint, uint>? notifyMergeAttempt,
|
||||
ItemInteractionController? itemInteraction,
|
||||
Action? onClose)
|
||||
{
|
||||
|
|
@ -91,6 +95,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendStackableMerge = sendStackableMerge;
|
||||
_notifyMergeAttempt = notifyMergeAttempt;
|
||||
_itemInteraction = itemInteraction;
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
|
||||
|
|
@ -197,12 +203,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||
Action<uint, uint>? notifyMergeAttempt = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
|
||||
ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
||||
sendStackableMerge, notifyMergeAttempt, itemInteraction,
|
||||
onClose);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
|
|
@ -417,6 +426,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
|
||||
// when a physical item is released on an occupied inventory cell. A legal
|
||||
// merge consumes the drop; an illegal merge falls through to insert/move.
|
||||
if (targetList == _contentsGrid
|
||||
&& targetCell.ItemId != 0
|
||||
&& TryMergeStacks(item, targetCell.ItemId))
|
||||
return;
|
||||
|
||||
uint container; int placement;
|
||||
if (targetList == _contentsGrid)
|
||||
{
|
||||
|
|
@ -439,6 +456,39 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
private bool TryMergeStacks(uint sourceId, uint targetId)
|
||||
{
|
||||
if (_sendStackableMerge is null
|
||||
|| _objects.Get(sourceId) is not { } source
|
||||
|| _objects.Get(targetId) is not { } target)
|
||||
return false;
|
||||
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
new StackMergeItem(
|
||||
source.ObjectId,
|
||||
source.WeenieClassId,
|
||||
source.StackSize,
|
||||
source.StackSizeMax,
|
||||
source.TradeState),
|
||||
new StackMergeItem(
|
||||
target.ObjectId,
|
||||
target.WeenieClassId,
|
||||
target.StackSize,
|
||||
target.StackSizeMax,
|
||||
target.TradeState),
|
||||
_itemInteraction?.CanMakeInventoryRequest ?? true,
|
||||
requestedAmount: Math.Max(1, source.StackSize));
|
||||
if (plan is not { } merge)
|
||||
return false;
|
||||
|
||||
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
|
||||
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
|
||||
// it is an attempt notice, not a later server-confirm event.
|
||||
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
|
||||
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||||
private bool IsContainerFull(uint container)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly Action<uint>? _selectItem;
|
||||
private readonly Func<uint>? _playerGuid;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private bool _disposed;
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
|
|
@ -100,7 +102,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null)
|
||||
Action<uint>? selectItem = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_combatState = combatState;
|
||||
|
|
@ -114,6 +118,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
_selectItem = selectItem;
|
||||
_playerGuid = playerGuid;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
|
|
@ -145,6 +151,15 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
|
||||
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
|
||||
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
|
||||
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
|
||||
// UiButton owns the retained drop-target seam; the panel owns policy.
|
||||
_inventoryButton.ItemDragAcceptSprite = 0x060011F7u;
|
||||
_inventoryButton.OnItemDragOver = InventoryButtonDragOver;
|
||||
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
|
||||
}
|
||||
|
||||
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
||||
foreach (var id in HiddenIds)
|
||||
|
|
@ -237,11 +252,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null,
|
||||
Action? toggleCombat = null,
|
||||
Action<uint>? selectItem = null)
|
||||
Action<uint>? selectItem = null,
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits, itemInteraction,
|
||||
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem);
|
||||
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
|
||||
playerGuid, sendPutItemInContainer);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
|
@ -392,6 +410,24 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
};
|
||||
}
|
||||
|
||||
private static ItemDragAcceptance InventoryButtonDragOver(ItemDragPayload payload)
|
||||
=> payload.ObjId != 0 && payload.SourceKind != ItemDragSource.ShortcutBar
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.None;
|
||||
|
||||
private void HandleInventoryButtonDrop(ItemDragPayload payload)
|
||||
{
|
||||
// gmToolbarUI::HandleDropRelease @ 0x004BE7C0 inventory-button branch:
|
||||
// only fresh physical items are placed into the player's main inventory.
|
||||
// A shortcut alias remains neutral and its remove-on-lift result stands.
|
||||
if (payload.ObjId == 0 || payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return;
|
||||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
if (player == 0 || _repo.Get(payload.ObjId) is null)
|
||||
return;
|
||||
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
|
||||
/// is one-shot and precedes both normal use and selection.
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ public sealed record ToolbarRuntimeBindings(
|
|||
Func<uint, float> HealthPercent,
|
||||
Func<uint, bool> HasHealth,
|
||||
Func<uint, uint> StackSize,
|
||||
Action<uint> SendQueryHealth);
|
||||
Action<uint> SendQueryHealth,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<uint, uint, int>? SendPutItemInContainer);
|
||||
|
||||
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ public sealed record InventoryRuntimeBindings(
|
|||
Action<uint>? SendUse,
|
||||
Action<uint>? SendNoLongerViewing,
|
||||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
Action<uint, uint, uint>? SendStackableMerge,
|
||||
Action<uint, uint>? SendWield,
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection);
|
||||
|
|
@ -365,7 +368,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
|
||||
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
|
||||
toggleCombat: b.ToggleCombat,
|
||||
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar));
|
||||
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
|
||||
playerGuid: b.PlayerGuid,
|
||||
sendPutItemInContainer: b.SendPutItemInContainer);
|
||||
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
|
||||
SelectedObjectController = Layout.SelectedObjectController.Bind(
|
||||
layout,
|
||||
|
|
@ -583,11 +588,15 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
|
||||
InventoryRuntimeBindings b = _bindings.Inventory;
|
||||
Action<uint, uint>? notifyMergeAttempt = ToolbarController is null
|
||||
? null
|
||||
: ToolbarController.ReplaceFullyMergedShortcut;
|
||||
InventoryController inventory = InventoryController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||
b.SendPutItemInContainer, b.ItemInteraction,
|
||||
b.SendPutItemInContainer, b.SendStackableMerge,
|
||||
notifyMergeAttempt, b.ItemInteraction,
|
||||
() => CloseWindow(WindowNames.Inventory));
|
||||
PaperdollController paperdoll = PaperdollController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
|
||||
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
|
||||
/// retained button rather than in UiRoot or a panel-specific hit-test workaround.
|
||||
/// </summary>
|
||||
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
|
||||
public Action<ItemDragPayload>? OnItemDrop { get; set; }
|
||||
public uint ItemDragAcceptSprite { get; set; }
|
||||
public uint ItemDragRejectSprite { get; set; }
|
||||
private ItemDragAcceptance _itemDragAcceptance;
|
||||
|
||||
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
|
||||
|
||||
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
|
||||
public uint ElementId => _info.Id;
|
||||
|
||||
|
|
@ -224,6 +237,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
}
|
||||
|
||||
uint dragSprite = _itemDragAcceptance switch
|
||||
{
|
||||
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
|
||||
ItemDragAcceptance.Reject => ItemDragRejectSprite,
|
||||
_ => 0u,
|
||||
};
|
||||
if (dragSprite != 0)
|
||||
{
|
||||
var (tex, _, _) = _resolve(dragSprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
|
|
@ -276,6 +302,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
OnClick?.Invoke();
|
||||
return OnClick is not null;
|
||||
case UiEventType.DragEnter:
|
||||
_itemDragAcceptance = e.Payload is ItemDragPayload payload
|
||||
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
|
||||
: ItemDragAcceptance.None;
|
||||
return OnItemDragOver is not null;
|
||||
case UiEventType.DragOver:
|
||||
_itemDragAcceptance = ItemDragAcceptance.None;
|
||||
return OnItemDragOver is not null;
|
||||
case UiEventType.DropReleased:
|
||||
_itemDragAcceptance = ItemDragAcceptance.None;
|
||||
if (e.Payload is ItemDragPayload dropped)
|
||||
OnItemDrop?.Invoke(dropped);
|
||||
return OnItemDrop is not null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1336,6 +1336,13 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
|
||||
}
|
||||
|
||||
/// <summary>Send StackableMerge (0x0054) with retail's already-clamped transfer amount.</summary>
|
||||
public void SendStackableMerge(uint sourceGuid, uint targetGuid, uint amount)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
|
||||
}
|
||||
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
/// <remarks>
|
||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ public static class ShortcutDropPlanner
|
|||
|
||||
/// <summary>
|
||||
/// Plan the toolbar side of <c>RecvNotice_FullMergingItem @ 0x004BE9B0</c>.
|
||||
/// The normal retail invariant is one object shortcut; all matching records are
|
||||
/// rekeyed here so a malformed duplicate cannot retain a destroyed stack id.
|
||||
/// <c>CreateShortcutToItem</c> removes an existing destination shortcut before
|
||||
/// placing it into the source shortcut's former slot.
|
||||
/// </summary>
|
||||
public static ShortcutMutation[] PlanFullStackMerge(
|
||||
IReadOnlyList<ShortcutEntry?> slots,
|
||||
|
|
@ -104,15 +104,21 @@ public static class ShortcutDropPlanner
|
|||
if (slots.Count != ShortcutStore.SlotCount || oldObjectId == 0 || newObjectId == 0)
|
||||
return Array.Empty<ShortcutMutation>();
|
||||
|
||||
var mutations = new List<ShortcutMutation>();
|
||||
var state = new ShortcutEntry?[ShortcutStore.SlotCount];
|
||||
for (int slot = 0; slot < slots.Count; slot++)
|
||||
{
|
||||
if (VisibleEntry(slots[slot]) is not { } entry || entry.ObjectId != oldObjectId)
|
||||
continue;
|
||||
|
||||
mutations.Add(ShortcutMutation.Remove(slot));
|
||||
mutations.Add(ShortcutMutation.Add(entry with { ObjectId = newObjectId }));
|
||||
state[slot] = slots[slot];
|
||||
}
|
||||
|
||||
var mutations = new List<ShortcutMutation>(3);
|
||||
int sourceSlot = FindFirstVisibleObject(state, oldObjectId);
|
||||
if (sourceSlot < 0)
|
||||
return Array.Empty<ShortcutMutation>();
|
||||
|
||||
ShortcutEntry sourceEntry = state[sourceSlot]!.Value;
|
||||
RemoveVisibleAt(state, sourceSlot, mutations);
|
||||
RemoveFirstVisibleObject(state, newObjectId, mutations);
|
||||
Add(state, sourceEntry with { ObjectId = newObjectId }, mutations);
|
||||
return mutations.ToArray();
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +155,14 @@ public static class ShortcutDropPlanner
|
|||
}
|
||||
}
|
||||
|
||||
private static int FindFirstVisibleObject(ShortcutEntry?[] state, uint objectId)
|
||||
{
|
||||
for (int slot = 0; slot < state.Length; slot++)
|
||||
if (VisibleEntry(state[slot]) is { } entry && entry.ObjectId == objectId)
|
||||
return slot;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
ShortcutEntry?[] state,
|
||||
ShortcutEntry entry,
|
||||
|
|
|
|||
49
src/AcDream.Core/Items/StackMergePlanner.cs
Normal file
49
src/AcDream.Core/Items/StackMergePlanner.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
public readonly record struct StackMergeItem(
|
||||
uint ObjectId,
|
||||
uint WeenieClassId,
|
||||
int StackSize,
|
||||
int MaxStackSize,
|
||||
int TradeState);
|
||||
|
||||
public readonly record struct StackMergePlan(uint SourceObjectId, uint TargetObjectId, uint Amount);
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of <c>ItemHolder::IsMergeAttemptLegal @ 0x00586F30</c> and the
|
||||
/// transfer-size branch in <c>ItemHolder::AttemptMerge @ 0x005878F0</c>.
|
||||
/// </summary>
|
||||
public static class StackMergePlanner
|
||||
{
|
||||
public static StackMergePlan? Plan(
|
||||
in StackMergeItem source,
|
||||
in StackMergeItem target,
|
||||
bool readyForInventoryRequest,
|
||||
int requestedAmount)
|
||||
{
|
||||
if (!readyForInventoryRequest
|
||||
|| source.ObjectId == 0
|
||||
|| target.ObjectId == 0
|
||||
|| source.ObjectId == target.ObjectId
|
||||
|| source.MaxStackSize <= 1
|
||||
|| target.MaxStackSize <= 1
|
||||
|| source.TradeState == 1
|
||||
|| target.TradeState == 1
|
||||
|| source.WeenieClassId != target.WeenieClassId)
|
||||
return null;
|
||||
|
||||
int targetSize = Math.Max(1, target.StackSize);
|
||||
int available = target.MaxStackSize - targetSize;
|
||||
if (available <= 0)
|
||||
return null;
|
||||
|
||||
int sourceSize = Math.Max(1, source.StackSize);
|
||||
int desired = requestedAmount > 0 ? Math.Min(requestedAmount, sourceSize) : sourceSize;
|
||||
uint transfer = (uint)Math.Min(desired, available);
|
||||
return transfer == 0
|
||||
? null
|
||||
: new StackMergePlan(source.ObjectId, target.ObjectId, transfer);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,8 @@ public class InventoryControllerTests
|
|||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
List<(uint source, uint target, uint amount)>? merges = null,
|
||||
List<(uint source, uint target)>? mergeNotices = null,
|
||||
string? ownerName = null,
|
||||
Action? onClose = null,
|
||||
SelectionState? selection = null)
|
||||
|
|
@ -66,6 +68,8 @@ public class InventoryControllerTests
|
|||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
||||
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
||||
onClose: onClose,
|
||||
selection: selection ?? new SelectionState());
|
||||
|
||||
|
|
@ -564,6 +568,67 @@ public class InventoryControllerTests
|
|||
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xA,
|
||||
WeenieClassId = 0x1234,
|
||||
StackSize = 40,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xA, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xB,
|
||||
WeenieClassId = 0x1234,
|
||||
StackSize = 95,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xB, Player, 1);
|
||||
var puts = new List<(uint item, uint container, int placement)>();
|
||||
var merges = new List<(uint source, uint target, uint amount)>();
|
||||
var notices = new List<(uint source, uint target)>();
|
||||
var selection = new SelectionState();
|
||||
var ctrl = Bind(layout, objects, puts: puts, merges: merges,
|
||||
mergeNotices: notices, selection: selection);
|
||||
|
||||
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
||||
|
||||
Assert.Equal(new[] { (0xAu, 0xBu, 5u) }, merges);
|
||||
Assert.Equal(new[] { (0xAu, 0xBu) }, notices);
|
||||
Assert.Empty(puts);
|
||||
Assert.Equal(0xBu, selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xA, WeenieClassId = 0x1111, StackSize = 10, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xA, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xB, WeenieClassId = 0x2222, StackSize = 10, StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(0xB, Player, 1);
|
||||
var puts = new List<(uint item, uint container, int placement)>();
|
||||
var merges = new List<(uint source, uint target, uint amount)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts, merges: merges);
|
||||
|
||||
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
||||
|
||||
Assert.Empty(merges);
|
||||
Assert.Contains((0xAu, Player, 1), puts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onSideBagCell_movesIntoThatBag()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -207,6 +207,59 @@ public class ToolbarControllerTests
|
|||
Assert.False(interaction.IsTargetModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_freshItemDrop_acceptsAndPlacesInPlayerBackpack()
|
||||
{
|
||||
const uint player = 0x5000u;
|
||||
const uint item = 0x5001u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { },
|
||||
playerGuid: () => player,
|
||||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 12, new UiItemSlot());
|
||||
|
||||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
|
||||
Assert.Equal(ItemDragAcceptance.Accept, button.ItemDragAcceptanceForTest);
|
||||
Assert.Equal(0x060011F7u, button.ItemDragAcceptSprite);
|
||||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
|
||||
Assert.Equal(new[] { (item, player, 0) }, puts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
|
||||
{
|
||||
const uint player = 0x5000u;
|
||||
const uint item = 0x5001u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { },
|
||||
playerGuid: () => player,
|
||||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
var payload = new ItemDragPayload(item, ItemDragSource.ShortcutBar, 3, new UiItemSlot());
|
||||
|
||||
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
|
||||
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
|
||||
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
|
||||
|
||||
Assert.Empty(puts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowButtonState_highlightsWhenOpen()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
using System.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
public sealed class WorldSessionInventoryActionTests
|
||||
{
|
||||
[Fact]
|
||||
public void SendStackableMerge_UsesNextSequenceAndExactBuilderBytes()
|
||||
{
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 65000));
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendStackableMerge(0xAAu, 0xBBu, 5u);
|
||||
|
||||
Assert.Equal(InventoryActions.BuildStackableMerge(1, 0xAAu, 0xBBu, 5u), captured);
|
||||
}
|
||||
}
|
||||
|
|
@ -121,11 +121,11 @@ public sealed class ShortcutDropPlannerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FullStackMerge_RekeysAllMatchesAndPreservesRawSpellWord()
|
||||
public void FullStackMerge_RemovesExistingTargetShortcutThenRekeysSourceSlot()
|
||||
{
|
||||
var slots = Empty();
|
||||
slots[2] = new ShortcutEntry(2, 0xA001u, 0x11223344u);
|
||||
slots[9] = new ShortcutEntry(9, 0xA001u, 0x55667788u);
|
||||
slots[9] = new ShortcutEntry(9, 0xB001u, 0x55667788u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
|
||||
slots, 0xA001u, 0xB001u);
|
||||
|
|
@ -133,9 +133,8 @@ public sealed class ShortcutDropPlannerTests
|
|||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(2),
|
||||
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
|
||||
ShortcutMutation.Remove(9),
|
||||
ShortcutMutation.Add(new ShortcutEntry(9, 0xB001u, 0x55667788u)),
|
||||
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
|
|
|
|||
60
tests/AcDream.Core.Tests/Items/StackMergePlannerTests.cs
Normal file
60
tests/AcDream.Core.Tests/Items/StackMergePlannerTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class StackMergePlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void MatchingStacks_TransfersRequestedAmount()
|
||||
{
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
Item(1, size: 40), Item(2, size: 20),
|
||||
readyForInventoryRequest: true, requestedAmount: 15);
|
||||
|
||||
Assert.Equal(new StackMergePlan(1, 2, 15), plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transfer_IsLimitedByDestinationCapacity()
|
||||
{
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
Item(1, size: 40), Item(2, size: 95),
|
||||
readyForInventoryRequest: true, requestedAmount: 40);
|
||||
|
||||
Assert.Equal(new StackMergePlan(1, 2, 5), plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRequestedAmount_UsesWholeSourceStack()
|
||||
{
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
Item(1, size: 7), Item(2, size: 20),
|
||||
readyForInventoryRequest: true, requestedAmount: 0);
|
||||
|
||||
Assert.Equal(new StackMergePlan(1, 2, 7), plan);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 10u, 10, 0, 100, 100)]
|
||||
[InlineData(true, 11u, 10, 0, 100, 100)]
|
||||
[InlineData(true, 10u, 10, 1, 100, 100)]
|
||||
[InlineData(true, 10u, 10, 0, 1, 100)]
|
||||
[InlineData(true, 10u, 10, 0, 100, 1)]
|
||||
[InlineData(true, 10u, 100, 0, 100, 100)]
|
||||
public void IllegalMerge_ReturnsNoPlan(
|
||||
bool ready,
|
||||
uint targetWcid,
|
||||
int targetSize,
|
||||
int sourceTradeState,
|
||||
int sourceMax,
|
||||
int targetMax)
|
||||
{
|
||||
var source = new StackMergeItem(1, 10, 10, sourceMax, sourceTradeState);
|
||||
var target = new StackMergeItem(2, targetWcid, targetSize, targetMax, 0);
|
||||
|
||||
Assert.Null(StackMergePlanner.Plan(source, target, ready, requestedAmount: 10));
|
||||
}
|
||||
|
||||
private static StackMergeItem Item(uint id, int size)
|
||||
=> new(id, WeenieClassId: 10, StackSize: size, MaxStackSize: 100, TradeState: 0);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue