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