acdream/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md
Erik 132bf36daa docs(D.2b-B): B-Grid implementation plan
4-task TDD plan: UiItemList grid mode (CellOffset/Columns/cell pitch),
the LayoutImporter.Resolve sub-window mount (ShouldMountBaseChildren +
attach base subtree), GameWindow swap of the placeholder for the real
Import(0x21000023), then full-suite regression guard + visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:32:37 +02:00

390 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D.2b B-Grid Implementation Plan (inventory sub-window mount + UiItemList grid)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `LayoutImporter.Import(0x21000023)` produce the full nested inventory frame and give `UiItemList` an N-cell grid, so F12 shows the real inventory window.
**Architecture:** A ~4-line mount in `LayoutImporter.Resolve` attaches a base element's resolved children to a childless, media-less inheritor (the three gmInventoryUI panels nest via the existing `BaseElement`+`BaseLayoutId` path). `UiItemList` gains a column-count + cell-pitch layout, with single-cell (toolbar) behavior preserved by defaults. `GameWindow` swaps the Sub-phase A placeholder for the real import.
**Tech Stack:** C# .NET 10, xUnit, the `AcDream.App.UI` toolkit + `LayoutImporter`, `DatCollection`.
**Spec:** `docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md`
---
## File Structure
- **Modify** `src/AcDream.App/UI/UiItemList.cs` — add `Columns`/`CellWidth`/`CellHeight` + grid layout (`CellOffset` helper, `LayoutCells`).
- **Modify** `src/AcDream.App/UI/Layout/LayoutImporter.cs``ShouldMountBaseChildren` predicate + the `Resolve` mount.
- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — swap the placeholder for `Import(0x21000023)`.
- **Create** `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` — grid layout tests.
- **Create** `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` — mount predicate tests.
(`InternalsVisibleTo` from `AcDream.App``AcDream.App.Tests` is already configured, so `internal static` helpers are testable — see `UiItemSlot.DragAcceptVisual`.)
---
## Task 1: `UiItemList` grid mode
**Files:**
- Modify: `src/AcDream.App/UI/UiItemList.cs`
- Test: `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` (create)
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs`:
```csharp
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public class UiItemListGridTests
{
[Fact]
public void CellOffset_RowMajor()
{
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36));
Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36));
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
}
[Fact]
public void GridMode_PositionsCellsInColumns()
{
var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
list.Flush(); // drop the ctor's default cell
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
var c4 = list.GetItem(4)!;
Assert.Equal(36f, c4.Left);
Assert.Equal(36f, c4.Top);
Assert.Equal(36f, c4.Width);
Assert.Equal(36f, c4.Height);
}
[Fact]
public void FillMode_SizesSingleCellToList()
{
// CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
var list = new UiItemList { Width = 36, Height = 36 };
list.Flush();
list.AddItem(new UiItemSlot());
var c = list.Cell;
Assert.Equal(0f, c.Left);
Assert.Equal(0f, c.Top);
Assert.Equal(36f, c.Width);
Assert.Equal(36f, c.Height);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"`
Expected: FAIL — compile error, `'UiItemList' does not contain a definition for 'Columns'` / `'CellOffset'`.
- [ ] **Step 3: Implement grid mode**
In `src/AcDream.App/UI/UiItemList.cs`, replace the `AddItem` method and the `OnDraw` method:
```csharp
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
_cells.Add(cell);
AddChild(cell);
LayoutCells();
}
```
```csharp
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets Width/Height AFTER construction, so re-layout each frame:
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
LayoutCells();
}
```
Then add these members to the class (e.g. after the `Cell` property):
```csharp
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
public int Columns { get; set; } = 1;
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
/// to the whole list (the toolbar single-slot legacy). Set &gt;0 (with CellHeight) for a grid.</summary>
public float CellWidth { get; set; }
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
public float CellHeight { get; set; }
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
/// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
/// cell to the list; grid (CellWidth&gt;0) tiles cells row-major at the cell pitch.</summary>
private void LayoutCells()
{
if (CellWidth <= 0f)
{
if (_cells.Count > 0)
{
var c = _cells[0];
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height;
}
return;
}
int cols = Columns < 1 ? 1 : Columns;
for (int i = 0; i < _cells.Count; i++)
{
var (x, y) = CellOffset(i, cols, CellWidth, CellHeight);
var cell = _cells[i];
cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
git commit -m "feat(ui): D.2b-B — UiItemList N-cell grid mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Sub-window mount in `LayoutImporter.Resolve`
**Files:**
- Modify: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (add `ShouldMountBaseChildren`; restructure `Resolve`)
- Test: `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` (create)
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs`:
```csharp
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class LayoutImporterMountTests
{
[Fact]
public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
=> Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));
[Fact]
public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));
[Fact]
public void DoesNotMount_WhenDerivedHasOwnChildren()
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));
[Fact]
public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"`
Expected: FAIL — compile error, `'LayoutImporter' does not contain a definition for 'ShouldMountBaseChildren'`.
- [ ] **Step 3: Add the predicate**
In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, add this method just above the private `Resolve` method (after the `Import` method, in the "Inheritance resolution" region):
```csharp
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
```
- [ ] **Step 4: Wire the mount into `Resolve`**
In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, replace the entire body of the private `Resolve` method:
```csharp
private static ElementInfo Resolve(
DatCollection dats,
ElementDesc d,
HashSet<(uint layoutId, uint elementId)> baseChain)
{
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
result.Children.AddRange(baseChildren);
return result;
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"`
Expected: PASS (4 tests).
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
git commit -m "feat(ui): D.2b-B — sub-window mount (inheritor attaches base subtree)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Swap the placeholder for the real import (`GameWindow`)
No unit tests — GL-coupled, runs only with `ACDREAM_RETAIL_UI=1`; verified by build + the visual check in Task 4.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the Sub-phase A placeholder block in `_options.RetailUi`)
- [ ] **Step 1: Replace the placeholder mount**
In `src/AcDream.App/Rendering/GameWindow.cs`, find the Sub-phase A placeholder block:
```csharp
// Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12
// (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window
// manager. Throwaway scaffolding: Sub-phase B replaces the body with the
// real gmInventoryUI (0x21000023) nested layout, keeping the same name.
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = 220, Top = 120, Width = 320, Height = 400,
Visible = false,
};
_uiHost.Root.AddChild(inventoryWindow);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles).");
```
Replace it with the real import:
```csharp
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
AcDream.App.UI.Layout.ImportedLayout? invLayout;
lock (_datLock)
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
if (invLayout is not null)
{
var inventoryWindow = invLayout.Root;
inventoryWindow.Visible = false;
inventoryWindow.Anchors = AcDream.App.UI.AnchorEdges.None; // user-positioned
inventoryWindow.Draggable = true;
_uiHost.Root.AddChild(inventoryWindow);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
```
(`_dats`, `_datLock`, `ResolveChrome`, and `vitalsDatFont` are all in scope in this block — the vitals/chat imports above use the same.)
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(ui): D.2b-B — F12 shows the real gmInventoryUI (0x21000023)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Regression guard + full verification
- [ ] **Step 1: Run the full test suite (regression guard)**
Run: `dotnet test`
Expected: PASS — full suite green (2757 baseline + 3 grid + 4 mount-predicate = ~2764). The existing `LayoutImporter` / vitals / chat / toolbar tests passing confirms the mount is inert for those windows (their bases are childless prototypes).
- [ ] **Step 2: Hand off for visual verification**
Launch with `ACDREAM_RETAIL_UI=1` (standard launch in CLAUDE.md) and confirm with the user:
- F12 now shows the **real nested inventory frame** (outer chrome + backpack strip on the right + a 3D-items area + an empty paperdoll panel), not the blank placeholder.
- F12 again hides it; clicking it raises it; it doesn't toggle while chat is focused (Sub-phase A behavior intact).
- **Vitals, chat, and toolbar look unchanged** (the mount regression guard, visually).
Watch the launch log for `[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).` and the absence of importer errors.
Do NOT mark B-Grid done until the user confirms the visual.
---
## Self-Review
**Spec coverage:**
- Sub-window mount (spec §4) → Task 2 (`ShouldMountBaseChildren` + `Resolve`). ✓
- `UiItemList` grid mode (spec §5) → Task 1. ✓
- Placeholder → real import swap (spec §9) → Task 3. ✓
- Grid + mount-predicate tests (spec §6) → Tasks 12. ✓
- Regression guard, vitals/chat/toolbar unchanged (spec §6/§8) → Task 4 Step 1 (suite) + Step 2 (visual). ✓
- No divergence row (spec §7) → nothing to add. ✓
- Visual acceptance (spec §8) → Task 4 Step 2. ✓
**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step shows full code. ✓
**Type consistency:** `Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells` (Task 1), `ShouldMountBaseChildren(int,int,int)` (Task 2 — same signature in predicate, test, and `Resolve` call site), `LayoutImporter.Import(_dats, 0x21000023u, ResolveChrome, vitalsDatFont)` (Task 3, matches the vitals/chat call shape). ✓