Newer
Older
rpg / packages / engine / src / inventory / Inventory.ts
/**
 * Инвентарь — жанронезависимый контейнер предметов.
 * Хранит счётчики по 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);
    }
}