# Сохранения игры — `$.save`

Подсистема сохраняет состояние игры целиком и по частям: данные `$.store`,
мир (узлы со всем поддеревом через `$.prefab`) и метаданные — в **слоты**,
файлы `saves/slot-<имя>.json`. Слот знает свою версию, поэтому старые
сохранения доезжают до текущего формата сами, а строки `export()`/`import()`
годятся для `$.http`, буфера обмена и тестов.

```js
$.ready(() => {
    $.save.dir('saves');            // где лежат слоты (по умолчанию saves)

    // Меню: три слота + автосейв в слоте 0.
    $('#save-1').on('click', () => $.save.slot(1).save());
    $('#load-2').on('click', () => $.save.slot(2).load());

    // Автосейв раз в минуту, не трогая текущий слот.
    $.save.autosave(60000);

    // Обмен строкой: облако, буфер обмена, тесты.
    const text = $.save.export();
    $.http.post('https://example.com/save', text);
});
```

Три правила, из которых растёт весь модуль:

1. **Ничего своего про узлы и key-value.** Мир описывает `$.prefab.save()`,
   данные берёт `$.store.all()`, файлы пишет `$.fs` — `$.save` только собирает
   из них слот, добавляет версию и миграции.
2. **Версия обязательна.** Формат всегда пишется с `version`; загрузка
   прогоняет данные через миграции и отказывается открывать сохранение
   «из будущего» (понятным сообщением, а не исключением).
3. **Слот — это файл.** `slot-1.json`, `slot-quick.json`, `slot-0.json` —
   сколько угодно слотов в каталоге `saves/`; `list()` показывает их меню.

---

## 1. Что внутри слота

```json
{
  "format": "r2d.save",
  "version": 2,
  "saved_at": 1730000000000,
  "saved_frame": 1234,
  "time": 20.5,
  "scene": "level1",
  "store": { "highscore": 1200, "kills": 7 },
  "world": [ { "tag": "player", "id": "hero", "x": 100, "y": 200, "children": [] } ],
  "speeds": [ [0, 0] ],
  "meta": {}
}
```

| Поле | Смысл |
|---|---|
| `format` | всегда `'r2d.save'` — по нему видно, что это наш слот, а не чужой JSON |
| `version` | версия формата: `1` — запись `$.store.save()` / сцена `$.prefab`, `2` — слот |
| `saved_at` | `Date.now()` в момент сохранения (для меню: «12 минут назад») |
| `saved_frame`, `time` | кадр и игровое время на момент снимка |
| `scene` | имя активной сцены (`$.scene.current()`), **справочно** — см. §9 |
| `store` | данные `$.store.all()` — счётчики, инвентарь, настройки |
| `world` | корневые узлы `ctx.nodes` в формате `$.prefab` (или `null`) |
| `speeds` | скорости тел в порядке обхода узлов, `null` — если тел нет |
| `meta` | что положит игра: `$.save.save({ meta: { chapter: 2 } })` |

Поля `format`, `version`, `store`, `world`, `speeds`, `meta`, `saved_at`,
`saved_frame`, `time`, `scene` **зарезервированы**. Объект без них считается
«сырыми» данными `$.store` (как в `$.store.load()`), поэтому старый файл
`save.json` тоже открывается как слот.

---

## 2. Пространство имён `$.save`

| Функция | Возвращает | Назначение |
|---|---|---|
| `$.save.slot(name?)` | слот / `$.save` | текущий слот или переключение (`slot(2)`) |
| `$.save.dir(path?)` | каталог / `$.save` | каталог слотов (`'saves'` по умолчанию) |
| `$.save.path(slot?)` | строка | путь к файлу слота |
| `$.save.version()` | число | текущая версия формата (`2`) |
| `$.save.save(slotOrOpts?, opts?)` | `bool` | записать состояние в слот |
| `$.save.load(slotOrOpts?, opts?)` | `bool` | прочитать слот и применить |
| `$.save.read(slot?)` | объект / `null` | данные слота **без** применения |
| `$.save.write(payload, slot?)` | `bool` | записать готовый снимок |
| `$.save.exists(slot?)` | `bool` | есть ли файл слота |
| `$.save.list(opts?)` | массив | слоты каталога (`{ meta: false }` — без чтения файлов) |
| `$.save.info(slot?)` | объект / `null` | метаданные одного слота |
| `$.save.remove(slot?)` | `bool` | удалить файл слота |
| `$.save.snapshot(opts?)` | объект | снимок состояния без записи на диск |
| `$.save.storeData()` | объект | только данные `$.store` |
| `$.save.worldData()` | массив / `null` | только узлы мира |
| `$.save.apply(payload, opts?)` | `bool` | применить снимок (объект) |
| `$.save.applyStore(data, mode?)` | `bool` | применить данные (`'merge'` — дополнить) |
| `$.save.applyWorld(nodes, opts?)` | число | собрать мир, вернуть число корней |
| `$.save.export(opts?)` | строка | снимок строкой JSON |
| `$.save.import(text, opts?)` | `bool` | применить строку JSON |
| `$.save.autosave(ms?, slot?)` | id | автосейв по таймеру (слот `0`) |
| `$.save.stopAutosave()` | `$.save` | выключить автосейв |
| `$.save.counter(key, delta?)` | число | счётчик в `$.store` |
| `$.save.stats()` | объект | сводка модуля и `last_error` |

`slotOrOpts` — либо номер/имя слота, либо сразу объект настроек:
`$.save.save()`, `$.save.save(3)`, `$.save.save('quick')`,
`$.save.save({ world: false, meta: { chapter: 2 } })` — всё одно и то же.

`opts` у `save/snapshot/export`: `world` (true), `store` (true), `speeds`
(true), `meta`.
`opts` у `load/import/apply`: `world` (true), `store` (`true` — заменить всё,
`'merge'` — дополнить, `false` — не трогать), `clear` (true), `speeds` (true).

---

## 3. Слоты

```js
$.save.slot(2).save();        // saves/slot-2.json
$.save.exists(2);             // true
$.save.info(2).store_keys;    // сколько ключей в данных
$.save.info(2).world_nodes;   // сколько узлов в мире (с детьми)
$.save.list();                // [{ slot, path, size, version, saved_at, scene, … }]
$.save.remove(2);
$.save.load(2);               // текущий слот переключается на 2
```

`list()` по умолчанию читает и разбирает каждый файл — это нужно меню
сохранений (версия, время, размер). Для дешёвого списка есть
`$.save.list({ meta: false })`: только `{ slot, path, file }` без чтения.

Слоты сортируются по-человечески: числовые сначала и по возрастанию
(`2` раньше `10`), затем именованные по алфавиту.

---

## 4. Снимок по частям

```js
// Только инвентарь и счётчики, без мира.
const data = $.save.snapshot({ world: false });
$.save.apply(data, { world: false });

// Только мир, данные не трогаем.
$.save.save({ store: false });
$.save.load(1, { store: false });

// Дополнить текущие данные данными из слота.
$.save.load(1, { store: 'merge' });

// Добавить мир из слота поверх текущего (не удаляя узлы).
$.save.applyWorld($.save.read(1).world, { clear: false });
```

`applyWorld()` по умолчанию **чистит** текущий мир (`clear: true`), чтобы
загруженный слот не смешивался с недоигранной партией. С `clear: false` узлы
добавляются к существующим. Скорости тел (`speeds`) применяются к новым телам
по порядку обхода — сохранённая в полёте пуля продолжает лететь.

---

## 5. Строки: `export()` и `import()`

Строка — тот же слот, только без файла. Это основной путь для `$.http`,
буфера обмена, облачных сохранений и тестов без движка.

```js
const text = $.save.export();                    // мир + данные
await $.http.post('https://example.com/save', text);

const remote = await $.http.text('https://example.com/save');
if (!$.save.import(remote)) $.log('сохранение не подошло: ' + $.save.stats().last_error);

$.save.export({ world: false });                 // только данные
$.save.import(text, { store: 'merge' });         // дополнить, а не заменить
```

`import()` никогда не бросает исключение: битый JSON, пустая строка, массив
вместо объекта и «версия из будущего» возвращают `false`, а причина остаётся
в `$.save.stats().last_error` и уходит в журнал.

---

## 6. Версии и миграции

| Версия | Что это | Как читается |
|---|---|---|
| `1` | `$.store.save()`: `{ version: 1, saved_frame, data: {...} }` | данные → `store`, мир пустой |
| `1` | сцена `$.prefab.saveScene()`: `{ version: 1, name, nodes: [...] }` | `nodes` → `world` |
| без версии | «сырой» объект данных `$.store` | весь объект → `store` |
| `2` | слот `$.save` | читается как есть |

Миграция помечается полем `migrated_from`: игра может показать «сохранение
из старой версии» и перезаписать слот уже в новом формате.

```js
const info = $.save.info(1);
if (info.legacy) $.log('слот из версии ' + info.version);
```

Сохранение с `version` больше текущей не открывается: `read()`/`load()`
возвращают `null`/`false`, `info()` — `null`, а `last_error` объясняет причину.

---

## 7. Автосейв

```js
$.save.autosave(60000);        // каждую минуту игрового времени в слот 0
$.save.autosave(30000, 'auto');// или в свой слот
$.save.stopAutosave();
$.save.list();                 // слот '0' видно как обычный слот
```

Автосейв идёт через `$.time.every()`, поэтому уважает `$.time.pause()` и
`$.time.scale()`, и **не** переключает текущий слот: игрок может сохраняться
руками в слот 1, пока автосейв пишет в 0. Если `$.time` нет (модульный тест
без движка), `autosave()` вернёт `0` и объяснит это в журнале.

---

## 8. Счётчики

Всё, что лежит в `$.store` (убийства, собранное золото, открытые двери),
уезжает в слот автоматически. Для типового случая «просто счётчик» есть
короткая форма:

```js
$.save.counter('kills', 1);    // увеличить и вернуть новое значение
$.save.counter('kills');       // прочитать (0, если счётчика ещё нет)
$.store.set('inventory', ['меч', 'щит']);   // инвентарь — обычные данные $.store
```

---

## 9. Ограничения

* **Сцены не переключаются сами.** Поле `scene` справочное: `load()` не зовёт
  `$.scene.load()`, иначе поверх сцены из кода лёг бы ещё и мир из слота.
  Хотите начать с сохранённой сцены — вызовите `$.scene.load($.save.read(1).scene)`.
* **Мир восстанавливается через `$.prefab`.** Если `$.prefab` не установлен
  (или модуль собран без него), `worldData()` вернёт `null`, слот сохранит
  только данные `$.store`, а в журнале будет объяснение.
* **Скрипты узлов не сохраняются** — `$.prefab` хранит данные, а не функции.
  После загрузки узлы создаются заново: подписки, таймеры и твины нужно
  навесить самому (обычно в `$.ready`/сцене).
* **Скорости — по порядку обхода.** `speeds` сопоставляются узлам
  «узел → дети» на момент снимка; если игра сама удаляет узлы между
  сохранением и загрузкой, соответствие может сбиться (это не ошибка загрузки,
  а цена «дешёвого» формата).
* **`list()` читает файлы.** Десятки больших слотов — это десятки чтений;
  для частого обновления меню используйте `list({ meta: false })`.
* **Текстуры движок не выгружает** (см. `resource.md`): у `$.save` своей
  выгрузки нет вообще — он пишет файлы и ничего не кэширует, кроме строки
  текущего каталога и слота.
* **Бинарных слотов нет.** Всё, что не переживает `JSON.stringify` (функции,
  `Map`/`Set`, ссылки на живые объекты), срезается при сохранении — так же,
  как в `$.prefab`.

---

## 10. Чистые функции (тесты без движка)

Экспортируются для `tests/js/save_test.mjs` — их можно звать без движка:

| Функция | Смысл |
|---|---|
| `normalizeSlot(slot)` / `normalizeDir(dir)` | каноническое имя слота и каталога |
| `slotFileName(slot)` / `slotPath(dir, slot)` / `slotFromFile(file)` | файлы слотов |
| `compareSlots(a, b)` / `sortSlots(list)` | человеческая сортировка |
| `makeSave(raw)` / `serializeSave(raw)` | канонический снимок и его JSON |
| `migrateSave(raw)` | миграция любой версии к текущей (`null` — не сохранение) |
| `parseSaveJson(text)` | строка → `{ ok, data, raw, error }` без исключений (`raw` — объект файла, нужен для `saveInfo`) |
| `flattenNodes(data)` | обход узлов и детей в порядке `nodeToData()` |
| `saveInfo(raw, extra)` | метаданные слота для `list()`/`info()` |
