Files
fishing-game/tests/orca-playtest.js
T

411 lines
23 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;
* - ошибки страницы (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);
// 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 clickByText('Дневник');
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');
await clickByText('×');
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
ok('лавка закрыта', true);
// 8. Shiny: форсируем шанс, полный цикл, бейдж «ШИНИ» в DOM
await evalPage('SHINY_CHANCE = 1; "ok"');
try {
await castToWait();
await forceBite();
await hookIt();
await forceCatch();
ok('шини: улов получен (state=CARD)', true);
await shot('orca-shiny.png');
const shiny = await evalPage('({badge: document.getElementById("cardShiny").textContent, hidden: document.getElementById("cardShiny").classList.contains("hidden"), gold: document.getElementById("card").classList.contains("shiny-card")})');
ok('шини: бейдж «ШИНИ» и золотая карточка',
!!(shiny && shiny.hidden === false && String(shiny.badge).indexOf('ШИНИ') >= 0 && shiny.gold === true),
JSON.stringify(shiny).slice(0, 120));
} finally {
await evalPage('SHINY_CHANCE = 0.03; "ok"');
}
await clickByText('Отлично!');
await waitState('IDLE');
// 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. M3: фонарик в лавке (id=4, пометка «ночь»). Лавка открыта на вкладке,
// где её закрыли в шаге 7 (🏅) — переключаем на Снасти.
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('лавка: «Фонарик» в снастях (id=4, пометка «ночь»)', 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);
})();