acdream/src/AcDream.Core/Items/ShortcutDropPlanner.cs
Erik dc1649c493 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>
2026-07-11 09:46:32 +02:00

187 lines
6.4 KiB
C#

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>.
/// <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,
uint oldObjectId,
uint newObjectId)
{
ArgumentNullException.ThrowIfNull(slots);
if (slots.Count != ShortcutStore.SlotCount || oldObjectId == 0 || newObjectId == 0)
return Array.Empty<ShortcutMutation>();
var state = new ShortcutEntry?[ShortcutStore.SlotCount];
for (int slot = 0; slot < slots.Count; slot++)
{
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();
}
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 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,
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;
}
}