# Машина состояний — `$.state` и `.fsm()`

`state.js` — машина состояний (FSM) для **логики игры**: `idle → run → air →
land`, фазы босса, режимы двери, состояния экрана. Это не то же самое, что
`$.anim.stateMachine()`: та машина выбирает **клип** (какая анимация играет), а
`$.state` — **смысл** происходящего. Обе спокойно живут на одном узле.

```js
$.ready(() => {
    $.state.create({
        name: 'hero',
        initial: 'idle',
        states: {
            idle: { on: { jump: 'air', move: { target: 'run', guard: (m) => m.data.moving } } },
            run:  { on: { stop: 'idle', jump: 'air' } },
            air:  {
                initial: 'up',                                     // составное состояние
                states: {
                    up:   { on: { land: 'down' } },                // цель-сосед: air.down
                    down: { on: { land: 'idle' } },                // цель от корня
                },
            },
        },
    });

    $('#hero').fsm('hero');                                    // привязка к узлу
    $('#hero').on('collision', () => { if ($('#hero').fsm() === 'air.up') $('#hero').fsmSend('land'); });

    $.state.get('#hero').onEnter('air', () => $.sound.play('whoosh'));
});
```

Метод узла называется **`.fsm()`**, а не `.state()`: `.state()`, `.states()`,
`.toState()`, `.stateMachine()` и `.stateTime()` уже заняты анимацией
(`src/highlevel/anim.js`, см. `_CONTRACT.md` §5) — переопределять их нельзя.

---

## 1. Спецификация машины

```js
const machine = $.state.create({
    name: 'door',          // имя в реестре ($.state.byName)
    initial: 'closed',     // стартовое состояние; по умолчанию — первое в states
    history: true,         // вести историю (по умолчанию true)
    historyLimit: 64,      // сколько записей хранить
    data: { locked: true },// произвольные данные машины, видны в guard/action
    node: '#door',         // сразу привязать к узлу (необязательно)
    states: { /* … */ },
});
```

Спецификация проверяется **один раз при создании**: опечатка в имени состояния
или в цели перехода бросает исключение с перечнем доступных состояний, а не
всплывает в бою.

| Поле состояния | Тип | Смысл |
|---|---|---|
| `enter` | `(machine, data, event) => void` | вошли в состояние |
| `exit` | `(machine, data, event) => void` | вышли из состояния |
| `update` | `(machine, dt) => void` | покадровое обновление активного состояния |
| `on` | `{ событие: цель }` | таблица переходов (см. §2) |
| `initial` | строка | стартовое подсостояние (для составных) |
| `states` | объект | вложенные состояния |

Ошибка внутри `enter`/`exit`/`update`/`guard`/`action` **не роняет игру**: она
уходит в `$.log` (`$: ошибка в …`), а машина продолжает работать.

---

## 2. Переходы

| Форма записи | Пример | Смысл |
|---|---|---|
| строка | `on: { jump: 'air' }` | безусловный переход |
| объект | `on: { move: { target: 'run', guard, action } }` | с условием и действием |
| функция | `on: { go: (m, data) => data.where \|\| false }` | цель вычисляется на месте |
| ловушка | `on: { '*': 'idle' }` | любое необработанное событие |

Правила:

* **Цель** ищется сначала от корня (`'air.down'`), затем среди соседей
  исходного состояния: `down` из `air.up` — это `air.down`.
* **Событие всплывает**: если его не обработал лист (`air.up`), смотрим `air`,
  потом корень. Одно событие — один переход, дальше всплытие прекращается.
* **`guard(machine, data)`** решает, состоится ли переход. `can(event, data)`
  проверяет то же самое, но **без побочных эффектов**: `action`, `enter` и
  `exit` не вызываются.
* **`action(machine, data, event)`** выполняется до входа в новое состояние
  (звук, счётчик, запись в `machine.data`).
* **Неизвестное событие** — не ошибка: `send()` возвращает `false`, состояние
  не меняется. Падение `guard` трактуется как отказ.
* **Переход в текущее состояние перезапускает его** (`exit` → `enter`): так
  «сбросить» атаку или таймер состояния без отдельного события.
* `set(name)` — принудительный переход без события; неизвестное имя бросает
  исключение (это ошибка программиста, а не игровая ситуация).

---

## 3. Составные состояния (минимально, но честно)

Поддержано:

* у состояния может быть `states` и `initial` — вход в родителя автоматически
  входит в `initial` (и так до листа);
* активный путь — массив: `['air', 'up']`, `is('air')` истинно и для листа
  `air.up`, `is('air.up')` — только для него;
* события ищутся от листа к родителям;
* относительные цели (`down` из `air.up` → `air.down`);
* `enter`/`exit` и хуки вызываются по каждому уровню пути: вход от корня к
  листу, выход от листа к корню. Родитель, который остался активным, `exit` не
  получает;
* имена состояний в API **полные**: `current()`, `is()`, `onEnter()`,
  `history()` работают со строками вида `air.up`. Точка — разделитель, в самих
  именах состояний её использовать нельзя.

Не поддержано (осознанно): параллельные состояния (несколько активных веток
одновременно), «исторические» узлы-псевдосостояния Godot, вход в состояние с
конкретной глубины, отдельный контекст на подсостояние. Если нужно
параллельное поведение — заведите две машины: они друг о друге не знают.

---

## 4. История

| Вызов | Что возвращает |
|---|---|
| `machine.history()` | имена листьев в порядке входа; первый — стартовое состояние |
| `machine.previous()` | состояние до текущего (или `null`) |
| `machine.back(data)` | переход в предыдущее состояние; `false`, если его нет |
| `machine.transitions()` | журнал `{ from, to, event }`; стартовый вход имеет `from: null` |

`history: false` отключает запись (и `back()` вместе с ней). Длина
ограничена `historyLimit`.

---

## 5. Чистое ядро (для тестов без движка)

Граф переходов не знает ни про `$`, ни про движок — `tests/js/state_test.mjs`
проверяет его напрямую:

| Экспорт | Назначение |
|---|---|
| `normalizeStateSpec(spec)` | проверка и канонизация спецификации; бросает с перечнем доступных состояний |
| `createMachine(spec, hooks?)` | машина без реестра и узла |
| `stateExists(states, name)` | есть ли состояние (`'air.jump'` или `['air','jump']`) |
| `statePathOf(states, name)` | путь до состояния; бросает, если его нет |
| `flatStateNames(states, prefix?)` | плоский список имён, включая вложенные |
| `defAtPath(states, path)` | описание состояния по пути или `null` |
| `findTransition(states, path, event)` | переход с учётом всплытия и `'*'` |
| `resolveTransitionTarget(states, scope, target)` | абсолютная и относительная цель → путь |
| `descendToLeaf(states, path)` | достройка пути до `initial` составных состояний |
| `enterExitPlan(fromPath, toPath)` | `{ common, exit, enter }` для перехода |
| `pathHas(path, name)` | активен ли предок или сам лист |
| `isMachine(value)` | машина ли это (а не узел) |

---

## 6. Публичное API

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

| Функция | Назначение |
|---|---|
| `create(spec)` | создать машину; она попадает в реестр и тикает в кадре |
| `get(nodeOrSelector)` | машина узла или `null` |
| `attach(nodeOrSelector, machineOrName)` | привязать машину к узлу |
| `detach(nodeOrSelector)` | отвязать (машина продолжает жить) |
| `send(nodeOrSelector, event, data)` | отправить событие → `true`, если переход состоялся |
| `set(nodeOrSelector, name, data)` | принудительный переход |
| `is(nodeOrSelector, name)` | активно ли состояние (с учётом вложенности) |
| `can(nodeOrSelector, event, data)` | возможен ли переход (guard'ы проверяются) |
| `current(nodeOrSelector)` | полное имя активного листа |
| `byName(name)` | машина из реестра по `spec.name` |
| `list()` | все живые машины |
| `destroy(machineOrName)` | уничтожить машину |
| `clear()` | уничтожить все машины (например, при смене сцены) |

Первым аргументом везде принимается узел, обёртка `$('#hero')` или селектор.

### Машина

| Метод | Назначение |
|---|---|
| `send(event, data)` / `can(event, data)` | переход по событию / проверка без побочных эффектов |
| `set(name, data)` | принудительный переход |
| `is(name)` / `has(name)` | состояние активно / существует в графе |
| `current()` / `path()` | полное имя листа / массив активного пути |
| `states()` / `events()` | все имена состояний / события, доступные сейчас |
| `history()` / `previous()` / `back(data)` / `transitions()` | история (§4) |
| `onEnter(name, fn)` / `onExit(name, fn)` | хуки; `'*'` — любое состояние; `fn(machine, state, event, data)` |
| `onTransition(fn)` | `fn(machine, from, to, event)` после каждого перехода |
| `update(dt)` | шаг машины: `update` активных состояний от корня к листу |
| `time()` / `totalTime()` | секунды в текущем листе / всего |
| `attach(node)` / `detach()` / `node()` | привязка к узлу |
| `destroy()` | погасить машину (дальше `send()` → `false`) |
| `toJSON()` | сводка `{ name, current, path, data }` — для агента, логов и `$.store` |
| `data` | пользовательские данные (видны в guard/action) |

### Методы узла

| Метод | Назначение |
|---|---|
| `.fsm()` | имя текущего состояния (геттер) или `null` |
| `.fsm(machine \| name \| spec)` | привязать машину, машину из реестра или создать из `{ initial, states }` |
| `.fsm(null)` | отвязать |
| `.fsmSend(event, data)` | отправить событие (цепочный, результат — через `$.state.send`) |

---

## 7. Кадровый шаг

Модуль экспортирует `installState($)` (ставит `$.state`, методы узла `.fsm()` и
`.fsmSend()`) и `tickState(dt)` — их вызывает `api.js` при сборке API и в
кадровом цикле.

`tickState(dt)`:

* зовёт `machine.update(dt)` у всех машин реестра;
* отвязывает машины от удалённых узлов (`node.removed`) — сама машина остаётся
  жить, её может держать игровой код.

Машина, не привязанная ни к какому узлу (машина игры: экран, волна, глава),
тикает точно так же.

---

## 8. Примеры

### Дверь с замком и историей

```js
$.state.create({
    name: 'door',
    initial: 'closed',
    data: { locked: true },
    states: {
        closed: {
            on: {
                open: { target: 'opening', guard: (m) => !m.data.locked },
                unlock: { target: 'closed', action: (m) => { m.data.locked = false; } },
            },
        },
        opening: { enter: () => $.sound.play('door'), on: { done: 'open' } },
        open: { on: { close: 'closed' } },
    },
});
$('#door').fsm('door');
$.flow.after(400, () => $.state.send('#door', 'done'));   // анимация доиграла
```

### Фазы босса и машина боя — две разные машины

```js
// Машина фаз живёт на узле босса.
const phases = $.state.create({ initial: 'idle', states: { idle: {}, rage: {}, dying: {} } });
phases.onTransition((m, from, to) => $.log(`босс: ${from} → ${to}`));
$('#boss').fsm(phases);

// Машина боя — на игроке, они друг о друге не знают.
$.state.create({
    name: 'player',
    initial: 'calm',
    states: {
        calm: { on: { aggro: { target: 'fight', guard: (m) => m.data.threat > 0 } } },
        fight: { on: { '*': 'calm' } },
    },
    node: '#hero',
});

$.signal.on('boss:hp', (hp) => {
    if (hp < 50 && $.state.is('#boss', 'idle')) $.state.send('#boss', 'rage');
    $.state.get('#hero').data.threat = 1;
});
```

Одна машина на узел: `attrs.fsm` хранит одну привязку, повторный `.fsm(...)`
заменяет её. Вторую машину того же узла держите в переменной и работайте с ней
напрямую (`machine.send(...)`), а не через `$.state.get(node)`.

---

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

* **Одна машина на узел.** Привязка живёт в `node.attrs.fsm`; повторный
  `.fsm(...)` заменяет предыдущую машину (старая остаётся в реестре).
* **Нет параллельных состояний** и псевдосостояний истории Godot — см. §3.
* **`can()` вызывает функции-цели и `guard`** (иначе не проверить условие).
  Побочные эффекты должны быть только в `action`/`enter`/`exit`.
* **Машины не сериализуются.** `$.prefab.toData()` не сохраняет состояние
  машины: сохраняйте `machine.current()` и `machine.data` сами.
* **Имена состояний — плоские строки с точкой-разделителем.** Точка внутри
  имени сломает и `is()`, и историю.
* **`tickState` идёт после игровой логики кадра** (как и остальные подсистемы):
  `update` состояний видит мир уже обновлённым в этом кадре.

---

## 10. Тесты

| Файл | Что проверяет |
|---|---|
| `tests/js/state_test.mjs` | граф, порядок enter/exit, guard'ы, история, привязка к узлу |
| `tests/agent/highlevel_state_test.py` | то же в живом движке: `$.state.*`, `.fsm()`/`.fsmSend()` |
| `tests/fixtures/state/` | игра-фикстура для интеграционного теста |

```bash
build/_deps/quickjs-build/qjs tests/js/state_test.mjs
python3 tests/agent/highlevel_state_test.py     # после сборки движка
```
