# Инвентарь (модель)

Модуль `packages/engine/src/inventory/Inventory.ts` — жанронезависимый
контейнер предметов. Движок знает только **модель**: стаки, лимиты, события.
Реестр предметов (имена, описания) и UI-сцены — на стороне игры
(в этой игре: `apps/game/src/data/items.ts`, `InventoryScene`).

## Модель

Содержимое — `Map<itemId, count>`: стаки неявные (количество), порядок слотов
— порядок вставки.

```ts
export interface InventoryOptions {
    /** Максимум разных слотов (id). undefined/0 — без лимита. */
    maxSlots?: number;
    /** Максимум штук в одном слоте. undefined/0 — без лимита. */
    maxPerStack?: number;
}

const inv = new Inventory({ maxSlots: 12, maxPerStack: 99 });
```

Лимиты **не бросают исключений** — `add` обрезает добавление и возвращает,
сколько реально влезло (все вызовы в игре игнорируют возврат — совместимо):

```ts
inv.add('cloth');            // → 1 (сколько влезло)
inv.add('cloth', 5);         // частичное добавление под лимит
inv.spaceFor('cloth');       // сколько ещё влезет (стак + свободные слоты)
inv.remove('cloth', 2);      // → убрано; пустой слот исчезает
inv.count('cloth'); inv.has('cloth');
inv.all;                     // [{ id, count }] — порядок вставки
inv.size;                    // занято слотов
inv.slotLimit; inv.stackLimit; // Infinity, если не заданы
inv.empty; inv.clear();
inv.serialize(); inv.load(data); // { items: Record<string, number> } — формат сейвов, не менять
```

`load` игнорирует мусор и `count <= 0`, клампит под лимиты (берёт первые N
слотов), эмитит batch-событие. `inventoryFromData(data, options?)` — фабрика.

## События

`onChange(fn)` возвращает отписку. Аргумент — деталь изменения:

```ts
export interface InventoryChange {
    kind: 'add' | 'remove' | 'clear' | 'load';
    /** Затронутый id; для batch-событий 'clear'/'load' — ''. */
    id: string;
    /** Знаковая дельта (+добавлено / -убрано); для 'clear'/'load' — 0. */
    count: number;
    /** Итоговый счётчик предмета после изменения. */
    after: number;
}

const off = inv.onChange((ch) => redraw(ch.kind === 'load' ? 'всё' : ch.id));
```

`add`/`remove` эмитят по одному событию на предмет; `clear`/`load` — одно
batch-событие (view просто перерисовывается целиком). События не эмитятся,
когда ничего не изменилось (`add` 0 влезло, `clear` на пустом).

## Типизация id

Класс генеричен: `Inventory<TId extends string = string>`. Игра может дать
`Inventory<ItemId>` — опечатка в id ловится компилятором; дефолт — строки,
чтобы движок не знал предметов.

## Граница движок/игра

- Движок: контейнер (этот модуль). Никаких имён предметов, цен, весов.
- Игра: реестр `ItemDef`, форматирование строк (`inventoryItems`), UI-сцена,
  лимиты сумки как контент (`new Inventory(...)` в `Game.ts`).

## Юза́бельные предметы (игра)

«Применение» предмета — чистый хелпер игры `useItemLine(id, {label}) → string | null`
(`apps/game/src/data/items.ts`): юза́бельные предметы дают строку-результат
(часы → «Время: 8:24»), остальные — null (сумка молчит). `InventoryScene`
показывает сумку списком с курсором (`MenuList`), Enter вызывает хелпер и
кладёт строку в `PixelText` внизу панели. Новые юза́бельные предметы
расширяют `useItemLine`, а не сцену.

## Тесты

`packages/engine/src/inventory/__tests__/Inventory.test.ts` — add/count,
remove, лимиты (частичное добавление, отказ на новый слот, load-кламп),
детали всех 4 kind события, round-trip serialize/load, clear, генерик.