TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1530 lines
64 KiB
C#
1530 lines
64 KiB
C#
using AcDream.App.Rendering.Gpu;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.App.UI;
|
||
using AcDream.App.UI.Layout;
|
||
using AcDream.Core.Combat;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Net.Messages;
|
||
using AcDream.Core.Selection;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI.Layout;
|
||
|
||
public class ToolbarControllerTests
|
||
{
|
||
private static readonly uint[] Row1 =
|
||
{ 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF };
|
||
private static readonly uint[] Row2 =
|
||
{ 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF };
|
||
|
||
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
|
||
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||
private const uint CharacterButtonId = 0x10000199u;
|
||
private const uint InventoryButtonId = 0x100001B1u;
|
||
private const uint UseButtonId = 0x1000019Du;
|
||
private const uint ExamineButtonId = 0x100001A5u;
|
||
private static readonly (uint ElementId, uint PanelId)[] PanelButtons =
|
||
{
|
||
(0x10000197u, 12u),
|
||
(0x10000198u, 13u),
|
||
(CharacterButtonId, RetailPanelCatalog.Character),
|
||
(0x1000055Au, 25u),
|
||
(0x1000019Au, 16u),
|
||
(0x1000019Bu, 10u),
|
||
(InventoryButtonId, RetailPanelCatalog.Inventory),
|
||
};
|
||
|
||
private static ShortcutStore Store(
|
||
IEnumerable<ShortcutEntry> shortcuts)
|
||
{
|
||
var store = new ShortcutStore();
|
||
store.Load(shortcuts);
|
||
return store;
|
||
}
|
||
|
||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
|
||
Dictionary<uint, UiElement> indicators) FakeToolbar()
|
||
{
|
||
var dict = new Dictionary<uint, UiElement>();
|
||
var slots = new Dictionary<uint, UiItemList>();
|
||
var indicators = new Dictionary<uint, UiElement>();
|
||
var root = new UiPanel();
|
||
foreach (var id in Row1) AddSlot(id);
|
||
foreach (var id in Row2) AddSlot(id);
|
||
// Combat indicators are retail UiButtons; one is visible at a time,
|
||
// and every stance-specific instance dispatches the same toggle command.
|
||
foreach (var id in CombatIds)
|
||
{
|
||
AddButton(id);
|
||
indicators[id] = dict[id];
|
||
}
|
||
foreach (var (elementId, panelId) in PanelButtons)
|
||
AddButton(elementId, panelId);
|
||
AddButton(UseButtonId);
|
||
AddButton(ExamineButtonId);
|
||
return (new ImportedLayout(root, dict), slots, indicators);
|
||
|
||
void AddSlot(uint id)
|
||
{
|
||
var list = new UiItemList(_ => (GpuTextureSlot.Unassigned, 0, 0)) { Width = 32, Height = 32 };
|
||
dict[id] = list; slots[id] = list; root.AddChild(list);
|
||
}
|
||
|
||
void AddButton(uint id, uint? panelId = null)
|
||
{
|
||
var info = new ElementInfo
|
||
{
|
||
Id = id,
|
||
Type = 1,
|
||
Width = 32,
|
||
Height = 32,
|
||
DefaultStateName = "Normal",
|
||
};
|
||
info.StateMedia["Normal"] = (0x1u, 1);
|
||
info.StateMedia["Highlight"] = (0x2u, 1);
|
||
info.StateMedia["Ghosted"] = (0x3u, 1);
|
||
if (panelId is { } value)
|
||
{
|
||
info.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||
{
|
||
Id = UiStateInfo.DirectStateId,
|
||
Properties = new UiPropertyBag
|
||
{
|
||
Values =
|
||
{
|
||
[0x10000029u] = new UiPropertyValue
|
||
{
|
||
Kind = UiPropertyKind.Enum,
|
||
UnsignedValue = value,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
var button = new UiButton(info, _ => (GpuTextureSlot.Unassigned, 0, 0)) { Width = 32, Height = 32 };
|
||
dict[id] = button;
|
||
root.AddChild(button);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Populate_bindsShortcutToCorrectSlot()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
|
||
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u), useItem: _ => { });
|
||
|
||
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
|
||
Assert.Equal(new GpuTextureSlot(0x77u), slots[Row1[0]].Cell.IconTexture);
|
||
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
|
||
}
|
||
|
||
[Fact]
|
||
public void SessionTableClearRemovesResolvedShortcutPresentation()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = 0x5001u,
|
||
WeenieClassId = 1u,
|
||
IconId = 0x06001234u,
|
||
});
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{
|
||
new(Index: 0, ObjectId: 0x5001u, SpellId: 0),
|
||
};
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(0x77u),
|
||
useItem: _ => { });
|
||
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
|
||
|
||
repo.Clear();
|
||
|
||
Assert.Equal(0u, slots[Row1[0]].Cell.ItemId);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeferredRebind_whenItemArrivesLate()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable(); // item NOT present yet
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 0) };
|
||
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x88u), useItem: _ => { });
|
||
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet
|
||
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u });
|
||
|
||
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
|
||
}
|
||
|
||
[Fact]
|
||
public void Dispose_UnsubscribesDeferredRebind_AndIsIdempotent()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 0) };
|
||
var controller = ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x88u), useItem: _ => { });
|
||
|
||
controller.Dispose();
|
||
controller.Dispose();
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = 0x5002u,
|
||
WeenieClassId = 1u,
|
||
IconId = 0x06005678u,
|
||
});
|
||
|
||
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId);
|
||
}
|
||
|
||
[Fact]
|
||
public void Click_emitsUseForBoundItem()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
|
||
uint used = 0;
|
||
var selection = new SelectionState();
|
||
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u),
|
||
useItem: g => used = g,
|
||
selection: selection);
|
||
UiItemSlot cell = slots[Row1[0]].Cell;
|
||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.MouseDown));
|
||
|
||
Assert.Equal(0x5001u, selection.SelectedObjectId);
|
||
Assert.Equal(0u, used);
|
||
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.Click));
|
||
|
||
Assert.Equal(0x5001u, used);
|
||
}
|
||
|
||
[Fact]
|
||
public void PanelButtons_bindByDatPanelId_andGhostUnavailablePanels()
|
||
{
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var toggles = new List<uint>();
|
||
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
ctrl.BindPanelButtons(
|
||
panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
|
||
toggles.Add);
|
||
|
||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||
|
||
Assert.Equal(new[] { RetailPanelCatalog.Inventory, RetailPanelCatalog.Character }, toggles);
|
||
foreach (var (elementId, panelId) in PanelButtons)
|
||
{
|
||
var button = (UiButton)layout.FindElement(elementId)!;
|
||
Assert.Equal(
|
||
panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
|
||
button.Enabled);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000010u;
|
||
const uint kit = 0x50000A01u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = kit, Type = ItemType.Misc,
|
||
Useability = 0x00220008u, // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF
|
||
TargetType = (uint)ItemType.Creature, // retail kind gate
|
||
});
|
||
repo.MoveItem(kit, pack, 0);
|
||
var useWithTarget = new List<(uint Source, uint Target)>();
|
||
var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
playerGuid: () => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
nowMs: () => 1_000);
|
||
int inventoryClicks = 0;
|
||
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
itemInteraction: interaction);
|
||
ctrl.BindPanelButtons(
|
||
panelId => panelId == RetailPanelCatalog.Inventory,
|
||
_ => inventoryClicks++);
|
||
interaction.ActivateItem(kit);
|
||
|
||
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||
|
||
Assert.Equal(0, inventoryClicks);
|
||
Assert.Equal(new[] { (kit, player) }, useWithTarget);
|
||
Assert.False(interaction.IsTargetModeActive);
|
||
}
|
||
|
||
[Fact]
|
||
public void InventoryButton_freshItemDrop_acceptsAndPlacesInPlayerBackpack()
|
||
{
|
||
const uint player = 0x5000u;
|
||
const uint item = 0x5001u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
|
||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
playerGuid: () => player,
|
||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 12, new UiItemSlot());
|
||
|
||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
|
||
Assert.Equal(ItemDragAcceptance.Accept, button.ItemDragAcceptanceForTest);
|
||
Assert.Equal(0x060011F7u, button.ItemDragAcceptSprite);
|
||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
|
||
|
||
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
|
||
Assert.Equal(new[] { (item, player, 0) }, puts);
|
||
}
|
||
|
||
[Fact]
|
||
public void InventoryButton_unownedGroundDropUsesDirectAttemptWithoutDestinationProjection()
|
||
{
|
||
const uint player = 0x5000u;
|
||
const uint item = 0x70005001u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Loot", Type = ItemType.Misc });
|
||
var placements = new List<(uint Item, uint Container, int Placement)>();
|
||
var directPuts = new List<(uint Item, uint Container, int Placement)>();
|
||
using var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
playerGuid: () => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
placeInBackpack: (i, c, p) => placements.Add((i, c, p)));
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: static (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: static _ => { },
|
||
itemInteraction: interaction,
|
||
playerGuid: () => player,
|
||
sendPutItemInContainer: (i, c, p) => directPuts.Add((i, c, p)));
|
||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||
var payload = new ItemDragPayload(item, ItemDragSource.Ground, 0, new UiItemSlot());
|
||
|
||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
|
||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
|
||
|
||
Assert.Empty(placements);
|
||
Assert.Equal(new[] { (item, player, 0) }, directPuts);
|
||
Assert.False(interaction.TryGetPendingBackpackPlacement(item, out _));
|
||
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
|
||
Assert.Equal(item, request.ItemId);
|
||
}
|
||
|
||
[Fact]
|
||
public void InventoryButton_ownedDropPublishesDestinationAndGloballyRejectsSecondRequest()
|
||
{
|
||
const uint player = 0x5000u;
|
||
const uint first = 0x70005002u;
|
||
const uint second = 0x70005003u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
const uint pack = 0x5004u;
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Name = "Player", Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Name = "Pack", Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = first, Name = "First Loot", Type = ItemType.Misc });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = second, Name = "Second Loot", Type = ItemType.Misc });
|
||
repo.MoveItem(first, pack, 0);
|
||
repo.MoveItem(second, pack, 1);
|
||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||
var messages = new List<string>();
|
||
using var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
playerGuid: () => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
systemMessage: messages.Add);
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: static (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: static _ => { },
|
||
itemInteraction: interaction,
|
||
playerGuid: () => player,
|
||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||
|
||
foreach (uint item in new[] { first, second })
|
||
{
|
||
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
|
||
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
|
||
}
|
||
|
||
Assert.Equal(new[] { (first, player, 0) }, puts);
|
||
Assert.True(interaction.TryGetPendingBackpackPlacement(first, out _));
|
||
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
|
||
Assert.Equal(first, request.ItemId);
|
||
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
|
||
}
|
||
|
||
[Fact]
|
||
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
|
||
{
|
||
const uint player = 0x5000u;
|
||
const uint item = 0x5001u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
|
||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
playerGuid: () => player,
|
||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||
var payload = new ItemDragPayload(item, ItemDragSource.ShortcutBar, 3, new UiItemSlot());
|
||
|
||
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
|
||
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
|
||
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
|
||
|
||
Assert.Empty(puts);
|
||
}
|
||
|
||
[Fact]
|
||
public void WindowButtonState_highlightsWhenOpen()
|
||
{
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
|
||
|
||
ctrl.BindPanelButtons(_ => true, _ => { });
|
||
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, true);
|
||
ctrl.SetPanelOpen(RetailPanelCatalog.Character, true);
|
||
|
||
Assert.Equal("Highlight", inventoryButton.ActiveState);
|
||
Assert.Equal("Highlight", characterButton.ActiveState);
|
||
|
||
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, false);
|
||
ctrl.SetPanelOpen(RetailPanelCatalog.Character, false);
|
||
|
||
Assert.Equal("Normal", inventoryButton.ActiveState);
|
||
Assert.Equal("Normal", characterButton.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
|
||
{
|
||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||
|
||
foreach (var (elementId, panelId) in PanelButtons)
|
||
{
|
||
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
|
||
Assert.True(button.TryGetEnumAttribute(0x10000029u, out uint actual));
|
||
Assert.Equal(panelId, actual);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void RetailFixture_ammoNumberUsesAuthoredMissileIndicatorElement()
|
||
{
|
||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||
|
||
var ammoIndicator = Assert.IsType<UiButton>(layout.FindElement(0x10000194u));
|
||
|
||
Assert.Equal(55f, ammoIndicator.Width);
|
||
Assert.Equal(58f, ammoIndicator.Height);
|
||
}
|
||
|
||
[Fact]
|
||
public void UseAndExamineButtons_dispatchSelectedObjectPolicy()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000002u;
|
||
const uint item = 0x50000003u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = item,
|
||
Type = ItemType.Misc,
|
||
Useability = ItemUseability.Contained,
|
||
});
|
||
repo.MoveItem(item, pack, 0);
|
||
var uses = new List<uint>();
|
||
var examines = new List<uint>();
|
||
uint selected = item;
|
||
var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
() => player,
|
||
sendUse: uses.Add,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
sendExamine: examines.Add);
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
itemInteraction: interaction,
|
||
selectedObjectId: () => selected);
|
||
|
||
((UiButton)layout.FindElement(UseButtonId)!).OnClick!.Invoke();
|
||
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
|
||
|
||
Assert.Equal(new[] { item }, uses);
|
||
Assert.Equal(new[] { item }, examines);
|
||
|
||
selected = 0;
|
||
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
|
||
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
|
||
}
|
||
|
||
[Fact]
|
||
public void UseButton_tracksEmptyUnusableAndTargetedSelection()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000002u;
|
||
const uint component = 0x50000003u;
|
||
const uint healthKit = 0x50000004u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = component,
|
||
Type = ItemType.SpellComponents,
|
||
Useability = ItemUseability.No,
|
||
});
|
||
repo.MoveItem(component, pack, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = healthKit,
|
||
Type = ItemType.Misc,
|
||
Useability = 0x00220008u,
|
||
TargetType = (uint)ItemType.Creature,
|
||
});
|
||
repo.MoveItem(healthKit, pack, 1);
|
||
|
||
var selection = new SelectionState();
|
||
using var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
() => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null);
|
||
using var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
itemInteraction: interaction,
|
||
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
|
||
selection: selection);
|
||
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
|
||
|
||
Assert.False(use.Enabled);
|
||
Assert.Equal("Ghosted", use.ActiveState);
|
||
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
|
||
Assert.Equal(InteractionModeKind.None, interaction.InteractionState.Current.Kind);
|
||
|
||
selection.Select(component, SelectionChangeSource.Inventory);
|
||
Assert.False(use.Enabled);
|
||
Assert.Equal("Ghosted", use.ActiveState);
|
||
|
||
selection.Select(healthKit, SelectionChangeSource.Inventory);
|
||
Assert.True(use.Enabled);
|
||
Assert.Equal("Normal", use.ActiveState);
|
||
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
|
||
Assert.Equal(
|
||
InteractionModeKind.UseItemOnTarget,
|
||
interaction.InteractionState.Current.Kind);
|
||
|
||
selection.Clear(SelectionChangeSource.Inventory);
|
||
Assert.False(use.Enabled);
|
||
Assert.Equal("Ghosted", use.ActiveState);
|
||
}
|
||
|
||
[Fact]
|
||
public void UseButton_selectedCombatItem_routesExistingWieldPath()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000002u;
|
||
const uint sword = 0x50000003u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = sword,
|
||
Type = ItemType.MeleeWeapon,
|
||
CombatUse = 1,
|
||
Useability = ItemUseability.No,
|
||
ValidLocations = EquipMask.MeleeWeapon,
|
||
});
|
||
repo.MoveItem(sword, pack, 0);
|
||
|
||
var selection = new SelectionState();
|
||
selection.Select(sword, SelectionChangeSource.Inventory);
|
||
var wields = new List<(uint Item, uint Location)>();
|
||
using var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
() => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: (item, location) => wields.Add((item, location)),
|
||
sendDrop: null);
|
||
using var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
itemInteraction: interaction,
|
||
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
|
||
selection: selection);
|
||
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
|
||
|
||
Assert.True(use.Enabled);
|
||
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
|
||
|
||
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields);
|
||
}
|
||
|
||
// ── C1: combat-mode indicator tests ─────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void AmmoNumber_usesStackableMissileWeapon_andTracksStackChanges()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint thrownWeapon = 0x50000002u;
|
||
const uint separateAmmo = 0x50000003u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = thrownWeapon,
|
||
StackSize = 7,
|
||
StackSizeMax = 25,
|
||
});
|
||
repo.MoveItem(thrownWeapon, player, newEquipLocation: EquipMask.MissileWeapon);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = separateAmmo,
|
||
StackSize = 80,
|
||
StackSizeMax = 100,
|
||
});
|
||
repo.MoveItem(separateAmmo, player, newEquipLocation: EquipMask.MissileAmmo);
|
||
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
playerGuid: () => player);
|
||
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
|
||
|
||
Assert.Equal("7", ammoButton.Label);
|
||
|
||
Assert.True(repo.UpdateStackSize(thrownWeapon, 6, value: 0));
|
||
Assert.Equal("6", ammoButton.Label);
|
||
}
|
||
|
||
[Fact]
|
||
public void AmmoNumber_forLauncher_usesAmmoSlot_andClearsOnUnwield()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000010u;
|
||
const uint bow = 0x50000002u;
|
||
const uint arrows = 0x50000003u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 });
|
||
repo.ApplyServerMove(
|
||
bow,
|
||
newContainerId: 0u,
|
||
newWielderId: player,
|
||
newEquipLocation: EquipMask.MissileWeapon);
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = arrows, StackSize = 42, StackSizeMax = 100 });
|
||
repo.ApplyServerMove(
|
||
arrows,
|
||
newContainerId: 0u,
|
||
newWielderId: player,
|
||
newEquipLocation: EquipMask.MissileAmmo);
|
||
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
playerGuid: () => player);
|
||
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
|
||
|
||
Assert.Equal("42", ammoButton.Label);
|
||
|
||
Assert.True(repo.ApplyServerMove(arrows, pack, newWielderId: 0u));
|
||
Assert.Null(ammoButton.Label);
|
||
}
|
||
|
||
[Fact]
|
||
public void CombatIndicators_allDispatchSharedRetailToggleCommand()
|
||
{
|
||
var (layout, _, indicators) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
int toggles = 0;
|
||
|
||
ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => GpuTextureSlot.Unassigned,
|
||
useItem: _ => { },
|
||
toggleCombat: () => toggles++);
|
||
|
||
foreach (uint id in CombatIds)
|
||
((UiButton)indicators[id]).OnClick?.Invoke();
|
||
|
||
Assert.Equal(CombatIds.Length, toggles);
|
||
}
|
||
|
||
[Fact]
|
||
public void UseShortcut_usesOrSelectsAccordingToRetailIntent()
|
||
{
|
||
const uint itemId = 0x50001001u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc });
|
||
var shortcuts = new[]
|
||
{
|
||
new ShortcutEntry(0, itemId, 0),
|
||
};
|
||
uint used = 0;
|
||
uint selected = 0;
|
||
var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(1u),
|
||
useItem: id => used = id,
|
||
selectItem: id => selected = id);
|
||
|
||
Assert.True(controller.UseShortcut(0, use: true));
|
||
Assert.Equal(itemId, used);
|
||
Assert.Equal(0u, selected);
|
||
|
||
Assert.True(controller.UseShortcut(0, use: false));
|
||
Assert.Equal(itemId, selected);
|
||
}
|
||
|
||
[Fact]
|
||
public void RightClickPhysicalShortcut_selectsAndExaminesItem()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint itemId = 0x50001001u;
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc });
|
||
var shortcuts = new[] { new ShortcutEntry(0, itemId, 0) };
|
||
var selection = new SelectionState();
|
||
var appraisals = new List<uint>();
|
||
using var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
playerGuid: () => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
sendExamine: appraisals.Add);
|
||
using var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(1u),
|
||
useItem: _ => { },
|
||
itemInteraction: interaction,
|
||
selectItem: id => selection.Select(id, SelectionChangeSource.Toolbar),
|
||
selection: selection);
|
||
|
||
UiItemSlot cell = slots[Row1[0]].Cell;
|
||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.RightClick));
|
||
|
||
Assert.Equal(itemId, selection.SelectedObjectId);
|
||
Assert.Equal(new uint[] { itemId }, appraisals);
|
||
}
|
||
|
||
[Fact]
|
||
public void UseShortcut_targetModePrecedesUseAndClearsOneShotMode()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000002u;
|
||
const uint source = 0x50000003u;
|
||
const uint target = 0x50000004u;
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject
|
||
{
|
||
ObjectId = source,
|
||
Type = ItemType.Misc,
|
||
Useability = 0x00220008u,
|
||
TargetType = (uint)ItemType.Creature,
|
||
});
|
||
repo.MoveItem(source, pack, 0);
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = target, Type = ItemType.Creature });
|
||
uint sentSource = 0;
|
||
uint sentTarget = 0;
|
||
var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
() => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t),
|
||
sendWield: null,
|
||
sendDrop: null,
|
||
nowMs: () => 1_000);
|
||
var shortcuts = new[]
|
||
{
|
||
new ShortcutEntry(0, target, 0),
|
||
};
|
||
var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(1u),
|
||
useItem: _ => { },
|
||
itemInteraction: interaction);
|
||
|
||
Assert.True(interaction.ActivateItem(source));
|
||
Assert.True(controller.UseShortcut(0, use: false));
|
||
|
||
Assert.Equal(source, sentSource);
|
||
Assert.Equal(target, sentTarget);
|
||
Assert.False(interaction.IsTargetModeActive);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateShortcutToItem_addsFirstEmptyOwnedEligibleItemOnce()
|
||
{
|
||
const uint player = 0x50000001u;
|
||
const uint pack = 0x50000002u;
|
||
const uint item = 0x50000003u;
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
|
||
repo.MoveItem(pack, player, 0);
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
|
||
repo.MoveItem(item, pack, 0);
|
||
var interaction = new ItemInteractionController(
|
||
repo,
|
||
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
|
||
new InteractionState(),
|
||
() => player,
|
||
sendUse: null,
|
||
sendUseWithTarget: null,
|
||
sendWield: null,
|
||
sendDrop: null);
|
||
var sends = new List<(uint Slot, uint Item)>();
|
||
var controller = ToolbarController.Bind(
|
||
layout,
|
||
repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(1u),
|
||
useItem: _ => { },
|
||
itemInteraction: interaction,
|
||
sendAddShortcut: entry => sends.Add(((uint)entry.Index, entry.ObjectId)));
|
||
|
||
Assert.True(controller.CreateShortcutToItem(item));
|
||
Assert.Equal(item, slots[Row1[0]].Cell.ItemId);
|
||
Assert.Equal(new[] { (0u, item) }, sends);
|
||
|
||
Assert.False(controller.CreateShortcutToItem(item));
|
||
Assert.Single(sends);
|
||
}
|
||
|
||
/// <summary>
|
||
/// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible;
|
||
/// the melee/missile/magic indicators (0x10000193/4/5) are hidden.
|
||
/// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669).
|
||
/// </summary>
|
||
[Fact]
|
||
public void CombatIndicator_defaultNonCombat_onlyPeaceVisible()
|
||
{
|
||
var (layout, _, indicators) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
|
||
// Only peace indicator (index 0 = 0x10000192) is visible.
|
||
Assert.True (indicators[0x10000192u].Visible, "peace indicator should be visible after bind");
|
||
Assert.False(indicators[0x10000193u].Visible, "melee indicator should be hidden after bind");
|
||
Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden after bind");
|
||
Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden after bind");
|
||
}
|
||
|
||
/// <summary>
|
||
/// SetCombatMode(Melee) hides peace/missile/magic and shows only the melee indicator.
|
||
/// </summary>
|
||
[Fact]
|
||
public void CombatIndicator_setCombatModeMelee_onlyMeleeVisible()
|
||
{
|
||
var (layout, _, indicators) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
|
||
ctrl.SetCombatMode(CombatMode.Melee);
|
||
|
||
Assert.False(indicators[0x10000192u].Visible, "peace indicator should be hidden in melee mode");
|
||
Assert.True (indicators[0x10000193u].Visible, "melee indicator should be visible in melee mode");
|
||
Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden in melee mode");
|
||
Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden in melee mode");
|
||
}
|
||
|
||
/// <summary>
|
||
/// CombatModeChanged event on CombatState automatically updates the indicator.
|
||
/// </summary>
|
||
[Fact]
|
||
public void CombatIndicator_liveSignal_updatesWhenCombatStateChanges()
|
||
{
|
||
var (layout, _, indicators) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var combat = new CombatState();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
combatState: combat);
|
||
|
||
// Initially NonCombat after bind.
|
||
Assert.True(indicators[0x10000192u].Visible, "peace should be visible initially");
|
||
|
||
// Server fires CombatModeChanged → Magic.
|
||
combat.SetCombatMode(CombatMode.Magic);
|
||
|
||
Assert.False(indicators[0x10000192u].Visible, "peace should be hidden in magic mode");
|
||
Assert.False(indicators[0x10000193u].Visible, "melee should be hidden in magic mode");
|
||
Assert.False(indicators[0x10000194u].Visible, "missile should be hidden in magic mode");
|
||
Assert.True (indicators[0x10000195u].Visible, "magic indicator should be visible");
|
||
}
|
||
|
||
// ── D1: Shortcut number (slot label) tests ───────────────────────────────
|
||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
|
||
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621).
|
||
|
||
// Fake digit arrays: 9 regular entries (0x10..0x18), 9 ghosted entries (0x20..0x28),
|
||
// 9 empty (background) entries (0x30..0x38).
|
||
private static readonly uint[] FakeRegular = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u };
|
||
private static readonly uint[] FakeGhosted = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u };
|
||
private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u };
|
||
|
||
/// <summary>
|
||
/// After Bind with regular/ghosted digit arrays, top-row cells (indices 0–8) have
|
||
/// ShortcutNum == i (the slot position) and ShortcutGhosted == false (default NonCombat).
|
||
/// Bottom-row cells (indices 9–17) have ShortcutNum == -1 (no label).
|
||
/// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ShortcutNumbers_afterBind_topRowHasNumbers_bottomRowEmpty()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
|
||
|
||
// Top row: ShortcutNum == slot index, ghosted == false.
|
||
for (int i = 0; i < Row1.Length; i++)
|
||
{
|
||
var cell = slots[Row1[i]].Cell;
|
||
Assert.Equal(i, cell.ShortcutNum);
|
||
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular at NonCombat");
|
||
}
|
||
// Bottom row: no shortcut number.
|
||
foreach (var id in Row2)
|
||
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail keeps physical shortcut labels regular in NonCombat, Melee, and Missile.
|
||
/// gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610 passes ghosted=true only for Magic.
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(CombatMode.NonCombat)]
|
||
[InlineData(CombatMode.Melee)]
|
||
[InlineData(CombatMode.Missile)]
|
||
public void ShortcutNumbers_nonMagicModes_useRegularDigits(CombatMode mode)
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
|
||
|
||
ctrl.SetCombatMode(mode);
|
||
|
||
for (int i = 0; i < Row1.Length; i++)
|
||
{
|
||
var cell = slots[Row1[i]].Cell;
|
||
Assert.Equal(i, cell.ShortcutNum);
|
||
Assert.False(cell.ShortcutGhosted, $"top-row slot {i} should be regular in {mode}");
|
||
cell.SetItem((uint)(0x5000 + i), new GpuTextureSlot(0x99u));
|
||
Assert.Same(FakeRegular, cell.ActiveDigitArray());
|
||
}
|
||
// Bottom row still has no number.
|
||
foreach (var id in Row2)
|
||
Assert.Equal(-1, slots[id].Cell.ShortcutNum);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Magic mode ghosts physical shortcut labels, and returning to NonCombat restores them.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ShortcutNumbers_magicGhosts_thenNonCombatRestoresRegularDigits()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
|
||
|
||
ctrl.SetCombatMode(CombatMode.Magic);
|
||
foreach (var id in Row1)
|
||
{
|
||
slots[id].Cell.SetItem(id, new GpuTextureSlot(0x99u));
|
||
Assert.True(slots[id].Cell.ShortcutGhosted);
|
||
Assert.Same(FakeGhosted, slots[id].Cell.ActiveDigitArray());
|
||
}
|
||
|
||
ctrl.SetCombatMode(CombatMode.NonCombat);
|
||
|
||
for (int i = 0; i < Row1.Length; i++)
|
||
{
|
||
Assert.False(slots[Row1[i]].Cell.ShortcutGhosted,
|
||
$"top-row slot {i} should be regular after returning to NonCombat");
|
||
Assert.Same(FakeRegular, slots[Row1[i]].Cell.ActiveDigitArray());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Digit arrays are correctly injected into each cell.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ShortcutNumbers_digitArraysInjected()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
|
||
|
||
foreach (var id in Row1)
|
||
{
|
||
Assert.Same(FakeRegular, slots[id].Cell.RegularDigits);
|
||
Assert.Same(FakeGhosted, slots[id].Cell.GhostedDigits);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// EmptyDigits (0x1000005e background digit) is injected into every slot cell.
|
||
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — empty-slot branch.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ShortcutNumbers_emptyDigitArrayInjected()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: FakeEmpty);
|
||
|
||
foreach (var id in Row1)
|
||
Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits);
|
||
foreach (var id in Row2)
|
||
Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits);
|
||
}
|
||
|
||
/// <summary>
|
||
/// When emptyDigits is null, cells have EmptyDigits == null (no digit on empty slots).
|
||
/// This is the safe fallback when the dat property 0x1000005e is absent.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ShortcutNumbers_nullEmptyDigits_cellsHaveNullEmptyDigits()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { },
|
||
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: null);
|
||
|
||
foreach (var id in Row1)
|
||
Assert.Null(slots[id].Cell.EmptyDigits);
|
||
}
|
||
|
||
// ── E1: Guid filter + ObjectRemoved tests (D.5.4) ───────────────────────
|
||
|
||
/// <summary>
|
||
/// ObjectAdded for a guid NOT in the shortcut list does NOT call iconIds again
|
||
/// (no spurious Populate on creature/NPC spawns in a busy zone).
|
||
/// D.5.4: ToolbarController filters to shortcut guids only.
|
||
/// The iconIds spy lets us count how many times Populate actually ran.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ObjectAdded_nonShortcutGuid_doesNotCallIconIds()
|
||
{
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
|
||
|
||
int iconCallCount = 0;
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => { iconCallCount++; return new GpuTextureSlot(0x77u); }, useItem: _ => { });
|
||
|
||
int callsAfterBind = iconCallCount; // 1 call from initial Populate
|
||
|
||
// Fire ObjectAdded with a completely unrelated guid (a creature, NOT a shortcut).
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0xDEADBEEFu, WeenieClassId = 42u, IconId = 0u });
|
||
|
||
// iconIds must NOT have been called again — the filter blocked Populate.
|
||
Assert.Equal(callsAfterBind, iconCallCount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ObjectAdded for a guid that IS in the shortcut list calls iconIds again (deferred bind).
|
||
/// This is the filtered-path counterpart of DeferredRebind_whenItemArrivesLate.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ObjectAdded_shortcutGuid_callsIconIds()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable(); // item NOT present yet
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 1, ObjectId: 0x5003u, SpellId: 0) };
|
||
|
||
int iconCallCount = 0;
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => { iconCallCount++; return new GpuTextureSlot(0x99u); }, useItem: _ => { });
|
||
|
||
Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate
|
||
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId);
|
||
|
||
// Now the shortcut item arrives — filter must PASS and Populate re-run.
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5003u, WeenieClassId = 1u, IconId = 0x06005678u });
|
||
|
||
Assert.Equal(1, iconCallCount); // iconIds called exactly once for the deferred bind
|
||
Assert.Equal(0x5003u, slots[Row1[1]].Cell.ItemId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ObjectRemoved for a guid that IS in the shortcut list clears the slot.
|
||
/// D.5.4: subscribes to ObjectRemoved so a removed item evicts its icon.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ObjectRemoved_shortcutGuid_clearsSlot()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5004u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 3, ObjectId: 0x5004u, SpellId: 0) };
|
||
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0xAAu), useItem: _ => { });
|
||
|
||
Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound
|
||
|
||
// Remove the item from the session (server despawn / trade away).
|
||
// Populate re-runs: item is gone from repo → slot clears.
|
||
repo.Remove(0x5004u);
|
||
|
||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// ObjectRemoved for a guid NOT in the shortcut list does NOT call iconIds again.
|
||
/// D.5.4: the ObjectRemoved subscription also filters to shortcut guids.
|
||
/// </summary>
|
||
[Fact]
|
||
public void ObjectRemoved_nonShortcutGuid_doesNotCallIconIds()
|
||
{
|
||
var (layout, _, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5005u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0xCAFEBABEu, WeenieClassId = 99u, IconId = 0u });
|
||
var shortcuts = new List<ShortcutEntry>
|
||
{ new(Index: 4, ObjectId: 0x5005u, SpellId: 0) };
|
||
|
||
int iconCallCount = 0;
|
||
ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => { iconCallCount++; return new GpuTextureSlot(0xBBu); }, useItem: _ => { });
|
||
|
||
int callsAfterBind = iconCallCount; // 1 call for the shortcut item
|
||
|
||
// Remove an unrelated object — filter must block Populate.
|
||
repo.Remove(0xCAFEBABEu);
|
||
|
||
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
|
||
}
|
||
|
||
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
|
||
|
||
/// <summary>Bind registers the controller as each slot list's drag handler and
|
||
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
|
||
[Fact]
|
||
public void Bind_registersDragHandler_andStampsSlots()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
|
||
var ctrl = ToolbarController.Bind(layout, repo,
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
|
||
for (int i = 0; i < Row1.Length; i++)
|
||
{
|
||
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
|
||
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
|
||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
|
||
}
|
||
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
|
||
for (int j = 0; j < Row2.Length; j++)
|
||
{
|
||
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
|
||
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
|
||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
|
||
}
|
||
}
|
||
|
||
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
|
||
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
|
||
[Fact]
|
||
public void OnDragOver_acceptsRealItem()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
|
||
var list = slots[Row1[0]];
|
||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||
Assert.Equal(ItemDragAcceptance.Accept, ctrl.OnDragOver(list, list.Cell, payload));
|
||
}
|
||
|
||
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
|
||
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
|
||
[Fact]
|
||
public void OnDragOver_rejectsZeroObjId()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
var list = slots[Row1[0]];
|
||
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||
Assert.Equal(ItemDragAcceptance.None, ctrl.OnDragOver(list, list.Cell, payload));
|
||
}
|
||
|
||
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
|
||
private static (System.Collections.Generic.List<ShortcutEntry> adds,
|
||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<ShortcutEntry> add, out System.Action<uint> rem)
|
||
{
|
||
var adds = new System.Collections.Generic.List<ShortcutEntry>();
|
||
var removes = new System.Collections.Generic.List<uint>();
|
||
add = entry => adds.Add(entry);
|
||
rem = i => removes.Add(i);
|
||
return (adds, removes);
|
||
}
|
||
|
||
[Fact]
|
||
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
|
||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||
uint selected = 0;
|
||
|
||
var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u), useItem: _ => { },
|
||
sendAddShortcut: add, sendRemoveShortcut: rem,
|
||
selectItem: item => selected = item, selectedObjectId: () => selected);
|
||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
|
||
|
||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||
|
||
Assert.Equal(0x5001u, selected);
|
||
Assert.Contains(3u, removes);
|
||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
|
||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0),
|
||
new(Index: 5, ObjectId: 0x5002u, SpellId: 0) };
|
||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||
var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u), useItem: _ => { },
|
||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||
|
||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||
|
||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
|
||
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
|
||
Assert.Contains(new ShortcutEntry(5, 0x5001u, 0u), adds);
|
||
Assert.Contains(new ShortcutEntry(3, 0x5002u, 0u), adds);
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleDropRelease_reindexesWithoutLosingRawShortcutFields()
|
||
{
|
||
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(3, 0x5001u, 0x11223344u),
|
||
new ShortcutEntry(5, 0x5002u, 0xA5C31234u),
|
||
};
|
||
var (adds, _) = NewSpies(out var add, out var remove);
|
||
var controller = ToolbarController.Bind(
|
||
layout, repo, Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(1u),
|
||
useItem: _ => { },
|
||
sendAddShortcut: add,
|
||
sendRemoveShortcut: remove);
|
||
|
||
var payload = Assert.IsType<ItemDragPayload>(slots[Row1[3]].Cell.GetDragPayload());
|
||
controller.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||
controller.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||
|
||
Assert.Contains(new ShortcutEntry(5, 0x5001u, 0x11223344u), adds);
|
||
Assert.Contains(new ShortcutEntry(3, 0x5002u, 0xA5C31234u), adds);
|
||
}
|
||
|
||
[Fact]
|
||
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
|
||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||
var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u), useItem: _ => { },
|
||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||
|
||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
|
||
|
||
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
|
||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||
Assert.Contains(new ShortcutEntry(7, 0x5001u, 0u), adds);
|
||
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, Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(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, Store(shortcuts),
|
||
iconIds: (_, _, _, _, _) => new GpuTextureSlot(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()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
var repo = new ClientObjectTable();
|
||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||
var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
|
||
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
|
||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||
var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
|
||
iconIds: (_,_,_,_,_) => new GpuTextureSlot(0x77u), useItem: _ => { },
|
||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||
|
||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
|
||
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
|
||
|
||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
|
||
Assert.Contains(new ShortcutEntry(3, 0x5001u, 0u), adds); // AddShortcut(3, A) sent
|
||
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
|
||
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
|
||
}
|
||
|
||
[Fact]
|
||
public void ToolbarSlots_useGreenCrossAcceptSprite()
|
||
{
|
||
var (layout, slots, _) = FakeToolbar();
|
||
ToolbarController.Bind(layout, new ClientObjectTable(),
|
||
new ShortcutStore(),
|
||
iconIds: (_,_,_,_,_) => GpuTextureSlot.Unassigned, useItem: _ => { });
|
||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||
}
|
||
}
|