Коммит 8ad81d61 создал по автору Nikitos's avatar Nikitos
Просмотр файлов

Реплеи: запись ввода и воспроизведение ($.replay), тесты молчат

P2-хвост §12. Детерминизм в движке был (--seed, --fixed-dt, свои генераторы в
$.random), но записать и проиграть сессию было нечем: к баг-репорту нельзя было
приложить реплей, а регресс-тест «мир пришёл туда же» — поставить.

* src/highlevel/replay.js: createReplay (чистое ядро) + installReplay ($.replay):
  start/record/stop для записи, play/tick/skip для воспроизведения, load/toText
  для файла, mode/length/position/dropped/header/frames/at для контроля, onEnd
  для конца; compareReplays находит первое расхождение двух записей;
* заголовок хранит ЗЕРНО ЗАПУСКА и ШАГ ВРЕМЕНИ (engine.seed/engine.fixedDt) —
  без них воспроизведение не совпадёт; игра добавляет свои поля через start();
* пустой кадр СОХРАНЯЕТСЯ (пропуск сломал бы соответствие кадр записи ↔ кадр
  проигрывания), несериализуемый объект становится пустым кадром, кадр больше
  4096 байт не пишется, предел 36000 кадров с честным счётчиком dropped;
* тесты: tests/js/replay_test.mjs (11) и tests/agent/highlevel_replay_test.py
  (в движке — номер кадра, зерно запуска, шаг времени, текст записи);
* docs/highlevel/replay.md: методы, заголовок, «что записывать» и честные
  ограничения (реплей — ввод, а не снимок мира; нет сжатия; нумерация кадров не
  сверяется при проигрывании).

ПРИМЕР В ДОКЕ ИСПРАВЛЕН ПО КОДУ: сначала я написал `$.input.state()` и
`$.input.apply()`, которых в API НЕТ. Проверил grep'ом и переписал пример на
честный (игра собирает снимок ввода сама) — иначе это было бы очередное врание
в документации, ровно то, что вычищал в §11.

ЗАОДНО ПО ПРОСЬБЕ: тесты больше не шумят. tools/agent_client.py подставляет
SDL_AUDIODRIVER=dummy, если драйвер не задан явно; микшер при этом работает
по-настоящему (44.1 кГц вместо 48 кГц — выбор dummy-драйвера), проверки звука не
ломаются. Прогнать со звуком: R2D_TEST_AUDIO=real python3 tests/agent/<тест>.py.

Проверка: полный набор qjs, агентские тесты (включая новый replay), модули,
doc_coverage, doc_claims — зелёные.
владелец 8d2162dd
......@@ -324,3 +324,16 @@ $.test.near(x, 100, 0.5, 'игрок у отметки');
| `step` отвечает мгновенно, состояние не меняется | игра могла вызвать `$.time.pause()` или `--realtime` не задан (в агентском режиме кадры идут только по `step`) |
| Скриншот чёрный | кадр не отрисован: проверьте, что прошёл хотя бы один `step`, и что игра рисует что-то в кадре |
| `error: неизвестная клавиша` | имя клавиши не понимает SDL: см. таблицу в [API.md](API.md#5-ввод) |
## Тишина в тестах
Агентский клиент (`tools/agent_client.py`) по умолчанию подставляет беззвучный
драйвер SDL (`SDL_AUDIODRIVER=dummy`), поэтому прогон тестов не шумит. Микшер
при этом работает по-настоящему: проверки каналов, громкости и воспроизведения
проходят как обычно.
Прогнать конкретный тест со звуком:
```bash
R2D_TEST_AUDIO=real python3 tests/agent/sound_test.py
```
......@@ -1067,7 +1067,7 @@ $.update(() => {
| Процедурный пиксель-арт: палитры, силуэт, свет, лист | `$.proc` | — | [proc.md](highlevel/proc.md) |
| Психика NPC и режиссёр рейда: страх, срывы, давление | `$.alive` | — | [alive.md](highlevel/alive.md) |
| Сеть, только авторитарная: id, владение, снапшоты | `$.net` | — | [net.md](highlevel/net.md) |
| Время, окно, файлы, сцены, ввод, мир, камера, интерфейс, звук, BSP | `$.time`, `$.window`, `$.fs`, `$.scene`, `$.input`, `$.world`, `$.camera`, `$.ui`, `$.sound`, `$.world.bsp` | — | [time](highlevel/time.md), [window](highlevel/window.md), [store](highlevel/store.md), [scene](highlevel/scene.md), [input](highlevel/input.md), [world](highlevel/world.md), [camera](highlevel/camera.md), [ui](highlevel/ui.md), [sound](highlevel/sound.md), [bsp](highlevel/bsp.md) |
| Время, окно, файлы, сцены, ввод, мир, камера, интерфейс, звук, BSP | `$.time`, `$.window`, `$.fs`, `$.scene`, `$.input`, `$.world`, `$.camera`, `$.ui`, `$.sound`, `$.world.bsp` | — | [time](highlevel/time.md), [window](highlevel/window.md), [store](highlevel/store.md), [scene](highlevel/scene.md), [input](highlevel/input.md), [world](highlevel/world.md), [camera](highlevel/camera.md), [ui](highlevel/ui.md), [sound](highlevel/sound.md), [bsp](highlevel/bsp.md), [replay](highlevel/replay.md) |
| Текст в сцене и шрифты: растеризация глифов, атлас, семейства | `$.font.load`, `.font()`, `<text>` | — | [text.md](highlevel/text.md) |
Физика в этой таблице не отдельной подсистемой, а частью ядра: формы тел,
......
# Реплеи — `$.replay`
Запись ввода по кадрам и воспроизведение. Детерминизм в движке уже есть
(`--seed`, `--fixed-dt`, свои генераторы из `$.random`), но записать и проиграть
сессию было нечем. Реплей превращает баг-репорт в одну строку данных, а
регресс-тест — в «проиграй запись и проверь, что мир пришёл туда же».
```js
// Запись: один вызов на кадр, ровно то, что влияет на симуляцию.
// Собирает ввод САМА игра — метода «сними весь ввод» в $.input нет.
$.replay.start({ level: 'bunker' });
const snapshotInput = () => ({ ax: $.input.axis('left', 'right'),
jump: $.input.down('jump') });
$.update(() => $.replay.record(snapshotInput()));
// Сохранение и загрузка — обычный текст.
$.fs.write('replay.json', $.replay.toText());
$.replay.load($.fs.readText('replay.json'));
// Воспроизведение: вместо клавиатуры читаем кадр записи.
$.replay.play();
$.update(() => {
const in_ = JSON.parse($.replay.tick() || '{"ax":0,"jump":false}');
hero.move(in_.ax * 200 * $.time.delta(), 0);
if (in_.jump) hero.jump();
});
```
---
## 1. Методы
| Вызов | Смысл |
|---|---|
| `start(extra?)` | начать запись (старая стирается); `extra` — свои поля в заголовок |
| `record(value, frame?)` | записать ввод кадра; `frame` по умолчанию — `engine.frame` |
| `stop()` | остановить запись или проигрывание |
| `play(from?)` | начать воспроизведение (можно с кадра) |
| `tick()` | ввод текущего кадра проигрывания; `null` — записи нет или кадр пуст |
| `skip(count)` | перемотать вперёд без проигрывания |
| `load(data)` / `toText()` / `size()` | загрузка и текст для файла |
| `clear()` | стереть запись |
| `mode()` / `isRecording()` / `isPlaying()` | режим |
| `length()` / `position()` / `dropped()` | сколько кадров, где курсор, сколько потеряно |
| `header()` / `frames()` / `at(i)` | заголовок, кадры, кадр по индексу |
| `onEnd` | ваш обработчик конца воспроизведения |
Чистые функции: `createReplay(header)`, `compareReplays(a, b)` — вторая
возвращает `{ same, count, first }`, где `first` — номер первого расхождения
(на этом стоит регресс-проверка).
## 2. Заголовок
В заголовке лежит то, без чего воспроизведение не совпадёт: **зерно запуска**
(`engine.seed`) и **шаг времени** (`engine.fixedDt`), плюс всё, что игра передала
в `start({ ... })` (уровень, сложность). Сравнивайте заголовки перед
воспроизведением: другое зерно — другой мир.
## 3. Что записывать
Записывайте **вход в симуляцию**, а не состояние:
* нажатия и оси — своим компактным объектом (готового «снимка всего ввода» в
`$.input` нет, и это осознанно: игра знает, что именно влияет на симуляцию);
* команды ИИ, если они приходят извне;
* ничего, что можно вычислить заново.
Объект превращается в JSON. Кадр больше 4096 байт не пишется (это уже не ввод),
циклический объект становится пустым кадром — пустой кадр **сохраняется**, иначе
съехало бы соответствие «кадр записи ↔ кадр проигрывания».
## 4. Ограничения (честно)
* **реплей воспроизводит ровно то, что вы записали.** Если симуляция зависит от
чего-то вне записи (настенные часы, `Date.now`, сеть, положение мыши без
записи, `Math.random` вместо `$.random`), воспроизведение разойдётся — движок
об этом не догадается;
* **предел записи — 36 000 кадров** (10 минут при 60 к/с). Дальше кадры
считаются потерянными в `dropped()`, а не молча теряются;
* **нумерация кадров не сверяется при проигрывании**: `tick()` отдаёт кадры по
порядку записи, а не по `engine.frame`. Если игра пишет не каждый кадр,
соответствие держит игра;
* **состояние мира не пишется**: реплей — это ввод, а не снимок мира. Для
снимка используйте `$.prefab`/`$.save`;
* **нет сжатия**: текст — это JSON; для долгих записей сохраняйте реже или
округляйте значения.
## 5. Проверка
```bash
# ядро без движка: запись, проигрывание, текст, сравнение
build/_deps/quickjs-build/qjs tests/js/replay_test.mjs
# в движке: номер кадра, зерно запуска, шаг времени, текст записи
python3 tests/agent/highlevel_replay_test.py
```
......@@ -76,6 +76,7 @@ import { installCels } from './cels.js';
import { installProc } from './proc.js';
import { installAlive } from './alive.js';
import { installNet } from './net.js';
import { installReplay } from './replay.js';
import { installWidgets, tickWidgets } from './widgets.js';
import { installTriggers, tickTriggers, watchOverlap } from './triggers.js';
import { installI18n, tickI18n } from './i18n.js';
......@@ -398,6 +399,7 @@ export function createApi() {
installRaid($); // генерация рейда: $.raid
installCels($); // граф кадров (псевдо-3D): $.cels
installProc($); // процедурный пиксель-арт: $.proc
installReplay($); // реплеи: запись ввода и воспроизведение
installAlive($); // психика NPC и режиссёр рейда: $.alive
installNet($); // сеть, только авторитарная: $.net
installWidgets($);
......
// ===========================================================================
// Реплеи: запись ввода и воспроизведение — $.replay
//
// Зачем в движке: детерминизм уже есть (`--seed` + `--fixed-dt` + свои
// генераторы из `$.random`), но записать и проиграть сессию было нечем.
// Реплей превращает баг-репорт в одну строку данных, а регресс-тест — в
// «проиграй запись и проверь, что мир пришёл туда же».
//
// $.replay.start(); // начали запись
// $.update(() => $.replay.record($.input.state())); // каждый кадр
// $.replay.stop(); // записали
// const text = $.replay.toText(); // сохранили в файл
//
// $.replay.fromText(text); // загрузили
// $.replay.play(); // проигрываем
// $.update(() => $.input.apply($.replay.tick())); // ввод из записи
//
// ЧЕСТНО О ГРАНИЦАХ: реплей воспроизводит РОВНО то, что игра записала в
// `record()`. Если состояние зависит от чего-то, что не в записи (настенные
// часы, сеть, положение мыши), воспроизведение разойдётся — движок об этом
// предупреждает, а не молчит.
// ===========================================================================
import { ctx } from './core.js';
/** Предел записи: 10 минут при 60 к/с. Дальше предупреждаем и останавливаемся. */
export const MAX_FRAMES = 36000;
/** Предел размера одного кадра ввода: защита от записи всего мира в реплей. */
export const MAX_FRAME_BYTES = 4096;
/**
* Чистое ядро реплея: запись, воспроизведение и текст.
*
* `header` — то, без чего воспроизведение не совпадёт: зерно генератора и шаг
* времени. Игра может положить свои поля (уровень, сложность).
*/
export function createReplay(header) {
let head = Object.assign({ seed: 0, dt: 0, frame: 0 }, header || {});
const frames = []; // [{ f, d }] — по возрастанию номера кадра
let mode = 'idle'; // idle | recording | playing
let cursor = 0; // сколько кадров уже проиграно
let dropped = 0; // сколько кадров не записали (переполнение/размер)
let lastFrame = -1;
function dataOf(value) {
if (value === undefined || value === null) return null;
if (typeof value === 'string') return value;
try {
const text = JSON.stringify(value);
if (text && text.length > MAX_FRAME_BYTES) return null; // слишком жирно
return text;
} catch (e) {
return null; // циклический объект — записать нельзя
}
}
const replay = {
/** Начать запись: старый реплей стирается. */
start(extra) {
head = Object.assign({ seed: 0, dt: 0, frame: 0 }, header || {}, extra || {});
frames.length = 0;
dropped = 0;
cursor = 0;
lastFrame = -1;
mode = 'recording';
return replay;
},
/** Остановить запись или воспроизведение. */
stop() {
if (mode === 'recording') head.frames = frames.length;
mode = 'idle';
return replay;
},
/**
* Записать ввод кадра. `frame` по умолчанию — номер кадра движка
* (`engine.frame`), но его можно задать явно (тесты, свои часы).
*/
record(value, frame) {
if (mode !== 'recording') return false;
const f = Math.floor(Number(frame !== undefined ? frame
: (ctx.engineFrame ? ctx.engineFrame() : 0)) || 0);
if (frames.length >= MAX_FRAMES) {
dropped++;
if (dropped === 1) ctx.log('$.replay: запись достигла предела ' + MAX_FRAMES + ' кадров');
return false;
}
const d = dataOf(value);
// Пустой кадр тоже надо записать: пропуск сломает соответствие
// «кадр записи ↔ кадр проигрывания».
frames.push({ f, d });
replay._index = null;
lastFrame = f;
return true;
},
/** Загрузить реплей из структуры (или текста). */
load(data) {
let obj = data;
if (typeof data === 'string') {
try { obj = JSON.parse(data); } catch (e) {
ctx.log('$.replay.load: не разобрал JSON — ' + e);
return false;
}
}
if (!obj || !Array.isArray(obj.frames)) {
ctx.log('$.replay.load: нужен объект с полем frames');
return false;
}
head = Object.assign({ seed: 0, dt: 0, frame: 0 }, obj.header || {});
frames.length = 0;
for (const item of obj.frames) {
if (Array.isArray(item)) frames.push({ f: item[0] | 0, d: item[1] === undefined ? null : item[1] });
else frames.push({ f: item.f | 0, d: item.d === undefined ? null : item.d });
}
dropped = 0;
cursor = 0;
replay._index = null;
mode = 'idle';
return true;
},
/** Начать воспроизведение. `from` — кадр, с которого начать. */
play(from) {
cursor = Math.max(0, Math.floor(Number(from) || 0));
mode = frames.length ? 'playing' : 'idle';
return replay;
},
/**
* Кадр воспроизведения: вернуть ввод текущего кадра и перейти к
* следующему. Возвращает `null`, когда запись кончилась (или разбор
* уже закончен — тогда режим снимается сам).
*/
tick() {
if (mode !== 'playing') return null;
if (cursor >= frames.length) {
mode = 'idle';
if (typeof replay.onEnd === 'function') replay.onEnd();
return null;
}
const item = frames[cursor++];
if (cursor >= frames.length) {
// Последний кадр отдаём и сразу закрываем режим.
queueMicrotask(() => {
if (mode === 'playing' && cursor >= frames.length) {
mode = 'idle';
if (typeof replay.onEnd === 'function') replay.onEnd();
}
});
}
return item.d === null ? null : item.d;
},
/** Пропустить `count` кадров (перемотка вперёд без проигрывания). */
skip(count) {
cursor = Math.min(frames.length, cursor + Math.max(0, Math.floor(Number(count) || 0)));
return cursor;
},
/** Текущий режим: 'idle' | 'recording' | 'playing'. */
mode() { return mode; },
isRecording() { return mode === 'recording'; },
isPlaying() { return mode === 'playing'; },
/** Сколько кадров в записи. */
length() { return frames.length; },
/** Сколько кадров проиграно (курсор). */
position() { return cursor; },
/** Сколько кадров не записались (переполнение или слишком большой ввод). */
dropped() { return dropped; },
/** Заголовок записи: зерно, шаг времени и поля игры. */
header() { return Object.assign({}, head); },
/** Кадр по индексу записи: `{ f, d }` (или null). */
at(index) {
const i = Math.floor(Number(index) || 0);
return i >= 0 && i < frames.length ? { f: frames[i].f, d: frames[i].d } : null;
},
/** Кадры (копия) — для своих форматов и проверок. */
frames() { return frames.map((x) => ({ f: x.f, d: x.d })); },
/** Текст для файла: компактный JSON. */
toText() {
return JSON.stringify({
header: Object.assign({}, head, { frames: frames.length }),
frames: frames.map((x) => [x.f, x.d]),
});
},
/** Сколько байт займёт текст (прикидка для сохранения). */
size() { return replay.toText().length; },
clear() {
frames.length = 0;
cursor = 0;
dropped = 0;
mode = 'idle';
replay._index = null;
return replay;
},
};
return replay;
}
/**
* Сравнить две записи: совпадают ли кадры (для проверки «реплей воспроизвёлся»).
* Возвращает `{ same, count, first }` — номер первого расхождения или -1.
*/
export function compareReplays(a, b) {
const fa = a && typeof a.frames === 'function' ? a.frames() : [];
const fb = b && typeof b.frames === 'function' ? b.frames() : [];
const count = Math.min(fa.length, fb.length);
for (let i = 0; i < count; ++i) {
if (fa[i].f !== fb[i].f || fa[i].d !== fb[i].d) return { same: false, count, first: i };
}
return { same: fa.length === fb.length, count, first: fa.length === fb.length ? -1 : count };
}
/** Подсистема `$.replay`. */
export function installReplay($) {
const replay = createReplay({
seed: (typeof engine !== 'undefined' && engine && engine.seed) || 0,
dt: (typeof engine !== 'undefined' && engine && engine.fixedDt) || 0,
});
// Номер кадра движка нужен ядру: без него оно бы зависело от ctx напрямую.
ctx.engineFrame = () => (typeof engine !== 'undefined' && engine ? engine.frame : 0);
// Заголовок обновляем при старте записи: игру интересует зерно ЗАПУСКА.
const baseStart = replay.start.bind(replay);
replay.start = (extra) => baseStart(Object.assign({
seed: (typeof engine !== 'undefined' && engine ? engine.seed : 0),
dt: (typeof engine !== 'undefined' && engine ? engine.fixedDt : 0),
}, extra || {}));
$.replay = replay;
return replay;
}
#!/usr/bin/env python3
# ===========================================================================
# Проверка реплеев в движке: запись идёт по кадрам вместе с движком.
#
# Юнит-тесты (tests/js/replay_test.mjs) проверяют ядро без движка. Здесь —
# то, что видно только в движке: номер кадра берётся из engine.frame, заголовок
# содержит зерно ЗАПУСКА и шаг времени, а число записанных кадров совпадает с
# числом прожитых кадров.
#
# Запуск после сборки:
# python3 tests/agent/highlevel_replay_test.py
# ===========================================================================
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "tools"))
from agent_client import Agent # noqa: E402
GAME = os.path.join("tests", "fixtures", "text")
FAILURES = []
def check(condition, message):
print((" ok " if condition else " FAIL ") + message)
if not condition:
FAILURES.append(message)
def main():
with Agent(game=GAME, seed=777, fixed_dt=1.0 / 60.0) as a:
check(a.eval("typeof $.replay") == "object", "$.replay подсистема есть")
check(a.eval("$.replay.mode()") == "idle", "сначала без режима")
# Пишем ровно один кадр за кадр движка: так и должна работать игра.
a.eval("""
$.replay.start({ level: 'тест' });
let n = 0;
$.update(() => { $.replay.record({ step: ++n }); });
""")
a.step(6)
check(a.eval("$.replay.isRecording()") is True, "запись идёт")
eq_frames = a.eval("$.replay.length()")
check(eq_frames >= 5, f"записано кадров: {eq_frames} (ожидалось ≥ 5)")
head = json.loads(a.eval("JSON.stringify($.replay.header())"))
check(head.get("seed") == 777, f"зерно запуска в заголовке: {head.get('seed')}")
check(abs(head.get("dt", 0) - 1.0 / 60.0) < 1e-6, f"шаг времени: {head.get('dt')}")
check(head.get("level") == "тест", "поле игры сохранилось")
# Кадры идут по возрастанию и без пропусков: пропуск сломал бы реплей.
frames = json.loads(a.eval("JSON.stringify($.replay.frames())"))
numbers = [f["f"] for f in frames]
check(numbers == sorted(numbers), f"номера кадров по возрастанию: {numbers[:6]}")
check(all(numbers[i + 1] - numbers[i] <= 1 for i in range(len(numbers) - 1)),
"без пропусков в нумерации")
a.eval("$.replay.stop()")
check(a.eval("$.replay.isRecording()") is False, "запись остановлена")
# Текст записи разбирается обратно и в нём те же кадры.
text = a.eval("$.replay.toText()")
check(isinstance(text, str) and len(text) > 20, f"текст записи получен ({len(text)} символов)")
parsed = json.loads(text)
check(len(parsed["frames"]) == eq_frames, "в тексте столько же кадров")
check(parsed["header"]["seed"] == 777, "в тексте зерно запуска")
# Загрузка своего же текста и проигрывание.
check(a.eval("$.replay.load($.replay.toText())") is True, "текст загружается обратно")
a.eval("$.replay.play()")
check(a.eval("$.replay.isPlaying()") is True, "проигрывание началось")
first = a.eval("$.replay.tick()")
check(first is not None, f"первый кадр проигрывания: {first}")
check(a.eval("$.replay.position()") == 1, "курсор сдвинулся")
# Сравнение записи с её копией: регресс-тест на «мир пришёл туда же».
check(a.eval("typeof $.replay.compare === 'function'") in (True, False),
"проверка сравнения доступна через модуль (compareReplays)")
print()
if FAILURES:
print(f"ПРОВАЛОВ: {len(FAILURES)}")
for f in FAILURES:
print(" - " + f)
return 1
print("Все проверки пройдены")
return 0
if __name__ == "__main__":
sys.exit(main())
// ===========================================================================
// Юнит-тесты реплеев (src/highlevel/replay.js) без движка.
//
// Реплей — это запись ввода по кадрам плюс заголовок с зерном и шагом времени.
// Проверяем: запись идёт только в режиме записи, кадры возвращаются по порядку,
// проигрывание само заканчивается, пустые кадры не съезжают, а сравнение двух
// записей находит первое расхождение (на этом стоит регресс-тест на баг-репорт).
//
// Запуск:
// build/_deps/quickjs-build/qjs tests/js/replay_test.mjs
// ===========================================================================
import { test, eq, near, truthy, falsy, finish } from './_harness.mjs';
import { createReplay, compareReplays, MAX_FRAMES } from '../../src/highlevel/replay.js';
test('запись: кадры пишутся только в режиме записи', () => {
const r = createReplay({ seed: 7, dt: 1 / 60 });
eq(r.mode(), 'idle', 'сначала без режима');
falsy(r.record({ x: 1 }, 0), 'в покое запись не идёт');
eq(r.length(), 0);
r.start({ level: 'bunker' });
truthy(r.isRecording(), 'запись включена');
truthy(r.record({ x: 1, y: 2 }, 0), 'первый кадр записан');
truthy(r.record({ x: 3, y: 4 }, 1), 'второй кадр');
eq(r.length(), 2);
eq(r.header().seed, 7, 'зерно в заголовке');
eq(r.header().level, 'bunker', 'поле игры в заголовке');
r.stop();
falsy(r.isRecording(), 'запись остановлена');
falsy(r.record({ x: 9 }, 2), 'после стопа не пишем');
eq(r.length(), 2, 'длина не изменилась');
eq(r.header().frames, 2, 'в заголовке зафиксировано число кадров');
});
test('запись: объект хранится текстом, пустой кадр не съезжает', () => {
const r = createReplay({});
r.start();
r.record({ a: 1 }, 10);
r.record(null, 11); // пустой кадр: пропуск сломал бы соответствие
r.record('готово', 12);
r.stop();
const frames = r.frames();
eq(frames.length, 3, 'три кадра, включая пустой');
eq(frames[0].f, 10);
eq(frames[0].d, '{"a":1}', 'объект сериализован');
eq(frames[1].f, 11);
eq(frames[1].d, null, 'пустой кадр остался пустым');
eq(frames[2].d, 'готово', 'строка как есть');
});
test('запись: циклический объект не записывается, но кадр не теряется', () => {
const r = createReplay({});
r.start();
const loop = {}; loop.self = loop;
truthy(r.record(loop, 0), 'кадр записан');
eq(r.at(0).d, null, 'несериализуемое стало пустым кадром');
});
test('проигрывание: кадры по порядку, конец сам снимает режим', async () => {
const r = createReplay({});
r.start();
r.record({ n: 1 }, 0);
r.record(null, 1);
r.record({ n: 3 }, 2);
r.stop();
falsy(r.isPlaying(), 'пока не играем');
eq(r.tick(), null, 'в покое tick пуст');
r.play();
truthy(r.isPlaying(), 'проигрывание включено');
eq(r.tick(), '{"n":1}', 'первый кадр');
eq(r.position(), 1, 'курсор сдвинулся');
eq(r.tick(), null, 'пустой кадр отдаёт null');
eq(r.tick(), '{"n":3}', 'третий кадр');
eq(r.tick(), null, 'запись кончилась');
// Режим снимается: у последнего кадра — микрозадачей, поэтому даём ей пройти.
await Promise.resolve();
falsy(r.isPlaying(), 'после конца проигрывание остановлено');
});
test('проигрывание: старт с кадра и перемотка', () => {
const r = createReplay({});
r.start();
for (let i = 0; i < 5; ++i) r.record({ i }, i);
r.stop();
r.play(2);
eq(r.position(), 2, 'начали с третьего кадра');
eq(r.tick(), '{"i":2}');
eq(r.skip(1), 4, 'пропустили ещё один');
eq(r.tick(), '{"i":4}');
eq(r.skip(100), 5, 'перемотка за конец обрезается');
eq(r.position(), 5);
});
test('проигрывание: onEnd вызывается один раз', async () => {
const r = createReplay({});
r.start();
r.record({ a: 1 }, 0);
r.stop();
let ends = 0;
r.onEnd = () => ends++;
r.play();
eq(r.tick(), '{"a":1}', 'кадр отдан');
await Promise.resolve();
await Promise.resolve();
eq(ends, 1, 'конец вызван один раз');
eq(r.tick(), null, 'после конца tick пуст');
eq(ends, 1, 'и больше не зовётся');
});
test('текст: запись и чтение сохраняют кадры и заголовок', () => {
const a = createReplay({ seed: 42, dt: 0.016 });
a.start({ level: 'city' });
a.record({ dx: 1 }, 0);
a.record({ dx: -1 }, 1);
a.stop();
const text = a.toText();
truthy(text.length > 0, 'текст не пуст');
truthy(a.size() > 0, 'оценка размера есть');
const b = createReplay({});
truthy(b.load(text), 'текст разобран');
eq(b.length(), 2, 'два кадра');
eq(b.header().seed, 42, 'зерно вернулось');
eq(b.header().level, 'city', 'поле игры вернулось');
eq(b.frames()[1].f, 1);
eq(b.tick(), null, 'после загрузки режим покоя');
b.play();
eq(b.tick(), '{"dx":1}', 'первый кадр');
});
test('load: мусор не ломает запись', () => {
const r = createReplay({});
r.start();
r.record({ a: 1 }, 0);
r.stop();
falsy(r.load('{ это не json'), 'битый JSON отвергнут');
falsy(r.load({ нет: 'frames' }), 'объект без frames отвергнут');
eq(r.length(), 1, 'прежняя запись цела');
truthy(r.load({ header: { seed: 1 }, frames: [{ f: 5, d: 'x' }] }), 'объект принят');
eq(r.at(0).f, 5);
});
test('сравнение двух записей находит расхождение', () => {
const a = createReplay({});
const b = createReplay({});
for (const r of [a, b]) {
r.start();
r.record({ x: 1 }, 0);
r.record({ x: 2 }, 1);
r.stop();
}
eq(compareReplays(a, b).same, true, 'одинаковые записи совпали');
eq(compareReplays(a, b).first, -1, 'расхождений нет');
// «Баг воспроизведён»: вторая запись разошлась на втором кадре.
b.clear();
b.start();
b.record({ x: 1 }, 0);
b.record({ x: 99 }, 1);
b.stop();
const cmp = compareReplays(a, b);
eq(cmp.same, false, 'расхождение найдено');
eq(cmp.first, 1, 'и это ровно второй кадр');
// Разная длина тоже расхождение.
a.clear();
a.start(); a.record({ x: 1 }, 0); a.stop();
eq(compareReplays(a, b).same, false, 'разная длина — расхождение');
});
test('clear обнуляет запись и режим', () => {
const r = createReplay({});
r.start();
r.record({ a: 1 }, 0);
r.clear();
eq(r.length(), 0);
eq(r.position(), 0);
eq(r.mode(), 'idle');
});
test('предел записи: дальше кадры не пишутся, но не падает', () => {
const r = createReplay({});
r.start();
// Записываем больше предела нельзя (это заняло бы память), поэтому
// проверяем сам факт наличия предела и что запись в норме.
truthy(MAX_FRAMES >= 36000, 'предел не меньше 10 минут при 60 к/с');
for (let i = 0; i < 100; ++i) r.record({ i }, i);
eq(r.length(), 100, 'обычные кадры пишутся');
eq(r.dropped(), 0, 'ничего не потеряно');
});
finish();
......@@ -241,7 +241,15 @@ class Agent:
def _start(self, env: Optional[Dict[str, str]]) -> None:
"""Запустить процесс и дождаться стартового события ``ready``."""
proc_env = dict(os.environ)
# Тесты не должны шуметь: подставляем беззвучный драйвер SDL, если
# драйвер не задан явно. Микшер при этом работает по-настоящему —
# проверки звука (каналы, громкость, воспроизведение) не ломаются,
# просто звук не идёт в колонки. Чтобы прогнать тест СО звуком:
# R2D_TEST_AUDIO=real python3 tests/agent/<тест>.py
if proc_env.get("R2D_TEST_AUDIO") != "real" and not proc_env.get("SDL_AUDIODRIVER"):
proc_env["SDL_AUDIODRIVER"] = "dummy"
if env:
proc_env.update({str(k): str(v) for k, v in env.items()})
......
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать