'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 = ''; } 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 = ''; } 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); })();