Files
fishing-game/tests/orca-playtest.js
T
andrey 126e402dee Экономика: ушедшие рыбы без наград — выплаты 10% цены убраны из обеих веток схода в updateReel() (escape>=1 и near-miss 1.45·d0), общий хвост вынесен в fishEscaped() (тост «Сбежала! <emoji> <имя> <вес> кг» → WAIT → biteTimer → sfx('escape')), устаревший near-miss-комментарий удалён; формат сейва не менялся. Тост обрыва: теперь с рыбой («💥 Линия лопнула! 🐟 Карась 0.5 кг» / «💥 Рыба крупнее предела удочки! …», разбор ov>1.15 сохранён); босс-обрыв фазы не тронут. Закрывает камень #12 из Этапа T4 (утешения за сходы = 43–47% раннего дохода, фармить сходы было выгоднее). Тесты: §9c переписан (пин save.quests/стрика — монеты не изменились; тост «Сбежала!» с весом без «🪙»), (2b) переиспользован (сход монет не даёт, HUD в согласии с сейвом), +1 чек тоста обрыва (чтение в следующем eval сразу после waitState('SNAP'): тост 1.6 с > SNAP 1.0 с); orca шаг 9 — «сход без выплат». 129/129, exit 0; Orca 61/61 live. Доки: DEVLOG Этап 23, PHYSICS-RESEARCH исходы, README/AGENTS 128→129.
2026-09-07 13:15:28 +03:00

494 lines
32 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;
* - сейв СБРАСЫВАЕТСЯ в игре (🗑️ → «Сбросить») после установки error-хуков:
* профиль Orca переживает localStorage между прогонами, а шаги прогона
* (вкладка «Снасти», «фонарик» как апгрейд удилища) завязаны на чистое
* состояние; в отличие от test.js (свежий headless-профиль) — обязательно;
* - ошибки страницы (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. Сброс сейва: профиль Orca переживает localStorage между прогонами, а
// шаги завязаны на ЧИСТОЕ состояние (вкладка «Снасти»: «фонарик» — это
// апгрейд-кнопка на удилище 0 при 1–3 ур.; на 4 ур. её нет → «no flask»).
// Сброс через игру (🗑️ → «Сбросить»): save=freshSave() + persist + сброс
// всех тест-форсов, БЕЗ перезагрузки страницы — error-хуки из 1b живут.
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)',
(await evalPage('save.coins === 0 ? "yes" : "coins=" + save.coins')) === '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 держит
// поклёвку, пока не сделаем новый заброс.
await evalPage('setDayTime(0.3); biteTimer = 30; "ok"');
await clickByID('btnRecall');
await waitState('IDLE');
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) {
await waitCond('state === "SNAP" ? (reel && (reel.holding = false, reel.escape = 0), "yes") : "state=" + state', 30000, 'SNAP фазы ' + phase);
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');
// 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);
})();