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
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue