@playbox-ai/playable-playtest
Playable Playtest
@playbox-ai/playable-playtest — состояние игры для AI-playtest и debugging.
Независим от Playable Kit, Settings и движка. ESM, TypeScript declarations,
без runtime-зависимостей. Совместимость с Cocos предназначена для Creator 3.x Web.
Установка: npm install --save-exact @playbox-ai/playable-playtest@0.1.0.
Для локальной разработки: npm run build, затем npm pack и установка .tgz.
Подключение к игровому bootstrap и внешней оболочке выполняется отдельно.
import { createPlaytest } from '@playbox-ai/playable-playtest';
import { attachPlaytestPreview } from '@playbox-ai/playable-playtest/preview';
// enabled и buildId передаёт ваш bootstrap. Универсального флага сборки нет.
const playtest = createPlaytest({ enabled: previewEnabled, buildId, runId: 'round-1' });
const unregister = playtest.exposeState({
description: 'phase: playing/won/lost; position: world units; timeRemaining: seconds.',
isReady: () => game.ready,
read: () => ({
phase: game.phase,
player: { position: { x: game.player.x, y: game.player.y } },
timeRemaining: game.timeRemaining,
objective: { collected: game.collected, required: game.required },
}),
});
const detach = attachPlaytestPreview(playtest);
// При реальном рестарте: game.ready = false; playtest.startRun('round-2');
// Затем создать/сбросить раунд и выставить game.ready = true.
// При teardown: detach(); unregister(); playtest.dispose();
exposeState() регистрирует один provider на экземпляр. read() вызывается
только при запросе снимка, синхронно, без изменения игры. isReady необязателен;
без него зарегистрированный provider считается готовым. Async read не поддержан.
На этапе загрузки/рестарта/смены сцены возвращай false из isReady.
Игровые ошибки не подавляй; ошибка чтения становится read_failed.
Получение из оболочки
import { createPlaytestHost } from '@playbox-ai/playable-playtest/host';
// После iframe load; не импортировать /host в игру.
const host = createPlaytestHost(iframe);
const snapshot = await host.getSnapshot();
// Browser automation может обращаться к host через явно выставленный
// оболочкой window.playtestHost. Пакет сам глобальные переменные не создаёт.
host.dispose();
Результат: { buildId, runId, schemaVersion, sequence, capturedAt, description, state }.
capturedAt — Unix ms начала чтения; sequence растёт после каждого успешного
снимка в течение жизни экземпляра. Это номер снимка, не номер игрового кадра.
Если нужна привязка к кадру/симуляции, добавь tick в состояние игры.
runId должен быть уникален для каждого раунда, включая Reload страницы;
примеры ниже используют ID сессии и счётчик. buildId идентифицирует весь билд,
не только defaults. Старый снимок после действия не подтверждает его результат.
Контракт состояния
schemaVersion задаётся в exposeState, положительное целое, по умолчанию 1.
Повышай его при несовместимой смене формы или смысла state. Он независим от
версии протокола доступа (1) и идентификатора билда.
- Только выбранная разработчиком проекция: обычный JSON-объект, вложенные объекты/плотные массивы, строки, boolean, конечные числа и null.
- Не возвращать Cocos Node/Vec3, Three.Object3D, DOM, Map, Date, функции,
undefined, bigint, NaN/Infinity, циклы, accessors или Promise. Копируй
координаты явно:
{ x: node.position.x, y: node.position.y }. - Снимок отделён от объектов игры; изменение полученной копии не меняет игру.
- Ограничения: 64 KiB UTF-8 JSON состояния, глубина 32, 10 000 узлов; description до 4096 символов, ID до 128. Большие списки агрегируй/ограничивай и показывай общее количество и факт усечения.
- Снимок согласован в пределах синхронного чтения JS. Состояние в workers или других потоках игра сама публикует в стабильную проекцию перед чтением.
- Не добавляй скрытую информацию в обычный AI-playtest. Если debug-снимок раскрывает скрытые цели/врагов, явно пометь это в description и отчёте теста.
Протокол v1
Родитель → iframe:
{ channel: 'plbx:playtest', version: 1, kind: 'getSnapshot', requestId }.
Ответ → родитель:
{ channel, version: 1, kind: 'snapshot', requestId, ok: true, snapshot }
или { channel, version: 1, kind: 'snapshot', requestId, ok: false, error: { code, message } }.
Протокол только читает; restore, set, ввод и debug-команды в него не входят. Iframe принимает запросы только своего непосредственного родителя, host — ответы только своего iframe и ожидаемого requestId. Домен не зафиксирован; включённый preview разрешает чтение своему родителю на любом origin, в том числе sandbox с opaque origin. Не включай его для рекламных размещений. Это явный opt-in, не способ защитить секреты внутри браузерной игры.
Таймаут host по умолчанию 2000 ms (настройка 1–60000), максимум 32 запроса
одновременно. На iframe load незавершённые запросы отклоняются как reloaded.
Нет фонового стрима, автоповторов и очереди кадров. В неподдерживаемой игре
будет timeout, а не ложное состояние «готова». Синхронный зависший getter
нельзя прервать внутри игры: provider должен быть коротким.
Коды ошибок: disabled, not_ready, read_failed, invalid_state,
state_too_large, disposed, timeout, reloaded, invalid_response, busy, unsupported.
Ошибка provider не выбрасывается в игровой update; host получает rejected Promise.
Прямой debug-мост, без iframe
import { attachPlaytestDebug } from '@playbox-ai/playable-playtest/debug';
const detachDebug = attachPlaytestDebug(playtest);
// Browser/DevTools, только в явно включённом preview/playtest:
window.__PLAYBOX_PLAYTEST__.getCapabilities();
window.__PLAYBOX_PLAYTEST__.getSnapshot();
window.__PLAYBOX_PLAYTEST__.pause();
window.__PLAYBOX_PLAYTEST__.getStatus();
window.__PLAYBOX_PLAYTEST__.resume();
// При уничтожении: detachDebug(); затем отключить transport и runtime.
Глобал содержит только замороженный API, без App/Settings или setters состояния. В выключенном режиме он не создаётся. Повторная регистрация без detach — ошибка. Сохранившаяся после detach ссылка отклоняет вызовы как disposed.
Пауза использует существующий window.playboxCapture из Playbox hosting.
SDK не инжектит capture SDK и не зависит от платформы. Для самостоятельной игры
можно передать attachPlaytestDebug(playtest, { pauseController }), где controller
имеет синхронные pause()/resume() и getter isPaused: boolean. Адаптер должен
действительно останавливать цикл игры, сохранять рекламную паузу и не давать
скачка dt после resume. Без hosting/адаптера snapshot работает, pause даёт unsupported.
getCapabilities() → { snapshot: true, pause: boolean, pauseSource: 'hosting'|'game'|null }.
getStatus() → { paused: boolean|null, ownsPause: boolean, pauseSource }.
Повторный pause идемпотентен; resume/detach не снимают паузу, которая уже была
включена до первого вызова. У capture SDK нет токенов владения: параллельное
управление паузой из UI и агента во время опыта не поддержано.
Пауза не превращает SDK в manual/fixed-step симулятор.
Для ожидания на паузе используй часы инструмента вне страницы: hosting гейтит page timers. Если сторонний harness подменяет Date.now, capturedAt следует его часам; записывай реальное время получения снимка на стороне агента.
Примеры и проверка
- Обычная TS-игра: адаптер к фактическому состоянию игры.
- Cocos Creator: компонент с bind, readiness и cleanup.
- Браузерный стенд: iframe, обычные кнопки и снимки.
После build: из каталога пакета
python3 -m http.server 8090, затемhttp://localhost:8090/examples/browser/. npm test: runtime + protocol checks без браузера.npm run test:browser: реальный Chromium, два origin и opaque sandbox. Требует Node 22+ и Chrome; при необходимости задайCHROME_BIN.
Агент: снимок → обычный ввод → ожидание наблюдаемого результата → новый снимок.
Сверяй скриншот и состояние. Снимок сам по себе не доказывает отсутствие
визуальных багов и не останавливает игру. Пауза доступна через /debug при
наличии hosting или адаптера игры; пошаговая симуляция не реализована.
Лицензия — LICENSE.