From bf422daf578dd08b02c1aeea2d800a3dfc8728a9 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 10:51:29 +0900 Subject: [PATCH] feat: add inventory system primitives --- PROJECT_DESCRIPTION.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/index.d.ts | 74 +++++++++++++ examples/inventory.ts | 13 +++ src/gameplay/inventory.ts | 211 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 17 +++ tests/index.test.ts | 1 + tests/inventory.test.ts | 39 +++++++ 9 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 examples/inventory.ts create mode 100644 src/gameplay/inventory.ts create mode 100644 tests/inventory.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 79a1dbd..aa2bffd 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -39,7 +39,7 @@ npm run build | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -94,7 +94,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling, weighted alias sampling, object pooling, Fisher–Yates shuffle. -- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering, top-down movement controller with acceleration and drag, tile map renderer with chunking and collision tags, shadowcasting FOV utility. +- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering, top-down movement controller with acceleration and drag, tile map renderer with chunking and collision tags, shadowcasting FOV utility, inventory system primitives. - **Search:** Fuzzy search + scoring, Trie-based autocomplete, binary search, Levenshtein distance. - **Data tools:** Diff operations (LCS), deep clone, groupBy, JSON diff/patch helpers. - **Graph:** BFS distance map, DFS traversal, topological sort. diff --git a/README.md b/README.md index eb074e1..77e1a3f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CDN usage: | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 7133ba6..7fae198 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ - [x] Tile map renderer helpers (chunking, layering, collision tags) - [x] Shadowcasting field-of-view utilities and minimap helpers - **Systems for gameplay loops** - - [ ] Inventory system primitives (stacking, filtering, persistence hooks) + - [x] Inventory system primitives (stacking, filtering, persistence hooks) - [ ] Combat resolution helpers (cooldowns, damage formulas, status effects) - [ ] Quest/dialog state machine utilities - [ ] 2D lighting helpers (falloff, blending stubs) diff --git a/docs/index.d.ts b/docs/index.d.ts index 1b82c90..411f88c 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1510,6 +1510,80 @@ export function transparentFromTileMap( passable: (tileId: number) => boolean ): (x: number, y: number) => boolean; +/** + * Inventory item representation. + * Use for: describing stackable items and metadata. + * Import: gameplay/inventory.ts + */ +export interface InventoryItem { + id: string; + quantity: number; + metadata?: TMeta; +} + +/** + * Inventory slot container. + * Use for: iterating over slots and applying UI bindings. + * Import: gameplay/inventory.ts + */ +export interface InventorySlot { + item: InventoryItem | null; +} + +/** + * Inventory configuration options. + * Use for: defining slot capacity, max stack size, and item filters. + * Import: gameplay/inventory.ts + */ +export interface InventoryOptions { + slots: number; + maxStack?: number; + filter?: (item: InventoryItem) => boolean; +} + +/** + * Inventory snapshot serialisation. + * Use for: saving/loading inventory state. + * Import: gameplay/inventory.ts + */ +export interface InventorySnapshot { + slots: Array | null>; +} + +/** + * Inventory controller API. + * Use for: adding/removing items, filtering, and serialising state. + * Import: gameplay/inventory.ts + */ +export interface InventoryController { + addItem(item: AddItemOptions): number; + removeItem(id: string, quantity: number): number; + getTotalQuantity(id: string): number; + getSlots(): ReadonlyArray>; + clear(): void; + filter(predicate: (item: InventoryItem) => boolean): InventoryItem[]; + toJSON(): InventorySnapshot; + load(snapshot: InventorySnapshot): void; +} + +/** + * Creates a stack-based inventory. + * Use for: RPG inventories, loot systems, crafting requirements. + * Import: gameplay/inventory.ts + */ +export function createInventory(options: InventoryOptions): InventoryController; + +/** + * Item insertion payload used by the inventory controller. + * Use for: adding items with quantity and metadata. + * Import: gameplay/inventory.ts + */ +export interface AddItemOptions { + id: string; + quantity: number; + metadata?: TMeta; +} + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/inventory.ts b/examples/inventory.ts new file mode 100644 index 0000000..abbb17d --- /dev/null +++ b/examples/inventory.ts @@ -0,0 +1,13 @@ +import { createInventory } from '../src/index.js'; + +const inventory = createInventory<{ rarity: string }>({ + slots: 4, + maxStack: 5, + filter: (item) => item.metadata?.rarity !== 'cursed', +}); + +inventory.addItem({ id: 'potion', quantity: 3, metadata: { rarity: 'common' } }); +inventory.addItem({ id: 'potion', quantity: 4, metadata: { rarity: 'common' } }); +inventory.addItem({ id: 'elixir', quantity: 2, metadata: { rarity: 'rare' } }); + +console.log('inventory:', inventory.toJSON()); diff --git a/src/gameplay/inventory.ts b/src/gameplay/inventory.ts new file mode 100644 index 0000000..5b63c0c --- /dev/null +++ b/src/gameplay/inventory.ts @@ -0,0 +1,211 @@ +export interface InventoryItem { + id: string; + quantity: number; + metadata?: TMeta; +} + +export interface InventorySlot { + item: InventoryItem | null; +} + +export interface InventoryOptions { + slots: number; + maxStack?: number; + filter?: (item: InventoryItem) => boolean; +} + +export interface AddItemOptions { + id: string; + quantity: number; + metadata?: TMeta; +} + +export interface InventorySnapshot { + slots: Array | null>; +} + +export interface InventoryController { + addItem(item: AddItemOptions): number; + removeItem(id: string, quantity: number): number; + getTotalQuantity(id: string): number; + getSlots(): ReadonlyArray>; + clear(): void; + filter(predicate: (item: InventoryItem) => boolean): InventoryItem[]; + toJSON(): InventorySnapshot; + load(snapshot: InventorySnapshot): void; +} + +function assertPositiveInt(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } +} + +function normalizeOptions(options: InventoryOptions): Required> { + assertPositiveInt(options.slots, 'slots'); + const maxStack = options.maxStack ?? Number.POSITIVE_INFINITY; + if (maxStack <= 0) { + throw new Error('maxStack must be greater than 0.'); + } + const filter = options.filter ?? (() => true); + return { + slots: options.slots, + maxStack, + filter, + }; +} + +function createEmptySlots(count: number): InventorySlot[] { + return Array.from({ length: count }, () => ({ item: null })); +} + +function cloneItem(item: InventoryItem): InventoryItem { + return { + id: item.id, + quantity: item.quantity, + metadata: item.metadata, + }; +} + +/** + * Creates a stack-based inventory with optional filtering and serialization helpers. + * Useful for: RPG inventories, loot systems, and crafting components. + */ +export function createInventory(options: InventoryOptions): InventoryController { + const config = normalizeOptions(options); + const slots = createEmptySlots(config.slots); + + function addItem(item: AddItemOptions): number { + if (!Number.isInteger(item.quantity) || item.quantity <= 0) { + throw new Error('quantity must be a positive integer.'); + } + if (!config.filter({ id: item.id, quantity: item.quantity, metadata: item.metadata })) { + return item.quantity; + } + + let remaining = item.quantity; + + for (const slot of slots) { + if (!slot.item) { + continue; + } + if (slot.item.id !== item.id) { + continue; + } + const available = config.maxStack - slot.item.quantity; + if (available <= 0) { + continue; + } + const toTransfer = Math.min(available, remaining); + slot.item.quantity += toTransfer; + remaining -= toTransfer; + if (remaining === 0) { + return 0; + } + } + + for (const slot of slots) { + if (slot.item !== null) { + continue; + } + const toTransfer = Math.min(config.maxStack, remaining); + slot.item = { + id: item.id, + quantity: toTransfer, + metadata: item.metadata, + }; + remaining -= toTransfer; + if (remaining === 0) { + break; + } + } + + return remaining; + } + + function removeItem(id: string, quantity: number): number { + if (!Number.isInteger(quantity) || quantity <= 0) { + throw new Error('quantity must be a positive integer.'); + } + + let remaining = quantity; + for (const slot of slots) { + if (!slot.item || slot.item.id !== id) { + continue; + } + const toRemove = Math.min(slot.item.quantity, remaining); + slot.item.quantity -= toRemove; + remaining -= toRemove; + if (slot.item.quantity === 0) { + slot.item = null; + } + if (remaining === 0) { + break; + } + } + return quantity - remaining; + } + + function getTotalQuantity(id: string): number { + let total = 0; + for (const slot of slots) { + if (slot.item && slot.item.id === id) { + total += slot.item.quantity; + } + } + return total; + } + + function getSlots(): ReadonlyArray> { + return slots; + } + + function clear(): void { + for (const slot of slots) { + slot.item = null; + } + } + + function filter(predicate: (item: InventoryItem) => boolean): InventoryItem[] { + const results: InventoryItem[] = []; + for (const slot of slots) { + if (slot.item && predicate(slot.item)) { + results.push(cloneItem(slot.item)); + } + } + return results; + } + + function toJSON(): InventorySnapshot { + return { + slots: slots.map((slot) => (slot.item ? cloneItem(slot.item) : null)), + }; + } + + function load(snapshot: InventorySnapshot): void { + if (!Array.isArray(snapshot.slots) || snapshot.slots.length !== config.slots) { + throw new Error('snapshot slots length mismatch.'); + } + snapshot.slots.forEach((item, index) => { + if (item) { + if (!Number.isInteger(item.quantity) || item.quantity < 0) { + throw new Error('snapshot item quantity must be a non-negative integer.'); + } + slots[index].item = cloneItem(item); + } else { + slots[index].item = null; + } + }); + } + + return { + addItem, + removeItem, + getTotalQuantity, + getSlots, + clear, + filter, + toJSON, + load, + }; +} diff --git a/src/index.ts b/src/index.ts index 2cd2c9d..e19881e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,7 @@ export const examples = { createTopDownController: 'examples/topDownMovement.ts', createTileMapController: 'examples/tileMap.ts', computeFieldOfView: 'examples/shadowcasting.ts', + createInventory: 'examples/inventory.ts', }, ai: { seek: 'examples/steering.ts', @@ -627,6 +628,22 @@ export type { FovGrid, } from './gameplay/shadowcasting.js'; +/** + * Inventory controller for stack-based item management. + * + * Example file: examples/inventory.ts + */ +export { createInventory } from './gameplay/inventory.js'; + +export type { + InventoryOptions, + InventoryController, + InventoryItem, + InventorySlot, + InventorySnapshot, + AddItemOptions, +} from './gameplay/inventory.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index 0c9b59a..42e2c38 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -111,6 +111,7 @@ describe('package entry point', () => { | 'createTopDownController' | 'createTileMapController' | 'computeFieldOfView' + | 'createInventory' >(); }); }); diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts new file mode 100644 index 0000000..73a4ca8 --- /dev/null +++ b/tests/inventory.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { createInventory } from '../src/index.js'; + +describe('createInventory', () => { + it('stacks items and reports leftovers', () => { + const inventory = createInventory({ slots: 2, maxStack: 5 }); + expect(inventory.addItem({ id: 'potion', quantity: 3 })).toBe(0); + expect(inventory.addItem({ id: 'potion', quantity: 4 })).toBe(0); + expect(inventory.getTotalQuantity('potion')).toBe(7); + }); + + it('supports filters and removal', () => { + const inventory = createInventory<{ rarity: string }>({ + slots: 3, + maxStack: 10, + filter: (item) => item.metadata?.rarity !== 'cursed', + }); + + expect(inventory.addItem({ id: 'gem', quantity: 1, metadata: { rarity: 'cursed' } })).toBe(1); + expect(inventory.addItem({ id: 'gem', quantity: 2, metadata: { rarity: 'rare' } })).toBe(0); + expect(inventory.getTotalQuantity('gem')).toBe(2); + + expect(inventory.removeItem('gem', 1)).toBe(1); + expect(inventory.getTotalQuantity('gem')).toBe(1); + }); + + it('serializes and restores state', () => { + const inventory = createInventory({ slots: 2, maxStack: 5 }); + inventory.addItem({ id: 'arrow', quantity: 3 }); + + const snapshot = inventory.toJSON(); + inventory.clear(); + expect(inventory.getTotalQuantity('arrow')).toBe(0); + + inventory.load(snapshot); + expect(inventory.getTotalQuantity('arrow')).toBe(3); + }); +});