600 lines
42 KiB
JavaScript
600 lines
42 KiB
JavaScript
'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): вкладка «🗺️»; покупка «Озёра» (65536 🪙) — списание,
|
||
// АВТО-ПЕРЕХОД 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('локации: покупка «Озёра» (65536 🪙) — авто-переход, тост, списание',
|
||
!!(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-пилюля 🌧️
|
||
// и варианты ×3 (ratio в ОДНОМ eval — без смены состояния между ними);
|
||
// SHINY_CHANCE = 1 → gold гарантирован и в дождь (p = 1×3, ролл < 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) ×3 (WEATHER.rain.variantMult)', Math.abs(rMult - 3) < 1e-9, String(rMult));
|
||
await evalPage('SHINY_CHANCE = 1; forceVariant = "gold"; "ok"'); // M6: forceVariant — медь/кварц не перехватят ролл
|
||
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.06; forceVariant = null; 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 держит
|
||
// поклёвку, пока не сделаем новый заброс.
|
||
// Босс весит 20–50 кг (Этап 37) — стартовая «Дедова удочка» (предел 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);
|
||
})();
|