/**
* Инвентарь — жанронезависимый контейнер предметов.
* Хранит счётчики по id: `Map<itemId, count>`. Стеки неявные — количество.
* Лимиты (слоты/стак) обрезают добавление и возвращают факт — модель не бросает.
* Смена состава оповещает подписчиков (view-агностично): подписка — как у EventBus,
* но события эмитит сам контейнер, чтобы работать без внешней шины.
*/
/** Лимиты контейнера (undefined/0 — без лимита). */
export interface InventoryOptions {
/** Максимум разных слотов (id). */
maxSlots?: number;
/** Максимум штук в одном слоте. */
maxPerStack?: number;
}
export type InventoryChangeKind = 'add' | 'remove' | 'clear' | 'load';
/** Деталь изменения: знаковая дельта + итог; clear/load — batch-событие (id '', count 0). */
export interface InventoryChange {
kind: InventoryChangeKind;
id: string;
count: number;
/** Итоговый счётчик предмета после изменения (0 для clear/load). */
after: number;
}
/** Создать инвентарь из сериализованного состояния. */
export function inventoryFromData(
data: InventoryData | undefined | null,
options: InventoryOptions = {}
): Inventory {
const inv = new Inventory(options);
if (data) inv.load(data);
return inv;
}
/** Сериализованное состояние инвентаря (для сейвов; формат стабильный). */
export interface InventoryData {
items: Record<string, number>;
}
export class Inventory<TId extends string = string> {
private counts = new Map<string, number>();
private listeners = new Set<(change: InventoryChange) => void>();
private slotLimitValue: number;
private stackLimitValue: number;
constructor(options: InventoryOptions = {}) {
this.slotLimitValue = options.maxSlots && options.maxSlots > 0 ? options.maxSlots : Infinity;
this.stackLimitValue = options.maxPerStack && options.maxPerStack > 0 ? options.maxPerStack : Infinity;
}
/** Подписка на изменения. Возвращает отписку. */
onChange(fn: (change: InventoryChange) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
/**
* Добавить n штук (лимиты обрезают; n <= 0 — ничего).
* Возвращает, сколько реально влезло.
*/
add(itemId: TId, n = 1): number {
if (n <= 0) return 0;
const before = this.counts.get(itemId) ?? 0;
if (before === 0 && this.counts.size >= this.slotLimitValue) return 0;
const gained = Math.min(n, this.stackLimitValue - before);
if (gained <= 0) return 0;
this.counts.set(itemId, before + gained);
this.notify({ kind: 'add', id: itemId, count: gained, after: before + gained });
return gained;
}
/** Сколько ещё влезет этого предмета (лимиты стака и слотов). */
spaceFor(itemId: TId): number {
const have = this.counts.get(itemId) ?? 0;
if (have > 0) return Math.max(0, this.stackLimitValue - have);
return this.counts.size >= this.slotLimitValue ? 0 : this.stackLimitValue;
}
/**
* Убрать n штук предмета. Если после вычитания стало <= 0 — слот исчезает.
* Возвращает, сколько реально убрано (нельзя убрать больше, чем есть).
*/
remove(itemId: TId, n = 1): number {
const have = this.counts.get(itemId) ?? 0;
if (have <= 0 || n <= 0) return 0;
const left = Math.max(0, have - n);
if (left === 0) this.counts.delete(itemId);
else this.counts.set(itemId, left);
this.notify({ kind: 'remove', id: itemId, count: -Math.min(n, have), after: left });
return Math.min(n, have);
}
/** Сколько штук предмета лежит (0, если нет). */
count(itemId: TId): number {
return this.counts.get(itemId) ?? 0;
}
/** Есть ли предмет (в количестве >= 1). */
has(itemId: TId): boolean {
return this.count(itemId) > 0;
}
/** Все слоты (id -> количество); порядок вставки. */
get all(): Array<{ id: string; count: number }> {
return [...this.counts].map(([id, count]) => ({ id, count }));
}
get empty(): boolean {
return this.counts.size === 0;
}
/** Занято слотов (разных id). */
get size(): number {
return this.counts.size;
}
/** Лимит слотов (Infinity — без лимита). */
get slotLimit(): number {
return this.slotLimitValue;
}
/** Лимит стака (Infinity — без лимита). */
get stackLimit(): number {
return this.stackLimitValue;
}
/** Полностью очистить (новая игра). */
clear(): void {
if (this.counts.size === 0) return;
this.counts.clear();
this.notify({ kind: 'clear', id: '', count: 0, after: 0 });
}
serialize(): InventoryData {
return { items: Object.fromEntries(this.counts) };
}
/** Загрузить состояние (мусор и count <= 0 игнорируются; кламп под лимиты). */
load(data: InventoryData): void {
this.counts.clear();
for (const [id, count] of Object.entries(data.items ?? {})) {
if (typeof count !== 'number' || count <= 0) continue;
if (this.counts.size >= this.slotLimitValue) break; // берём первые N слотов
this.counts.set(id, Math.min(count, this.stackLimitValue));
}
this.notify({ kind: 'load', id: '', count: 0, after: 0 });
}
private notify(change: InventoryChange): void {
for (const fn of this.listeners) fn(change);
}
}