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 <cursoragent@cursor.com>
This commit is contained in:
149
src/player/inventory.ts
Normal file
149
src/player/inventory.ts
Normal file
@@ -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<string, number>();
|
||||
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
244
tests/inventory.test.ts
Normal file
244
tests/inventory.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user