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:
Erik 2026-07-11 09:26:57 +02:00
parent b5b230c860
commit 3281e0acb4
8 changed files with 462 additions and 23 deletions

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

View file

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