# Потоки и таймеры — `$.flow`

`flow.js` — сценарные последовательности поверх игрового времени: «подожди,
открой дверь, подожди, закрой», «запусти три волны параллельно», «повтори
пять раз с паузой». Всё расписание живёт на `$.time`, поэтому пауза
останавливает потоки, а `$.time.scale` их ускоряет. `Date.now()` и `setTimeout`
не используются: при `--fixed-dt` прогон детерминирован — одни и те же кадры
дают один и тот же порядок вызовов.

```js
$.ready(() => {
    $.flow.series([
        () => $.sound.play('rumble'),           // шаг
        400,                                     // пауза 400 мс игрового времени
        () => doorOpen(),                        // ещё шаг
        () => $.flow.parallel([                  // шаг, который ждёт параллель
            $.flow.delay(600),
            () => $.camera.shake(6, 300),
        ]),
    ]).then(() => $.log('дверь открыта'));

    // Отмена по условию — одной строкой:
    $.signal.on('player:died', () => $.flow.cancelAll());
});
```

---

## 1. Чем отличается от `$.time.after` / `$.time.every` / `$.sequence`

| | `$.time.after/every` | `$.sequence([...])` | `$.flow` |
|---|---|---|---|
| Отмена | по id таймера | нет | хендл отменяет поток **вместе с вложенными** |
| Результат / цепочки | нет | нет | `then()` возвращает новый хендл |
| Параллельность | вручную | нет | `parallel` |
| Повторы | `every(ms, fn)` | нет | `repeat(n, fn)` |
| Шаг «не раньше кадра» | — | шаги слипаются в один кадр | каждый шаг — отдельный кадр |
| Время | игровое | игровое | игровое |

`$.time.after` и `$.time.every` никуда не делись — они про «просто таймер».
`$.flow` нужен там, где таймеров становится несколько и их надо отменять
вместе.

---

## 2. Хендл потока

`series`, `parallel`, `delay`, `after`, `repeat` возвращают **хендл**:

| Метод / поле | Смысл |
|---|---|
| `.then(onOk, onErr)` | новый хендл; `onOk(value, handle)` — когда поток завершился |
| `.catch(onErr)` | только ошибка |
| `.cancel(reason?)` | отменить поток |
| `.done()` | поток завершён (успех, ошибка или отмена) |
| `.isPending()` / `.isDone()` / `.isFailed()` / `.isCancelled()` | состояние |
| `.value` | значение результата (у `series`/`parallel` — массив) |
| `.error` | `Error` при провале или отмене |
| `.state` | `'pending' \| 'done' \| 'failed' \| 'cancelled'` |
| `.toJSON()` | сводка `{ kind, state }` — хендлы ссылаются друг на друга, поэтому без неё `JSON.stringify` падал бы |

Правила отмены:

* отмена родителя отменяет **вложенные** потоки, которые он запустил;
* отмена хвоста цепочки (`delay(100).then(f).cancel()`) отменяет и её начало —
  иначе таймер дожил бы до конца «в пустоту»;
* `cancel()` безопасен: повторный вызов и отмена завершённого дают `false`;
* `$.flow.cancelAll()` снимает всё расписание (и потоки, и отдельные задержки).

---

## 3. Шаги

Шагом может быть:

| Шаг | Пример | Поведение |
|---|---|---|
| число | `250` | пауза в мс игрового времени |
| функция | `() => door.open()` | вызывается сразу; результат обрабатывается (см. ниже) |
| хендл | `$.flow.delay(100)` | поток ждёт его завершения |
| `Promise` | `fetch(...)` | ждёт (завершится в микротаске, вне кадра) |

Результат функции-шага:

| Вернула | Что делает поток |
|---|---|
| число | пауза на это число мс (в `value` не попадает) |
| хендл / `Promise` | ждёт завершения |
| что угодно ещё | шаг завершён, значение попадает в `value` |
| `undefined` / `null` | шаг завершён |

```js
const h = $.flow.series([
    () => 'раз',            // value: ['раз']
    100,                    // пауза
    () => $.flow.delay(50), // ждём вложенный поток
    () => 42,               // это ПАУЗА 42 мс, а не значение
]);
h.then((values) => $.log(values));   // ['раз']
```

В `then` число — это **значение** (задержку оформляйте `$.flow.delay(n)`), в
шагах — **пауза**. Это разные вещи, и путать их не стоит.

---

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

| Функция | Возвращает | Назначение |
|---|---|---|
| `$.flow.series(steps)` | хендл | шаги по очереди; `value` — массив результатов |
| `$.flow.parallel(steps)` | хендл | все шаги сразу; `value` — результаты в порядке шагов |
| `$.flow.delay(ms)` | хендл | задержка; `delay(0)` завершается сразу |
| `$.flow.after(ms, fn)` | хендл | однократный вызов через `ms` (то же, что `delay(ms).then(fn)`) |
| `$.flow.repeat(n, fn)` | хендл | `n` повторов `fn(i)`; `n < 0` — бесконечно, `n = 0` — ничего |
| `$.flow.cancel(handle)` | bool | отменить поток |
| `$.flow.cancelAll()` | число отмен | отменить всё расписание |
| `$.flow.active()` | число | сколько потоков в работе |
| `$.flow.now()` | мс | игровое время расписания (не `Date.now()`) |
| `$.flow.scheduler()` | объект | планировщик — для отладки и юнит-тестов |

Провал шага переводит хендл в `failed`:

* ошибку видно в `.error`, состояние — `.isFailed()`;
* `.catch(onErr)` / `.then(ok, onErr)` её получают;
* если обработчика нет, ошибка уходит в `$.log` (`$.flow: …`) — тихой она не
  остаётся;
* провал ветки `parallel` отменяет остальные ветки: работа, которую никто не
  ждёт, — это забытые таймеры и звуки, которые доиграют без сцены.

---

## 5. Время и кадровый шаг

| Вызов | Назначение |
|---|---|
| `tickFlow(dt)` | кадровый шаг; время берётся из `$.time.delta()` — пауза и `$.time.scale` действуют сами |
| `tickFlowMs(ms)` | явный шаг в миллисекундах: юнит-тесты и пошаговые прогоны |
| `advanceScheduler(sched, ms)` | шаг конкретного расписания (чистая функция) |

Дисциплина кадра: **следующая ступень потока стартует не раньше следующего
`tickFlow`**. Это защищает от «слипания» длинной серии в один кадр и от
бесконечного цикла `repeat(-1, fn)` без кадров. Первый шаг при создании потока
выполняется сразу.

Без `$.time` (юнит-тест) `tickFlow(dt)` понимает `dt` в секундах — как и все
остальные `tick*` в подсистеме.

Модуль экспортирует `installFlow($)` (ставит `$.flow`), `tickFlow(dt)` и
`tickFlowMs(ms)` — их вызывает `api.js` при сборке API и в кадровом цикле.
`tickFlow` надо звать **без аргумента** (или с `dt` — он всё равно берёт время
из `$.time`): тогда пауза и `$.time.scale` действуют на потоки.

---

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

Планировщик не знает ни про `$`, ни про движок:

| Экспорт | Назначение |
|---|---|
| `createScheduler()` | пустое расписание `{ tasks, now, seq, on_error }` |
| `scheduleAfter(sched, delayMs, fn, periodMs?)` | поставить задачу (с `periodMs` — периодическую) |
| `advanceScheduler(sched, ms)` | продвинуть время и выполнить созревшее |
| `cancelTask(sched, task)` / `cancelAllTasks(sched)` | снять задачу / все |
| `schedulerSize(sched)` | сколько живых задач |
| `flowSeries` / `flowParallel` / `flowDelay` / `flowRepeat` | потоки без `$` |
| `cancelFlow(handle)` / `isFlowHandle(value)` / `activeFlowCount()` | хендлы |
| `settleHandle(handle, value)` / `failHandle(handle, error)` | ручное завершение (тесты, внешние события) |

Порядок в планировщике детерминирован: по сроку (`at`), при равных сроках — по
порядку постановки. Задача, поставленная **внутри** шага, срабатывает не
раньше следующего вызова `advanceScheduler` — так расписание не зацикливается
само на себя.

---

## 7. Примеры

### Катсцена с отменой

```js
let scene = null;

function playIntro() {
    scene = $.flow.series([
        () => $.camera.at(400, 300),
        800,
        () => $.sound.play('thunder'),
        200,
        () => $('#hero').show(),
        () => $.flow.repeat(3, (i) => {
            $.fx.pulse(100 + i * 40, 300, { radius: 30 });
            return 200;                       // пауза между вспышками
        }),
    ]).then(() => $.log('катсцена закончилась'));
}

$.input.on('key', (e) => { if (e.key === 'Escape') scene.cancel('игрок пропустил'); });
```

### Волны врагов

```js
$.flow.repeat(3, (wave) => {
    for (let i = 0; i < 4; i++) $.world.spawn('enemy', 100 + i * 60, 80, { wave });
    return $.flow.series([
        1500,                                                   // пауза до следующей волны
        () => $.log(`волна ${wave + 1} закончилась`),
    ]);
}).then(() => $.log('все волны отбиты'));
```

### Одноразовый таймер, который можно отменить

```js
const bomb = $.flow.after(3000, () => { $.fx.shockwave(x, y, { radius: 300 }); boom(); });
$('#defuser').on('use', () => { if (bomb.cancel()) $.log('обезврежено'); });
```

---

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

* **Потоки не сериализуются.** После загрузки сейва сценарные последовательности
  надо запускать заново.
* **Шаг-`Promise` завершается в микротаске**, а не в кадре: порядок
  относительно других шагов в этом случае не гарантирован. Для
  детерминированных сценариев используйте `delay`/`series`.
* **`repeat(-1, fn)` без паузы** прокручивает одну итерацию за кадр: это
  безопасно, но это не «мгновенный» цикл — для настоящего цикла берите
  обычный `for`.
* **Ошибка шага останавливает поток.** Продолжить после ошибки можно только
  снаружи: `h.catch(...)` и новый поток.
* **`cancelAll()` глобальный.** Он снимает и чужие потоки (например, таймер
  интерфейса) — если это нежелательно, отменяйте конкретные хендлы.
* **Точность — кадр.** `delay(16)` при 60 FPS может сработать в том же кадре,
  а может в следующем; расписание считает время, а не «спит».

---

## 9. Тесты

| Файл | Что проверяет |
|---|---|
| `tests/js/flow_test.mjs` | планировщик, порядок шагов, отмену вложенных потоков, `parallel`, `repeat`, `then`, ошибки, привязку к `$.time` |
| `tests/agent/highlevel_state_test.py` | `$.flow` в живом движке (вместе с `$.state` и `$.signal`) |

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