feat(items): Mobile-owned container inventory + stack merge/split (#209) #211

Merged
marco merged 6 commits from feat/item-stacking into main 2026-07-25 22:19:13 +02:00
Owner

Summary

Reworks the inventory into a Mobile-owned container graph (ModernUO model) and lands player-facing stack merge/split via drag&drop on top — the epic in #209. Before this, every item lived in a flat ItemRegistry keyed by Owner with an ItemPlace enum, and CarriedWeight was a cache the World facade pushed onto the player. Now the Mobile owns its inventory and the graph is the ownership model.

Delivered in four stages (kept as separate commits; squash on merge):

  • Stage 1 — container graph (server-only). Item.Parent (Mobile | Container | world), Container : Item with TotalWeight/TotalItems cached and bubbled up the parent chain on change (ModernUO's UpdateTotal), a lazy Mobile.Backpack, unlimited nesting with a cycle guard, MaxItems(125)+weight capacity. CarriedWeight is now derived on the entity, not pushed by the facade. Recursive per-player serialization (v5) with a v3→v5 load upgrade. No behaviour change.
  • Stage 2 — nesting + container windows. A Lua-authored bag (container = true) becomes a Container; recursive OwnedItem.Contents on the wire; a server-validated MoveItem intent re-parents an owned item into one of the player's own containers (capacity + cycle checked, rolled back on reject). Client: InventoryPanel → ContainerGump + ContainerWindows (state off GameScreen per the Screen HARD GATE), and the backpack window resized to a UO-modest size.
  • Stage 3 — stacking capstone. Self-validating Item.Fill/SplitOff/MergeInto; server-authoritative StackMerge (drag a stack onto a same-kind stack, overflow stays on source) and StackSplit (shift+drag → SplitAmountGump, 1≤n<Amount); partial ground drop; amount→art tiers extracted to a unit-tested Client.Core StackArtSelector.
  • Stage 3 P6 — quantity-aware names. "arrow" / "10 arrows" / "60,000 coins", nouns held client-side (cliloc-style ItemNameCatalog, nothing on the wire), shown as a hover tooltip in the container window.

Server-authoritative throughout (every move/merge/split/drop is an intent the server validates against ownership, capacity, cycle, range, and stack bounds); single-threaded sim (one InvokeAsync per op); World stays a thin facade; content authored in Lua. ProtocolVersion 14 → 17.

Screenshots / recording

Captured by piloting the client via the debug harness from a fresh DB.

Resized backpack (now UO-modest) rendered as a container gump, with a bag + items:

backpack

A coin stack with its real art and amount label (500):

coins

Honest note on what the harness can't drive: the blind debug harness can't inject a double-click (open a nested bag window), a shift+drag (the split modal), a two-same-kind-stack setup for a drag-merge (/give auto-stacks and caps at 500), or hover (the quantity-name tooltip). Those interactions are covered by unit tests and code review (see below) but should get a manual playtest before release — flagging rather than faking a green box.

How it was tested

  • just lint (CSharpier + analyzers) — 0 warnings; just test — all green (GameServer 271, Client.Core 147, Shared 107, Scripting 25 incl. the api.d.lua drift-check, Auth 19, +others; 0 failures); just check-docs clean; whole solution builds (client + tools).
  • New unit tests: InventoryContainerTests (weight bubbling 1/2-level, cycle, capacity, v5 round-trip, v3→v5 upgrade, MoveItem re-parent/full/cycle), StackingTests (merge combine/overflow/full/different-kind/non-stackable; split valid/out-of-range-rejected-server-side/units-invariant; partial drop + re-pickup), StackArtSelectorTests, StackNameTests, ContainerWindowLayoutTests, InventoryGraphTests, and protocol round-trips for the new messages.
  • Visual (harness, fresh DB): resized backpack + container gump; real coin-stack art + amount label (above).

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (pure .NET + MonoGame; no OS-specific dependency)
  • Tests added/updated for this change
  • Linked the related issue (#209); its Definition of Done is met except two flagged follow-ups: bag shell currently weighs 0 (a gameplay-semantics call), and per-kind stackArt thresholds use global defaults (the Client.Core selector has the override hook; content-side delivery deferred).

Closes #209

Follow-up fixes (review)

  • Real rock art for the stone item — it was rendering as the generic PlaceholderArt diamond (a §Assets HARD GATE violation); now packs the real UO rock static (item_stone 0x1363). Backpack screenshot above updated to the real art. (The bag icon stays a placeholder deliberately — it's slated for custom art via the Asset Editor.)
  • Right-click closing a gump no longer steps the player — a right-hold that began over a gump is now swallowed for the whole hold, so closing the backpack/paperdoll with the right button doesn't also walk.
## Summary Reworks the inventory into a **Mobile-owned container graph** (ModernUO model) and lands player-facing **stack merge/split via drag&drop** on top — the epic in #209. Before this, every item lived in a flat `ItemRegistry` keyed by `Owner` with an `ItemPlace` enum, and `CarriedWeight` was a cache the `World` facade pushed onto the player. Now the `Mobile` **owns** its inventory and the graph is the ownership model. Delivered in four stages (kept as separate commits; squash on merge): - **Stage 1 — container graph (server-only).** `Item.Parent` (Mobile | Container | world), `Container : Item` with `TotalWeight`/`TotalItems` cached and **bubbled up the parent chain** on change (ModernUO's `UpdateTotal`), a lazy `Mobile.Backpack`, unlimited nesting with a cycle guard, `MaxItems`(125)+weight capacity. `CarriedWeight` is now **derived on the entity**, not pushed by the facade. Recursive per-player serialization (v5) with a v3→v5 load upgrade. No behaviour change. - **Stage 2 — nesting + container windows.** A Lua-authored `bag` (`container = true`) becomes a `Container`; recursive `OwnedItem.Contents` on the wire; a server-validated `MoveItem` intent re-parents an owned item into one of the player's own containers (capacity + cycle checked, rolled back on reject). Client: `InventoryPanel` → `ContainerGump` + `ContainerWindows` (state off `GameScreen` per the Screen HARD GATE), and the **backpack window resized** to a UO-modest size. - **Stage 3 — stacking capstone.** Self-validating `Item.Fill`/`SplitOff`/`MergeInto`; server-authoritative `StackMerge` (drag a stack onto a same-kind stack, overflow stays on source) and `StackSplit` (shift+drag → `SplitAmountGump`, `1≤n<Amount`); partial ground drop; amount→art tiers extracted to a unit-tested `Client.Core` `StackArtSelector`. - **Stage 3 P6 — quantity-aware names.** `"arrow"` / `"10 arrows"` / `"60,000 coins"`, nouns held client-side (cliloc-style `ItemNameCatalog`, nothing on the wire), shown as a hover tooltip in the container window. Server-authoritative throughout (every move/merge/split/drop is an intent the server validates against ownership, capacity, cycle, range, and stack bounds); single-threaded sim (one `InvokeAsync` per op); `World` stays a thin facade; content authored in Lua. `ProtocolVersion` 14 → 17. ## Screenshots / recording Captured by piloting the client via the debug harness from a fresh DB. **Resized backpack (now UO-modest) rendered as a container gump, with a bag + items:** ![backpack](https://git.homelab.devncode.it/attachments/55ca163e-2e3d-4138-8bcc-68cc00e75ff5) **A coin stack with its real art and amount label (`500`):** ![coins](https://git.homelab.devncode.it/attachments/003d77d0-9c57-4996-a6f6-baeff37dfbed) **Honest note on what the harness can't drive:** the blind debug harness can't inject a double-click (open a nested bag window), a shift+drag (the split modal), a two-same-kind-stack setup for a drag-merge (`/give` auto-stacks and caps at 500), or hover (the quantity-name tooltip). Those interactions are covered by unit tests and code review (see below) but should get a manual playtest before release — flagging rather than faking a green box. ## How it was tested - **`just lint`** (CSharpier + analyzers) — 0 warnings; **`just test`** — all green (GameServer **271**, Client.Core **147**, Shared 107, Scripting 25 incl. the `api.d.lua` drift-check, Auth 19, +others; 0 failures); **`just check-docs`** clean; whole solution builds (client + tools). - **New unit tests:** `InventoryContainerTests` (weight bubbling 1/2-level, cycle, capacity, v5 round-trip, v3→v5 upgrade, MoveItem re-parent/full/cycle), `StackingTests` (merge combine/overflow/full/different-kind/non-stackable; split valid/out-of-range-rejected-server-side/units-invariant; partial drop + re-pickup), `StackArtSelectorTests`, `StackNameTests`, `ContainerWindowLayoutTests`, `InventoryGraphTests`, and protocol round-trips for the new messages. - **Visual (harness, fresh DB):** resized backpack + container gump; real coin-stack art + amount label (above). ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (pure .NET + MonoGame; no OS-specific dependency) - [x] Tests added/updated for this change - [x] Linked the related issue (#209); its Definition of Done is met except two flagged follow-ups: bag shell currently weighs 0 (a gameplay-semantics call), and per-kind `stackArt` thresholds use global defaults (the `Client.Core` selector has the override hook; content-side delivery deferred). Closes #209 ### Follow-up fixes (review) - **Real rock art for the `stone` item** — it was rendering as the generic PlaceholderArt diamond (a §Assets HARD GATE violation); now packs the real UO rock static (`item_stone` `0x1363`). Backpack screenshot above updated to the real art. (The `bag` icon stays a placeholder deliberately — it's slated for custom art via the Asset Editor.) - **Right-click closing a gump no longer steps the player** — a right-hold that began over a gump is now swallowed for the whole hold, so closing the backpack/paperdoll with the right button doesn't also walk.
Move the inventory from the flat ItemRegistry (keyed by Owner + ItemPlace)
to a Mobile-owned container graph (ModernUO model): Item.Parent (Mobile |
Container | world), Container : Item with cached TotalWeight/TotalItems that
bubble deltas up the parent chain, a lazy Mobile.Backpack, unlimited nesting
with a cycle guard, and MaxItems(125)+weight capacity. CarriedWeight is now
derived on the entity, not pushed by the World facade. Recursive per-player
v4 item / v5 player serialization with a v3-flat load upgrade. Server-only,
no behaviour change; +8 unit tests.
Bags nest inside the backpack and open as their own draggable Gump windows.
Server: a Lua-authored 'bag' (container=true) becomes a Container in the
graph; recursive OwnedItem.Contents on the wire (InventoryState carries the
backpack id); a server-validated MoveItem intent re-parents an owned item
into one of the player's own containers (capacity + cycle checked, rolled
back on reject). Client: InventoryPanel replaced by ContainerGump +
ContainerWindows (state off GameScreen per the Screen HARD GATE), backpack
resized to a UO-modest window; pure geometry/graph logic in Client.Core.
ProtocolVersion 15->16. +new tests.
Stack arithmetic as self-validating Item mutators (Fill/SplitOff/MergeInto,
weight bubbles via SetAmount). Server-authoritative StackMerge (drag a stack
onto a same-kind stack, overflow stays on source) and StackSplit (shift+drag
-> SplitAmountGump, 1<=n<Amount) intents; Drop gains a partial Amount.
Amount->art tier decision extracted to a unit-tested Client.Core
StackArtSelector. ProtocolVersion 16->17. +StackingTests (11) + selector
tests. P6 (quantity-aware display names) deferred to a follow-up.
feat(items): quantity-aware item display names (#209 Stage 3 P6)
Some checks failed
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Has been cancelled
53fc3cb4ac
A stackable item's name carries its count: "arrow" / "10 arrows" /
"60,000 coins". Nouns live client-side (UO cliloc-style ItemNameCatalog,
like SystemMessageCatalog — the client owns wording, nothing on the wire);
StackName.Format composes it (pure, unit-tested). Shown as a hover tooltip
in the container window (a UI component, not GameScreen). No protocol
change. +StackNameTests.
Stone rendered as the generic PlaceholderArt diamond in the backpack — a
§Assets HARD GATE violation (never a placeholder shape as content). Pack the
real UO rock static (0x1363) as item_stone and rebuild assets.isoa.
fix(client): right-click that closes a gump no longer steps the player
All checks were successful
ci / Lua content lint (pull_request) Successful in 14s
ci / Lint & Test (pull_request) Successful in 12m15s
9defbb50f2
Closing the backpack/paperdoll with the right button also walked the pg: the
close fires on the press frame, then the still-held button finds OverPanel
false (the gump just closed) and starts a world step. Latch a right-hold that
began over a gump and swallow the whole hold until release.
marco merged commit 348fe777e9 into main 2026-07-25 22:19:13 +02:00
marco deleted branch feat/item-stacking 2026-07-25 22:19:13 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!211
No description provided.