# Сеть — `$.net` (только авторитарная модель)

**Решение проекта.** В высокоуровневом API `$` существует **только авторитарный
мультиплеер**: один узел — хост-сервер, его симуляция всегда права; клиенты не
вычисляют игровое состояние, а присылают ввод и рисуют то, что подтвердил
сервер. Peer-to-peer, детерминированный лок-степ и «у каждого своя правда» в
API не выставляются: там, где выбор есть, побеждает состояние сервера.

```js
$.net.host(7777, { maxPlayers: 8 });     // сервер: авторитет
$.net.join('127.0.0.1', 7777);           // клиент: только ввод и рендер

$.net.on('join',  p => spawnPlayer(p));  // событие сервера
$.net.on('leave', p => despawnPlayer(p));
$.net.replicate('#hero', { owner: p });  // сервер объявляет репликацию
$.net.send('input', { seq: 1, right: true });      // клиент → сервер
$.net.on('input', (p, d) => applyInput(p, d));     // только на сервере
$.net.on('snapshot', s => $.net.apply(s));         // клиент применяет правду
```

---

## 1. Что модель снимает и что требует

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

**Требует:** стабильные сетевые id, овнершип тел по игроку, предсказание
локального игрока и интерполяция чужих, лаг-компенсация на сервере и
**инвариант «клиент не пишет авторитетное»**.

## 2. Роли и запреты

| Метод | Смысл |
|---|---|
| `$.net.host(port, { maxPlayers })` | сервер: мир считает этот узел |
| `$.net.join(address, port, { player })` | клиент: только ввод и рендер |
| `$.net.leave()` / `reset()` | выйти / сбросить всё |
| `role()` / `isServer()` / `isClient()` / `online()` | состояние узла |
| `describe()` / `stats()` | строка и счётчики; `stats.lastSendOk` — ушёл ли последний пакет |

Повторный `host` или `join` на занятом узле отклоняется — роли не смешиваются.

## 3. Игроки и владение

| Метод | Смысл |
|---|---|
| `addPlayer()` / `removePlayer(p)` | сервер: игрок пришёл / ушёл (события `join`/`leave`) |
| `replicate(узел, { owner })` | сервер объявляет узел реплицируемым; возвращает сетевой id |
| `unreplicate(узел)` | снять с репликации |
| `ownerOf(узел)` / `owns(player, узел)` / `ownedBy(player)` | чей узел |
| `idOf(узел)` / `nodeOfId(id)` | сетевой id и обратно |

**Сетевые id стабильны**: один узел — один номер, номера **не переиспользуются**
в пределах сессии. Иначе клиент сопоставил бы чужую сущность со своей.

`replicate` на клиенте **отказывает** — объявлять репликацию может только сервер.

## 4. Снапшоты и дельта

Сервер собирает состояние описанных узлов (`snapshotNow()` → `{ tick, entities }`,
где у сущности `owner`, `x`, `y`, `hp`), а отправляет **дельту** от последнего
подтверждённого: попадают только изменившиеся поля, удалённые помечены `null`.

```js
const full  = diffSnapshot(null, snapshot, tick);          // первый — полный
const delta = diffSnapshot(confirmed, snapshot, tick);     // дальше — только изменения
```

Клиент применяет снапшот: `apply(snapshot)` / `on('snapshot', ...)`. Предыдущее
подтверждённое состояние **не перезаписывается** — оно нужно для интерполяции.

## 5. Своё и чужое

| Метод | Смысл |
|---|---|
| `confirmed()` / `previous()` | два последних подтверждённых состояния |
| `get(key, field)` | значение из подтверждённого |
| `polated(key, own, t)` | своё — подтверждённое, чужое — интерполированное |
| `nextInput()` | следующий номер ввода для отправки |

Своих игроков не интерполируем (их предсказывает клиент), чужих показываем
между двумя подтверждёнными снапшотами. Числа и массивы интерполируются,
нечисловые поля берутся из нового снапшота.

## 6. Ввод

```js
$.net.send('input', { seq, left, right, jump });   // клиент → сервер
$.net.on('input', (p, d) => applyInput(p, d));     // только на сервере
```

Номера ввода идут по порядку: повтор и переупорядочивание игнорируются, пропуск
(потерянный пакет) не догоняется — ввод уже неактуален. Очередь короткая, старые
записи вытесняются. На клиенте `receive({ channel: 'input' })` **отклоняется**:
ввод применяет только сервер.

## 7. Транспорт

Транспорт подключается снаружи — так модель не зависит от сокетов:

```js
$.net.attach({
    listen(port, opts) {},     // сервер: слушать порт
    connect(host, port, opts) {},  // клиент: подключиться
    send(message) {},          // отправить { channel, data }
    poll() { return []; },     // вернуть принятые сообщения
    close() {},
});
$.net.poll();                  // игра зовёт в своём кадре
```

**Транспорт движка на SDL3_net** подключается одной строкой:

```js
$.net.bindEngine();        // false, если сборка без R2D_ENABLE_NET
$.net.engineBound();       // true — сейчас работает транспорт движка
```

Канал — **датаграммы** (UDP): сервер отвечает на адрес отправителя, не заводя
соединений на каждого игрока, а потеря пакета не блокирует остальных. Сообщение
сериализуется в байты (`encodeMessage`/`decodeMessage`, обычный JSON), движок
возит байты.

Низкоуровневые вызовы движка: `engine.netHost(port)`, `engine.netJoin(host, port)`,
`engine.netSend(bytes, to?, toPort?)`, `engine.netPoll()`, `engine.netClose()`,
`engine.netStatus()`, `engine.netSimulate(loss, delay?, seed?)`.

**Игрок определяется АДРЕСОМ пира, а не тем, что клиент написал о себе**: первый
пакет с нового адреса заводит игрока на сервере (`$.net.peers()` показывает
привязанные адреса). Клиент не может назваться чужим номером.

## 8. Задержка, предсказание и лаг-компенсация

### Симуляция плохой сети

```js
$.net.simulate({ loss: 10, delay: 150, jitter: 30, seed: 7 });
$.net.simulation();          // { loss, delay, jitter, seed }
$.net.delayed();             // сколько пакетов ждёт своей задержки
$.net.simulateOff();         // всё по нулям
```

| Поле | Смысл |
|---|---|
| `loss` | процент потерь `0..100`: пакет считается отправленным, но не уходит |
| `delay` | миллисекунды задержки |
| `jitter` | случайная добавка `[0, jitter)` к задержке |
| `seed` | сид: потери и разброс воспроизводимы |

**Задержка делается ОЧЕРЕДЬЮ отложенных отправок, а не сном.** Спать в кадре
нельзя, поэтому пакет кладётся с временем «когда отправить» и реально уходит из
`poll()`, когда это время придёт. Очередь на 64 пакета; при переполнении — одно
предупреждение в журнал и пакет теряется (кадр не роняется).

`$.net.delayed()` показывает, что задержка **действительно** работает: сразу
после `send` в очереди есть пакет, а получателя он ещё не достиг.

Потери применяются **при постановке**, поэтому потерянный пакет не занимает
очередь задержки.

### RTT

```js
$.net.ping($.time.now());     // клиент: раз в секунду-две, не каждый кадр
$.net.rtt();                  // круговая задержка в миллисекундах
$.net.latency();              // половина задержки в секундах
```

Сервер отвечает `pong` тем же числом, что прислал клиент, поэтому задержка
считается по разнице времени и не зависит от часов на разных машинах.

### Предсказание локального игрока

```js
// Та же чистая функция шага, что и на сервере.
$.net.predict((state, input) => ({ ...state, x: state.x + input.dx }));

$.net.applyInput({ seq: 1, dx: 4 });   // применилось сразу
$.net.send('input', { seq: 1, dx: 4 });
const st = $.net.prediction().state(); // мгновенный отклик
```

Когда приходит снапшот, клиент **откатывается** к серверному состоянию и
**повторяет** неподтверждённые вводы. Правда всегда серверная, отклик —
мгновенный. Диагностика «дёрганости»: `prediction().corrections()` (сколько
откатов) и `prediction().error()` (насколько предсказание разошлось с правдой).

### Сглаживание откатов

```js
// В отрисовке: плавное состояние вместо симуляционного.
const view = $.net.prediction().visual(dt, { rate: 14, snap: 200 });
$('#hero').at(view.x, view.y);

$.net.prediction().visualError();   // насколько визуал отстал (отладка)
$.net.prediction().resetVisual();   // забыть визуал (переход между сценами)
```

**Зачем.** После отката клиент повторяет неподтверждённый ввод, и `state()` может
прыгнуть. Если рисовать его напрямую, каждая коррекция **дёргает картинку**.
Сглаживание держит отдельное визуальное состояние и подтягивает его к
симуляционному, поэтому игрок видит плавное движение, а правда остаётся
серверной.

| Опция | Смысл |
|---|---|
| `rate` | скорость догона, 1/с (по умолчанию 12) |
| `snap` | расхождение, с которого сглаживание **сдаётся** и ставит значение сразу (0 — никогда) |
| `fields` | какие поля сглаживать (по умолчанию все числовые) |

**Сглаживаются только числовые поля.** Позу (`pose`) интерполировать бессмысленно,
флаг — невозможно, поэтому они берутся как есть.

**Первый вызов ставит цель сразу** — иначе визуал «приползал» бы из нуля при
появлении игрока.

`snap` нужен для настоящих телепортов: если игрока перенесло, тянуть его через
полкарты нельзя — это выглядит хуже, чем мгновенный перенос.

Доля пути за кадр считается экспонентой (`1 - exp(-rate*dt)`), а не `rate*dt`:
иначе при просадке кадра значение перескакивало бы цель.

Чтобы подтверждение работало, сервер кладёт в снапшот номер обработанного ввода:
сущность с полем `seq` (например `{ x, seq }`). Клиент берёт наибольший `seq`.

### Лаг-компенсация (только сервер)

```js
$.net.record($.time.now());              // сервер: в своём кадре
const past = $.net.rewind('#enemy', now); // где он был «сейчас минус RTT/2»
```

Сервер проверяет попадание по состоянию на момент **RTT/2 назад**, а не по
текущему: клиент стреляет по тому, кого видел. `history()` показывает размер и
окно истории; ёмкость и окно (`capacity`, `seconds`) задаёт `createHistory`.

## 9. Ограничения (честно)

* **петля замкнута, но не всё измерено**: клиент → сервер и сервер → клиент
  работают (проверено двумя процессами движка), `RTT` считается по `ping`/`pong`
  (§8), однако порядок и переупорядочивание пакетов не измеряются, а
  подтверждений доставки нет — «дошло ли» игра узнаёт только по следующему
  снапшоту;
* **предсказание есть, но шаг симуляции — ваш**: клиент повторяет ровно ту
  функцию, которую вы дали в `predict`; если она не совпадает с серверной,
  откаты будут чаще (и это видно в `corrections()`);
* **история позиций ведётся вручную**: сервер зовёт `record()` сам; если забыть,
  лаг-компенсация молча ничего не найдёт;
* **сглаживание откатов — только числовые поля**: поза (`pose`) и флаги берутся
  из нового снапшота как есть, а `snap` при большом расхождении ставит значение
  сразу, без догона (см. §8);
* **симуляция задерживает только отправку**: очередь отложенных пакетов живёт на
  стороне отправителя (`r2d_net_send`), поэтому входящие приходят как есть —
  буфера приёма с задержкой нет; переупорядочивания и дублей симуляция тоже не
  создаёт: пакеты теряются и задерживаются, но не приходят в другом порядке;
* **шаг симуляции отдельно от кадра не вынесен**: `poll()` зовёт игра в своём
  `$.update`;
* **агентских команд `net`/`net-peer` нет** — сетевые сценарии в тестах
  разыгрываются двумя процессами движка (см. `tests/agent/net_loopback_test.py`);
* **сжатия и шифрования нет**: снапшоты — обычные объекты, для локальной сети и
  тестов этого достаточно, для интернета — нет.

## 10. Две проверки

```bash
# модель: id, владение, дельта, интерполяция, ввод, инвариант «клиент не пишет»
build/_deps/quickjs-build/qjs tests/js/net_test.mjs

# транспорт: два экземпляра движка на localhost (SDL3_net по петле)
python3 tests/agent/net_loopback_test.py
```


