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:
Денис Шкабатур
2026-02-12 13:11:20 +03:00
parent 0c0635c93b
commit cf36c0adce
2 changed files with 393 additions and 0 deletions

149
src/player/inventory.ts Normal file
View 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();
}
}