AGENT_API.md 21,9 КБ
Newer Older
Nikitos's avatar
Nikitos включено в состав коммита
1
2
3
4
5
6
7
8
9
10
11
# russiano2d — агентский интерфейс (низкий уровень)

Движок можно запустить в **режиме агента**: тогда им управляет не человек, а
программа (ИИ-агент, CI, скрипт). Окно скрыто, время детерминировано, а весь
обмен идёт **по одной JSON-строке на запрос и одну на ответ** через stdin/stdout.

Низкий уровень — это то, что умеет C-ядро. Высокоуровневые хелперы для игрового
кода (`$.agent`, проверки, снимки мира в терминах игры) описаны в
[HIGH_LEVEL_API.md](HIGH_LEVEL_API.md), а готовый клиент и раннер тестов лежат в
`tools/agent_client.py` и `tools/run_tests.py`.

12
13
14
15
Если ты агент, который собирается **править сам движок**, сначала прочитай
[AGENT_IMPLEMENTATION_RULES.md](AGENT_IMPLEMENTATION_RULES.md) — там правила
работы, рабочий цикл и стоп-условия.

Nikitos's avatar
Nikitos включено в состав коммита
16
17
18
19
20
21
22
23
24
25
26
27
28
29
---

## 1. Запуск

```bash
./build/russiano2d --agent --game demos --scene platformer
./build/russiano2d --agent --headless --fixed-dt 0.0166666667 --seconds 30
```

| Флаг | Смысл |
|---|---|
| `--agent` | Режим агента: цикл кадров управляется командами, stdin/stdout — протокол |
| `--headless` | Окно создаётся скрытым (`SDL_WINDOW_HIDDEN`). Скриншоты работают |
| `--fixed-dt <сек>` | Детерминированный шаг: `dt` постоянный, время не зависит от реального |
30
| `--seed <N>` | Начальное зерно для `$.random` (доступно как `engine.seed`); по умолчанию `12345` — то же, что `DEFAULT_SEED` в `$.random` |
Nikitos's avatar
Nikitos включено в состав коммита
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
| `--frames <N>` | Выйти ровно после N кадров (удобно для дымовых прогонов без команд) |
| `--scene <имя>` | Открыть сцену сразу, минуя меню |
| `--game <каталог>` | Каталог игры (точка входа `<каталог>/main.js`) |
| `--stats` | Печатать статистику кадра раз в секунду (в stderr) |

Обычный режим (`--seconds`, `--screenshot`, `--overlay`) работает как раньше;
`--agent` можно совмещать с `--stats` и `--seconds`.

### Что означают флаги для детерминизма

* `--fixed-dt` заменяет реальный `dt` на константу: `engine.dt` всегда равен
  ей, `engine.time` растёт ровно на неё за кадр Формула «кадр N = время N·dt»
  держится при любой загрузке машины.
* `--headless` убирает зависимость от дисплея и от фокуса окна.
* `--seed` + `$.random` дают воспроизводимую последовательность случайных чисел.
* Физика Box2D и так шагает фиксированным шагом `1/60`.

Рекомендуемый набор для тестов:
`--agent --headless --fixed-dt 0.0166666667 --no-hot-reload`.

---

## 2. Транспорт и формат

* Запрос — **одна строка** JSON-объекта, заканчивается `\n`.
* Ответ — **одна строка** JSON-объекта, заканчивается `\n`.
* Весь прочий вывод движка (логи, предупреждения, `console.log`) идёт в
  **stderr** — stdout чист и пригоден для парсинга.
* При старте, до первого ответа, печатается `{"event":"ready", ...}`.
* Обязательное поле запроса — `cmd` (строка). Поле `id` необязательно и
  возвращается в ответе как есть — удобно для сопоставления.
* Ошибка никогда не завершает движок: ответ `{"ok":false,"error":"..."}`.
* Закрытие stdin завершает процесс (как `quit`).

Пример обмена:

```
→ {"cmd":"ping","id":1}
← {"ok":true,"pong":true,"frame":0,"time":0.0,"id":1}
→ {"cmd":"step","frames":60}
← {"ok":true,"frames":60,"frame":60}
→ {"cmd":"eval","code":"$.world.count()"}
← {"ok":true,"result":3}
```

### Режим ожидания

В режиме агента кадры **не идут сами**. Пока не выполнена команда, которая
шагает время (`step`), движок ждёт следующую строку на stdin. Поэтому один и
тот же сценарий воспроизводится побитово. Команда `--realtime` не
предусмотрена: если нужно живое время, запускайте без `--agent`.

---

## 3. Команды

### 3.1. Базовые

| `cmd` | Параметры | Ответ |
|---|---|---|
| `ping` | — | `{"ok":true,"pong":true,"frame":N,"time":T}` |
| `frames` | — | `{"ok":true,"frame":N,"time":T}` |
| `quit` | — | `{"ok":true}` и выход |
| `reload` | — | `{"ok":true,"reloads":N}` — перезапустить скрипты (hot reload вручную) |

### 3.2. Время и шаги

| `cmd` | Параметры | Ответ |
|---|---|---|
| `step` | `frames` (int, по умолчанию `1`), `dt` (number, необязательно) | `{"ok":true,"frames":N,"frame":N2}` |

`step` прогоняет ровно `frames` кадров: каждый — это `begin_frame` →
физика → `onUpdate` → `onRender` → GPU. Кадры рисуются по-настоящему, поэтому
после `step` доступен корректный скриншот.

Максимум за одну команду — 100000 кадров (защита от вечного цикла).

### 3.3. Состояние

| `cmd` | Параметры | Ответ |
|---|---|---|
| `state` | — | `{"ok":true,"state":{...},"frame":N,"time":T}` |

Поле `state` формируется игровым кодом: если высокоуровневое API загружено,
`$.agent` регистрирует провайдер снимка, и в `state` попадают мир, игрок,
камера, счётчики сцены — всё, что игра считает нужным показать агенту
(см. `$.agent.snapshot()` в [HIGH_LEVEL_API.md](HIGH_LEVEL_API.md)).

Если провайдер не зарегистрирован (игра на «голом» `engine.*`), движок отдаёт
встроенный минимум:

```json
{ "frame": 120, "time": 2.0, "fps": 60.0, "bodies": 14, "sprites": 68,
  "draws": 2, "reloads": 0, "scene": "platformer", "game": null }
```

127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
### 3.3.1. Список сущностей — `query`

```json
{"cmd":"query","sel":".enemy","limit":10}
```

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `sel` | `string` | `"*"` | Селектор `$` — тот же, что у `$(...)` |
| `limit` | `number` | `0` | Предел списка (`0` — все) |

Ответ: `{"ok":true,"sel":".enemy","nodes":[{…}, …]}` — краткие описания узлов
(тег, id, классы, позиция, размер, `hp`, видимость, тело, `ui`, `aria`).

Разбор селектора делает игровой JS (`$.agent.nodes`) — движок только перевозит
строку и упаковывает ответ. Поэтому у агента, DevTools и игры **одна**
реализация поиска, а не три разные. Если игра не вызвала `$.agent.install()`
(игра на «голом» `engine.*`), команда отвечает ошибкой.

### 3.3.2. Одна сущность — `inspect`

```json
{"cmd":"inspect","sel":"#hero"}
```

Ответ: `{"ok":true,"sel":"#hero","node":{…}}`, а если селектор ничего не нашёл —
`"node": null`. Параметр `sel` обязателен.

Именно этими двумя командами агент получает «что есть в мире» и «что это за
сущность», не сочиняя `eval` с рукописным JS.

### 3.3.3. Профиль — `profile`

```json
{"cmd":"profile","sel":".enemy","x":100,"y":100,"radius":500}
```

| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `sel` | `string` | — | Селектор `$`: сколько сущностей ему соответствует |
| `x`, `y`, `radius` | `number` | — | Круг для замера нативного запроса; без них `query` будет `null` |

Ответ: `{"ok":true,"profile":{…}}`, где

| Поле | Смысл |
|---|---|
| `sel`, `count` | селектор и число сущностей по нему (считает игровой JS) |
| `bodies` | живых тел в мире сейчас |
| `query` | `{ native, x, y, radius, candidates, results, ms, cap, truncated }` — нативный поиск: сколько кандидатов дал broadphase, сколько прошло отсев по расстоянию, сколько это заняло |
| `frame` | `{ frame_ms, real_ms, unaccounted_ms, frames, zones:[{name, ms, peak, gpu, valid}] }` — зоны кадра |
| `allocations` | всегда `null`: движок аллокации не измеряет и не притворяется, что измерил |

Только факты: движок не объясняет, «почему долго» — это дело вызывающей
стороны ([AGENT_IMPLEMENTATION_RULES.md](AGENT_IMPLEMENTATION_RULES.md)
правила 8–9).

Nikitos's avatar
Nikitos включено в состав коммита
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
### 3.4. Вычисление кода

| `cmd` | Параметры | Ответ |
|---|---|---|
| `eval` | `code` (строка) | `{"ok":true,"result":<JSON>}` либо `{"ok":false,"error":"..."}` |

`code` выполняется **в том же JS-контексте, что и игра**: доступны `engine`,
`$`, `Global`, переменные модулей (через `$.eval`/`$.get`), физика, UI.

Значение приводится к JSON через `JSON.stringify`:
* числа, строки, булевы, `null` — как есть;
* объекты и массивы — рекурсивно;
* `undefined`-результат отдаётся как `null`;
* циклические структуры → ошибка `преобразование результата в JSON не удалось`.

Примеры:

```json
{"cmd":"eval","code":"engine.frame"}
{"cmd":"eval","code":"$('#hero').pos()"}
{"cmd":"eval","code":"$('.enemy').length"}
{"cmd":"eval","code":"$.world.raycast(0,0,500,500)"}
{"cmd":"eval","code":"$.time.pause()"}
```

Код может быть многострочным: используйте `\n` внутри JSON-строки.

### 3.5. Виртуальный ввод

Виртуальный ввод не требует ни окна, ни человека. Состояние ввода действует
на последующие кадры, пока его не отпустят.

| `cmd` | Параметры | Ответ |
|---|---|---|
| `key` | `key` (имя SDL, напр. `"Space"`, `"A"`, `"Escape"`), `action`: `"down"`, `"up"`, `"tap"` (по умолчанию `tap`) | `{"ok":true,"key":"Space","action":"tap"}` |
| `keys` | `hold` — массив имён; **заменяет** весь удерживаемый набор (`[]` — отпустить всё) | `{"ok":true,"hold":["A","Space"]}` |
| `mouse` | `button`: `1` ЛКМ, `2` СКМ, `3` ПКМ; `action`: `"down"`, `"up"`, `"click"` | `{"ok":true}` |
| `mouseMove` | `dx`, `dy` (относительное) либо `x`, `y` (абсолютное, в логических точках) | `{"ok":true,"x":..,"y":..}` |
| `wheel` | `amount` (number) | `{"ok":true}` |
| `text` | `text` (строка, UTF-8) — символы, которые игрок «набрал» в этом кадре | `{"ok":true,"text":"..."}` |

`text` нужен для `<ui.input>` и любых текстовых полей: SDL присылает ввод
событием `SDL_EVENT_TEXT_INPUT`, синтезировать его снаружи нельзя, поэтому
агент дописывает символы прямо в буфер кадра. Игра читает их через
`engine.textInput()` или `$.input.text()`.

```
{"cmd":"text","text":"привет"}
{"cmd":"step","frames":1}
```

`tap` — нажатие ровно на один кадр (на следующий `step`), то есть `keyPressed`
в JS сработает один раз.

Имена клавиш — как в `engine.scancode()` (см. [API.md](API.md), раздел 5):
`"A"`…`"Z"`, `"Space"`, `"Return"`, `"Escape"`, `"Left"`, `"F1"`, `"Left Shift"`…
Неизвестное имя → `{"ok":false,"error":"неизвестная клавиша: X"}`.

Типовой прогон «пройти вправо и прыгнуть»:

```
{"cmd":"keys","hold":["D"]}
{"cmd":"step","frames":60}
{"cmd":"key","key":"Space","action":"tap"}
{"cmd":"step","frames":30}
{"cmd":"keys","hold":[]}
```

### 3.6. Скриншоты

| `cmd` | Параметры | Ответ |
|---|---|---|
| `screenshot` | `path` (строка; относительный путь — от каталога запуска) | `{"ok":true,"path":"...","width":W,"height":H}` |

Команда рисует **один дополнительный кадр**, читает его из swapchain и только
потом отвечает — файл к моменту ответа уже на диске. Формат — PNG.

---

## 4. Клиент на Python

`tools/agent_client.py` — обёртка без внешних зависимостей (только стандартная
библиотека). Класс `Agent` — контекстный менеджер: закрытие соединения
завершает движок.

```python
import os, sys
sys.path.insert(0, "tools")
from agent_client import Agent, ROOT

with Agent(game="demos", scene="platformer", seed=7) as a:
    a.step(30)
    st = a.state()
    x0 = st["player"]["x"]

    a.keys(["D"])                 # удерживать «вправо»
    a.step(60)
    a.keys([])
    assert a.state()["player"]["x"] > x0

    a.key("Space", "tap")         # прыжок
    a.step(20)

    print(a.eval("$.world.count()"))
    a.screenshot(os.path.join(ROOT, "build", "shot.png"))
```

Метод `cmd(name, **params)` — общий: `a.cmd("step", frames=10)`.

### Раннер тестов

```bash
python3 tools/run_tests.py                 # все tests/agent/*_test.py
python3 tools/run_tests.py platformer_test # только указанные (позиционные)
python3 tools/run_tests.py --list          # показать список
python3 tools/run_tests.py --fast          # быстрый набор
R2D_BINARY=build-release/russiano2d python3 tools/run_tests.py
R2D_TEST_TIMEOUT=120 python3 tools/run_tests.py
```

Раннер различает три исхода:

* `ok`   — тест вернул 0 **и** напечатал хотя бы одну проверку `  ok  …`;
* `fail` — ненулевой код или строка `  FAIL …`;
* `skip` — код 0, но проверок не было (нет ассета, нет дисплея и т. п.).
  «Пропуск» — это **не** «зелено».

Лог каждого теста — `build/test_<имя>.log`.

---

## 5. Как это устроено внутри

| Часть | Файл |
|---|---|
| Разбор/сборка JSON, буфер строк | `src/json.c/.h` |
| Протокол, команды, виртуальный ввод | `src/agent.c/.h` |
| Разбор флагов, цикл кадров, скриншот по запросу | `src/main.c` |
| `eval`/`state` в JS-контексте, `engine.setSnapshot` | `src/script.c` |
| Виртуальный ввод в приложении | `src/app.c` |
| Рейкаст и запросы Box2D для `eval` | `src/physics.c` |

---

## 6. Что уже есть в репозитории

| Файл | Что проверяет |
|---|---|
| `tools/agent_client.py` | клиент протокола: `Agent(...)`, `cmd/eval/step/state/key/keys/mouse/screenshot` |
| `tools/run_tests.py` | раннер: гоняет `tests/agent/*_test.py`, различает `ok` / `fail` / `skip` |
| `tests/agent/agent_protocol_test.py` | сам протокол: шаги, детерминизм, `eval`, виртуальный ввод, скриншот, перезапуск |
| `tests/agent/highlevel_api_test.py` | высокоуровневое API `$` на фикстуре `tests/fixtures/hello` |
| `tests/agent/game_test.py` | игра по умолчанию: меню → уровень, ходьба, прыжок, монеты, пауза |
| `tests/agent/demos_test.py` | все демо: сцена открывается, рисуется и не пишет ошибок |
| `tests/agent/build_test.py` | сборка игры в один файл: запуск без проекта, шифрование, защита от подмены |
Nikitos's avatar
ф    
Nikitos включено в состав коммита
338
| `tests/agent/highlevel_*_test.py` | подсистемы `$` по отдельности: `anim`, `tilemap`, `tilemap_ysort`, `particles`, `nav`, `navmesh`, `prefab`, `audiobus`, `layers`, `widgets`, `widgets_anchor`, `tween`, `triggers`, `i18n`, `pool`, `physics`, `http`, `render`, `timeline` |
Nikitos's avatar
Nikitos включено в состав коммита
339
| `tests/agent/highlevel_guide_test.py` | страж документации: достаёт листинг из `docs/tutorial-first-game.md` и запускает его |
340
| `tests/js/*_test.mjs` | юнит-тесты логики модулей под `qjs` — без движка и без сборки (79 наборов) |
Nikitos's avatar
Nikitos включено в состав коммита
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
| `tests/fixtures/*` | маленькие игры для тестов (`hello`, `bare`, `spawn`, `dynimport`, по одной на подсистему) |

```bash
python3 tools/run_tests.py --fast          # быстрый набор (~12 с)
python3 tools/run_tests.py                 # все тесты
python3 tools/run_tests.py demos_test      # только выбранный
python3 tests/agent/demos_test.py light    # тест можно запускать и напрямую

# Логика подсистем без движка: сборка не нужна, секунды
build/_deps/quickjs-build/qjs tests/js/nav_test.mjs
```

## 7. Что игра может рассказать о себе

Низкий уровень отдаёт то, что знает C. Чтобы агент понимал игру, она сама
описывает себя — через высокоуровневое API:

```js
// Поле в снимке состояния: приходит в ответе на {"cmd":"state"}.
$.agent.expose('score', () => score);
$.agent.expose('wave', () => currentWave);

// Полный снимок строится автоматически: кадр, время, сцена, камера, мир,
// список узлов (позиция, здоровье, видимость), игрок, элементы интерфейса.
// Его же видно в ответе на state — поля frame/time дублируются на верхнем
// уровне ответа, поэтому простые проверки не требуют разбора вложенности.

// Самопроверка игры: результаты попадают в снимок (поле tests) и в журнал.
$.test.check($('.enemy').length === 5, 'врагов пятеро');
$.test.equal($('#hero').hp(), 100, 'здоровье целое');
$.test.near(x, 100, 0.5, 'игрок у отметки');
```

Проверки `$.test` печатают строки `  ok  ` / `  FAIL ` в журнал движка
(в агентском режиме это stderr) и складываются в `state.tests`, поэтому их
видно и человеку, и программе.

## 8. Если что-то не работает

| Симптом | Причина и лечение |
|---|---|
| `движок закрыл stdout, ожидание ready` | процесс упал: смотрите stderr, обычно это ошибка загрузки точки входа |
| Ответы приходят, но с мусором перед JSON | кто-то печатает в stdout в обход движка — в агентском режиме stdout должен быть чистым |
| `step` отвечает мгновенно, состояние не меняется | игра могла вызвать `$.time.pause()` или `--realtime` не задан (в агентском режиме кадры идут только по `step`) |
| Скриншот чёрный | кадр не отрисован: проверьте, что прошёл хотя бы один `step`, и что игра рисует что-то в кадре |
| `error: неизвестная клавиша` | имя клавиши не понимает SDL: см. таблицу в [API.md](API.md#5-ввод) |
387
388
389
390
391
392
393
394
395
396
397
398
399

## Тишина в тестах

Агентский клиент (`tools/agent_client.py`) по умолчанию подставляет беззвучный
драйвер SDL (`SDL_AUDIODRIVER=dummy`), поэтому прогон тестов не шумит. Микшер
при этом работает по-настоящему: проверки каналов, громкости и воспроизведения
проходят как обычно.

Прогнать конкретный тест со звуком:

```bash
R2D_TEST_AUDIO=real python3 tests/agent/sound_test.py
```