# Сигналы — `$.signal`

`signal.js` — именованная шина событий в духе Godot: `on` / `once` / `off` /
`emit` / `waitFor`, с приоритетами, отложенной доставкой и счётчиками
подписчиков. Это «радио» игры: один объект крикнул — все, кому надо, услышали,
и никто ни о ком не знает.

```js
$.ready(() => {
    $.signal.on('enemy:died', (enemy, score) => {
        $.store.set('score', ($.store.get('score') || 0) + score);   // система очков
    }, { priority: 10 });                            // сработает раньше остальных

    $.signal.once('level:start', () => $.sound.play('intro'));

    $('#hero').on('collision', (e) => {
        const other = e.data.other.get(0);
        if (other.tag === 'enemy') {
            other.kill();
            $.signal.emit('enemy:died', other, 10);  // аргументов сколько угодно
        }
    });
});
```

---

## 1. Чем отличается от `$.on` и `.on()` у узла

| | `$.on` / `$.emit` | `.on()` у узла | `$.signal` |
|---|---|---|---|
| Кто адресат | глобальные подписки | конкретный узел | имя события |
| Приоритеты | нет | нет | есть (`{ priority }`) |
| `emit` внутри `emit` | вклинивается | вклинивается | встаёт в очередь |
| Ожидание (`then`) | нет | нет | `waitFor` |
| Счётчик подписчиков | нет | нет | `count`/`list`/`names` |
| Одноразовая подписка | вручную | вручную | `once` |

События узла (`node.emit('hit')`) шину не задевают и наоборот — это разные
миры. Мост между ними ставится одной строкой, если нужен:

```js
$('#door').on('enter', (e) => $.signal.emit('door:enter', e.data.other));
```

---

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

Модуль экспортирует `installSignal($)` — её вызывает `api.js` при сборке API
(кадрового шага у шины нет: рассылка идёт в момент `emit`).

| Функция | Возвращает | Назначение |
|---|---|---|
| `$.signal.on(name, fn, opts?)` | `id` | подписка; `opts`: `{ priority, once }` |
| `$.signal.once(name, fn, opts?)` | `id` | подписка на одно срабатывание |
| `$.signal.off(name, fnOrId?)` | число снятых | без второго аргумента снимает всех подписчиков имени |
| `$.signal.emit(name, ...args)` | число вызовов | рассылка; `0` — если доставка отложена |
| `$.signal.clear(name?)` | число снятых | снять подписки имени или все |
| `$.signal.count(name?)` | число | подписчиков у имени; без имени — всего |
| `$.signal.has(name)` | bool | есть ли живые подписчики |
| `$.signal.names()` | массив | имена с подписчиками или ожиданиями |
| `$.signal.list(name)` | массив | `{ id, priority, once }` — для отладки и тестов |
| `$.signal.waitFor(name, opts?)` | ожидание | `opts.timeout` — мс игрового времени |
| `$.signal.waiters(name?)` | число | сколько ожиданий висит |
| `$.signal.bus()` | объект шины | для отладки и юнит-тестов |

Имя сигнала — любая непустая строка; договоритесь о схеме (`enemy:died`,
`ui:open`) — это единственный «контракт» между отправителем и получателем.

---

## 3. Порядок и приоритеты

```js
$.signal.on('hit', () => $.log('третий'));                    // priority 0
$.signal.on('hit', () => $.log('первый'), { priority: 100 });
$.signal.on('hit', () => $.log('второй'), { priority: 100 });
```

* больше `priority` — раньше вызов; приоритет по умолчанию — `0`
  (`DEFAULT_PRIORITY`), дробные и `NaN` тоже становятся нулём;
* при равном приоритете — порядок подписки (не «как повезёт»: список
  поддерживается вставкой в нужное место, а не пересортировкой).

Порядок важен, когда один обработчик готовит данные для другого: урон должен
примениться раньше, чем HUD перерисуется.

---

## 4. Отложенная доставка: `emit` внутри `emit`

```js
$.signal.on('outer', () => {
    $.log('outer:1');
    $.signal.emit('inner');       // встанет в очередь, вернёт 0
    $.log('outer:2');
});
$.signal.on('inner', () => $.log('inner'));

$.signal.emit('outer');
// outer:1 → outer:2 → inner
```

Вложенное событие **не вклинивается** в текущую рассылку: получатель не видит
«полусобытие». Очередь разбирается в порядке поступления (FIFO), сколько бы
уровней вложенности ни было.

Во время рассылки можно подписываться и отписываться:

* отписавшийся до своей очереди обработчик не вызывается;
* новый подписчик получит только следующие события (текущая рассылка идёт по
  снимку списка);
* `once` снимается **до** вызова, поэтому `emit` из обработчика не вызовет его
  второй раз.

Ошибка в обработчике не рвёт рассылку: она уходит в `$.log`
(`$.signal: ошибка в обработчике "имя": …`), остальные подписчики получают
событие.

---

## 5. Ожидание сигнала: `waitFor`

```js
const [enemy] = await $.signal.waitFor('enemy:died');

// или без await — тогда ждём по-походному:
const door = $.signal.waitFor('door:open', { timeout: 3000 });
door.then(() => $.log('открылась'), (error) => $.log('не дождались: ' + error.message));
```

| Метод ожидания | Смысл |
|---|---|
| `.then(onOk, onErr)` | `onOk(...args)` при сигнале; `onErr(error, waiter)` при отмене/таймауте |
| `.catch(onErr)` | только ошибка |
| `.cancel(reason?)` | снять ожидание (сигнал его больше не разбудит) |
| `.done()` | сработало/отменено? |
| `.value()` | массив аргументов сигнала или `null` |

Особенности:

* если сигнал **уже пришёл**, `then` вызывает обработчик синхронно (в том же
  кадре) — сценарный код не зависит от микротасков;
* если нет — возвращается настоящий `Promise`, поэтому `await` работает;
* `timeout` требует `$.time` (в юнит-тестах без движка таймаут не ставится и в
  лог уходит предупреждение); при срабатывании ожидание отменяется с ошибкой
  `$.signal.waitFor("имя"): истёк таймаут …`;
* ожидание не считается подписчиком: `count()` его не видит, а `waiters()` —
  видит.

---

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

`tests/js/signal_test.mjs` проверяет шину напрямую, без `$` и движка:

| Экспорт | Назначение |
|---|---|
| `createBus()` | пустая шина |
| `subscribe(bus, name, fn, opts)` | подписка → запись подписки |
| `unsubscribe(bus, name, fnOrSub)` | отписка → число снятых |
| `emit(bus, name, args)` | рассылка; `args` — массив |
| `clearBus(bus, name?)` | снятие подписок |
| `subscriberCount(bus, name?)`, `subscribersOf(bus, name)`, `busNames(bus)` | счётчики и списки |
| `waitFor(bus, name)`, `waiterCount(bus, name?)` | ожидания |
| `pendingEmits(bus)` | длина очереди отложенных рассылок |
| `signalName(name)` | проверка имени (бросает с подсказкой) |

---

## 7. Пример: смерть врага без единой связи между системами

```js
// Система очков
$.signal.on('enemy:died', (enemy, score) => {
    $.store.set('score', ($.store.get('score') || 0) + score);
}, { priority: 100 });

// Звук — после очков, чтобы не тормозить счёт
$.signal.on('enemy:died', () => $.sound.play('die'), { priority: 0 });

// Интерфейс — одноразовая подписка на первую смерть
$.signal.once('enemy:died', () => $.log('Первый!'));
```

---

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

* **Подписки не переживают смену сцены сами.** Шина модульная: чистите её
  `$.signal.clear()` в обработчике смены сцены, иначе старые замыкания будут
  держать удалённые узлы.
* **`emit` синхронный.** Он не «размазывает» работу по кадрам: тяжёлые
  обработчики тормозят кадр — уносите работу в `$.flow`.
* **Аргументы не копируются.** Передавайте значения или immutability сами:
  обработчик получает те же объекты.
* **Нет wildcard-подписок.** `$.signal.on('*', …)` — это буквальное имя `'*'`,
  а не «все события»; для глобального перехвата есть `$.on('*')`.
* **Приоритеты — целые числа.** Дробные и `NaN` превращаются в 0.
* **Порядок `waitFor`-ожиданий** — порядок постановки; они срабатывают после
  обычных подписчиков этого имени.

---

## 9. Тесты

| Файл | Что проверяет |
|---|---|
| `tests/js/signal_test.mjs` | приоритеты, отложенную доставку, once, отписку во время рассылки, ошибки, waitFor |
| `tests/agent/highlevel_state_test.py` | `$.signal` в живом движке (вместе с `$.state` и `$.flow`) |

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