From cf36c0adcef1cb8d6ac58006d9b102b4a9622a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 13:11:20 +0300 Subject: [PATCH] feat: weight-based inventory with element stacking (Phase 4.2) Inventory uses real atomic/molecular masses (AMU). Same items auto-stack. Respects weight limits and slot limits. Supports elements and compounds via chemistry registries. 28 new tests (162 total). Co-authored-by: Cursor --- src/player/inventory.ts | 149 ++++++++++++++++++++++++ tests/inventory.test.ts | 244 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/player/inventory.ts create mode 100644 tests/inventory.test.ts diff --git a/src/player/inventory.ts b/src/player/inventory.ts new file mode 100644 index 0000000..84eb664 --- /dev/null +++ b/src/player/inventory.ts @@ -0,0 +1,149 @@ +/** + * Player Inventory — Weight-Based with Element Stacking + * + * Items are elements (by symbol) or compounds (by id). + * Weight = real atomic/molecular mass. + * Same items stack automatically (count increases). + */ + +import { ElementRegistry } from '../chemistry/elements'; +import { CompoundRegistry } from '../chemistry/compounds'; + +/** A single inventory entry */ +export interface InventoryItem { + readonly id: string; + readonly count: number; +} + +/** + * Get the mass of an item (element or compound). + * Returns atomic mass for elements, molecular mass for compounds, 0 for unknown. + */ +export function getItemMass(id: string): number { + const el = ElementRegistry.getBySymbol(id); + if (el) return el.atomicMass; + + const comp = CompoundRegistry.getById(id); + if (comp) return comp.mass; + + return 0; +} + +/** + * Weight-based inventory with slot limits and auto-stacking. + * + * Weight uses real atomic/molecular masses (AMU). + * Same items stack into a single slot. + */ +export class Inventory { + private counts = new Map(); + + /** Maximum total weight (AMU) */ + readonly maxWeight: number; + /** Maximum number of unique item types (slots) */ + readonly maxSlots: number; + + constructor(maxWeight = 500, maxSlots = 20) { + this.maxWeight = maxWeight; + this.maxSlots = maxSlots; + } + + /** + * Add items to inventory. + * @returns actual count added (may be less if weight limit reached, 0 if impossible) + */ + addItem(id: string, count = 1): number { + if (count <= 0) return 0; + + const mass = getItemMass(id); + if (mass <= 0) return 0; + + // New item needs an available slot + const isNewItem = !this.counts.has(id); + if (isNewItem && this.counts.size >= this.maxSlots) return 0; + + // Calculate how many can fit by weight + const currentWeight = this.getTotalWeight(); + const spaceLeft = this.maxWeight - currentWeight; + const maxByWeight = Math.floor(spaceLeft / mass); + const actualAdd = Math.min(count, maxByWeight); + + if (actualAdd <= 0) return 0; + + const existing = this.counts.get(id) ?? 0; + this.counts.set(id, existing + actualAdd); + + return actualAdd; + } + + /** + * Remove items from inventory. + * @returns actual count removed (may be less if not enough in stock) + */ + removeItem(id: string, count = 1): number { + if (count <= 0) return 0; + + const current = this.counts.get(id) ?? 0; + if (current <= 0) return 0; + + const actualRemove = Math.min(count, current); + const newCount = current - actualRemove; + + if (newCount <= 0) { + this.counts.delete(id); + } else { + this.counts.set(id, newCount); + } + + return actualRemove; + } + + /** Get item count (0 if not in inventory) */ + getCount(id: string): number { + return this.counts.get(id) ?? 0; + } + + /** Check if inventory has at least `count` of an item */ + hasItem(id: string, count = 1): boolean { + return (this.counts.get(id) ?? 0) >= count; + } + + /** Get all items as array of { id, count } */ + getItems(): InventoryItem[] { + const items: InventoryItem[] = []; + for (const [id, count] of this.counts) { + items.push({ id, count }); + } + return items; + } + + /** Total weight of all items (sum of mass * count) */ + getTotalWeight(): number { + let total = 0; + for (const [id, count] of this.counts) { + total += getItemMass(id) * count; + } + return total; + } + + /** Weight of a single item stack */ + getItemWeight(id: string): number { + const count = this.counts.get(id) ?? 0; + return getItemMass(id) * count; + } + + /** Number of unique item types (occupied slots) */ + get slotCount(): number { + return this.counts.size; + } + + /** Whether inventory has zero items */ + isEmpty(): boolean { + return this.counts.size === 0; + } + + /** Remove all items */ + clear(): void { + this.counts.clear(); + } +} diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts new file mode 100644 index 0000000..37cbb57 --- /dev/null +++ b/tests/inventory.test.ts @@ -0,0 +1,244 @@ +/** + * Inventory System Tests — Phase 4.2 + * + * Weight-based inventory with element stacking, + * mass limits, and slot limits. + */ + +import { describe, it, expect } from 'vitest'; +import { Inventory, getItemMass } from '../src/player/inventory'; + +// === Item Mass Lookup === + +describe('getItemMass', () => { + it('returns atomic mass for elements', () => { + expect(getItemMass('H')).toBeCloseTo(1.008); + expect(getItemMass('Na')).toBeCloseTo(22.99); + expect(getItemMass('Fe')).toBeCloseTo(55.845); + expect(getItemMass('O')).toBeCloseTo(15.999); + }); + + it('returns molecular mass for compounds', () => { + expect(getItemMass('NaCl')).toBeCloseTo(58.44); + expect(getItemMass('H2O')).toBeCloseTo(18.015); + expect(getItemMass('CO2')).toBeCloseTo(44.01); + }); + + it('returns 0 for unknown items', () => { + expect(getItemMass('UNKNOWN')).toBe(0); + expect(getItemMass('')).toBe(0); + }); +}); + +// === Inventory Core === + +describe('Inventory — creation', () => { + it('starts empty', () => { + const inv = new Inventory(); + expect(inv.isEmpty()).toBe(true); + expect(inv.getItems()).toEqual([]); + expect(inv.getTotalWeight()).toBe(0); + expect(inv.slotCount).toBe(0); + }); + + it('respects custom limits', () => { + const inv = new Inventory(100, 5); + expect(inv.maxWeight).toBe(100); + expect(inv.maxSlots).toBe(5); + }); +}); + +describe('Inventory — adding items', () => { + it('adds elements', () => { + const inv = new Inventory(); + const added = inv.addItem('H', 3); + expect(added).toBe(3); + expect(inv.getCount('H')).toBe(3); + expect(inv.isEmpty()).toBe(false); + expect(inv.slotCount).toBe(1); + }); + + it('adds compounds', () => { + const inv = new Inventory(); + const added = inv.addItem('NaCl', 1); + expect(added).toBe(1); + expect(inv.getCount('NaCl')).toBe(1); + }); + + it('stacks same elements', () => { + const inv = new Inventory(); + inv.addItem('Na', 2); + inv.addItem('Na', 3); + expect(inv.getCount('Na')).toBe(5); + expect(inv.slotCount).toBe(1); + }); + + it('stacking does not consume new slot', () => { + const inv = new Inventory(10000, 3); + inv.addItem('H', 1); + inv.addItem('O', 1); + inv.addItem('Na', 1); + const added = inv.addItem('H', 5); + expect(added).toBe(5); + expect(inv.getCount('H')).toBe(6); + expect(inv.slotCount).toBe(3); + }); + + it('returns 0 for unknown items', () => { + const inv = new Inventory(); + expect(inv.addItem('UNKNOWN', 1)).toBe(0); + expect(inv.isEmpty()).toBe(true); + }); + + it('returns 0 for zero/negative count', () => { + const inv = new Inventory(); + expect(inv.addItem('H', 0)).toBe(0); + expect(inv.addItem('H', -1)).toBe(0); + }); +}); + +describe('Inventory — removing items', () => { + it('removes items', () => { + const inv = new Inventory(); + inv.addItem('H', 5); + const removed = inv.removeItem('H', 3); + expect(removed).toBe(3); + expect(inv.getCount('H')).toBe(2); + }); + + it('cleans up slot when count reaches zero', () => { + const inv = new Inventory(); + inv.addItem('H', 3); + inv.removeItem('H', 3); + expect(inv.getCount('H')).toBe(0); + expect(inv.slotCount).toBe(0); + expect(inv.isEmpty()).toBe(true); + }); + + it('removes only what is available', () => { + const inv = new Inventory(); + inv.addItem('H', 2); + const removed = inv.removeItem('H', 5); + expect(removed).toBe(2); + expect(inv.getCount('H')).toBe(0); + }); + + it('returns 0 for non-existent item', () => { + const inv = new Inventory(); + expect(inv.removeItem('H', 1)).toBe(0); + }); + + it('returns 0 for zero/negative count', () => { + const inv = new Inventory(); + inv.addItem('H', 5); + expect(inv.removeItem('H', 0)).toBe(0); + expect(inv.removeItem('H', -1)).toBe(0); + }); +}); + +describe('Inventory — weight system', () => { + it('calculates total weight from atomic masses', () => { + const inv = new Inventory(); + inv.addItem('Na', 2); // 22.99 * 2 = 45.98 + expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 2); + }); + + it('calculates mixed element + compound weight', () => { + const inv = new Inventory(); + inv.addItem('Na', 1); // 22.99 + inv.addItem('NaCl', 1); // 58.44 + expect(inv.getTotalWeight()).toBeCloseTo(22.99 + 58.44); + }); + + it('limits additions by weight', () => { + const inv = new Inventory(100); // 100 AMU limit + // Na mass = 22.99 → max 4 fit (4 * 22.99 = 91.96) + const added = inv.addItem('Na', 10); + expect(added).toBe(4); + expect(inv.getCount('Na')).toBe(4); + expect(inv.getTotalWeight()).toBeCloseTo(22.99 * 4); + }); + + it('weight frees up after removal', () => { + const inv = new Inventory(100); + inv.addItem('Na', 4); // 91.96 + inv.removeItem('Na', 2); // now 45.98 used, ~54 free + const added = inv.addItem('Na', 3); // 2 more fit (45.98 + 2*22.99 = 91.96) + expect(added).toBe(2); + expect(inv.getCount('Na')).toBe(4); + }); + + it('getItemWeight returns stack weight', () => { + const inv = new Inventory(); + inv.addItem('Na', 3); + expect(inv.getItemWeight('Na')).toBeCloseTo(22.99 * 3); + }); + + it('getItemWeight returns 0 for absent item', () => { + const inv = new Inventory(); + expect(inv.getItemWeight('H')).toBe(0); + }); +}); + +describe('Inventory — slot limits', () => { + it('respects max slots', () => { + const inv = new Inventory(10000, 3); + inv.addItem('H', 1); + inv.addItem('O', 1); + inv.addItem('Na', 1); + const added = inv.addItem('Fe', 1); // 4th slot + expect(added).toBe(0); + expect(inv.slotCount).toBe(3); + expect(inv.hasItem('Fe')).toBe(false); + }); + + it('frees slot after full removal', () => { + const inv = new Inventory(10000, 2); + inv.addItem('H', 1); + inv.addItem('O', 1); + expect(inv.addItem('Na', 1)).toBe(0); // full + inv.removeItem('O', 1); // free a slot + expect(inv.addItem('Na', 1)).toBe(1); // now fits + expect(inv.slotCount).toBe(2); + }); +}); + +describe('Inventory — queries', () => { + it('hasItem checks against count', () => { + const inv = new Inventory(); + inv.addItem('H', 3); + expect(inv.hasItem('H', 1)).toBe(true); + expect(inv.hasItem('H', 3)).toBe(true); + expect(inv.hasItem('H', 4)).toBe(false); + expect(inv.hasItem('O')).toBe(false); + }); + + it('getItems returns all items', () => { + const inv = new Inventory(); + inv.addItem('H', 3); + inv.addItem('Na', 2); + const items = inv.getItems(); + expect(items).toHaveLength(2); + expect(items).toContainEqual({ id: 'H', count: 3 }); + expect(items).toContainEqual({ id: 'Na', count: 2 }); + }); + + it('getCount returns 0 for absent item', () => { + const inv = new Inventory(); + expect(inv.getCount('H')).toBe(0); + }); +}); + +describe('Inventory — clear', () => { + it('removes everything', () => { + const inv = new Inventory(); + inv.addItem('H', 3); + inv.addItem('Na', 2); + inv.addItem('NaCl', 1); + inv.clear(); + expect(inv.isEmpty()).toBe(true); + expect(inv.getTotalWeight()).toBe(0); + expect(inv.slotCount).toBe(0); + expect(inv.getItems()).toEqual([]); + }); +});