179 lines
24 KiB
Markdown
179 lines
24 KiB
Markdown
# Браузерный плейтест через 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, раздел «Проверка»):
|
||
1. `node --check` на вынутый из `index.html` JS;
|
||
2. `cd tests && node test.js` — **главный рубеж** (headless, всегда доступен);
|
||
3. `node tests/orca-playtest.js` — браузерный плейтест (требует запущенный Orca app);
|
||
4. `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`).
|
||
|
||
Запуск (из каталога репо):
|
||
|
||
```bash
|
||
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 =====
|
||
```
|
||
|
||
## Подводные камни
|
||
|
||
1. **Троттлинг rAF в фоновой вкладке.** Вкладка Orca обычно не на переднем плане → `requestAnimationFrame` троттлится → игровое время идёт **медленнее** реального (кадр = максимум 0.05 c игрового). Поэтому **никаких фиксированных sleep в ожидании состояний**: все ожидания — polling с таймаутом (`waitCond`/`waitState`, шаг ~300 мс, таймауты 10–30 с). Фиксированные `sleep` допустимы только как «удержание кнопки» (зарядка ~0.7 c, улов ~2 c).
|
||
2. **Окно подсечки короче CLI-roundtrip.** Окно BITE — 0.35–0.8 с, а один CLI-вызов стоит ~0.3–0.5 с. Рецепт стабильного перехвата: `biteTimer = 0.05` → polling, где **в том же eval, что детекция `BITE`, сразу выставляется `biteT = 5`** (отдельный eval не успевает: на первопланарной вкладке игра идёт в реальном времени, и окно закроется до его применения) → СНАЧАЛА скриншот, потом тап.
|
||
3. **Цитирование: только `execFile` с массивом аргументов, без shell.** JS-выражения содержат кавычки — при передаче одной строкой через shell (PTY) они ломаются. В скрипте все вызовы — `child_process.execFile(ORCA, argsArray, …)`; никакого `exec("orca-ide …")`.
|
||
4. **Pointer Events с уникальным `pointerId`.** Игра слушает `pointerdown` на canvas и `pointerup` на window. Каждое взаимодействие (down/up-пара) получает свой `pointerId` (счётчик в скрипте) — так события не путаются с реальными.
|
||
5. **Тосты недолгие (1.6 с).** Текст тоста near-miss читается **в том же eval**, что и ожидание `WAIT` (`state === "WAIT" ? toast.textContent : ""`) — отдельный последующий вызов может опоздать. Скриншот `orca-escape.png` — best-effort (тост мог погаснуть); ok() ставится по тексту.
|
||
6. **CLI может transient-повиснуть.** Наблюдались единичные зависания `eval` (~1 мин), проходящие при повторе. Поэтому `orca()` ретраит вызовы (3 раза, линейный бэкофф 0.5/1 c; повторы пишутся в **stderr**, чтобы не мешать stdout с PASS/FAIL), а `execFile` жжёт зависший процесс таймаутом 20 с. **Исключение — `tab create`: ретраить нельзя** (получится дубль вкладки), он идёт через `orcaCall` один раз. `tab close` в `finally` ретраится 2 раза — вызов идемпотентен.
|
||
7. **Эмодзи — квадраты без шрифта.** Без `font-noto-emoji` эмодзи в скриншотах (и в окне Orca) рендерятся тофу. На скринах это ОК — проверка не по пикселям; для красивых скриншотов поставьте шрифт.
|
||
8. **`eval` возвращает объекты JSON-строкой.** `result.result` для объекта/массива — двойная сериализация (строка-JSON); `evalPage` в скрипте декодирует. Притом строка-«MENU» парсится как есть.
|
||
9. **Босс-фазы: `SNAP` не сбрасывает `reel.holding`.** `onUp` слушает только `CHARGE`/`REEL` — отпускание пальца в состоянии `SNAP` (босс-обрыв фазы) оставляет `holding = true`, и после авто-перехода в следующую фазу игра «втягивает» сама. Поэтому в шаге 12 сброс `reel.holding = false` делается **в том же eval, что детекция перехода фазы** (см. п. 13; тот же приём, что `biteT = 5` в п. 2). Сам босс-бой — цикл из 3 фаз с polling'ом, `forceCatch()` тут не подходит: на фазах 1–2 вместо `CARD` будет босс-обрыв.
|
||
10. **Профиль Orca переживает localStorage между прогонами.** В отличие от `test.js` (свежий headless-профиль в каждом запуске), встроенный браузер Orca помнит сейв (`fishing-save-v1`) — и прошлый прогресс ломает шаги, завязанные на чистое состояние: после M5 «фонарик» — не отдельная строка, а апгрейд-кнопка на удилище 0 (только при 1–3 ур.; на 4 ур. кнопки нет → шаг 11 падал «no flask»). Поэтому шаг 2b сбрасывает сейв **через игру** (пауза → «В меню» → 🗑️ = `save = freshSave()` + persist + сброс всех тест-форсов) **до старта геймплея**, без перезагрузки страницы — error-хуки шага 1b остаются на месте.
|
||
11. **Сейв профиля может прийти с НОЧЬЮ — таймаут на клёве (шаг 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).
|
||
12. **Босс 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-гейтинг требует прокачки всей цепочки удочек. В headless `test.js` той же роли играет прокачанная леска — §M4 идёт после апгрейд-тестов M5.
|
||
13. **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.
|
||
|
||
## Как добавить новый шаг
|
||
|
||
1. В секции «Прогон» в нужном месте добавьте действия через хелперы (`evalPage`, `waitCond`/`waitState`, `pointerDown/Up`, `clickByText`, `shot`) — фиксированные sleep на ожидание состояний не писать (пункт 1 выше).
|
||
2. После каждого шага — `ok('название', условие, extra)`.
|
||
3. Если шаг форсирует игру (например, `SHINY_CHANCE = 1`), возвращайте исходное значение в `finally` — иначе сломаете следующие циклы и сейв вкладки.
|
||
4. Скриншот — `shot('orca-<имя>.png')`: файл уходит в CWD, не путать с `shot-*.png` из `tests/test.js` (автотесты) и `screenshots-wiki/` из `tests/gen-screenshots.js`.
|
||
5. Обновите таблицу шагов в этом документе и число проверок в 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` |
|