epic(items): Mobile-owned container inventory (ModernUO) → stack merge/split capstone #209

Closed
opened 2026-07-25 18:51:38 +02:00 by marco · 0 comments
Owner

Epic. Rework the inventory into a Mobile-owned container graph (ModernUO model), then land player-facing stack merge/split via drag&drop as the capstone. Reviewed via critical-design-review, grounded in the local ModernUO checkout (~/Development/ModernUO).

Today everything lives in a flat ItemRegistry keyed by Owner with an ItemPlace enum, and CarriedWeight/Equipment are caches pushed onto the player by the facade. The correct model (ModernUO): the Mobile owns its inventory — a Backpack that is a Container : Item; Item.Parent is a Mobile (equipped), a Container (inside a bag, nestable), or the world (ground); weight is a cached total bubbled up the parent chain on change (Item.UpdateTotal(delta), Item.cs:3131), recomputed bottom-up on restore (Container.UpdateTotals, Container.cs:345). Containers cap on MaxItems (125) and MaxWeight; a stack counts as one item.

Agreed decisions

  • Ownership on the entity: Mobile.Backpack (a Container), Item.Parent graph; ItemPlace enum → parent-derived placement. Applies to every Mobile (players + creatures).
  • Weight = Mobile.Backpack.TotalWeight + equipped, cached + delta-bubbled by the container/mobile (not pushed by the facade). CarriedWeight stops being a facade-synced field.
  • Capacity = MaxItems (125) and MaxWeight; a stack (pile) is one item toward MaxItems. Over-cap drop → SystemMessageId.ContainerFull.
  • Nesting = unlimited, with a cycle guard (a container can't be dropped into itself or its descendants).
  • world.sav v3 → upgrade at load (read the old flat owned items, parent them into the new backpack). No wipe.
  • Creatures get a backpack lazily (null until a container/loot is needed).
  • ItemRegistry stays as the root/world-item store (ground/placed) + an id→Item index for O(1) lookup from wire intents; ownership/structure live on the graph, not in the registry.
  • Client: each open container is a Gump window (reuse the framework); InventoryPanel becomes the backpack-container gump (converted in Stage 2). Nested bags open their own window.
  • Stacking capstone: maxStack is the sole stackable flag (1 = non-stackable); loot auto-stack = first-fit; split UX = shift+drag → quantity prompt as a SplitAmountGump : Gump; partial ground drop in scope; no rate-limit for now; amount-based art hardened into a Client.Core selector with per-kind stackArt content; stackable display name carries the quantity ("arrow" / "10 arrows" / "60000 coins", noun from content, format from the catalog); backpack art resized down (~UO-modest).

Stages

  • Stage 1 — Container model core (server-only, no visible change). Container : Item (child items); Item.Parent (Mobile | Container | world); placement derived from parent; Mobile owns a Backpack; weight cached + delta-bubbled, full recompute on restore; MaxItems+MaxWeight capacity; cycle guard; ItemRegistry → root store + id index; recursive per-player serialization (Item/Container v4) + a v3→v4 load upgrade. Existing pickup/give/equip/weight tests stay green (the safety net). No visible surface → no screenshots.
  • Stage 2 — Nesting + container windows (client + wire). Bags in bags; open/close-container intents; contents sent on open; each container is a Gump; InventoryPanel converted. Bump ProtocolVersion. Screenshots: backpack open, a nested bag open.
  • Stage 3 — Stacking capstone. merge (drop A onto B) / split (shift+drag → SplitAmountGump) / partial ground drop, on the graph, via Item.Fill/MergeInto/SplitOff + Container stacking-on-drop; amount-art selector + per-kind stackArt; quantity display names; resized bag. Bump ProtocolVersion.

Invariants Check

  • Scope ✓ — owner-defined as the project (inventory architecture; split is the capstone).
  • Server-authoritative ✓ — move/open/drop are intents; server validates parent/capacity/range/ownership/cycle; ground-drop re-validates the tile.
  • GM authorization N/A.
  • Identity model N/A.
  • Protocol versioned ✓ — container open/close + contents + per-parent placement → bump ProtocolVersion in the same change (Stage 2/3).
  • String catalog ✓ — ContainerFull and any cue are SystemMessageId; item names are content noun + catalog format.
  • Single-threaded sim ✓ — graph mutations in one InvokeAsync; weight bubble is synchronous in-thread; no lock; no sub-component lock.
  • World.cs HARD GATE ✓ — World delegates; weight bubbling lives on Container/Mobile (removes the recompute from the facade).
  • Screen HARD GATE (client) ✓ — container windows are Gump components; the screen coordinates.
  • Client engine-independence ✓ — container layout + amount-art selection are pure Client.Core; render/gesture is glue.
  • Gameplay/Networking separation ✓ — item graph in Gameplay/, transport in Networking/, meet in GameSessionHandler.
  • Act on the instance ✓ — ownership + weight on the entity (Mobile.Backpack, Container.TotalWeight bubbling); the registry is only existence-store + id index, no state shadowing.
  • Extend by type, not switch ✓ — Container : Item is extend-by-type; capacity/stack are fields/def, no kind-switch.
  • Server-paced actions N/A — instantaneous.
  • Persistence (GameServer) ✓ (note) — per-entity self-serialization; Container bumps its own version and serializes children recursively; per-player self-contained sub-graph, no cross-player refs, no global linker; v3→v4 load upgrade; no tick-loop IO. The recursive-serialization migration is the one delicate spot.
  • Persistence (Auth) N/A.
  • Process separation N/A — GameServer only; JWT unchanged.
  • Typed options ✓ — default MaxItems/MaxWeight are GameOptions fields, default in one place.
  • Broadcasts / AoI ✓ — only root/ground items are AoI (GroundItem.Amount exists); container contents are private, sent on open to the requester.
  • Multi-platform ✓ — pure .NET + existing render.
  • Assets required ✓ — real pack container art (container_backpack/container_bag gumps exist); missing → not drawn, never a placeholder as content.
  • Asset naming HARD GATE ✓ — descriptive container art names, never generic.
  • ModernUO as reference ✓ — follow Item.Parent/RootParent, UpdateTotal delta-bubble, Container.UpdateTotals on restore; diverge on no global serial graph (per-player sub-graph, no linker).
  • Docs & DoD ✓ — new docs/inventory.md; update architecture.md; correct the stale CLAUDE.md "no inventory yet" bullets; DoD-delta below.

No ✗. The World.cs and act-on-instance HARD GATES are improved.

Server-side validation (trust boundary)

  • Move/drop into a container: item + target owned/reachable by the actor; target is a Container; within MaxItems+MaxWeight; not a cycle (target is not the item nor a descendant). Server re-parents and re-bubbles totals.
  • Open a container: owned or in range; server sends the contents (never the client enumerating).
  • Weight/overweight computed server-side; movement stays gated on it.

Verification plan

  • Unit (World/entity): add-to-container bubbles the Mobile's weight; a 2-level nest bubbles twice; remove re-bubbles; cycle rejected; capacity (items + weight) rejected with ContainerFull; restore rebuilds totals (v4 round-trip); v3→v4 upgrade parents old flat items into the backpack.
  • Integration (WebSocket): open-container returns contents; move-item reflects; version-mismatch rejected 426.
  • Screenshots: Stage 1 N/A — no visible surface; Stage 2 backpack + nested bag; Stage 3 stack, merge before/after, partial ground drop + re-pickup filling a stack, resized bag. Fresh DB; GM /give via type+Enter (not say).

Definition of Done

  • Mobile owns its inventory via a Backpack Container; Item.Parent (Mobile/Container/world) replaces the ItemPlace enum; creatures get a backpack lazily.
  • CarriedWeight is derived from the container graph (bubbled by the container/mobile), not pushed by the World facade; the item wrappers are pure delegation.
  • Containers nest to any depth; dropping a container into itself/a descendant is rejected; over-MaxItems(125)/over-MaxWeight drops are rejected with ContainerFull (a stack = one item).
  • Save round-trips the recursive graph (Container self-serializes children with its own version); a v3 blob upgrades into the new backpack at load with no data loss.
  • Each open container is a Gump window; nested bags open their own; InventoryPanel is the backpack gump.
  • Stack merge (drag A→B, correct overflow), split (shift+drag → SplitAmountGump, server-validated amount), and partial ground drop + first-fit re-pickup all work.
  • Stackable art comes from a unit-tested Client.Core selector over per-kind stackArt content; a stackable item's name reads "arrow"/"10 arrows"/"60000 coins" (noun from content, format from catalog).
  • Backpack window visibly smaller; ProtocolVersion bumped with each wire change; docs/inventory.md added, architecture.md + the stale CLAUDE.md "no inventory yet" bullets updated.
  • Base DoD: dotnet test green, whole-solution build (client + tools), zero warnings; multi-platform preserved.
**Epic.** Rework the inventory into a **Mobile-owned container graph** (ModernUO model), then land player-facing **stack merge/split via drag&drop** as the capstone. Reviewed via `critical-design-review`, grounded in the local ModernUO checkout (`~/Development/ModernUO`). Today everything lives in a flat `ItemRegistry` keyed by `Owner` with an `ItemPlace` enum, and `CarriedWeight`/`Equipment` are caches *pushed onto the player by the facade*. The correct model (ModernUO): the `Mobile` **owns** its inventory — a `Backpack` that is a `Container : Item`; `Item.Parent` is a Mobile (equipped), a Container (inside a bag, nestable), or the world (ground); weight is a cached total **bubbled up the parent chain** on change (`Item.UpdateTotal(delta)`, `Item.cs:3131`), recomputed bottom-up on restore (`Container.UpdateTotals`, `Container.cs:345`). Containers cap on `MaxItems` (125) **and** `MaxWeight`; a stack counts as one item. ## Agreed decisions - **Ownership on the entity**: `Mobile.Backpack` (a `Container`), `Item.Parent` graph; `ItemPlace` enum → parent-derived placement. Applies to every Mobile (players + creatures). - **Weight** = `Mobile.Backpack.TotalWeight + equipped`, cached + delta-bubbled by the container/mobile (not pushed by the facade). `CarriedWeight` stops being a facade-synced field. - **Capacity** = `MaxItems` (125) **and** `MaxWeight`; a stack (pile) is one item toward `MaxItems`. Over-cap drop → `SystemMessageId.ContainerFull`. - **Nesting** = unlimited, with a cycle guard (a container can't be dropped into itself or its descendants). - **`world.sav` v3** → **upgrade at load** (read the old flat owned items, parent them into the new backpack). No wipe. - **Creatures get a backpack lazily** (null until a container/loot is needed). - **`ItemRegistry`** stays as the root/world-item store (ground/placed) + an id→Item index for O(1) lookup from wire intents; ownership/structure live on the graph, not in the registry. - **Client**: each open container is a `Gump` window (reuse the framework); `InventoryPanel` becomes the backpack-container gump (converted in Stage 2). Nested bags open their own window. - **Stacking capstone**: `maxStack` is the sole stackable flag (`1 = non-stackable`); loot auto-stack = first-fit; split UX = shift+drag → quantity prompt as a `SplitAmountGump : Gump`; partial ground drop in scope; no rate-limit for now; amount-based art hardened into a `Client.Core` selector with per-kind `stackArt` content; stackable **display name carries the quantity** (`"arrow"` / `"10 arrows"` / `"60000 coins"`, noun from content, format from the catalog); backpack art resized down (~UO-modest). ## Stages - **Stage 1 — Container model core (server-only, no visible change).** `Container : Item` (child items); `Item.Parent` (Mobile | Container | world); placement derived from parent; `Mobile` owns a `Backpack`; weight cached + delta-bubbled, full recompute on restore; `MaxItems`+`MaxWeight` capacity; cycle guard; `ItemRegistry` → root store + id index; **recursive per-player serialization (Item/Container v4) + a v3→v4 load upgrade**. Existing pickup/give/equip/weight tests stay green (the safety net). *No visible surface → no screenshots.* - **Stage 2 — Nesting + container windows (client + wire).** Bags in bags; open/close-container intents; contents sent on open; each container is a `Gump`; `InventoryPanel` converted. Bump `ProtocolVersion`. Screenshots: backpack open, a nested bag open. - **Stage 3 — Stacking capstone.** merge (drop A onto B) / split (shift+drag → `SplitAmountGump`) / partial ground drop, on the graph, via `Item.Fill`/`MergeInto`/`SplitOff` + `Container` stacking-on-drop; amount-art selector + per-kind `stackArt`; quantity display names; resized bag. Bump `ProtocolVersion`. ## Invariants Check - **Scope** ✓ — owner-defined as the project (inventory architecture; split is the capstone). - **Server-authoritative** ✓ — move/open/drop are intents; server validates parent/capacity/range/ownership/cycle; ground-drop re-validates the tile. - **GM authorization** N/A. - **Identity model** N/A. - **Protocol versioned** ✓ — container open/close + contents + per-parent placement → bump `ProtocolVersion` in the same change (Stage 2/3). - **String catalog** ✓ — `ContainerFull` and any cue are `SystemMessageId`; item names are content noun + catalog format. - **Single-threaded sim** ✓ — graph mutations in one `InvokeAsync`; weight bubble is synchronous in-thread; no lock; no sub-component lock. - **World.cs HARD GATE** ✓ — `World` delegates; weight bubbling lives on `Container`/`Mobile` (removes the recompute from the facade). - **Screen HARD GATE (client)** ✓ — container windows are `Gump` components; the screen coordinates. - **Client engine-independence** ✓ — container layout + amount-art selection are pure `Client.Core`; render/gesture is glue. - **Gameplay/Networking separation** ✓ — item graph in `Gameplay/`, transport in `Networking/`, meet in `GameSessionHandler`. - **Act on the instance** ✓ — ownership + weight on the entity (`Mobile.Backpack`, `Container.TotalWeight` bubbling); the registry is only existence-store + id index, no state shadowing. - **Extend by type, not switch** ✓ — `Container : Item` is extend-by-type; capacity/stack are fields/def, no kind-switch. - **Server-paced actions** N/A — instantaneous. - **Persistence (GameServer)** ✓ (note) — per-entity self-serialization; `Container` bumps its own version and serializes children recursively; per-player self-contained sub-graph, **no cross-player refs**, no global linker; v3→v4 load upgrade; no tick-loop IO. The recursive-serialization migration is the one delicate spot. - **Persistence (Auth)** N/A. - **Process separation** N/A — GameServer only; JWT unchanged. - **Typed options** ✓ — default `MaxItems`/`MaxWeight` are `GameOptions` fields, default in one place. - **Broadcasts / AoI** ✓ — only root/ground items are AoI (`GroundItem.Amount` exists); container contents are private, sent on open to the requester. - **Multi-platform** ✓ — pure .NET + existing render. - **Assets required** ✓ — real pack container art (`container_backpack`/`container_bag` gumps exist); missing → not drawn, never a placeholder as content. - **Asset naming HARD GATE** ✓ — descriptive container art names, never generic. - **ModernUO as reference** ✓ — follow `Item.Parent`/`RootParent`, `UpdateTotal` delta-bubble, `Container.UpdateTotals` on restore; diverge on no global serial graph (per-player sub-graph, no linker). - **Docs & DoD** ✓ — new `docs/inventory.md`; update `architecture.md`; correct the stale `CLAUDE.md` "no inventory yet" bullets; DoD-delta below. No `✗`. The World.cs and act-on-instance HARD GATES are improved. ## Server-side validation (trust boundary) - **Move/drop into a container**: item + target owned/reachable by the actor; target is a `Container`; within `MaxItems`+`MaxWeight`; not a cycle (target is not the item nor a descendant). Server re-parents and re-bubbles totals. - **Open a container**: owned or in range; server sends the contents (never the client enumerating). - **Weight/overweight** computed server-side; movement stays gated on it. ## Verification plan - **Unit (World/entity)**: add-to-container bubbles the Mobile's weight; a 2-level nest bubbles twice; remove re-bubbles; cycle rejected; capacity (items + weight) rejected with `ContainerFull`; restore rebuilds totals (v4 round-trip); **v3→v4 upgrade** parents old flat items into the backpack. - **Integration (WebSocket)**: open-container returns contents; move-item reflects; version-mismatch rejected 426. - **Screenshots**: Stage 1 **N/A — no visible surface**; Stage 2 backpack + nested bag; Stage 3 stack, merge before/after, partial ground drop + re-pickup filling a stack, resized bag. Fresh DB; GM `/give` via `type`+`Enter` (not `say`). ## Definition of Done - [ ] `Mobile` owns its inventory via a `Backpack` `Container`; `Item.Parent` (Mobile/Container/world) replaces the `ItemPlace` enum; creatures get a backpack lazily. - [ ] `CarriedWeight` is derived from the container graph (bubbled by the container/mobile), not pushed by the `World` facade; the item wrappers are pure delegation. - [ ] Containers nest to any depth; dropping a container into itself/a descendant is rejected; over-`MaxItems`(125)/over-`MaxWeight` drops are rejected with `ContainerFull` (a stack = one item). - [ ] Save round-trips the recursive graph (`Container` self-serializes children with its own version); a v3 blob upgrades into the new backpack at load with no data loss. - [ ] Each open container is a `Gump` window; nested bags open their own; `InventoryPanel` is the backpack gump. - [ ] Stack merge (drag A→B, correct overflow), split (shift+drag → `SplitAmountGump`, server-validated amount), and partial ground drop + first-fit re-pickup all work. - [ ] Stackable art comes from a unit-tested `Client.Core` selector over per-kind `stackArt` content; a stackable item's name reads `"arrow"`/`"10 arrows"`/`"60000 coins"` (noun from content, format from catalog). - [ ] Backpack window visibly smaller; `ProtocolVersion` bumped with each wire change; `docs/inventory.md` added, `architecture.md` + the stale `CLAUDE.md` "no inventory yet" bullets updated. - [ ] Base DoD: `dotnet test` green, whole-solution build (client + tools), zero warnings; multi-platform preserved.
marco changed title from feat(items): stack management — merge/split drag&drop, partial ground drop, amount art + quantity names to epic(items): Mobile-owned container inventory (ModernUO) → stack merge/split capstone 2026-07-25 19:05:39 +02:00
marco closed this issue 2026-07-25 22:19:13 +02:00
Sign in to join this conversation.
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#209
No description provided.