24 KiB
Браузерный плейтест через Orca CLI
tests/orca-playtest.js — плейтест игры в настоящем браузере: он открывает вкладку встроенного браузера Orca app, сбрасывает сейв (чистое состояние) и прогоняет полный геймплей (меню → бросок → клёв → подсечка → улов → лавка (дневник, 🎯, 🏅, 🎨 скины, 🗺️ локации) → золотая рыба в дождь + туман (M8) → near-miss → ночь → фонарик в лавке → босс-рыба → хаб «Меню» (Э29)) через Orca CLI и проверяет состояния, DOM, ачивки и консоль.
В отличие от puppeteer-теста (tests/test.js), здесь нет отдельного headless Chromium — управление идёт командами CLI против браузера, которым пользуются люди. Это ловит то, что headless пропускает: отличия среды Orca-браузера, троттлинг фоновых вкладок, реальные CLI-нюансы.
Когда запускать
- Перед коммитом, рядом с остальными проверками (см. AGENTS.md, раздел «Проверка»):
node --checkна вынутый изindex.htmlJS;cd tests && node test.js— главный рубеж (headless, всегда доступен);node tests/orca-playtest.js— браузерный плейтест (требует запущенный Orca app);node tests/gen-screenshots.js— скриншоты (15 кадров × 3 вьюпорта, 24-битный PNG).
- В отличие от puppeteer-теста, требует запущенный Orca app. Если Orca не запущен — скрипт честно падает на шаге 0 с понятной ошибкой; в таком случае шаг пропускается и не блокирует коммит (главный рубеж —
tests/test.js).
Требования
| Что | Зачем | Как проверить |
|---|---|---|
| Запущенный Orca app (desktop) | Вкладка создаётся в его встроенном браузере | orca-ide status --json → result.runtime.state === "ready" |
orca-ide в PATH или переменная ORCA_CLI_COMMAND |
Скрипт вызывает CLI через process.env.ORCA_CLI_COMMAND || 'orca-ide' |
which orca-ide |
На Linux голое имя
orcaможет резолвиться в GNOME screen reader — НЕ используйте его; в терминалах Orca алиасorcaработает, но скрипт намеренно берётorca-ide(или переопределение черезORCA_CLI_COMMAND).
Запуск (из каталога репо):
node tests/orca-playtest.js # скриншоты orca-*.png кладутся в CWD
Только встроенные модули Node (child_process, fs, path) — npm install не нужен.
Таблица шагов
| # | Шаг | Что проверяется (ok()) |
|---|---|---|
| 0 | status |
runtime готов; иначе «запусти Orca app» и exit 1 |
| 1 | tab create --url file://…/index.html |
получился browserPageId |
| 1b | установка error-хуков (window.__errs, error + unhandledrejection) |
хуки стоят до старта геймплея (polling переживает eval-ошибки грузящейся страницы) |
| 2 | ожидание DOM | меню (#menu) показано |
| 2b | сброс сейва — настоящий UI-путь хаба «Меню» (Э29): «Играть» → ⏸ → «В меню» → 🗑️ «Очистить» → «Сбросить» (save = freshSave() + persist) |
#gamemenu открывается из паузы (#pause скрыт, state !== "MENU" — сессия сохранена), после сброса — #menu (инструкции), #gamemenu скрыт, save.coins === 0 |
| 3 | клик «Играть» (.click() в DOM) |
state === "IDLE" |
| 4 | pointerdown на canvas → polling CHARGE → удержание ~0.7 c → pointerup |
CHARGE, затем WAIT (бросок) |
| 5 | biteTimer = 0.05 → polling BITE (через APPROACH) с расширением окна biteT = 5 в том же eval → скриншот → тап |
BITE, затем REEL (подсечка) |
| 6 | reel.d = 0.5 + удержание ~2 c → polling CARD (30 c) → скриншот |
CARD, в карточке имя вида и «+N» |
| 7 | «Отлично!» → «🛒» → polling #shop → скриншот → вкладка «📔» → скриншот → вкладка «🎯» → скриншот → вкладка «🏅» → скриншот → «×» |
лавка открыта/закрыта, дневник/квесты/достижения рендерятся |
| 7c | вкладка «🎨» → polling рендера → save.coins = 200000 (выдача) → покупка поплавка «Красный» (клик и чтение состояния в одном eval) |
группы «Лодка»/«Поплавок», списание (−500), авто-экипировка save.bobberSkin=1 |
| 7d | вкладка «🗺️» → покупка «Озёра» (клик + состояние + тост в одном eval) → скриншот → возврат на «Реку» | авто-переход save.location=1, тост «Добро пожаловать на Озёра!», списание (−100000), выбор купленной |
| 8 | forceWeather = "rain" → пилюля 🌧️ → ratio variantChance(gold) дождь/ясно (в одном eval) → SHINY_CHANCE = 1; forceVariant = "gold" → полный цикл → скриншот → проверка DOM → ачивки → SHINY_CHANCE = 0.06; forceVariant = null; forceWeather = null |
бейдж «ЗОЛОТАЯ» + gold-card; ×3 (M8); ачивки Этап 22 first_shiny+rain_variant (wxRainV=1)+skin_first+lake_first — выдаются НА УЛОВЕ (checkAchievements в catchFish, не в лавке) |
| 8b | forceWeather = "fog" → пилюля 🌫️ → ratio biteChance() туман/ясно (в одном eval) → снятие форса → polling скрытия пилюли |
клёв ×1,5 (M8), пилюля показ/скрытие |
| 9 | цикл до REEL → reel.d = reel.d0 * 1.45 + 2 → polling WAIT с чтением тоста в том же eval |
тост содержит «Сбежала» |
| 9e | хаб «Меню» (Э29): монеты → лавка → × → ⏸ → «В меню» → «Магазин»/«Достижения»/«Дневник» (× шаг назад в хаб) → «Инструкции» → «Играть» → ⏸ → «В меню» → 🔊 ×2 → «Продолжить»; скриншот orca-gamemenu.png |
монеты открывают лавку на «Лавке» (tabTackle), из хаба — свои вкладки 🏅/📔, × возвращает в хаб (uiOpen=true, геймплей не стартует), Инструкции = #menu, mute-тогл на каждом клике, «Продолжить» → uiOpen=false, state=IDLE |
| 10 | setDayTime(0.85) с чтением dayPhase в том же eval → скриншот |
dayPhase === "night" |
| 11 | «🛒» → вкладка «🎣» → поиск «фонарик» в #shopBody |
в снастях есть апгрейд-фонарик (⬆ 70 🪙, пометка про ночь) |
| 12 | forceBoss = true + pickSpecies → осётр → выдача «Титана» через сейв (save.rod=3; см. п. 12 «Подводных камней») → полный цикл: REEL (старт боя: stateBoss, bossPhase=1) → 3 фазы (в каждой reel.d = 0.4 → polling персистентного bossPhase===фаза+1 со сбросом holding в том же eval, см. п. 13 → авто-REEL следующей фазы; фаза 3 → CARD) → скриншот → forceBoss = false в finally |
rod().maxW ≥ 40 (стартовая удочка не выдерживает вес босса), старт босс-боя, карточка после 3 фаз (stateBoss=false), имя «Осётр-БОСС» с бейджем |
| 13 | console --limit 100 |
нет сообщений уровня error |
| 13b | чтение window.__errs |
нет pageerror за прогон (JS-ошибки и unhandledrejection) |
| 14 | tab close (в finally, 2 попытки — close идемпотентен) |
вкладка закрыта при любом исходе |
Итог — сводка ИТОГ: N/N и exit code: 0 все PASS, 1 есть FAIL, 2 краш сценария, 3 глобальный таймаут (~4 мин).
Пример вывода
PASS | Orca runtime готов
PASS | вкладка с игрой открыта | pageId=d94df318
PASS | меню показано
PASS | пауза → «В меню»: открыт хаб #gamemenu, #pause скрыт, сессия сохранена (state!=="MENU")
PASS | сброс сейва через «Меню»: coins=0, #gamemenu скрыт, возврат к инструкциям
PASS | день перед геймплеем: dayPhase!=="night" (сейв профиля мог прийти с ночью)
PASS | старт: state=IDLE
PASS | зарядка: state=CHARGE
PASS | бросок: state=WAIT
PASS | клёв: state=BITE (через APPROACH)
PASS | скриншот orca-bite.png
PASS | подсечка: state=REEL
PASS | улов: state=CARD
PASS | скриншот orca-card.png
PASS | карточка улова: имя вида и «+N» | {"hidden":false,"name":"Карась","val":"+2 🪙"}
PASS | лавка открыта (вкладка Снасти)
PASS | скриншот orca-shop.png
PASS | дневник показан
PASS | скриншот orca-journal.png
PASS | 🎯 вкладка: стрик и квесты дня
PASS | скриншот orca-rewards.png
PASS | 🏅 вкладка: список достижений
PASS | скриншот orca-ach.png
PASS | 🎨 вкладка: скины лодки и поплавка
PASS | скины: покупка «Красный» (500 🪙) — списание и авто-экипировка | {"base":199500,"coins":199500,"owned":true,"skin":1}
PASS | скриншот orca-skins.png
PASS | 🗺️ вкладка: локации (Река / Озёра)
PASS | локации: покупка «Озёра» (100000 🪙) — авто-переход, тост, списание | {"base":99500,"coins":99500,"loc":1,"owned":true,"toast":"Добро пожаловать на Озёра!"}
PASS | скриншот orca-locations.png
PASS | локации: возврат на Реку (выбор купленной)
PASS | лавка закрыта
PASS | погода rain: HUD-пилюля 🌧️
PASS | погода rain: variantChance(gold) ×3 (WEATHER.rain.variantMult) | 3
PASS | зарядка: state=CHARGE
PASS | бросок: state=WAIT
PASS | клёв: state=BITE (через APPROACH)
PASS | подсечка: state=REEL
PASS | золотая рыба: улов получен (state=CARD)
PASS | скриншот orca-shiny.png
PASS | золотая рыба: бейдж «ЗОЛОТАЯ» и золотая карточка (gold-card) | {"badge":"ЗОЛОТАЯ","gold":true,"hidden":false}
PASS | ачивки Этап 22: first_shiny + rain_variant (улов в дождь) + skin_first + lake_first | {"lake":true,"rain":true,"shiny":true,"skin":true,"wx":1}
PASS | погода fog: HUD-пилюля 🌫️
PASS | погода fog: biteChance ×1.5 (WEATHER.fog.biteMult) | 1.5
PASS | погода снята: пилюля скрыта
PASS | зарядка: state=CHARGE
PASS | бросок: state=WAIT
PASS | клёв: state=BITE (через APPROACH)
PASS | подсечка: state=REEL
PASS | near-miss: тост «Сбежала» после схода | Сбежала! 🐠 Окунь 0.2 кг
PASS | скриншот orca-escape.png
PASS | ночь: dayPhase=night при dayTime 0.85 | night
PASS | скриншот orca-night.png
PASS | лавка: фонарик — апгрейд 1 удилища (⬆ 70, пометка про ночь)
PASS | босс: выдан «Титан» (до 40 кг) — стартовая удочка не выдерживает вес босса
PASS | зарядка: state=CHARGE
PASS | бросок: state=WAIT
PASS | клёв: state=BITE (через APPROACH)
PASS | подсечка: state=REEL
PASS | босс: старт боя (stateBoss=true, bossPhase=1, reel.boss) | {"b":true,"ph":1,"rb":true}
PASS | босс: карточка после 3 фаз (stateBoss=false)
PASS | скриншот orca-boss.png
PASS | босс: карточка «Осётр-БОСС» с бейджем 🏆 | Осётр-БОСС | 🏆 БОСС!
PASS | монеты → лавка открыта на вкладке «Лавка» (tabTackle active)
PASS | × из лавки (открыта монетами) → uiOpen=false, возврат в игру
PASS | скриншот orca-gamemenu.png
PASS | ⏸ → «В меню»: хаб открыт, #pause скрыт, сессия сохранена (state!=="MENU")
PASS | «Магазин» → лавка на «Лавке» (tabTackle), #gamemenu скрыт
PASS | × из лавки → возврат в «Меню» (uiOpen=true, геймплей не стартует)
PASS | «Достижения» → лавка на вкладке 🏅
PASS | «Дневник» → лавка на вкладке 📔
PASS | «Инструкции» → #menu виден, хаб скрыт
PASS | «Играть» из инструкций → state=IDLE, #gamemenu скрыт
PASS | 🔊 «Звук» в «Меню»: каждый клик — тогл | {"m0":false,"m1":true,"m2":false}
PASS | «Продолжить» → хаб скрыт, uiOpen=false, state=IDLE, #btnPause виден
PASS | нет ошибок в консоли (console --limit 100)
PASS | нет pageerror за прогон (window.onerror / unhandledrejection) | []
===== ИТОГ: 76/76 =====
Подводные камни
- Троттлинг rAF в фоновой вкладке. Вкладка Orca обычно не на переднем плане →
requestAnimationFrameтроттлится → игровое время идёт медленнее реального (кадр = максимум 0.05 c игрового). Поэтому никаких фиксированных sleep в ожидании состояний: все ожидания — polling с таймаутом (waitCond/waitState, шаг ~300 мс, таймауты 10–30 с). Фиксированныеsleepдопустимы только как «удержание кнопки» (зарядка ~0.7 c, улов ~2 c). - Окно подсечки короче CLI-roundtrip. Окно BITE — 0.35–0.8 с, а один CLI-вызов стоит ~0.3–0.5 с. Рецепт стабильного перехвата:
biteTimer = 0.05→ polling, где в том же eval, что детекцияBITE, сразу выставляетсяbiteT = 5(отдельный eval не успевает: на первопланарной вкладке игра идёт в реальном времени, и окно закроется до его применения) → СНАЧАЛА скриншот, потом тап. - Цитирование: только
execFileс массивом аргументов, без shell. JS-выражения содержат кавычки — при передаче одной строкой через shell (PTY) они ломаются. В скрипте все вызовы —child_process.execFile(ORCA, argsArray, …); никакогоexec("orca-ide …"). - Pointer Events с уникальным
pointerId. Игра слушаетpointerdownна canvas иpointerupна window. Каждое взаимодействие (down/up-пара) получает свойpointerId(счётчик в скрипте) — так события не путаются с реальными. - Тосты недолгие (1.6 с). Текст тоста near-miss читается в том же eval, что и ожидание
WAIT(state === "WAIT" ? toast.textContent : "") — отдельный последующий вызов может опоздать. Скриншотorca-escape.png— best-effort (тост мог погаснуть); ok() ставится по тексту. - CLI может transient-повиснуть. Наблюдались единичные зависания
eval(~1 мин), проходящие при повторе. Поэтомуorca()ретраит вызовы (3 раза, линейный бэкофф 0.5/1 c; повторы пишутся в stderr, чтобы не мешать stdout с PASS/FAIL), аexecFileжжёт зависший процесс таймаутом 20 с. Исключение —tab create: ретраить нельзя (получится дубль вкладки), он идёт черезorcaCallодин раз.tab closeвfinallyретраится 2 раза — вызов идемпотентен. - Эмодзи — квадраты без шрифта. Без
font-noto-emojiэмодзи в скриншотах (и в окне Orca) рендерятся тофу. На скринах это ОК — проверка не по пикселям; для красивых скриншотов поставьте шрифт. evalвозвращает объекты JSON-строкой.result.resultдля объекта/массива — двойная сериализация (строка-JSON);evalPageв скрипте декодирует. Притом строка-«MENU» парсится как есть.- Босс-фазы:
SNAPне сбрасываетreel.holding.onUpслушает толькоCHARGE/REEL— отпускание пальца в состоянииSNAP(босс-обрыв фазы) оставляетholding = true, и после авто-перехода в следующую фазу игра «втягивает» сама. Поэтому в шаге 12 сбросreel.holding = falseделается в том же eval, что детекция перехода фазы (см. п. 13; тот же приём, чтоbiteT = 5в п. 2). Сам босс-бой — цикл из 3 фаз с polling'ом,forceCatch()тут не подходит: на фазах 1–2 вместоCARDбудет босс-обрыв. - Профиль Orca переживает localStorage между прогонами. В отличие от
test.js(свежий headless-профиль в каждом запуске), встроенный браузер Orca помнит сейв (fishing-save-v1) — и прошлый прогресс ломает шаги, завязанные на чистое состояние: после M5 «фонарик» — не отдельная строка, а апгрейд-кнопка на удилище 0 (только при 1–3 ур.; на 4 ур. кнопки нет → шаг 11 падал «no flask»). Поэтому шаг 2b сбрасывает сейв через игру (пауза → «В меню» → 🗑️ =save = freshSave()+ persist + сброс всех тест-форсов) до старта геймплея, без перезагрузки страницы — error-хуки шага 1b остаются на месте. - Сейв профиля может прийти с НОЧЬЮ — таймаут на клёве (шаг 5). Сброс 2b обнуляет данные, но НЕ глобальный
dayTime(день после сброса продолжается — так задумано), а прошлый прогон оставлял в сейвеdayTime≈0.85(шаг 10 «ночь»). Ночью без фонарика клёва нет совсем: ветка клёва стоит ЗА гейтомhasLight()вupdateGame()— не срабатывает дажеforceBite(он внутри else-if). Симптом:timeout: state="BITE" (last: state=WAIT)при живом game-loop (бросок прошёл). Лечится шагом 2c:setDayTime(0.3)+ проверкаdayPhase !== "night"до старта геймплея (шаг 10 сам ставит ночь черезsetDayTime(0.85)и не зависит от входного dayTime). - Босс 20–50 кг (Этап 37) рвёт стартовую удочку — обрыв вместо босс-фаз (шаг 12). Предел «Дедовой удочки» 3 кг: босс весит минимум 20 кг →
over=w/maxW ≥ 6.7(до ≈16.7 при 50 кг) — босс рвёт слабые удочки ещё жёстче, чем в M4: натяжение достигает прочности лески РАНЬШЕ, чемd≤0даёт босс-обрыв фазы → настоящий SNAP (stateBoss=false→ IDLE), симптомtimeout: state="REEL" (last: state=IDLE). Лечится выдачей «Титана» через сейв (save.rod=3, до 40 кг): купить по UI нельзя — M5-гейтинг требует прокачки всей цепочки удочек. В headlesstest.jsтой же роли играет прокачанная леска — §M4 идёт после апгрейд-тестов M5. - SNAP-окно 0.6 с vs CLI-roundtrip ~0.5 с — ждать
bossPhase, неSNAP(шаг 12). Фазовый обрыв даётstate='SNAP'наsnapT=0.6игрового времени; poll может проскочить это окно, а оставшийся зажатым указатель спокойно дотягивает босса доCARDуже в фазе 3 (симптом:timeout: "SNAP фазы 2" (last: state=CARD)). Ждём персистентныйbossPhase===фаза+1(инкремент в том же переходе, что авто-REEL) со сбросомholdingв том же eval. В headless-тесте window короткий и CDP-раундтрип ~20 мс — там работает и ожидание SNAP.
Как добавить новый шаг
- В секции «Прогон» в нужном месте добавьте действия через хелперы (
evalPage,waitCond/waitState,pointerDown/Up,clickByText,shot) — фиксированные sleep на ожидание состояний не писать (пункт 1 выше). - После каждого шага —
ok('название', условие, extra). - Если шаг форсирует игру (например,
SHINY_CHANCE = 1), возвращайте исходное значение вfinally— иначе сломаете следующие циклы и сейв вкладки. - Скриншот —
shot('orca-<имя>.png'): файл уходит в CWD, не путать сshot-*.pngизtests/test.js(автотесты) иscreenshots-wiki/изtests/gen-screenshots.js. - Обновите таблицу шагов в этом документе и число проверок в AGENTS.md, если менялось общее количество ok().
Справочник CLI (используемые команды)
| Команда | Что даёт (в --json) |
|---|---|
orca-ide status --json |
result.runtime.state === "ready" |
orca-ide tab create --url <url> --json |
result.browserPageId (дальше везде --page <id>) |
orca-ide eval --expression <js> --page <id> --json |
result.result (объекты — JSON-строкой) |
orca-ide screenshot --page <id> --json |
result.data = base64 PNG |
orca-ide console --limit N --page <id> --json |
result.messages: [{text, type}], type: "error" — ошибки |
orca-ide tab close --page <id> --json |
result.closed |