Files
fishing-game/tests/orca-playtest.js
T
andrey 4ee0d4fce3 Монеты → лавка и хаб «Меню» (Этап 29): клик по #coins в HUD открывает «Лавку рыбака» на вкладке «Лавка» (#tabTackle: «Снасти»→«Лавка», 🎣→🛒; секции «🎣 Удилища»/«🪱 Приманки» внутри не тронуты); openShop(tab?) — аргумент форсит вкладку (syncTabs() вынесен из setTab), HUD 🛒 зовёт без аргумента — помнит последнюю. Новый диалог «Меню» (#gamemenu, из паузы «В меню»): «Продолжить» (uiOpen=false + gameplayStart), Магазин/Достижения/Дневник (fromMenuShop(tab) → openShop), «Инструкции» (#menu, «Играть» вернёт в игру), 🔊 Звук и 🗑️ Очистить — ПЕРЕНЕСЕНЫ из HUD (id btnMute/btnReset сохранены; в .hudbtns остались 🛒 и ⏸). «В меню» больше не ставит state='MENU': сессия (заброс/вываживание) заморожена под оверлеем и продолжается с того же места; state='MENU' — только загрузка/сброс (там #menu с инструкциями — старт новой игры прежний). shopFromMenu: лавка, открытая из «Меню», при × возвращает в хаб (uiOpen=true, без gameplayStart); из HUD/монет — в игру. Z-порядок: #gamemenu в DOM позже #shop/#resetDlg — при открытии лавки/сброса из хаба гменю прячется, иначе перекрыл бы их. YG-счётчики сбалансированы: вход в хаб из паузы (gameplayStop уже сделал onPause), «Продолжить» → gameplayStart (пин §Y8(4): stop +1, start +0). i18n: gm_* ключи RU/EN, tab_tackle 'Лавка'/'Shop', updateHUD печатает 🔊/🔇 + «Звук», setLang дёргает updateHUD. Все emoji набора уже были в субсете встроенного шрифта — перегенерация tools/emoji-font.py не потребовалась. Тесты: §9/Y1/Y2/Y6 флоу btnReset/btnMute — через toGameMenu() (⏸ → «В меню»); §Y6(5) переписан (хаб открыт, state сохранён), (6) заходит к btnPlay через «Инструкции»; новая §Y8 (HUD-состав, монеты→«Лавка», вкладки из хаба, ×→хаб, mute-тогл по семантике — фикстура сейва §«локации» ставит muted:true, «Продолжить», инструкции). 177/177, exit 0. shots.js: кадр gamemenu (15/15), screenshots/ обновлены, визуально проверены (лавка/хаб/сброс/инструкции). orca-playtest.js: сброс 2b — настоящий UI-путь хаба (не синтетический клик по скрытой кнопке); новая секция 9e — переходы по хабу (монеты, Магазин/Достижения/Дневник + × назад, Инструкции, 🔊-тогл через объект — скаляры CLI приходят JSON-строками, «Продолжить»); закрыты давние флейки живого браузера: сейв профиля с ночью прошлого прогона (шаг 2c — форс дня: ночью без фонарика ветка клёва за гейтом hasLight, forceBite не работает), босс 5–40 кг рвал стартовую удочку натяжением до босс-фаз (выдаём «Титан», save.rod=3 — по UI нельзя, M5-гейтинг) и транзитное SNAP-окно 0.6 с vs CLI-roundtrip (ждём персистентный bossPhase, отпускание в том же eval). 76/76. check.mjs OK, pack 5/5, pack-smoke 6/6. Доки: AGENTS.md (177 чеков, «Меню», монеты→лавка, пауза), README (вкладка «Лавка», хаб), DEVLOG Этап 29, BROWSER-TESTING (п. 11–13, таблица шагов 2b/9e/12).
2026-09-09 15:44:04 +03:00

600 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* Браузерный плейтест игры через Orca CLI (вкладка встроенного браузера Orca app).
*
* Запуск из каталога репо, при ЗАПУЩЕННОМ Orca app:
* node tests/orca-playtest.js
*
* Отличия от puppeteer-теста (tests/test.js):
* - CLI-roundtrip (~0.3-0.5 c) длиннее окна подсечки (0.35-0.8 c) →
* перед тапом biteT расширяется до 5 c (СНАЧАЛА скриншот, потом тап);
* - вкладка Orca в фоне → requestAnimationFrame троттлится → игровое время
* идёт МЕДЛЕННЕЕ реального. Фиксированные sleep НЕ ждут состояния: все
* ожидания — polling с таймаутом (шаг POLL_MS). Фиксированные sleep
* используются только как «удержание кнопки»;
* - JS-выражения передаются ТОЛЬКО через child_process.execFile с массивом
* аргументов (БЕЗ shell) — иначе кавычки ломаются в PTY;
* - сейв СБРАСЫВАЕТСЯ в игре через хаб «Меню» (Играть → ⏸ → «В меню» → 🗑️ →
* «Сбросить», Э29) после установки error-хуков: профиль Orca переживает
* localStorage между прогонами, а шаги прогона (вкладка «Лавка», «фонарик»
* как апгрейд удилища) завязаны на чистое состояние; в отличие от test.js
* (свежий headless-профиль) — обязательно; перед геймплеем — форс дня (2c):
* сейв мог прийти с ночью прошлого прогона (ночью без фонарика клёва нет);
* - в конце прогона (9e) — переходы по хабу «Меню»: монеты → «Лавка»,
* Магазин/Достижения/Дневник (× шаг назад), Инструкции, 🔊, «Продолжить»;
* - ошибки страницы (pageerror) ловятся хуками window error/unhandledrejection,
* установленными сразу после открытия вкладки, и проверяются в конце прогона;
* - ретраи CLI-вызовов — с линейным бэкоффом; повторы пишутся в stderr,
* чтобы не засорять stdout с PASS/FAIL.
*
* Только встроенные модули Node: child_process, fs, path.
*/
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');
const ORCA = process.env.ORCA_CLI_COMMAND || 'orca-ide';
const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
const POLL_MS = 300; // шаг polling'а состояний
const CALL_TIMEOUT_MS = 20000; // таймаут одного CLI-вызова
const GLOBAL_TIMEOUT_MS = 240000; // ~4 минуты на весь прогон
const results = [];
function ok(name, cond, extra = '') {
results.push({ name, pass: !!cond, extra });
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
}
function printSummary() {
const failed = results.filter(r => !r.pass);
console.log('\n===== ИТОГ: ' + (results.length - failed.length) + '/' + results.length + ' =====');
return failed.length;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ---------- Orca CLI ---------- */
let pageId = null;
// Один вызов orca-ide: args БЕЗ --json (флаг добавляется здесь), ответ — распарсенный JSON.
// Всегда execFile + массив аргументов, без shell.
function orcaCall(args) {
return new Promise((resolve, reject) => {
execFile(ORCA, args.concat(['--json']), { timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => {
if (err) {
const why = err.killed ? 'timeout ' + CALL_TIMEOUT_MS + 'ms' : String(err.message || '').slice(0, 150);
return reject(new Error(ORCA + ' ' + args.join(' ') + ': ' + why + (stderr ? ' ' + String(stderr).slice(0, 150) : '')));
}
try { resolve(JSON.parse(stdout)); }
catch (e) { reject(new Error('bad JSON from orca: ' + String(stdout).slice(0, 150))); }
});
});
}
// То же, но с ретраями: CLI может transient-повиснуть (hang ловится таймаутом execFile).
// eval'ы в нашем сценарии идемпотентны, поэтому повтор безопасен.
// ВНИМАНИЕ: tab create ретраить нельзя (получится дубль вкладки) — вызовите orcaCall.
// Повторы идут с линейным бэкоффом (0.5 c, 1 c, …) и пишутся в stderr.
async function orca(args, attempts) {
const n = attempts || 3;
let lastErr;
for (let i = 0; i < n; i++) {
try { return await orcaCall(args); }
catch (e) {
lastErr = e;
if (i < n - 1) {
console.error('[retry ' + (i + 1) + '/' + (n - 1) + '] ' + args.join(' ') + ': '
+ String(e.message || e).slice(0, 100));
await sleep(500 * (i + 1));
}
}
}
throw lastErr;
}
// eval в странице. Значение берётся из result.result;
// объекты/массивы CLI возвращает JSON-строкой → декодируем.
async function evalPage(expr) {
const r = await orca(['eval', '--expression', expr, '--page', String(pageId)]);
const raw = r && r.result ? r.result.result : undefined;
if (typeof raw === 'string' && (raw[0] === '{' || raw[0] === '[')) {
try { return JSON.parse(raw); } catch (e) { return raw; }
}
return raw;
}
// Polling условия: expr обязан вернуть "yes" (иначе — любой диагностический текст).
async function waitCond(expr, timeout, label) {
const t0 = Date.now();
let last = '';
while (Date.now() - t0 < (timeout || 30000)) {
try { last = String(await evalPage(expr)); }
catch (e) { last = '<eval: ' + String(e.message).slice(0, 60) + '>'; }
if (last === 'yes') return;
await sleep(POLL_MS);
}
throw new Error('timeout: ' + (label || 'условие') + ' (last: ' + last + ')');
}
const waitState = (st, timeout) =>
waitCond('state === "' + st + '" ? "yes" : "state=" + state', timeout, 'state="' + st + '"');
/* ---------- Ввод игры (Pointer Events, как слушает игра) ---------- */
let pointerSeq = 0;
// pointerdown — на canvas, pointerup — на window; уникальный pointerId на каждое взаимодействие.
async function pointerDown() {
const id = ++pointerSeq;
await evalPage('document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + id + ',isPrimary:true,bubbles:true})); "down"');
return id;
}
async function pointerUp(id) {
await evalPage('window.dispatchEvent(new PointerEvent("pointerup",{pointerId:' + id + ',isPrimary:true,bubbles:true})); "up"');
}
// Клик по кнопке по ТОЧНОМУ тексту (trim). Если кнопки нет — в ошибку идут
// тексты доступных кнопок: сразу видно, на каком экране мы застряли.
async function clickByText(text) {
const expr = '(function(){var b=Array.from(document.querySelectorAll("button")).find(function(x){return x.textContent.trim()==="' + text + '";});if(b){b.click();return "yes";}return JSON.stringify(Array.from(document.querySelectorAll("button")).map(function(x){return x.textContent.trim();}).slice(0,12));})()';
const r = await evalPage(expr);
if (r !== 'yes') throw new Error('кнопка не найдена: «' + text + '»; есть: ' + JSON.stringify(r).slice(0, 160));
}
// Клик по кнопке по id.
async function clickByID(id) {
await evalPage('document.getElementById("' + id + '").click()');
}
// Скриншот вкладки → CWD/orca-*.png (result.data = base64 PNG).
async function shot(name) {
try {
const r = await orca(['screenshot', '--page', String(pageId)]);
const data = r && r.result ? r.result.data : null;
if (!data) throw new Error('нет result.data в ответе screenshot');
fs.writeFileSync(path.resolve(process.cwd(), name), Buffer.from(data, 'base64'));
ok('скриншот ' + name, true);
} catch (e) {
ok('скриншот ' + name, false, String(e.message || e).slice(0, 120));
}
}
/* ---------- Игровые циклы (хелперы, переиспользуются в шагах 4-6, 8 и 9) ---------- */
async function castToWait() {
const pid = await pointerDown();
await waitState('CHARGE', 20000);
ok('зарядка: state=CHARGE', true);
await sleep(700); // удержание ~0.7 c реального времени
await pointerUp(pid);
await waitState('WAIT', 30000); // анимация броска (0.65 c игрового) в троттлинге — дольше
ok('бросок: state=WAIT', true);
}
// Ускоряем клёв и расширяем окно подсечки. ВАЖНО: biteT=5 ставится в ТОМ ЖЕ
// eval, что и детекция BITE — отдельный eval не успевает: CLI-roundtrip
// (~0.3-0.5 c) длиннее окна подсечки (0.35-0.8 c), а на ПЕРВОПЛАНАРНОЙ вкладке
// игра идёт в реальном времени.
async function forceBite() {
await evalPage('forceBite = true; biteTimer = 0.05; "ok"');
const t0 = Date.now();
let last = '';
while (Date.now() - t0 < 30000) {
try { last = String(await evalPage('state === "BITE" ? (biteT = 5, "yes") : "state=" + state')); }
catch (e) { last = '<eval: ' + String(e.message).slice(0, 60) + '>'; }
if (last === 'yes') break;
await sleep(POLL_MS);
}
if (last !== 'yes') throw new Error('timeout: state="BITE" (last: ' + last + ')');
ok('клёв: state=BITE (через APPROACH)', true);
}
async function hookIt() {
const pid = await pointerDown();
await sleep(100);
await pointerUp(pid);
await waitState('REEL', 20000);
ok('подсечка: state=REEL', true);
}
// Заставляем улов: d почти ноль, удержание ~2 c, polling CARD с таймаутом 30 c.
// Т2b: сброс и удержание в ОДНОМ eval — гэп между отдельными eval'ами на
// реалтайм-вкладке успевает заполнить escape-бар (1.67 c) и рыба сбегает; фазу
// тоже сбрасываем в затишье (в рывке держать = трение + нагрузка, до обрыва).
async function forceCatch() {
const pid = ++pointerSeq;
await evalPage('reel.d = 0.5; reel.phase = "calm"; reel.phaseT = 5; reel.escape = 0;'
+ 'document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + pid + ',isPrimary:true,bubbles:true})); "ok"');
try {
await sleep(2000);
await waitState('CARD', 30000);
} finally {
await pointerUp(pid);
}
}
/* ---------- Прогон ---------- */
(async () => {
// Глобальный таймаут: даже если что-то повисло — закрываем вкладку и выходим.
const killer = setTimeout(async () => {
console.error('\nGLOBAL TIMEOUT: прогон длиннее 4 минут, останавливаюсь.');
if (pageId) { try { await orcaCall(['tab', 'close', '--page', String(pageId)]); } catch (e) {} }
printSummary();
process.exit(3);
}, GLOBAL_TIMEOUT_MS);
let crashed = null;
try {
// 0. Orca runtime готов (иначе — понятная ошибка и exit 1)
const st = await orca(['status']);
const ready = !!(st && st.result && st.result.runtime && st.result.runtime.state === 'ready');
ok('Orca runtime готов', ready,
ready ? '' : 'запусти Orca app (result: ' + JSON.stringify(st && st.result).slice(0, 150) + ')');
if (!ready) { printSummary(); process.exit(1); }
// 1. Вкладка с игрой (без ретраев — иначе дубль вкладки)
const t = await orcaCall(['tab', 'create', '--url', GAME_URL]);
pageId = t && t.result ? t.result.browserPageId : null;
ok('вкладка с игрой открыта', !!pageId, 'pageId=' + String(pageId).slice(0, 8));
if (!pageId) throw new Error('tab create: нет browserPageId');
// 1b. Хуки ошибок страницы (pageerror): window error + unhandledrejection,
// ошибки копятся в window.__errs до конца прогона. waitCond переживает
// eval-ошибки ещё грузящейся страницы (повторит, пока не вернётся "yes").
await waitCond('(function(){if(window.__errs)return "yes";'
+ 'window.__errs=[];'
+ 'window.addEventListener("error",function(e){if(e.message)window.__errs.push("error: "+String(e.message));},true);'
+ 'window.addEventListener("unhandledrejection",function(e){window.__errs.push("promise: "+String(e.reason));});'
+ 'return "yes";})()', 15000, 'установка error-хуков');
// 2. Меню показано (page может ещё грузиться — polling переживёт eval-ошибки)
await waitCond('!document.getElementById("menu").classList.contains("hidden") ? "yes" : "loading"', 15000, 'меню показано');
ok('меню показано', true);
// 2b. Сброс сейва — НАСТОЯЩИЙ UI-путь через хаб «Меню» (Э29): Играть → пауза ⏸ →
// «В меню» → 🗑️ «Очистить» → «Сбросить». С этапа 29 сброс живёт ТОЛЬКО в
// #gamemenu (из HUD кнопка убрана) — прогоняем путь пользователя, а не
// синтетический клик по скрытой кнопке. Сброс: save = freshSave() + persist +
// сброс всех тест-форсов, БЕЗ перезагрузки страницы — error-хуки из 1b живут.
// (Профиль Orca переживает localStorage между прогонами, а шаги завязаны на
// ЧИСТОЕ состояние: «фонарик» — апгрейд-кнопка на удилище 0 при 1–3 ур.)
await clickByText('Играть');
await waitState('IDLE');
await clickByID('btnPause');
await waitCond('!document.getElementById("pause").classList.contains("hidden") ? "yes" : "no pause"', 15000, 'пауза');
await clickByID('btnPauseMenu');
await waitCond('!document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no gmenu"', 15000, 'хаб «Меню»');
ok('пауза → «В меню»: открыт хаб #gamemenu, #pause скрыт, сессия сохранена (state!=="MENU")',
(await evalPage('document.getElementById("pause").classList.contains("hidden") && state !== "MENU" ? "yes" : "st=" + state')) === 'yes');
await clickByID('btnReset');
await waitCond('!document.getElementById("resetDlg").classList.contains("hidden") ? "yes" : "no dlg"', 15000, 'диалог сброса');
await clickByID('btnResetYes');
await waitCond('!document.getElementById("menu").classList.contains("hidden") ? "yes" : "no menu"', 15000, 'меню после сброса');
ok('сброс сейва через «Меню»: coins=0, #gamemenu скрыт, возврат к инструкциям',
(await evalPage('(save.coins === 0 && document.getElementById("gamemenu").classList.contains("hidden")) ? "yes" : "coins=" + save.coins')) === 'yes');
// 2c. Форс дня: сейв профиля может прийти с НОЧЬЮ (прошлый прогон оставил
// dayTime≈0.85), а сброс 2b не трогает глобальный dayTime (день продолжается).
// Ночью без фонарика клёва нет — даже под forceBite (ветка клёва стоит ЗА
// гейтом hasLight) → шаг 5 падал по таймауту. Известный флейк: гасим днём.
await evalPage('setDayTime(0.3); "ok"');
ok('день перед геймплеем: dayPhase!=="night" (сейв профиля мог прийти с ночью)',
(await evalPage('dayPhase !== "night" ? "yes" : "phase=" + dayPhase')) === 'yes');
// 3. Старт
await clickByText('Играть');
await waitState('IDLE');
ok('старт: state=IDLE', true);
// 4. Зарядка и бросок
await castToWait();
// 5. Клёв и подсечка (скриншот СНАЧАЛА, потом тап)
await forceBite();
await shot('orca-bite.png');
await hookIt();
// 6. Улов и карточка
await forceCatch();
ok('улов: state=CARD', true);
await shot('orca-card.png');
const card = await evalPage('({name: document.getElementById("cardName").textContent, val: document.getElementById("cardVal").textContent, hidden: document.getElementById("card").classList.contains("hidden")})');
ok('карточка улова: имя вида и «+N»',
!!(card && card.hidden === false && card.name && String(card.val).indexOf('+') === 0),
JSON.stringify(card).slice(0, 120));
// 7. Лавка: снасти, дневник, закрытие
await clickByText('Отлично!');
await waitState('IDLE');
await clickByText('🛒');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка открыта');
ok('лавка открыта (вкладка Снасти)', true);
await shot('orca-shop.png');
await clickByID('tabJournal');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Бросков") >= 0 ? "yes" : "no journal"', 15000, 'дневник рендерится');
ok('дневник показан', true);
await shot('orca-journal.png');
// 7b. M2: вкладки «🎯» (стрик + квесты) и «🏅» (достижения)
await clickByID('tabRewards');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Квесты дня") >= 0 ? "yes" : "no quests"', 15000, '🎯 вкладка: квесты');
ok('🎯 вкладка: стрик и квесты дня', true);
await shot('orca-rewards.png');
await clickByID('tabAch');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Достижения") >= 0 ? "yes" : "no ach"', 15000, '🏅 вкладка: достижения');
ok('🏅 вкладка: список достижений', true);
await shot('orca-ach.png');
// 7c. M-skins: вкладка «🎨» — группы «Лодка»/«Поплавок»; покупка поплавка
// «Красный» (500 🪙) с выданными монетами: списание + авто-экипировка.
// Клик и чтение состояния — в ОДНОМ eval: toast/списания синхронные.
// Ачивка skin_first выдастся на следующем улове (checkAchievements() в
// catchFish, а не в лавке) — проверяем в шаге 8.
await clickByID('tabSkins');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Лодка") >= 0 && document.getElementById("shopBody").textContent.indexOf("Поплавок") >= 0 ? "yes" : "no skins"', 15000, 'скины рендерятся');
ok('🎨 вкладка: скины лодки и поплавка', true);
await evalPage('save.coins = 200000; updateHUD(); "ok"'); // выдача: покупка не зависит от заработка прогона
const skin = await evalPage('(function(){document.querySelector(\'#shopBody button[data-act="buyskin"][data-type="bobber"][data-i="1"]\').click();'
+ 'return {skin: save.bobberSkin, owned: save.bobberSkins.indexOf(1) >= 0, coins: save.coins, base: 200000 - BOBBER_SKINS[1].cost};})()');
ok('скины: покупка «Красный» (500 🪙) — списание и авто-экипировка',
!!(skin && skin.skin === 1 && skin.owned === true && skin.coins === skin.base),
JSON.stringify(skin).slice(0, 140));
await shot('orca-skins.png');
// 7d. Локации (этап 5): вкладка «🗺️»; покупка «Озёра» (100000 🪙) — списание,
// АВТО-ПЕРЕХОД save.location и тост (читаем в том же eval — тост 1.6 c).
// Ачивка lake_first — тоже на следующем улове (шаг 8). Затем возврат
// на Реку: дальше прогон идёт в каноничной реке.
await clickByID('tabLocations');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Озёра") >= 0 ? "yes" : "no loc"', 15000, 'локации рендерятся');
ok('🗺️ вкладка: локации (Река / Озёра)', true);
const loc = await evalPage('(function(){document.querySelector(\'#shopBody button[data-act="buylocation"][data-i="1"]\').click();'
+ 'return {loc: save.location, owned: save.locations.indexOf(1) >= 0, coins: save.coins,'
+ ' base: 200000 - BOBBER_SKINS[1].cost - LOCATIONS[1].cost, toast: document.getElementById("toast").textContent};})()');
ok('локации: покупка «Озёра» (100000 🪙) — авто-переход, тост, списание',
!!(loc && loc.loc === 1 && loc.owned === true && loc.coins === loc.base && String(loc.toast).indexOf('Озёра') >= 0),
JSON.stringify(loc).slice(0, 140));
await shot('orca-locations.png');
await evalPage('document.querySelector(\'#shopBody button[data-act="selectlocation"][data-i="0"]\').click(); "ok"');
ok('локации: возврат на Реку (выбор купленной)',
(await evalPage('save.location === 0 ? "yes" : "loc=" + save.location')) === 'yes');
await clickByText('×');
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
ok('лавка закрыта', true);
// 8. Золотая рыба В ДОЖДЬ (M6 + M8 + Этап 22): форс дождя → HUD-пилюля 🌧️
// и варианты ×2.5 (ratio в ОДНОМ eval — без смены состояния между ними);
// SHINY_CHANCE = 1 → gold гарантирован и в дождь (p = 1×2.5, ролл < p
// всегда). Улов в дождь → wxRainV=1 → rain_variant; покупки из шагов
// 7c/7d → skin_first/lake_first: checkAchievements() вызывается в
// catchFish(), поэтому все четыре ачивки проверяем здесь, после улова.
await evalPage('forceWeather = "rain"; "ok"');
try {
await waitCond('!document.getElementById("weather").classList.contains("hidden") && document.getElementById("weather").textContent.indexOf("🌧️") >= 0 ? "yes" : "no pill"', 15000, 'погода: пилюля rain');
ok('погода rain: HUD-пилюля 🌧️', true);
const rMult = await evalPage('(function(){forceWeather=null;var b=variantChance(VARIANTS.find(function(v){return v.id==="gold";}));'
+ 'forceWeather="rain";return variantChance(VARIANTS.find(function(v){return v.id==="gold";})) / b;})()');
ok('погода rain: variantChance(gold) ×2.5 (WEATHER.rain.variantMult)', Math.abs(rMult - 2.5) < 1e-9, String(rMult));
await evalPage('SHINY_CHANCE = 1; "ok"');
await castToWait();
await forceBite();
await hookIt();
await forceCatch();
ok('золотая рыба: улов получен (state=CARD)', true);
await shot('orca-shiny.png'); // с дождевым визуалом и пилюлей в HUD
const shiny = await evalPage('({badge: document.getElementById("cardVariant").textContent, hidden: document.getElementById("cardVariant").classList.contains("hidden"), gold: document.getElementById("card").classList.contains("gold-card")})');
ok('золотая рыба: бейдж «ЗОЛОТАЯ» и золотая карточка (gold-card)',
!!(shiny && shiny.hidden === false && String(shiny.badge).indexOf('ЗОЛОТАЯ') >= 0 && shiny.gold === true),
JSON.stringify(shiny).slice(0, 120));
const ach = await evalPage('({shiny: !!save.ach.first_shiny, rain: !!save.ach.rain_variant, skin: !!save.ach.skin_first, lake: !!save.ach.lake_first, wx: save.wxRainV || 0})');
ok('ачивки Этап 22: first_shiny + rain_variant (улов в дождь) + skin_first + lake_first',
!!(ach && ach.shiny === true && ach.rain === true && ach.skin === true && ach.lake === true && ach.wx === 1),
JSON.stringify(ach).slice(0, 140));
} finally {
await evalPage('SHINY_CHANCE = 0.03; forceWeather = null; "ok"');
}
await clickByText('Отлично!');
await waitState('IDLE');
// 8b. M8: туман — пилюля 🌫️ и клёв ×1.5 (ratio biteChance в ОДНОМ eval:
// тот же castTo/dayPhase, фаза суток на отношение не влияет). Затем снятие
// форса: пилюля скрывается (реальная погода за прогон не успеет начать-
// ся: gap ≥120 c игрового времени, а прогон короче).
await evalPage('forceWeather = "fog"; "ok"');
try {
await waitCond('!document.getElementById("weather").classList.contains("hidden") && document.getElementById("weather").textContent.indexOf("🌫️") >= 0 ? "yes" : "no pill"', 15000, 'погода: пилюля fog');
ok('погода fog: HUD-пилюля 🌫️', true);
const fMult = await evalPage('(function(){forceWeather="fog";var f=biteChance();forceWeather=null;return f / biteChance();})()');
ok('погода fog: biteChance ×1.5 (WEATHER.fog.biteMult)', Math.abs(fMult - 1.5) < 1e-9, String(fMult));
} finally {
await evalPage('forceWeather = null; "ok"');
await waitCond('document.getElementById("weather").classList.contains("hidden") ? "yes" : "pill open"', 15000, 'погода: пилюля скрыта');
}
ok('погода снята: пилюля скрыта', true);
// 9. Near-miss: сход без выплат. Тост живёт недолго → текст читаем в ТОМ ЖЕ
// eval, что и ожидание WAIT (не отдельным вызовом).
await castToWait();
await forceBite();
await hookIt();
await evalPage('reel.d = reel.d0 * 1.45 + 2; "ok"'); // T2b: near-miss порог 1.45·d0
const t0 = Date.now();
let toast = '';
while (Date.now() - t0 < 30000) {
try {
toast = String(await evalPage('state === "WAIT" ? document.getElementById("toast").textContent : ""'));
if (toast) break;
} catch (e) { /* вкладка/CLI в миг сбоя — повторяем */ }
await sleep(POLL_MS);
}
ok('near-miss: тост «Сбежала» после схода', toast.indexOf('Сбежала') >= 0, toast.slice(0, 80));
await shot('orca-escape.png'); // best-effort: тост мог уже погаснуть, ok() — по тексту
// 9b. M3: ночь — dayTime 0.85 → фаза night. Фазу проверяем в ТОМ ЖЕ eval, что
// и setDayTime (кадры идут, время суток ползёт). Ночью без фонарика клёв не
// стартует — поплавок просто ждёт, скриншот показывает ночной визуал.
const night = await evalPage('(function(){ setDayTime(0.85); return dayPhase; })()');
ok('ночь: dayPhase=night при dayTime 0.85', night === 'night', String(night));
await shot('orca-night.png');
// 9c. M5: фонарик в лавке — апгрейд 1 удилища (не приманка id=4): в строке
// удилища кнопка «⬆ 70 🪙» и подпись «фонарик (ночь)». Лавка открыта на
// вкладке, где её закрыли в шаге 7 (🏅) — переключаем на Снасти.
// Сейв чистый (шаг 2b) → удилище 0 на 1 ур. → апгрейд-кнопка на месте.
await clickByText('🛒');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка открыта');
await clickByID('tabTackle');
await waitCond('document.getElementById("shopBody").textContent.indexOf("фонарик") >= 0 ? "yes" : "no flask"', 15000, 'апгрейд-фонарик в снастях');
ok('лавка: фонарик — апгрейд 1 удилища (⬆ 70, пометка про ночь)', true);
await clickByText('×');
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
// 9d. M4: босс-рыба — forceBoss + осётр, 3 фазы «рвёт и уходит» до босс-карточки.
// Из WAIT (ночь) — отзываем поплавок и возвращаем день; biteTimer=30 держит
// поклёвку, пока не сделаем новый заброс.
// Босс весит 5–40 кг — стартовая «Дедова удочка» (предел 3 кг) его не
// выдерживает: натяжение ≥ прочности лески → НАСТОЯЩИЙ обрыв (SNAP,
// stateBoss=false → IDLE) вместо босс-фаз, шаг падал по таймауту на REEL.
// Выдаём «Титан» (до 40 кг; купить по UI нельзя — M5-гейтинг требует
// прокачки всей цепочки). В headless-тесте ту же роль играет прокачанная
// леска: §M4 идёт после апгрейд-тестов M5.
await evalPage('setDayTime(0.3); biteTimer = 30; "ok"');
await clickByID('btnRecall');
await waitState('IDLE');
await evalPage('(function(){ if (save.rods.indexOf(3) < 0) save.rods.push(3); save.rod = 3; save.rodLv[3] = 1; persist(); return "ok"; })()');
ok('босс: выдан «Титан» (до 40 кг) — стартовая удочка не выдерживает вес босса',
(await evalPage('rod().maxW >= 40 ? "yes" : "maxW=" + rod().maxW')) === 'yes');
await evalPage('window.__pick = pickSpecies; pickSpecies = function(){ return SPECIES.find(function(s){ return s.id === "sturge"; }); }; forceBoss = true; "ok"');
try {
await castToWait();
await forceBite();
await hookIt();
// Т2b: за гэп между подсечкой и этой проверкой escape-бар успел подрасти —
// гасим, чтобы гонка «CLI-roundtrip vs пассивный сход» не решалась против бота.
const bs = await evalPage('if (reel) reel.escape = 0; ({b: stateBoss, ph: bossPhase, rb: !!(reel && reel.boss)})');
ok('босс: старт боя (stateBoss=true, bossPhase=1, reel.boss)',
bs && bs.b === true && bs.ph === 1 && bs.rb === true, JSON.stringify(bs).slice(0, 80));
// Фазы 1-2: d→0 — босс-обрыв (SNAP без штрафа) → авто-REEL в следующую (глубже)
// фазу. В SNAP holding сбрасываем в ТОМ ЖЕ eval, что и детекцию: onUp в
// состоянии SNAP reel.holding не чистит (см. п.9 «Подводных камней»).
// Т2b: сброс d/phase/escape и pointerdown — в ОДНОМ eval (тот же паттерн, что
// в forceBite): CLI-roundtrip между отдельными eval'ами — это «пассив в
// затишье», а бар схода за 1.67 c игрового времени заполняется → босс сбежал.
for (let phase = 1; phase <= 3; phase++) {
const pid = ++pointerSeq;
await evalPage('reel.d = 0.4; reel.phase = "calm"; reel.phaseT = 5; reel.escape = 0;'
+ 'document.getElementById("game").dispatchEvent(new PointerEvent("pointerdown",{pointerId:' + pid + ',isPrimary:true,bubbles:true})); "ok"');
if (phase < 3) {
// Фазовый обрыв живёт 0.6 с (snapT) — CLI-roundtrip может проскочить мимо
// состояния SNAP, и оставшийся зажатым указатель дотянет босса до CARD
// уже в фазе 3. Ждём ПЕРСИСТЕНТНОГО bossPhase===phase+1 (инкремент в том
// же переходе, что state='REEL'); отпускание — в том же eval (onUp в SNAP
// holding не чистит, см. п.9 «Подводных камней»).
await waitCond('(function(){ if (bossPhase === ' + (phase + 1) + ') { if (reel) reel.holding = false; return "yes"; } return "phase=" + bossPhase + "/st=" + state; })()', 30000, 'босс-фаза ' + (phase + 1));
await pointerUp(pid);
await waitState('REEL', 30000);
} else {
await waitState('CARD', 30000);
await pointerUp(pid);
}
}
ok('босс: карточка после 3 фаз (stateBoss=false)',
(await evalPage('state === "CARD" && !stateBoss ? "yes" : "no"')) === 'yes');
await shot('orca-boss.png');
const nm = await evalPage('document.getElementById("cardName").textContent + " | " + document.getElementById("cardBoss").textContent');
ok('босс: карточка «Осётр-БОСС» с бейджем 🏆',
String(nm).indexOf('Осётр') >= 0 && String(nm).indexOf('БОСС') >= 0, String(nm).slice(0, 60));
} finally {
await evalPage('forceBoss = false; if (window.__pick) pickSpecies = window.__pick; "ok"');
}
await clickByText('Отлично!');
await waitState('IDLE');
// 9e. Э29: хаб «Меню» — переходы по кнопкам (вход из IDLE). Монеты → лавка на
// «Лавке»; ⏸ → «В меню» → Магазин/Достижения/Дневник (× шаг назад в хаб,
// uiOpen остаётся true), Инструкции (#menu → «Играть» вернёт в игру),
// 🔊 (тогл) и «Продолжить» (возврат в игру с того же места).
await clickByID('coins');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка по монетам');
ok('монеты → лавка открыта на вкладке «Лавка» (tabTackle active)',
(await evalPage('document.getElementById("tabTackle").classList.contains("active") ? "yes" : "no"')) === 'yes');
await clickByID('btnCloseShop');
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
ok('× из лавки (открыта монетами) → uiOpen=false, возврат в игру',
(await evalPage('!uiOpen ? "yes" : "ui=true"')) === 'yes');
await clickByID('btnPause');
await waitCond('!document.getElementById("pause").classList.contains("hidden") ? "yes" : "no pause"', 15000, 'пауза');
await clickByID('btnPauseMenu');
await waitCond('!document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no gmenu"', 15000, 'хаб «Меню»');
await shot('orca-gamemenu.png');
ok('⏸ → «В меню»: хаб открыт, #pause скрыт, сессия сохранена (state!=="MENU")',
(await evalPage('document.getElementById("pause").classList.contains("hidden") && state !== "MENU" ? "yes" : "st=" + state')) === 'yes');
// Магазин/Достижения/Дневник: каждый открывает лавку на своей вкладке, × возвращает в хаб
await clickByID('btnGmShop');
await waitCond('!document.getElementById("shop").classList.contains("hidden") && document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no"', 15000, 'Магазин из хаба');
ok('«Магазин» → лавка на «Лавке» (tabTackle), #gamemenu скрыт',
(await evalPage('document.getElementById("tabTackle").classList.contains("active") ? "yes" : "no"')) === 'yes');
await clickByID('btnCloseShop');
await waitCond('document.getElementById("shop").classList.contains("hidden") && !document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no"', 15000, '× → хаб');
ok('× из лавки → возврат в «Меню» (uiOpen=true, геймплей не стартует)',
(await evalPage('uiOpen === true ? "yes" : "ui=false"')) === 'yes');
await clickByID('btnGmAch');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'Достижения из хаба');
ok('«Достижения» → лавка на вкладке 🏅',
(await evalPage('document.getElementById("tabAch").classList.contains("active") ? "yes" : "no"')) === 'yes');
await clickByID('btnCloseShop');
await waitCond('document.getElementById("shop").classList.contains("hidden") && !document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no"', 15000, '× → хаб (2)');
await clickByID('btnGmJournal');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'Дневник из хаба');
ok('«Дневник» → лавка на вкладке 📔',
(await evalPage('document.getElementById("tabJournal").classList.contains("active") ? "yes" : "no"')) === 'yes');
await clickByID('btnCloseShop');
await waitCond('document.getElementById("shop").classList.contains("hidden") && !document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no"', 15000, '× → хаб (3)');
// Инструкции: тот же #menu, что на старте; «Играть» возвращает в игру
await clickByID('btnGmHowto');
await waitCond('!document.getElementById("menu").classList.contains("hidden") && document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no"', 15000, 'инструкции из хаба');
ok('«Инструкции» → #menu виден, хаб скрыт', true);
await clickByID('btnPlay');
await waitState('IDLE');
ok('«Играть» из инструкций → state=IDLE, #gamemenu скрыт',
(await evalPage('document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "gm open"')) === 'yes');
// Звук и «Продолжить» в хабе: тогл на каждом клике, возврат в игру
await clickByID('btnPause');
await waitCond('!document.getElementById("pause").classList.contains("hidden") ? "yes" : "no pause"', 15000, 'пауза 2');
await clickByID('btnPauseMenu');
await waitCond('!document.getElementById("gamemenu").classList.contains("hidden") ? "yes" : "no gmenu"', 15000, 'хаб 2');
const m0 = (await evalPage('({m: save.muted})')).m; // объект: скаляры CLI возвращает JSON-строкой ('true'/'false')
await clickByID('btnMute');
const m1 = (await evalPage('({m: save.muted})')).m;
await clickByID('btnMute');
const m2 = (await evalPage('({m: save.muted})')).m;
ok('🔊 «Звук» в «Меню»: каждый клик — тогл', m1 === !m0 && m2 === m0, JSON.stringify({ m0: m0, m1: m1, m2: m2 }));
await clickByID('btnGmResume');
await waitCond('document.getElementById("gamemenu").classList.contains("hidden") && !uiOpen ? "yes" : "no"', 15000, '«Продолжить»');
ok('«Продолжить» → хаб скрыт, uiOpen=false, state=IDLE, #btnPause виден',
(await evalPage('state === "IDLE" && !document.getElementById("btnPause").classList.contains("hidden") ? "yes" : "st=" + state')) === 'yes');
// 10. Консоль: нет ошибок уровня error
const c = await orca(['console', '--limit', '100', '--page', String(pageId)]);
const msgs = (c && c.result && c.result.messages) || [];
const errs = msgs.filter(m => m.type === 'error');
ok('нет ошибок в консоли (console --limit 100)', errs.length === 0,
errs.slice(0, 3).map(m => m.text).join(' | ').slice(0, 200));
// 10b. Pageerror: JS-ошибки и unhandledrejection, пойманные хуками за прогон
const pe = await evalPage('(window.__errs || []).slice(0, 5)');
ok('нет pageerror за прогон (window.onerror / unhandledrejection)',
Array.isArray(pe) && pe.length === 0, JSON.stringify(pe).slice(0, 200));
} catch (e) {
crashed = e;
} finally {
// 11. Вкладка закрывается при ЛЮБОМ исходе (2 попытки — close идемпотентен)
clearTimeout(killer);
if (pageId) { try { await orca(['tab', 'close', '--page', String(pageId)], 2); } catch (e) {} }
}
if (crashed) {
console.error('\nPLAYTEST CRASH: ' + (crashed && crashed.message || String(crashed)));
printSummary();
process.exit(2);
}
process.exit(printSummary() ? 1 : 0);
})();