fix(ui): port source-aware toolbar drops
Plan retail shortcut drops from a complete post-lift snapshot so fresh inventory items use cyclic-right displacement while toolbar aliases restore to their vacated slot. Apply local mutations atomically, preserve raw shortcut fields, emit exact Remove/Add ordering, and retire AP-102 after the live ACE gate. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
b5b230c860
commit
3281e0acb4
8 changed files with 462 additions and 23 deletions
|
|
@ -205,7 +205,6 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
|
||||
| ~~AP-99~~ | **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-101 | Most toolbar panel/Use/Examine 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, health/name, quick-slot use/select/create actions, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
|
||||
| AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` |
|
||||
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
|
||||
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
|
||||
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11.
|
||||
- **✓ 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 — 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.
|
||||
|
|
|
|||
|
|
@ -474,9 +474,9 @@ same slot after a clean reconnect.
|
|||
- [ ] 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.
|
||||
- [ ] For an inventory-to-occupied-slot drop, place the displaced shortcut at
|
||||
- [x] For an inventory-to-occupied-slot drop, place the displaced shortcut at
|
||||
retail's first-empty-to-the-right slot, never at the inventory grid index.
|
||||
- [ ] Validate the complete mutation before changing state, apply it atomically, and
|
||||
- [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
|
||||
|
|
@ -487,9 +487,21 @@ explicit neutral/accept/reject result instead of a Boolean. Inventory and
|
|||
paperdoll enforce retail's `(DropItemFlags & 0xE) == 0` physical-item gate, so a
|
||||
toolbar alias released on either surface only leaves its remove-on-lift shortcut
|
||||
removal in place; it cannot move, unwield, or wield the underlying object. The
|
||||
remaining source-aware occupied-toolbar transaction and raw-field preservation
|
||||
stay in Wave 4.2/4.3. Live gate passed with an equipped helmet: dropping its
|
||||
toolbar alias onto inventory removed only the shortcut and left the helmet worn.
|
||||
raw-field preservation landed in Wave 4.2. Live gate passed with an equipped
|
||||
helmet: dropping its toolbar alias onto inventory removed only the shortcut and
|
||||
left the helmet worn.
|
||||
|
||||
**Wave 4.3b implementation (2026-07-11):** a pure Core `ShortcutDropPlanner`
|
||||
now plans the complete post-lift mutation before state changes. Fresh inventory
|
||||
drops use the exact cyclic-right search; aliases use only the vacated toolbar
|
||||
source; duplicate-object removal, wraparound, a full bar, visually empty non-object
|
||||
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.
|
||||
|
||||
### 4.4 Finish toolbar controls
|
||||
|
||||
|
|
|
|||
|
|
@ -508,26 +508,54 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
|
||||
// evict the target's occupant, place the dragged item there, and bump the evicted item into
|
||||
// the (now-vacated) source slot if it's free.
|
||||
int target = targetCell.SlotIndex;
|
||||
ShortcutEntry? evictedEntry = _store.GetEntry(target); // RemoveShortCutInSlotNum(target)
|
||||
uint evicted = evictedEntry?.ObjectId ?? 0u;
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
|
||||
ShortcutEntry dragged = (payload.Shortcut ?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u))
|
||||
.WithIndex(target);
|
||||
_store.Set(dragged); // AddShortcut(dragged, target)
|
||||
_sendAddShortcut?.Invoke(dragged);
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // displaced → vacated source slot
|
||||
ShortcutEntry displaced = evictedEntry!.Value.WithIndex(payload.SourceSlot);
|
||||
_store.Set(displaced);
|
||||
_sendAddShortcut?.Invoke(displaced);
|
||||
}
|
||||
EnsureStoreLoadedForMutation();
|
||||
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
|
||||
? ShortcutDropSource.ShortcutAlias
|
||||
: ShortcutDropSource.FreshItem;
|
||||
ShortcutEntry dragged = payload.Shortcut
|
||||
?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u);
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
|
||||
|
||||
ApplyShortcutPlan(plan);
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toolbar notice port for <c>gmToolbarUI::RecvNotice_FullMergingItem @ 0x004BE9B0</c>.
|
||||
/// The owning inventory pipeline calls this when ACE confirms a source stack was fully merged.
|
||||
/// </summary>
|
||||
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
|
||||
{
|
||||
EnsureStoreLoadedForMutation();
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
|
||||
_store.Snapshot(), oldObjectId, newObjectId);
|
||||
ApplyShortcutPlan(plan);
|
||||
if (plan.Length > 0)
|
||||
Populate();
|
||||
}
|
||||
|
||||
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
|
||||
{
|
||||
// Apply the already-validated transaction locally first, then reproduce the
|
||||
// planner's exact Remove/Add wire order. Shortcut events have no rejection reply.
|
||||
foreach (var mutation in plan)
|
||||
{
|
||||
if (mutation.Kind == ShortcutMutationKind.Remove)
|
||||
_store.Remove(mutation.Slot);
|
||||
else if (mutation.Entry is { } entry)
|
||||
_store.Set(entry);
|
||||
}
|
||||
|
||||
foreach (var mutation in plan)
|
||||
{
|
||||
if (mutation.Kind == ShortcutMutationKind.Remove)
|
||||
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
|
||||
else if (mutation.Entry is { } entry)
|
||||
_sendAddShortcut?.Invoke(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
173
src/AcDream.Core/Items/ShortcutDropPlanner.cs
Normal file
173
src/AcDream.Core/Items/ShortcutDropPlanner.cs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>Retail source branch selected by <c>DropItemFlags</c>.</summary>
|
||||
public enum ShortcutDropSource
|
||||
{
|
||||
FreshItem,
|
||||
ShortcutAlias,
|
||||
}
|
||||
|
||||
public enum ShortcutMutationKind
|
||||
{
|
||||
Remove,
|
||||
Add,
|
||||
}
|
||||
|
||||
/// <summary>One ordered local-and-wire mutation in a retail shortcut transaction.</summary>
|
||||
public readonly record struct ShortcutMutation(
|
||||
ShortcutMutationKind Kind,
|
||||
int Slot,
|
||||
ShortcutEntry? Entry)
|
||||
{
|
||||
public static ShortcutMutation Remove(int slot) => new(ShortcutMutationKind.Remove, slot, null);
|
||||
|
||||
public static ShortcutMutation Add(ShortcutEntry entry)
|
||||
=> new(ShortcutMutationKind.Add, entry.Index, entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of the shortcut-slot branches in
|
||||
/// <c>gmToolbarUI::HandleDropRelease @ 0x004BE7C0</c>. The complete ordered
|
||||
/// mutation list is computed before the caller changes client state or emits wire events.
|
||||
/// </summary>
|
||||
public static class ShortcutDropPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan a toolbar-slot drop against the post-lift slot snapshot. A shortcut alias has
|
||||
/// already been removed from <paramref name="slots"/> by
|
||||
/// <c>RecvNotice_ItemListBeginDrag @ 0x004BD930</c>; a fresh item has not.
|
||||
/// </summary>
|
||||
public static ShortcutMutation[] PlanDrop(
|
||||
IReadOnlyList<ShortcutEntry?> slots,
|
||||
ShortcutDropSource source,
|
||||
int sourceSlot,
|
||||
int targetSlot,
|
||||
ShortcutEntry dragged)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slots);
|
||||
if (slots.Count != ShortcutStore.SlotCount
|
||||
|| (uint)targetSlot >= ShortcutStore.SlotCount
|
||||
|| dragged.ObjectId == 0)
|
||||
return Array.Empty<ShortcutMutation>();
|
||||
|
||||
var state = new ShortcutEntry?[ShortcutStore.SlotCount];
|
||||
for (int i = 0; i < state.Length; i++)
|
||||
state[i] = slots[i];
|
||||
|
||||
var mutations = new List<ShortcutMutation>(4);
|
||||
ShortcutEntry? displaced = VisibleEntry(state[targetSlot]);
|
||||
RemoveVisibleAt(state, targetSlot, mutations);
|
||||
|
||||
if (source == ShortcutDropSource.FreshItem)
|
||||
{
|
||||
// CreateShortcutToItem(item, target, pickup=true) removes any prior
|
||||
// occurrence of the item before adding the new target entry.
|
||||
RemoveFirstVisibleObject(state, dragged.ObjectId, mutations);
|
||||
Add(state, dragged.WithIndex(targetSlot), mutations);
|
||||
|
||||
if (displaced is { } entry && entry.ObjectId != dragged.ObjectId)
|
||||
{
|
||||
int empty = FirstEmptyToRight(state, targetSlot);
|
||||
if (empty >= 0)
|
||||
Add(state, entry.WithIndex(empty), mutations);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Add(state, dragged.WithIndex(targetSlot), mutations);
|
||||
if (displaced is { } entry
|
||||
&& entry.ObjectId != dragged.ObjectId
|
||||
&& (uint)sourceSlot < ShortcutStore.SlotCount
|
||||
&& IsVisuallyEmpty(state[sourceSlot]))
|
||||
{
|
||||
Add(state, entry.WithIndex(sourceSlot), mutations);
|
||||
}
|
||||
}
|
||||
|
||||
return mutations.ToArray();
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public static ShortcutMutation[] PlanFullStackMerge(
|
||||
IReadOnlyList<ShortcutEntry?> slots,
|
||||
uint oldObjectId,
|
||||
uint newObjectId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slots);
|
||||
if (slots.Count != ShortcutStore.SlotCount || oldObjectId == 0 || newObjectId == 0)
|
||||
return Array.Empty<ShortcutMutation>();
|
||||
|
||||
var mutations = new List<ShortcutMutation>();
|
||||
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 }));
|
||||
}
|
||||
return mutations.ToArray();
|
||||
}
|
||||
|
||||
private static ShortcutEntry? VisibleEntry(ShortcutEntry? entry)
|
||||
=> entry is { ObjectId: not 0 } ? entry : null;
|
||||
|
||||
private static bool IsVisuallyEmpty(ShortcutEntry? entry) => VisibleEntry(entry) is null;
|
||||
|
||||
private static void RemoveVisibleAt(
|
||||
ShortcutEntry?[] state,
|
||||
int slot,
|
||||
ICollection<ShortcutMutation> mutations)
|
||||
{
|
||||
if (!IsVisuallyEmpty(state[slot]))
|
||||
{
|
||||
state[slot] = null;
|
||||
mutations.Add(ShortcutMutation.Remove(slot));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveFirstVisibleObject(
|
||||
ShortcutEntry?[] state,
|
||||
uint objectId,
|
||||
ICollection<ShortcutMutation> mutations)
|
||||
{
|
||||
for (int slot = 0; slot < state.Length; slot++)
|
||||
{
|
||||
if (VisibleEntry(state[slot]) is not { } entry || entry.ObjectId != objectId)
|
||||
continue;
|
||||
|
||||
state[slot] = null;
|
||||
mutations.Add(ShortcutMutation.Remove(slot));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
ShortcutEntry?[] state,
|
||||
ShortcutEntry entry,
|
||||
ICollection<ShortcutMutation> mutations)
|
||||
{
|
||||
state[entry.Index] = entry;
|
||||
mutations.Add(ShortcutMutation.Add(entry));
|
||||
}
|
||||
|
||||
private static int FirstEmptyToRight(ShortcutEntry?[] state, int targetSlot)
|
||||
{
|
||||
// gmToolbarUI::GetFirstEmptyShortcutToTheRightOf @ 0x004BD560:
|
||||
// target+1..17, then 0..target, using visual item-list occupancy.
|
||||
for (int slot = targetSlot + 1; slot < state.Length; slot++)
|
||||
if (IsVisuallyEmpty(state[slot]))
|
||||
return slot;
|
||||
for (int slot = 0; slot <= targetSlot; slot++)
|
||||
if (IsVisuallyEmpty(state[slot]))
|
||||
return slot;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,9 @@ public sealed class ShortcutStore
|
|||
public ShortcutEntry? GetEntry(int slot)
|
||||
=> (uint)slot < SlotCount ? _entries[slot] : null;
|
||||
|
||||
/// <summary>Detached raw snapshot for planning a complete mutation before applying it.</summary>
|
||||
public ShortcutEntry?[] Snapshot() => (ShortcutEntry?[])_entries.Clone();
|
||||
|
||||
/// <summary>Visible object id at <paramref name="slot"/>, or zero.</summary>
|
||||
public uint Get(int slot) => GetEntry(slot)?.ObjectId ?? 0u;
|
||||
|
||||
|
|
|
|||
|
|
@ -865,6 +865,67 @@ public class ToolbarControllerTests
|
|||
Assert.DoesNotContain(adds, a => a.Index == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_freshInventoryToOccupied_usesCyclicRightSlotNotInventoryIndex()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, IconId = 1u });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, IconId = 2u });
|
||||
var shortcuts = new[]
|
||||
{
|
||||
new ShortcutEntry(5, 0x5002u, 0xA5C31234u),
|
||||
};
|
||||
var wire = new List<string>();
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_, _, _, _, _) => 1u,
|
||||
useItem: _ => { },
|
||||
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"),
|
||||
sendRemoveShortcut: slot => wire.Add($"remove:{slot}"));
|
||||
|
||||
var payload = new ItemDragPayload(
|
||||
0x5001u, ItemDragSource.Inventory, SourceSlot: 12, new UiItemSlot());
|
||||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||||
Assert.Equal(0x5002u, slots[Row1[6]].Cell.ItemId);
|
||||
Assert.Equal(0u, slots[Row2[3]].Cell.ItemId); // inventory grid index 12 is irrelevant
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"remove:5",
|
||||
"add:5:00005001:00000000",
|
||||
"add:6:00005002:A5C31234",
|
||||
}, wire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceFullyMergedShortcut_rekeysEntryAndPreservesRawFields()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, IconId = 1u });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, IconId = 2u });
|
||||
var shortcuts = new[]
|
||||
{
|
||||
new ShortcutEntry(4, 0x5001u, 0x11223344u),
|
||||
};
|
||||
var wire = new List<string>();
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_, _, _, _, _) => 1u,
|
||||
useItem: _ => { },
|
||||
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"),
|
||||
sendRemoveShortcut: slot => wire.Add($"remove:{slot}"));
|
||||
|
||||
ctrl.ReplaceFullyMergedShortcut(0x5001u, 0x5002u);
|
||||
|
||||
Assert.Equal(0x5002u, slots[Row1[4]].Cell.ItemId);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"remove:4",
|
||||
"add:4:00005002:11223344",
|
||||
}, wire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoSelf_reAddsToSource()
|
||||
{
|
||||
|
|
|
|||
162
tests/AcDream.Core.Tests/Items/ShortcutDropPlannerTests.cs
Normal file
162
tests/AcDream.Core.Tests/Items/ShortcutDropPlannerTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class ShortcutDropPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreshItemToEmpty_AddsOnlyAtTarget()
|
||||
{
|
||||
var slots = Empty();
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 11, targetSlot: 4,
|
||||
new ShortcutEntry(11, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Add(new ShortcutEntry(4, 0xA001u, 0u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshItemToOccupied_MovesDisplacedToFirstEmptyOnRight()
|
||||
{
|
||||
var slots = Empty();
|
||||
slots[5] = new ShortcutEntry(5, 0xB001u, 0x11223344u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
|
||||
new ShortcutEntry(0, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(5),
|
||||
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
|
||||
ShortcutMutation.Add(new ShortcutEntry(6, 0xB001u, 0x11223344u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshItemToOccupied_CyclicSearchWrapsToSlotZeroSide()
|
||||
{
|
||||
var slots = Full();
|
||||
slots[2] = null;
|
||||
slots[16] = new ShortcutEntry(16, 0xB001u, 0u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 16,
|
||||
new ShortcutEntry(0, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0u)), plan[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshItemToFullBar_DropsDisplacedWhenNoEmptySlotExists()
|
||||
{
|
||||
var slots = Full();
|
||||
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
|
||||
new ShortcutEntry(0, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(5),
|
||||
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutAliasToOccupied_RestoresDisplacedToVacatedSource()
|
||||
{
|
||||
var slots = Empty(); // alias was already removed from source slot 3 at lift
|
||||
slots[5] = new ShortcutEntry(5, 0xB001u, 0x55667788u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.ShortcutAlias, sourceSlot: 3, targetSlot: 5,
|
||||
new ShortcutEntry(3, 0xA001u, 0x11223344u));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(5),
|
||||
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0x11223344u)),
|
||||
ShortcutMutation.Add(new ShortcutEntry(3, 0xB001u, 0x55667788u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshDuplicate_RemovesOldOccurrenceBeforeSearchingForDisplacedSlot()
|
||||
{
|
||||
var slots = Full();
|
||||
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
|
||||
slots[7] = new ShortcutEntry(7, 0xA001u, 0xA5C31234u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
|
||||
new ShortcutEntry(0, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(5),
|
||||
ShortcutMutation.Remove(7),
|
||||
ShortcutMutation.Add(new ShortcutEntry(5, 0xA001u, 0u)),
|
||||
ShortcutMutation.Add(new ShortcutEntry(7, 0xB001u, 0u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonObjectRecord_IsVisuallyEmptyForCyclicSearch()
|
||||
{
|
||||
var slots = Full();
|
||||
slots[6] = new ShortcutEntry(6, 0u, 0x01020304u);
|
||||
slots[5] = new ShortcutEntry(5, 0xB001u, 0u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||||
slots, ShortcutDropSource.FreshItem, sourceSlot: 0, targetSlot: 5,
|
||||
new ShortcutEntry(0, 0xA001u, 0u));
|
||||
|
||||
Assert.Equal(ShortcutMutation.Add(new ShortcutEntry(6, 0xB001u, 0u)), plan[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullStackMerge_RekeysAllMatchesAndPreservesRawSpellWord()
|
||||
{
|
||||
var slots = Empty();
|
||||
slots[2] = new ShortcutEntry(2, 0xA001u, 0x11223344u);
|
||||
slots[9] = new ShortcutEntry(9, 0xA001u, 0x55667788u);
|
||||
|
||||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
|
||||
slots, 0xA001u, 0xB001u);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
ShortcutMutation.Remove(2),
|
||||
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
|
||||
ShortcutMutation.Remove(9),
|
||||
ShortcutMutation.Add(new ShortcutEntry(9, 0xB001u, 0x55667788u)),
|
||||
}, plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDrop_ReturnsNoMutation()
|
||||
{
|
||||
Assert.Empty(ShortcutDropPlanner.PlanDrop(
|
||||
Empty(), ShortcutDropSource.FreshItem, 0, -1,
|
||||
new ShortcutEntry(0, 0xA001u, 0u)));
|
||||
Assert.Empty(ShortcutDropPlanner.PlanDrop(
|
||||
Empty(), ShortcutDropSource.FreshItem, 0, 0,
|
||||
new ShortcutEntry(0, 0u, 0u)));
|
||||
}
|
||||
|
||||
private static ShortcutEntry?[] Empty() => new ShortcutEntry?[ShortcutStore.SlotCount];
|
||||
|
||||
private static ShortcutEntry?[] Full()
|
||||
{
|
||||
var slots = Empty();
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
slots[i] = new ShortcutEntry(i, (uint)(0xC000 + i), 0u);
|
||||
return slots;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue