Files
fishing-game/tests/test.js
T
andrey b3f5b0724c Этап 64: выпуск рыбы из аквариума — только из диалога «Слоты» и с подтверждением; перерисовка наживок (червь на конце крючка, воблер-минноу, блёстка-вращалка). Выпуск: кнопка #btnAqRelease из поповера #aqPop убрана (поповер — чистое инфо), на строке жильца во вкладке «Слоты» #aqShop — кнопка «Выпустить» (data-act=askrelease, делегирование #aqShopBody), inline-подтверждение #aqShopConfirm (паттерн #aqPickConfirm, строка подсвечивается .replace, текст aq_release_q RU/EN), подтверждение — aqReleaseAt(i) (переименовано из aqReleaseSel: индекс вместо aqSel; aqSel при выпуске левее сдвигается, по индексу — снимается), по-прежнему без монет и без телепорта остальных; aqShopRelPending сбрасывается в openAqShop/closeAqShop/setAqTab и при сбросе прогресса. Наживки (чистый визуал, данные в записях LURES.hook, switch по id нет): Воблер — body (тельце-минноу: эллипс+хвост+брюшко+лопатка, bead — красный глаз, крючок выглядит тройником под вобом), Блёстка — blade (золотой лепесток с шириной |sin(t·4.5)|, кромка+блик, рисуется внутри gold-тени до restore), Червь — bait вдоль жала с торчащим кончиком и головным утолщением (lineWidth 1.6→2.2). Геометрия hx/hy/hookPos не тронута; баланс/сейв/ачивки/i18n/emoji не тронуты, миграция не нужна. Тесты: §10e2 — пин body/blade (#hex, у остальных нет); §AQ(8) переписан на новый флоу (поповер без кнопок → askrelease → подтверждение → «Отмена»/«Выпустить»), (8b) — aqReleaseAt(0). Проверки: check.mjs OK; emoji --check OK; test.js 261/261 PASS exit 0. Доки: AGENTS.md (тест-глобалы, пункты Э46/Э52/Э62/Э63), DEVLOG (Этап 64).
2026-11-06 15:54:55 +03:00

4164 lines
245 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';
/* Автотест игры: прогон механик через CDP в headless Chromium */
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
const results = [];
function ok(name, cond, extra = '') {
results.push({ name, pass: !!cond, extra });
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
}
async function waitState(page, st, timeout = 8000) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
cur = await page.evaluate(() => state);
if (cur === st) return cur;
await new Promise(r => setTimeout(r, 40));
}
throw new Error('timeout waiting state "' + st + '", current=' + cur);
}
// Ждём условие в странице (паттерн waitState для произвольного выражения): фиксированные sleep
// хрупки при fps<20 — dt игры клампится (dt=min(0.05,…)), реальные 1.5 с ≠ 1.5 с игрового
async function waitCond(page, expr, timeout = 5000) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
cur = await page.evaluate(expr);
if (cur) return cur;
await new Promise(r => setTimeout(r, 40));
}
throw new Error('timeout waiting condition, last=' + JSON.stringify(cur));
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// 🗑️/🔊 переехали в хаб «Меню» (#gamemenu): из игры он открывается через паузу —
// ⏸ → «В меню». Работает из игрового состояния (state!=='MENU', без оверлеев).
async function toGameMenu(p) {
await p.click('#btnPause');
await sleep(100);
await p.click('#btnPauseMenu');
await sleep(100);
}
(async () => {
// Guard от границы полуночи: если дата сменилась посреди прогона, падают date-ассерты
// (пины quests/streak на todayStr, ~0.2%/прогон). Если до полуночи ≤ SAFE_MIN минут —
// ждём, пока не пройдёт полночь + SAFE_MIN (прогон стартует в безопасном окне).
const SAFE_MIN = 5;
{
const n = new Date();
const m = n.getHours() * 60 + n.getMinutes() + n.getSeconds() / 60;
const toMid = 1440 - m;
let waitMin = 0;
if (m <= SAFE_MIN) waitMin = SAFE_MIN - m;
else if (toMid <= SAFE_MIN) waitMin = toMid + SAFE_MIN;
if (waitMin > 0) {
console.log('⏳ Граница полуночи: жду ' + Math.ceil(waitMin) + ' мин, чтобы дата не сменилась во время прогона');
await sleep(waitMin * 60000);
}
}
const browser = await puppeteer.launch({
executablePath: process.env.CHROMIUM || '/usr/bin/chromium-browser',
headless: true,
args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars', '--mute-audio'],
defaultViewport: { width: 390, height: 844, deviceScaleFactor: 2 },
});
const page = await browser.newPage();
const consoleErrors = [];
const pageErrors = [];
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => pageErrors.push(String(e)));
await page.goto(GAME_URL, { waitUntil: 'load' });
await sleep(600);
// --- 1. Меню ---
ok('меню показано', await page.$eval('#menu', el => !el.classList.contains('hidden')));
await page.screenshot({ path: 'shot-menu.png' });
// --- 2. Старт и бросок ---
await page.click('#btnPlay');
ok('старт: state=IDLE', (await page.evaluate(() => state)) === 'IDLE');
await page.mouse.move(195, 600);
await page.mouse.down();
await sleep(700);
ok('зарядка: state=CHARGE', (await page.evaluate(() => state)) === 'CHARGE');
await page.screenshot({ path: 'shot-charge.png' });
await page.mouse.up();
await waitState(page, 'WAIT');
ok('бросок: state=WAIT', true);
await page.screenshot({ path: 'shot-wait.png' });
// --- 3. Клёв и подсечка ---
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
ok('клев: state=BITE', true);
await page.screenshot({ path: 'shot-bite.png' });
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
ok('подсечка: state=REEL', true);
ok('реель объекта создан', await page.evaluate(() => !!reel && reel.d > 0 && reel.d0 > 0));
// --- 4. Вываживание: поймали ---
await page.evaluate(() => { reel.d = 0.4; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
ok('ловля: state=CARD', true);
const cardInfo = await page.evaluate(() => ({
visible: !document.getElementById('card').classList.contains('hidden'),
name: document.getElementById('cardName').textContent,
sub: document.getElementById('cardSub').textContent,
val: document.getElementById('cardVal').textContent,
rec: !document.getElementById('cardRecord').classList.contains('hidden'),
coins: save.coins,
}));
ok('карточка улова показана', cardInfo.visible, JSON.stringify(cardInfo));
const wMatch = cardInfo.sub.match(/([\d.]+) кг/);
ok('на карточке есть вес рыбы', !!wMatch && cardInfo.sub.includes('🪙/кг'));
ok('первый улов вида = рекорд', cardInfo.rec);
await page.screenshot({ path: 'shot-card.png' });
await page.click('#btnCardOk');
ok('после карты: state=IDLE', (await page.evaluate(() => state)) === 'IDLE');
// --- 4b. Перегруз: тяжёлая рыба на слабой удочке рвёт шнур быстро ---
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
reel.w = 12; reel.ov = 12 / rod().maxW; reel.aggr = 1;
reel.d = reel.d0 = 150;
});
await page.mouse.down();
await waitState(page, 'SNAP', 6000);
await page.mouse.up();
await waitState(page, 'IDLE', 6000); // T2b: худший кейс (calm→pull) ≈ 2.3 с + SNAP 1 с
ok('перегруз: 12 кг на удочке 3 кг = быстрый обрыв', true);
// --- 4c. Рекорд: улов ниже лучшего — бейджа нет ---
await page.evaluate(() => { for (const s of SPECIES) save.caught[s.id] = { n: 1, best: 9999 }; });
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
ok('улов ниже рекорда: бейдж «РЕКОРД!» скрыт', await page.evaluate(() =>
document.getElementById('cardRecord').classList.contains('hidden')));
await page.click('#btnCardOk');
await sleep(100);
// --- 5. Бросок 2: обрыв линии ---
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.tension = rod().line - 0.3; });
await page.mouse.down();
await waitState(page, 'SNAP', 3000).catch(() => {});
const snapToast = await page.evaluate(() => document.getElementById('toast').textContent);
await page.mouse.up();
await waitState(page, 'IDLE', 3000);
ok('обрыв: SNAP -> IDLE', true);
ok('обрыв: в тосте рыба с весом («💥 …» + «кг»)',
snapToast.startsWith('💥') && snapToast.includes('кг'),
snapToast);
// --- 6. Бросок 3: срыв рыбы (не тянем) ---
await page.mouse.down(); await sleep(600); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
reel.phase = 'pull'; reel.phaseT = 9999;
reel.d = reel.d0 * 1.38;
});
await waitState(page, 'WAIT', 15000);
ok('срыв: рыба ушла -> WAIT', true);
// --- 7. Магазин: покупка и экипировка ---
// M5: гейтинг — снасть №(i−1) должна быть на 4 ур.; уровни форсим, смысл
// проверки (купить 2-е удилище и 2-ю приманку) сохраняется
await page.evaluate(() => { save.coins = 10000; save.rodLv[0]=4; save.lureLv[0]=4; });
await page.click('#btnShop');
await sleep(150);
ok('магазин открыт', await page.$eval('#shop', el => !el.classList.contains('hidden')));
await page.screenshot({ path: 'shot-shop.png' });
const rodDesc = await page.$eval('#shopBody .item .itemdesc', el => el.textContent);
ok('у удилища указан предел веса', rodDesc.includes('кг'));
await page.click('#shopBody [data-act="buy"][data-type="rod"]');
const rodAfter = await page.evaluate(() => ({ rod: save.rod, coins: save.coins, owned: save.rods.length }));
ok('куплено удилище', rodAfter.rod === 1 && rodAfter.coins === 9850 && rodAfter.owned === 2, JSON.stringify(rodAfter));
await page.click('#shopBody [data-act="buy"][data-type="lure"]');
const lureAfter = await page.evaluate(() => ({ lure: save.lure, coins: save.coins }));
ok('куплена приманка', lureAfter.lure === 1 && lureAfter.coins === 9750, JSON.stringify(lureAfter));
await page.click('#tabJournal');
await sleep(100);
const jtxt = await page.$eval('#shopBody', el => el.textContent);
ok('дневник рендерится', jtxt.includes('Бросков') && jtxt.includes('🪙/кг'));
ok('дневник показывает пойманную рыбу с макс. весом', (jtxt.includes('Карась') || jtxt.includes('Окунь') || jtxt.includes('Лещ') || jtxt.includes('Карп') || jtxt.includes('Щука') || jtxt.includes('Сом') || jtxt.includes('Осётр') || jtxt.includes('Золотая рыбка')) && jtxt.includes('макс '));
await page.screenshot({ path: 'shot-journal.png' });
await page.click('#btnCloseShop');
ok('магазин закрыт', await page.$eval('#shop', el => el.classList.contains('hidden')));
// --- 8. Сохранение после перезагрузки ---
await page.reload({ waitUntil: 'load' });
await sleep(500);
const saved = await page.evaluate(() => ({ coins: save.coins, rod: save.rod, lure: save.lure, casts: save.casts }));
ok('сохранение после reload', saved.coins === 9750 && saved.rod === 1 && saved.lure === 1 && saved.casts === 5, JSON.stringify(saved));
// --- 9. Сброс данных (играть сначала нужно зайти в игру; 🗑️ живёт в «Меню» из паузы) ---
await page.click('#btnPlay');
await sleep(150);
await toGameMenu(page);
await page.click('#btnReset');
await sleep(150);
ok('диалог сброса открыт', await page.$eval('#resetDlg', el => !el.classList.contains('hidden')));
await page.screenshot({ path: 'shot-reset.png' });
await page.click('#btnResetNo');
ok('отмена: диалог закрыт, данные не тронуты',
await page.$eval('#resetDlg', el => el.classList.contains('hidden'))
&& (await page.evaluate(() => save.coins)) === 9750);
await page.click('#btnReset');
await sleep(100);
await page.click('#btnResetYes');
await sleep(150);
const afterReset = await page.evaluate(() => ({
coins: save.coins, rod: save.rod, lure: save.lure, casts: save.casts,
caught: JSON.stringify(save.caught), state: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
ls: localStorage.getItem('fishing-save-v1'),
}));
ok('сброс: данные обнулены, возврат в меню',
afterReset.coins === 0 && afterReset.rod === 0 && afterReset.lure === 0
&& afterReset.casts === 0 && afterReset.caught === '{}' && afterReset.state === 'MENU'
&& afterReset.menu, JSON.stringify(afterReset));
ok('сброс: localStorage перезаписан', afterReset.ls === null || JSON.parse(afterReset.ls).coins === 0);
// --- Y1. SDK (стаб) ---
// YG — обёртка SDK Яндекс Игр: в file:// (без ?sdk=1) скрипт /sdk.js не создаётся —
// YG в стабе (sdk=null, active=false), геймплейная разметка дёргает только счётчики
// YG.counts. На входе — MENU (гарантия секции 9: сброс сейва). Порядок важен: btnPlay
// до магазина — в MENU оверлей #menu (z-20) выше #hud (z-10), координатный клик по
// #btnShop не долетает до кнопки (после btnPlay HUD без оверлея).
// (1) стаб: YG активен, SDK недоступен в file://
const y1stub = await page.evaluate(() => ({
active: YG.active, sdk: YG.sdk,
gs: typeof YG.gameplayStart, gp: typeof YG.gameplayStop
}));
ok('Y1 стаб: YG.active===false, YG.sdk===null, gameplayStart/gameplayStop — функции',
y1stub.active === false && y1stub.sdk === null && y1stub.gs === 'function' && y1stub.gp === 'function',
JSON.stringify(y1stub));
// (2) LoadingAPI: ready() — ровно один раз, в первом кадре frame()
const y1ready = await page.evaluate(() => ({ readySent: YG.readySent, ready: YG.counts.ready }));
ok('Y1 LoadingAPI: readySent=true, counts.ready===1 (первый кадр frame)',
y1ready.readySent === true && y1ready.ready === 1, JSON.stringify(y1ready));
// (3) геймплей-разметка: btnPlay из MENU → start, state=IDLE
// (на входе state=MENU; страховка — флоу сброса: пауза → «В меню» → btnReset → btnResetYes)
{
if ((await page.evaluate(() => state)) !== 'MENU') {
await toGameMenu(page);
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
}
}
const y1pB = await page.evaluate(() => ({ s: YG.counts.start, st: state }));
await page.click('#btnPlay');
await sleep(100);
const y1p = await page.evaluate(() => ({
s: YG.counts.start, st: state,
menu: document.getElementById('menu').classList.contains('hidden')
}));
ok('Y1 геймплей-разметка: #btnPlay из MENU → counts.start +1, state=IDLE, меню скрыто',
y1pB.st === 'MENU' && y1p.s === y1pB.s + 1 && y1p.st === 'IDLE' && y1p.menu === true,
JSON.stringify({ before: y1pB, after: y1p }));
// (4) геймплей-разметка: btnShop → stop, btnCloseShop → start (из IDLE — HUD без оверлея)
const y1sB = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop }));
await page.click('#btnShop');
await sleep(100);
const y1sM = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
await page.click('#btnCloseShop');
await sleep(100);
const y1sE = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
ok('Y1 геймплей-разметка: #btnShop → counts.stop +1 (uiOpen=true), #btnCloseShop → counts.start +1 (uiOpen=false)',
y1sM.t === y1sB.t + 1 && y1sM.s === y1sB.s && y1sM.open === true
&& y1sE.s === y1sM.s + 1 && y1sE.t === y1sM.t && y1sE.open === false,
JSON.stringify({ before: y1sB, mid: y1sM, after: y1sE }));
// (5) пауза: YG.onPause() → paused=true, AC.suspend()
// жест для AudioContext — клики выше (sfx→ac): до паузы AC в running;
// AC.suspend() асинхронен — polling до 'suspended' (в IDLE updateGame без sfx —
// контекст сам не возобновится)
const y1paB = await page.evaluate(() => {
ac();
return { ac: AC ? AC.state : null, t: YG.counts.stop };
});
await page.evaluate(() => YG.onPause());
await waitCond(page, () => AC && AC.state === 'suspended');
const y1pa = await page.evaluate(() => ({ paused: YG.paused, ac: AC ? AC.state : null, t: YG.counts.stop }));
ok('Y1 пауза: YG.onPause() → paused=true, AC.state="suspended", counts.stop +1',
y1pa.paused === true && y1pa.ac === 'suspended' && y1pa.t === y1paB.t + 1,
JSON.stringify({ before: y1paB, after: y1pa }));
// (6) visibilitychange-путь: hidden → onPause, visible → onResume
// сначала сбрасываем паузу из (5) (иначе проверка hidden тривиальна);
// в конце возвращаем document.hidden (маску — свойство — снимаем: нативный
// getter в тестовой вкладке → false)
const y1v = await page.evaluate(() => {
const set = h => {
Object.defineProperty(document, 'hidden', { value: h, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
};
set(false);
const pre = YG.paused;
set(true);
const hid = YG.paused;
set(false);
const shw = YG.paused;
delete document.hidden;
return { pre, hid, shw, doc: document.hidden };
});
ok('Y1 visibilitychange: visible→hidden → paused=true, hidden→visible → paused=false; document.hidden возвращён (false)',
y1v.pre === false && y1v.hid === true && y1v.shw === false && y1v.doc === false,
JSON.stringify(y1v));
// AC после (5) остаётся suspended (YG.onResume звук не возобновляет — по следующему
// жесту): два клика по 🔊 — sfx→ac→resume, muted при этом не меняется (false→true→false).
// Клик по canvas нельзя: в IDLE это заброс (save.casts/стрик до секции 9a).
// 🔊 теперь в хабе «Меню» — открыть паузой → «В меню».
await toGameMenu(page);
await page.click('#btnMute');
await page.click('#btnMute');
// (7) возврат в MENU: секция 9a заходит в игрой кликом по #btnPlay, а он лежит внутри
// меню (в IDLE меню hidden — кнопка не кликабельна). «Меню» уже открыто (после
// mute-кликов) — флоу сброса btnReset → btnResetYes (паттерн секции 9): сейв уже
// обнулён — данные не ломаются, квесты дня перегенерируются (9a их сразу чистит)
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y1end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
paused: YG.paused,
muted: save.muted,
doc: document.hidden
}));
ok('Y1 cleanup: возврат в MENU (меню видно, paused=false, muted=false, document.hidden=false)',
y1end.st === 'MENU' && y1end.menu === true && y1end.paused === false
&& y1end.muted === false && y1end.doc === false,
JSON.stringify(y1end));
// --- 9a. Золотая рыба: форсированный шанс — монеты ×3, счётчик sn, бейдж в карточке ---
await page.click('#btnPlay');
await sleep(150);
// M2-гвард: квесты и стрик не должны случайно выдать монеты и исказить точную проверку
await page.evaluate(() => {
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
});
const coinsBeforeShiny = await page.evaluate(() => save.coins);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; SHINY_CHANCE = 1; forceVariant = 'gold'; }); // M6: forceVariant — медь/кварц и пр. не перехватят ролл
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__testW = reel.w; window.__testSpId = reel.sp.id;
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const shinyInfo = await page.evaluate(() => ({
w: window.__testW,
price: SPECIES.find(s => s.id === window.__testSpId).price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
sn: save.caught[window.__testSpId].sn,
badgeHidden: document.getElementById('cardVariant').classList.contains('hidden'),
badgeText: document.getElementById('cardVariant').textContent,
goldCard: document.getElementById('card').classList.contains('gold-card'),
}));
const shinyBase = shinyInfo.w * shinyInfo.price * 3;
ok('золотая рыба: монеты = вес×цена×3 (±5%), sn=1, бейдж «ЗОЛОТАЯ» в карточке',
shinyInfo.val >= Math.max(1, Math.round(shinyBase * 0.9))
&& shinyInfo.val <= Math.round(shinyBase * 1.1)
&& shinyInfo.coins === coinsBeforeShiny + shinyInfo.val
&& shinyInfo.sn === 1
&& !shinyInfo.badgeHidden && shinyInfo.badgeText.includes('ЗОЛОТАЯ')
&& shinyInfo.goldCard,
JSON.stringify(shinyInfo));
await page.click('#btnCardOk');
await page.evaluate(() => { SHINY_CHANCE = 0.06; forceVariant = null; });
// --- 9b. Миграция сейва: старый формат (число и {n,best} без sn) ---
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: { carass: { n: 2, best: 0.5 }, perch: 3 }, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const mig = await page.evaluate(() => ({
menu: !document.getElementById('menu').classList.contains('hidden'),
coins: save.coins,
carass: save.caught.carass,
perch: save.caught.perch,
}));
ok('миграция: старый сейв читается (число → {n,best,sn:0}, без sn → sn=0), n/best на месте',
mig.menu && mig.coins === 77
&& mig.carass.n === 2 && mig.carass.best === 0.5 && mig.carass.sn === 0
&& mig.perch.n === 3 && mig.perch.best === 0 && mig.perch.sn === 0,
JSON.stringify(mig));
// --- 9c. Near-miss: сбежавшая рыба не даёт наград (утешение убрано) ---
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.quests = { date: todayStr(), list: [] }; // пин: квестовая награда не искажает монеты
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
});
const coinsBeforeNm = await page.evaluate(() => save.coins);
await page.mouse.down(); await sleep(600); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
reel.phase = 'pull'; reel.phaseT = 9999;
reel.d = reel.d0 * 1.38;
});
await waitState(page, 'WAIT', 15000);
const nm = await page.evaluate(() => ({
coins: save.coins,
toast: document.getElementById('toast').textContent,
}));
ok('near-miss: сбежавшая рыба монеты не даёт',
nm.coins === coinsBeforeNm,
JSON.stringify(nm) + ' before=' + coinsBeforeNm);
ok('near-miss: в DOM тост «Сбежала» с весом, без монет',
nm.toast.startsWith('Сбежала!') && nm.toast.includes('кг') && !nm.toast.includes('🪙'),
nm.toast);
// --- 9d. M2 квест: catch_weight (порог 0.1 кг) выполняется уловом, +reward ---
// после near-miss игра может быть «в воде» (WAIT) — выводим её в IDLE/МЕНЮ-старт
const m2start = await page.evaluate(() => state);
if(m2start === 'MENU'){
await page.click('#btnPlay');
await sleep(150);
}else{
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.mouse.move(195, 600); // клик утащил мышь на кнопку — возвращаем курсор на canvas
}
await page.evaluate(() => {
save.quests = { date: todayStr(), list: [{ tpl:'catch_weight', param:0.1, goal:1, prog:0, done:false, reward:120 }] };
save.streak = { last:todayStr(), days:3, shieldWeek:null }; // стрик уже «сегодня» — не мешает монетам
});
const m2qBefore = await page.evaluate(() => save.coins);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m2q = await page.evaluate(() => ({
done: save.quests.list[0].done,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
total: save.questsDoneTotal,
}));
ok('M2 квест: catch_weight выполнен уловом (+reward 120, questsDoneTotal=1)',
m2q.done===true && m2q.coins===m2qBefore+m2q.val+120 && m2q.total===1,
JSON.stringify(m2q));
await page.click('#btnCardOk');
await sleep(100);
// --- 9e. M2 новый день: date=вчера → перегенерация (сегодня, 3 квеста, prog=0, без повторов) ---
await page.evaluate(() => {
save.quests.date = dateShift(todayStr(), -1);
persist();
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m2d = await page.evaluate(() => ({
date: save.quests.date,
today: todayStr(),
n: save.quests.list.length,
progs: save.quests.list.map(q => q.prog),
uniq: new Set(save.quests.list.map(q => q.tpl)).size,
}));
ok('M2 новый день: квесты перегенерированы (сегодня, 3, prog=0, шаблоны без повторов)',
m2d.date===m2d.today && m2d.n===3 && m2d.progs.every(p=>p===0) && m2d.uniq===3,
JSON.stringify(m2d));
// --- 9e2. Этап 53: масштабирование квестов по save.earned ---
// questScale растёт с доходом; цели/награды денежного квеста масштабируются; catch_weight
// берёт порог от медианы текущей локации; catch_rarity повышает редкость с прогрессом.
const q53 = await page.evaluate(() => {
const snap = save.earned;
save.earned = 0; const s0 = questScale();
save.earned = 8000; const s1 = questScale();
save.earned = 200000; const s2 = questScale();
// цели/награды: genQuests детерминирован датой → сравним «жадный минимум/максимум»
const genAt = (e) => { save.earned = e; return genQuests('2030-01-01'); };
const low = genAt(0), high = genAt(500000);
const earnLow = low.find(q => q.tpl === 'earn_coins');
const earnHigh = high.find(q => q.tpl === 'earn_coins');
// catch_weight: порог от локации (Озёра тяжелее Реки)
save.location = 0; const wRiver = localMedianW();
save.location = 1; const wLake = localMedianW();
save.location = 0; save.earned = snap;
return { s0, s1, s2, earnLow, earnHigh, wRiver, wLake };
});
ok('Этап 53 квесты: questScale растёт с earned (0→1, 8000>1, 200000>8000); earn_coins goal/reward выше на большом прогрессе; catch_weight растёт с локацией (Озёра>Река)',
q53.s0 === 1 && q53.s1 > 1 && q53.s2 > q53.s1
&& q53.earnLow && q53.earnHigh && q53.earnHigh.goal > q53.earnLow.goal && q53.earnHigh.reward > q53.earnLow.reward
&& q53.wRiver >= 1 && q53.wLake > q53.wRiver,
JSON.stringify(q53));
// --- 9f. M2 стрик: вчерашний визит → days+1 и награда дня (день 3 → +75) ---
const m2s3before = await page.evaluate(() => {
save.streak = { last: dateShift(todayStr(), -1), days:2, shieldWeek:null };
save.coins = 500;
persist();
return save.coins;
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m2s3 = await page.evaluate(() => ({ days:save.streak.days, last:save.streak.last, today:todayStr(), coins:save.coins }));
ok('M2 стрик: вчера → день 3, last=сегодня, +75 (награда дня 3)',
m2s3.days===3 && m2s3.last===m2s3.today && m2s3.coins===m2s3before+75,
JSON.stringify(m2s3));
// --- 9g. M2 щит стрика: пропуск в середине недели → days+1 + shieldWeek; повторный — days=1 ---
await page.evaluate(() => {
save.streak = { last: dateShift(todayStr(), -2), days:2, shieldWeek:null };
save.coins = 500;
persist();
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m2sh = await page.evaluate(() => ({ days:save.streak.days, sw:save.streak.shieldWeek, cur:isoWeek(todayStr()), coins:save.coins }));
ok('M2 стрик-щит: пропуск прощён — день 3, shieldWeek=текущая ISO-неделя, +75',
m2sh.days===3 && m2sh.sw===m2sh.cur && m2sh.coins===575,
JSON.stringify(m2sh));
await page.evaluate(() => {
save.streak = { last: dateShift(todayStr(), -2), days:2, shieldWeek:isoWeek(todayStr()) };
save.coins = 500;
persist();
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m2sh2 = await page.evaluate(() => ({ days:save.streak.days, coins:save.coins }));
ok('M2 стрик-щит контр: щит уже использован в неделе — день 1, +25',
m2sh2.days===1 && m2sh2.coins===525,
JSON.stringify(m2sh2));
// --- 9h. M2 достижения: achBonus и множитель монет ×1.01 ---
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.ach = { first_catch:true };
save.quests = { date:todayStr(), list:[] }; // квесты не должны мешать точной проверке
SHINY_CHANCE = 0; // исключаем случайный gold (×3)
forceVariant = 'normal'; // M6: и silver/rainbow — точная проверка без множителя
window.__m2Pick = pickSpecies; // пин вида: улов rarity≥2 выдал бы rare_fish (coinPct≠0.01, +монеты) — флейка ~4,5%
pickSpecies = () => SPECIES[0]; // карась (rarity 0), как в §10c(4)
save.caught = {}; save.earned = 0; save.casts = 0; // чтобы за улов не выдалось другое достижение
save.coins = 500;
});
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__m2W = reel.w; window.__m2Price = reel.sp.price;
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m2a = await page.evaluate(() => ({
pct: achBonus().coinPct,
w: window.__m2W, price: window.__m2Price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
}));
const m2base = m2a.w * m2a.price;
ok('M2 достижения: achBonus().coinPct=0.01 и монеты ×1.01 (диапазон ±5%)',
m2a.pct===0.01
&& m2a.val>=Math.max(1,Math.round(m2base*0.94))
&& m2a.val<=Math.round(m2base*1.08)
&& m2a.coins===500+m2a.val,
JSON.stringify(m2a));
await page.evaluate(() => { SHINY_CHANCE = 0.06; forceVariant = null; pickSpecies = window.__m2Pick; });
await page.click('#btnCardOk');
await sleep(100);
// --- 9i. M2 UI лавки: вкладки 🎯 (стрик+квесты) и 🏅 (достижения) ---
await page.evaluate(() => {
save.quests = { date:todayStr(), list:[{ tpl:'casts', param:0, goal:10, prog:3, done:false, reward:60 }] };
});
await page.click('#btnShop');
await sleep(150);
ok('M2 UI: вкладки «🎯» и «🏅» есть в лавке',
await page.$('#tabRewards') !== null && await page.$('#tabAch') !== null);
await page.click('#tabRewards');
await sleep(100);
const m2rw = await page.evaluate(() => document.getElementById('shopBody').textContent);
ok('M2 UI 🎯: стрик «День…» и квест с прогрессом 3/10',
m2rw.indexOf('День')>=0 && m2rw.indexOf('Сделай 10 забросов за день')>=0 && m2rw.indexOf('3/10')>=0,
m2rw.slice(0, 100));
await page.click('#tabAch');
await sleep(100);
const m2ac = await page.evaluate(() => ({
txt: document.getElementById('shopBody').textContent,
got: document.querySelector('#shopBody .jrow.got .jc') !== null,
}));
ok('M2 UI 🏅: список достижений, first_catch выдан с ✓',
m2ac.txt.indexOf('Первый улов')>=0 && m2ac.got===true,
m2ac.txt.slice(0, 100));
await page.click('#btnCloseShop');
// --- 9j. M3: цикл дня/ночи, ночная таблица, фонарик ---
// (a) фаза по dayTime
const m3ph = await page.evaluate(() => {
setDayTime(0.8); const n=dayPhase;
setDayTime(0.3); const d=dayPhase;
return { n, d };
});
ok('M3 цикл: dayTime 0.8 → night, 0.3 → day',
m3ph.n==='night' && m3ph.d==='day', JSON.stringify(m3ph));
// (b) ночная таблица: тот же ролл — другая рыба (веса 40 vs 100 у карася)
const m3tbl = await page.evaluate(() => {
save.lure=0; // rare=0 — веса таблицы без модификаторов
const mr=Math.random; Math.random=()=>0.25;
setDayTime(0.8); const night=pickSpecies(1).id;
setDayTime(0.3); const day=pickSpecies(1).id;
Math.random=mr;
return { night, day,
wN:SPECIES_NIGHT.find(s=>s.id==='carass').weight,
wD:SPECIES.find(s=>s.id==='carass').weight };
});
ok('M3 ночная таблица: ночью — SPECIES_NIGHT (карась 40 vs 100), тот же ролл → другая рыба',
m3tbl.night==='perch' && m3tbl.day==='carass' && m3tbl.wN===40 && m3tbl.wD===100,
JSON.stringify(m3tbl));
// (c) ночь без фонарика: клёва нет, тост разовый
await page.evaluate(() => {
setDayTime(0.8);
save.lures=[0]; save.lure=0;
save.quests={date:todayStr(),list:[]}; // чтобы квестный тост не затёр проверочный
state='IDLE'; biteFish=null;
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite=true; biteTimer=0.05; });
await sleep(600);
const m3nb = await page.evaluate(() => ({
st:state, bf:!!biteFish, bt:biteTimer,
toast:document.getElementById('toast').textContent
}));
ok('M3 ночь без фонарика: клёв не стартует (WAIT, нет biteFish, biteTimer тикает), тост «Ночью нужен фонарик»',
m3nb.st==='WAIT' && !m3nb.bf && m3nb.bt<0 && m3nb.toast.includes('Ночью нужен фонарик'),
JSON.stringify(m3nb));
// (d) ночь с фонариком: клёв работает (M5: фонарик = апгрейд 1 удилища)
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.mouse.move(195, 600);
await page.evaluate(() => { setDayTime(0.8); save.rodLv[save.rod]=2; });
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite=true; biteTimer=0.05; });
await waitState(page, 'BITE', 4000);
ok('M3 ночь с фонариком (удочка 2+ ур.: save.rodLv[save.rod]>=2): клёв работает (BITE)', true);
// (e) лавка: фонарик — не приманка, а апгрейд 1 удилища (кнопка ⬆ 20 🪙,
// в подписи «фонарик (ночь)»); уровень 1 форсим, чтобы строка показывала именно его
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
const m3shop = await page.evaluate(() => {
save.rodLv[0]=1;
renderShop();
const items=Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const row=items.find(el=>el.querySelector('[data-act="upgrade"][data-type="rod"]'));
return row?{txt:row.textContent}:null;
});
ok('M5 лавка: фонарик = апгрейд 1 удилища (кнопка ⬆ 20 🪙, подпись «фонарик (ночь)»)',
m3shop && m3shop.txt.indexOf('20 🪙')>=0 && m3shop.txt.indexOf('фонарик')>=0 && m3shop.txt.indexOf('ноч')>=0,
m3shop?m3shop.txt:'нет строки апгрейда');
await page.click('#btnCloseShop');
// (f) миграция: старый сейв без dayTime/lastSaveMs
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m3m = await page.evaluate(() => {
const ls=JSON.parse(localStorage.getItem('fishing-save-v1'));
return { dayTime:dayTime, dayPhase:dayPhase, ms:typeof save.lastSaveMs,
lsMs:typeof ls.lastSaveMs, lsDay:ls.dayTime };
});
ok('M3 миграция: старый сейв → dayTime≈0 (morning), lastSaveMs установлен (память и localStorage)',
m3m.dayTime<0.01 && m3m.dayPhase==='morning' && m3m.ms==='number'
&& m3m.lsMs==='number' && Math.abs(m3m.lsDay)<0.01,
JSON.stringify(m3m));
// --- 9k. M4: босс-рыба (Осётр-босс) — 3 фазы, награда ×5, достижение, карточка ---
await page.click('#btnPlay');
await sleep(150);
// форсируем босса и стурджа; квесты/стрик не должны искажать проверки
await page.evaluate(() => {
forceBoss = true; SHINY_CHANCE = 0;
window.__m4Pick = pickSpecies;
pickSpecies = () => SPECIES.find(s => s.id === 'sturge');
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
save.bossBeaten = false; save.bossCount = 0; save.ach = {};
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
const m4a = await page.evaluate(() => ({
stateBoss, bossPhase, boss: reel.boss, spId: reel.sp.id, spBoss: reel.sp.boss, d0: reel.d0,
}));
ok('M4 босс: forceBoss + стурдж → старт босс-боя (stateBoss=true, bossPhase=1, reel.boss, sp.id=sturge)',
m4a.stateBoss === true && m4a.bossPhase === 1 && m4a.boss === true && m4a.spId === 'sturge' && m4a.spBoss === true,
JSON.stringify(m4a));
// бой: 3 фазы — d=0 → «обрыв» SNAP (без save.snaps++) → авто-REEL в следующую фазу → d=0 → … → фаза 3 → улов
const m4snapsBefore = await page.evaluate(() => save.snaps);
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'SNAP', 3000);
const m4p1 = await page.evaluate(() => {
const r = { snaps: save.snaps, boss: stateBoss, shake: shake > 0 };
reel.holding = false; // в обрыве не втягиваем — d фиксируется на старте фазы
return r;
});
await page.mouse.up();
await waitState(page, 'REEL', 3000);
const m4p2 = await page.evaluate(() => ({ ph: bossPhase, boss: stateBoss, d: reel.d, d0: reel.d0 }));
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'SNAP', 3000);
const m4p2snap = await page.evaluate(() => { reel.holding = false; return save.snaps; });
await page.mouse.up();
await waitState(page, 'REEL', 3000);
const m4p3 = await page.evaluate(() => ({ ph: bossPhase, d: reel.d, d0: reel.d0 }));
await page.evaluate(() => { window.__m4W = reel.w; reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m4p4 = await page.evaluate(() => ({ st: state, boss: stateBoss, ph: bossPhase, snaps: save.snaps }));
ok('M4 босс-бой: 3 фазы (SNAP-обрыв без save.snaps++ → d≈0.6·d0 → d≈1.0·d0 → CARD, stateBoss=false)',
m4p1.snaps === m4snapsBefore && m4p1.boss === true && m4p1.shake === true
&& m4p2.ph === 2 && m4p2.boss === true && m4p2.d >= m4p2.d0 * 0.55 && m4p2.d <= m4p2.d0 * 0.65
&& m4p2snap === m4snapsBefore
&& m4p3.ph === 3 && m4p3.d >= m4p3.d0 * 0.95 && m4p3.d <= m4p3.d0 * 1.05
&& m4p4.st === 'CARD' && m4p4.boss === false && m4p4.ph === 0 && m4p4.snaps === m4snapsBefore,
JSON.stringify({ m4p1, m4p2, m4p3, m4p4 }));
// награда: цена ×5 (±10% — есть разброс ±5% в catchFish), bossBeaten/bossCount
const m4c = await page.evaluate(() => ({
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
w: window.__m4W,
price: SPECIES.find(s => s.id === 'sturge').price,
bossBeaten: save.bossBeaten, bossCount: save.bossCount,
}));
const m4base = m4c.w * m4c.price * 5;
ok('M4 босс-награда: монеты = вес×цена×5 (±10%), bossBeaten=true, bossCount=1',
m4c.val >= Math.max(1, Math.round(m4base * 0.9)) && m4c.val <= Math.round(m4base * 1.1)
&& m4c.bossBeaten === true && m4c.bossCount === 1,
JSON.stringify(m4c));
ok('M4 достижение: boss_first выдан после победы над боссом',
await page.evaluate(() => save.ach.boss_first === true));
const m4ui = await page.evaluate(() => ({
name: document.getElementById('cardName').textContent,
badgeHidden: document.getElementById('cardBoss').classList.contains('hidden'),
badgeText: document.getElementById('cardBoss').textContent,
bossCard: document.getElementById('card').classList.contains('boss-card'),
}));
ok('M4 карточка босса: имя «Осётр-БОСС», бейдж cardBoss виден, класс boss-card',
m4ui.name === 'Осётр-БОСС' && m4ui.badgeHidden === false && m4ui.badgeText.includes('БОСС') && m4ui.bossCard === true,
JSON.stringify(m4ui));
await page.screenshot({ path: 'shot-boss.png' });
await page.click('#btnCardOk');
// сброс тест-форсов
await page.evaluate(() => {
forceBoss = false; SHINY_CHANCE = 0.06;
pickSpecies = window.__m4Pick;
});
// --- 9k2. Этап 37: босс в каждой локации — Озёра (рак) / Море (рыба-меч) / Океан (кит) / Болото (которыбка, Этап 56) ---
// Паттерн §9k: monkey-patch pickSpecies + forceBoss + save.location; единое правило веса:
// любой босс 20–50 кг (hook), награда ×5, save.bosses[id]=true, пер-босс ачивка (без coinPct)
const b37list = [
{ loc: 1, id: 'crayfish', ach: 'lake_boss', name: 'Гигантский рак' },
{ loc: 2, id: 'swordfish', ach: 'sea_boss', name: 'Рыба-меч' },
{ loc: 3, id: 'whale', ach: 'ocean_boss', name: 'Синий кит' },
{ loc: 4, id: 'meowfish', ach: 'swamp_boss', name: 'Которыбка' }, // Этап 56: босс Болота — которыбка (экс-целакант), дух — обычная рыба
];
await page.evaluate(() => { window.__b37Pick = pickSpecies; });
for (const b37c of b37list) {
await page.evaluate((c) => {
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
forceBoss = true; SHINY_CHANCE = 0;
pickSpecies = () => SPECIES.find(s => s.id === c.id);
save.location = c.loc;
// ВАЖНО: save.locations не трогаем — doCast() зовёт checkAchievements(), и «купленная»
// локация выдала бы lake/sea/ocean/swamp_first (+2% coinPct) ещё до catchFish →
// проверка награды босса (±10%) поплыла бы. Боссу достаточно save.location (forceBoss).
save.ach = {}; save.bosses = {}; save.bossBeaten = false; save.bossCount = 0;
save.caught = {};
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
}, b37c);
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
const b37a = await page.evaluate(() => ({
stateBoss, bossPhase, boss: reel.boss, spId: reel.sp.id,
w: reel.w, d0: reel.d0,
}));
ok('Этап 37 ' + b37c.name + ' (локация ' + b37c.loc + '): forceBoss → старт босс-боя (stateBoss=true, bossPhase=1, reel.boss, sp.id=' + b37c.id + '), вес 20–50 кг',
b37a.stateBoss === true && b37a.bossPhase === 1 && b37a.boss === true && b37a.spId === b37c.id
&& b37a.w >= 20 && b37a.w <= 50,
JSON.stringify(b37a));
await page.evaluate(() => {
window.__b37W = reel.w;
bossPhase = 3; // фазовые обрывы (§9k) не суть — сразу в улов (паттерн M6(4))
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const b37b = await page.evaluate((c) => ({
w: window.__b37W,
price: SPECIES.find(s => s.id === c.id).price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
bosses: save.bosses, bossBeaten: save.bossBeaten, bossCount: save.bossCount,
ach: !!save.ach[c.ach],
name: document.getElementById('cardName').textContent,
badgeHidden: document.getElementById('cardBoss').classList.contains('hidden'),
bossCard: document.getElementById('card').classList.contains('boss-card'),
}), b37c);
const b37base = b37b.w * b37b.price * 5;
ok('Этап 37 ' + b37c.name + ': монеты = w×price×5 (±10%), save.bosses.' + b37c.id + '=true, bossBeaten=true, bossCount=1, ачивка ' + b37c.ach,
b37b.val >= Math.max(1, Math.round(b37base * 0.9)) && b37b.val <= Math.round(b37base * 1.1)
&& b37b.bosses && b37b.bosses[b37c.id] === true && b37b.bossBeaten === true && b37b.bossCount === 1
&& b37b.ach === true,
JSON.stringify(b37b));
ok('Этап 37 ' + b37c.name + ': карточка «' + b37c.name + '-БОСС», бейдж cardBoss виден, класс boss-card',
b37b.name === b37c.name + '-БОСС' && b37b.badgeHidden === false && b37b.bossCard === true,
JSON.stringify({ name: b37b.name, badgeHidden: b37b.badgeHidden, bossCard: b37b.bossCard }));
await page.click('#btnCardOk');
await sleep(100);
}
// (4) миграция: старый сейв без save.bosses → {} (паттерн lc5: Object.assign не удаляет ключи —
// в-памяти значение чиним вручную перед loadSave)
const b37m = await page.evaluate(() => {
delete save.bosses; // «тест-фантом» из прогона выше
localStorage.setItem(SAVE_KEY, JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 1, earned: 10, bossBeaten: false, bossCount: 0
}));
loadSave();
return {
isObj: save.bosses !== undefined && typeof save.bosses === 'object' && Object.keys(save.bosses).length === 0
};
});
ok('Этап 37 миграция: старый сейв без save.bosses → {} (loadSave)',
b37m.isObj === true,
JSON.stringify(b37m));
// полный сброс тест-форсов и состояния (паттерн соседних блоков)
await page.evaluate(() => {
forceBoss = false; SHINY_CHANCE = 0.06; forceBite = false;
pickSpecies = window.__b37Pick;
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
save.location = 0;
save.bosses = {}; save.bossBeaten = false; save.bossCount = 0;
save.ach = {}; save.caught = {};
save.coins = 0;
});
// --- 9l. Регрессии багфиксов спринта 3 ---
// (1) квест «Сделай N забросов»: questHit('casts') вызывался без condFn и падал
// TypeError на каждом забросе — квест не рос, а persist()/checkAchievements()
// в doCast() пропускались (счётчик забросов терялся при перезагрузке)
await page.evaluate(() => {
save.quests = { date: todayStr(), list: [{ tpl: 'casts', param: 0, goal: 1, prog: 0, done: false, reward: 60 }] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null }; // стрик «сегодня» — не искажает монеты
save.coins = 100;
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
});
const castsQBefore = await page.evaluate(() => ({ casts: save.casts }));
const peBeforeCastsQ = pageErrors.length;
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
const castsQ = await page.evaluate(() => ({
prog: save.quests.list[0].prog,
done: save.quests.list[0].done,
coins: save.coins,
casts: save.casts,
lsCasts: JSON.parse(localStorage.getItem('fishing-save-v1')).casts,
hud: document.getElementById('coins').textContent,
}));
ok('багфикс квест «забросы»: квест растёт и выполняется на забросе (+60 🪙), без TypeError, заброс записан в сейв',
castsQ.prog === 1 && castsQ.done === true && castsQ.coins === 160
&& castsQ.casts === castsQBefore.casts + 1 && castsQ.lsCasts === castsQ.casts
&& pageErrors.length === peBeforeCastsQ,
JSON.stringify({ castsQ, pe: pageErrors.length - peBeforeCastsQ }));
// (2) награда квеста на забросе — вне catchFish: HUD монет должен обновиться сразу
ok('багфикс HUD квеста: счётчик монет обновлён сразу после выполнения квеста на забросе',
castsQ.hud === '🪙 ' + castsQ.coins, castsQ.hud);
// (2b) near-miss: сход монет не даёт — HUD в согласии с сейвом
await page.mouse.move(195, 600);
const nmHudBefore = await page.evaluate(() => save.coins);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
reel.phase = 'pull'; reel.phaseT = 9999;
reel.d = reel.d0 * 1.38;
});
await waitState(page, 'WAIT', 15000);
const nmHud = await page.evaluate(() => ({
coins: save.coins,
hud: document.getElementById('coins').textContent,
}));
ok('near-miss: сход монет не даёт, HUD в согласии с сейвом',
nmHud.coins === nmHudBefore && nmHud.hud === '🪙 ' + nmHud.coins,
JSON.stringify(nmHud) + ' before=' + nmHudBefore);
// (3) босс-бой: фазовый обрыв должен сбрасывать накопленную нагрузку —
// иначе «обрыв фазы без штрафа» с tension≈0.9·line тут же рвал шнур по-настоящему
await page.evaluate(() => {
forceBoss = true; SHINY_CHANCE = 0;
window.__pick2 = pickSpecies; pickSpecies = () => SPECIES.find(s => s.id === 'sturge');
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
save.quests = { date: todayStr(), list: [] };
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
reel.d = 2; reel.phase = 'calm'; reel.phaseT = 9999;
reel.tension = reel.line * 0.9; // рыба тянула почти до предела
});
await page.mouse.down();
await waitState(page, 'SNAP', 5000);
// палец НЕ отпускаем: после авто-возврата в REEL нагрузка должна стартовать с нуля
const bossPh = await page.evaluate(() => new Promise(res => {
const iv = setInterval(() => {
if (state === 'REEL') { clearInterval(iv); res({ st: state, ph: bossPhase, boss: stateBoss, tension: reel.tension, line: reel.line }); }
else if (state === 'SNAP' && !stateBoss) { clearInterval(iv); res({ st: 'REAL SNAP' }); }
}, 10);
}));
await page.mouse.up();
ok('багфикс босс-обрыв фазы: после авто-возврата в REEL нагрузка сброшена (tension≈0), бой продолжается',
bossPh.st === 'REEL' && bossPh.boss === true && bossPh.ph === 2 && bossPh.tension < bossPh.line * 0.1,
JSON.stringify(bossPh));
await page.evaluate(() => {
forceBoss = false; SHINY_CHANCE = 0.06; pickSpecies = window.__pick2;
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
});
// --- 9f. escape: рыба ускользает если долго не тянешь ---
// полный цикл: IDLE → CHARGE → WAIT → BITE → REEL
await page.mouse.move(195, 600);
await page.mouse.down();
await sleep(500);
await page.mouse.up(); // бросок
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; }); // ускоряем клёв
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up(); // подсечка
await waitState(page, 'REEL');
// ставим рыбу в покой, чтобы escape не мешал, и фиксируем aggr
// (aggr обязателен: при ag≥0.65 бар заполняется за <2 с и рыба уходит до замера — T5)
await page.evaluate(() => { reel.phase = 'calm'; reel.phaseT = 9999; reel.holding = false; reel.aggr = 0.3; reel.escape = 0; });
await sleep(100);
const escBefore = await page.evaluate(() => reel.escape);
await sleep(2000); // 2 с без удержания — escape растёт
const escAfter = await page.evaluate(() => ({ escape: reel.escape, state }));
ok('escape: растёт когда палец отпущен',
escAfter.escape > escBefore && escAfter.state === 'REEL',
JSON.stringify({ before: escBefore, after: escAfter.escape }));
// теперь жмём палец — escape должен уменьшаться
await page.mouse.down();
await sleep(800);
const escHeld = await page.evaluate(() => reel.escape);
await page.mouse.up();
ok('escape: уменьшается когда палец нажат',
escHeld < escAfter.escape,
JSON.stringify({ afterHold: escHeld, was: escAfter.escape }));
// рыба сбегает при escape >= 1
await page.evaluate(() => { reel.escape = 0.95; reel.holding = false; reel.phase = 'calm'; reel.phaseT = 9999; });
await sleep(600);
const escGone = await page.evaluate(() => ({ escape: reel.escape, state, toast: document.getElementById('toast').textContent }));
ok('escape: рыба сбегает при escape >= 1 (state=WAIT, тост «Сбежала!»)',
escGone.state === 'WAIT' && escGone.toast.includes('Сбежала!'),
JSON.stringify(escGone));
// сброс для следующих тестов
await page.evaluate(() => {
state = 'IDLE'; reel = null; biteFish = null;
});
// --- 10. Спринт 4: физика (stamina, drag, пустые клёвы) ---
// Выводим рыбу на крючок и держим один бой для тестов (1)-(4),
// между тестами вручную возвращаем reel в безопасное состояние (d посередине,
// tension=0, line велик — чтобы бой не закончился ловлей/обрывом в окне замера).
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
// (1) stamina: reel.st есть и убывает в бою
const st0 = await page.evaluate(() => {
reel.d0 = 200; reel.d = 100; reel.tension = 0; reel.line = 500;
reel.phase = 'calm'; reel.phaseT = 9999; reel.holding = false;
return reel.st;
});
ok('stamina: reel.st инициализируется (>0)', st0 > 0, 'st0=' + st0.toFixed(3));
await page.mouse.down(); // holding=true — удерживаем
await sleep(2000);
const st1 = await page.evaluate(() => reel.st);
ok('stamina: reel.st убывает в бою (st<st0 после 2 с удержания)', st1 < st0,
'st0=' + st0.toFixed(3) + ' st1=' + st1.toFixed(3));
// (2) stamina: усталая рыба слабее — fishForce глобальна, tired лачится при st<=0.12
const f = await page.evaluate(() => {
const r = { st: 1, tired: false, ov: 1 };
const F1 = fishForce(r, 0.5, true);
r.st = 0.05;
const F2 = fishForce(r, 0.5, true);
return { F1, F2 };
});
ok('stamina: слабая/уставшая рыба тянет слабее (F(st=0.05) < 0.6·F(st=1))',
f.F2 < f.F1 * 0.6, 'F1=' + f.F1.toFixed(1) + ' F2=' + f.F2.toFixed(1));
await page.evaluate(() => {
reel.d0 = 200; reel.d = 100; reel.tension = 0; reel.line = 500;
reel.phase = 'calm'; reel.phaseT = 9999; reel.st = 0.1; reel.tired = false;
});
await sleep(200); // кадры updateReel зафиксируют tired при st<=0.12
const tiredFlag = await page.evaluate(() => reel.tired);
ok('stamina: при st=0.1 рыба устала (reel.tired===true)', tiredFlag === true,
'tired=' + tiredFlag);
// (3) drag: рывок сильнее drag — леска скользит (d уходит, tension за чертой)
await page.evaluate(() => {
reel.w = 12; reel.ov = 4; reel.aggr = 1; reel.st = 1; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000); // палец всё ещё нажат (holding=true)
const slip = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок сильнее drag — леска скользит (d растёт, tension>drag)',
slip.d > slip.d0 && slip.tension > slip.drag,
JSON.stringify(slip));
// (4) drag: рывок слабее drag — спокойно втягиваем (d падает, tension под чертой)
await page.evaluate(() => {
reel.w = 0.3; reel.ov = 0.1; reel.aggr = 0.2; reel.st = 0.9; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000);
const reelIn = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок слабее drag — втягивание (d падает, tension<drag)',
reelIn.d < reelIn.d0 && reelIn.tension < reelIn.drag,
JSON.stringify(reelIn));
await page.mouse.up(); // отпустили палец
await page.evaluate(() => { state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
// (5) пустой клёв: без forceBite и с Math.random=0.99+ клёва нет
await page.evaluate(() => { setDayTime(0.3); state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => {
window.__mr = Math.random;
Math.random = () => 0.999; // выше любого biteChance (макс ~0.9 днём)
biteTimer = 0.05;
forceBite = false;
});
await sleep(700);
const empty = await page.evaluate(() => {
Math.random = window.__mr;
return { st: state, bf: !!biteFish };
});
ok('пустой клёв: без forceBite и Math.random=0.99+ — клёва нет (WAIT, нет biteFish)',
empty.st === 'WAIT' && !empty.bf, JSON.stringify(empty));
// --- 10b. M5: апгрейды снастей (lv 1..4), фонарик, гейтинг лавки ---
// (1) миграция: старый сейв без rodLv/lureLv, фонарик ещё приманка (lures:[0,4], lure:4)
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 4, rods: [0], lures: [0, 4], muted: false,
caught: {}, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m5m = await page.evaluate(() => ({
rodLv: save.rodLv, lureLv: save.lureLv,
lures: save.lures, lure: save.lure,
rods: save.rods, rod: save.rod
}));
ok('M5 миграция: старый сейв (нет rodLv/lureLv, lures:[0,4], lure:4) → rodLv={0:1}, lureLv={0:1}, lures без 4, lure=0',
m5m.rodLv[0]===1 && Object.keys(m5m.rodLv).length===1
&& m5m.lureLv[0]===1 && Object.keys(m5m.lureLv).length===1
&& m5m.lures.join(',')==='0' && m5m.lure===0
&& m5m.rods.join(',')==='0' && m5m.rod===0,
JSON.stringify(m5m));
// (2) данные: фонарика как приманки id=4 нет, поле nightOnly не используется
const m5t = await page.evaluate(() => ({
n: LURES.length,
ids: LURES.map(l => l.id).join(','),
noNight: LURES.every(l => !('nightOnly' in l))
}));
ok('M5 данные: LURES.length===4 (id 0..3, фонарика id=4 нет) и нет поля nightOnly',
m5t.n===4 && m5t.ids==='0,1,2,3' && m5t.noNight, JSON.stringify(m5t));
// (3) эффективные статы удочки 0 по уровням: точные (база × множители реализации)
const m5r = await page.evaluate(() => {
save.rod = 0;
const b = RODS[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.rodLv[0] = lv;
const r = rod();
out[lv] = { line: r.line, drag: r.drag, reel: r.reel, size: r.size, light: r.light };
}
save.rodLv[0] = 1;
return {
got: out,
exp: {
1: { line: b.line, drag: b.drag, reel: b.reel, size: b.size, light: false },
2: { line: b.line * 1.15, drag: b.drag, reel: b.reel, size: b.size, light: true },
3: { line: b.line * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size, light: true },
4: { line: b.line * 1.15 * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size + 0.10, light: true }
}
};
});
ok('M5 статы удочки 0: Lv1=база (line 100, drag 34, reel 1.0, size 0), Lv2 line≈115 (×1.15), Lv3 drag≈39.1 (×1.15) reel=1.1 (×1.10), Lv4 line≈132.25 (×1.15²) size=0.1',
[1, 2, 3, 4].every(lv => {
const g = m5r.got[lv], e = m5r.exp[lv];
return g.line === e.line && g.drag === e.drag && g.reel === e.reel && g.size === e.size && !!g.light === e.light;
}),
JSON.stringify(m5r.got));
// (4) эффективные статы приманки 0 по уровням
const m5l = await page.evaluate(() => {
save.lure = 0;
const b = LURES[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.lureLv[0] = lv;
const l = lure();
out[lv] = { bite: l.bite, rare: l.rare, size: l.size };
}
save.lureLv[0] = 1;
return {
got: out,
exp: {
1: { bite: b.bite, rare: b.rare, size: b.size },
2: { bite: b.bite * 1.15, rare: b.rare, size: b.size },
3: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size },
4: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size + 0.15 }
}
};
});
ok('M5 статы приманки 0: Lv1=база (bite 1.0, rare 0, size 0), Lv2 bite=1.15 (×1.15), Lv3 rare=1.0 (+1.0), Lv4 size=0.15 (+0.15)',
[1, 2, 3, 4].every(lv => {
const g = m5l.got[lv], e = m5l.exp[lv];
return g.bite === e.bite && g.rare === e.rare && g.size === e.size;
}),
JSON.stringify(m5l.got));
// (5) фонарик = апгрейд 1 удилища: hasLight
const m5h = await page.evaluate(() => {
save.rodLv[save.rod] = 1; const a = hasLight();
save.rodLv[save.rod] = 2; const b = hasLight();
save.rodLv[save.rod] = 4; const c = hasLight();
save.rodLv[save.rod] = 1;
return { a, b, c };
});
ok('M5 hasLight(): false на 1 ур., true на 2 ур. и 4 ур.',
m5h.a === false && m5h.b === true && m5h.c === true, JSON.stringify(m5h));
// (6) лавка: гейтинг — свежий сейв, rod 1 и lure 1 заблокированы
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.coins = 1000;
});
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
const m5g1 = await page.evaluate(() => {
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => ({
locked: el.classList.contains('locked'),
buy: el.querySelector('[data-act="buy"]') !== null,
lock: el.querySelector('button[disabled]') !== null,
desc: el.querySelector('.itemdesc').textContent
});
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: свежий сейв (1 ур.) — строки rod 1 и lure 1: класс locked, 🔒, нет buy, «Откроется: … до 4 ур.»',
m5g1.r1.locked && !m5g1.r1.buy && m5g1.r1.lock
&& m5g1.r1.desc.includes('Откроется') && m5g1.r1.desc.includes('до 4 ур.')
&& m5g1.l1.locked && !m5g1.l1.buy && m5g1.l1.lock && m5g1.l1.desc.includes('Откроется'),
JSON.stringify(m5g1));
// (7) лавка: предыдущая снасть на 4 ур. — buy появляется, locked снимается
const m5g2 = await page.evaluate(() => {
save.rodLv[0] = 4; save.lureLv[0] = 4;
renderShop();
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => {
const b = el.querySelector('[data-act="buy"]');
return { locked: el.classList.contains('locked'), buyTxt: b ? b.textContent : null };
};
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: после rodLv[0]=4 / lureLv[0]=4 — locked снят, кнопки buy «150 🪙» / «100 🪙»',
!m5g2.r1.locked && m5g2.r1.buyTxt === '150 🪙' && !m5g2.l1.locked && m5g2.l1.buyTxt === '100 🪙',
JSON.stringify(m5g2));
// (8) двойная защита: buy-ветка shopClick при закрытом гейтинге не покупает
const m5d = await page.evaluate(() => {
save.rodLv[0] = 1; save.coins = 1000;
const before = { rods: save.rods.join(','), coins: save.coins };
const el = document.createElement('button');
el.dataset.act = 'buy'; el.dataset.type = 'rod'; el.dataset.i = '1';
document.body.appendChild(el);
let err = null;
try { shopClick({ target: el }); } catch (e) { err = String(e); }
document.body.removeChild(el);
return { err, before,
after: { rods: save.rods.join(','), coins: save.coins },
toast: document.getElementById('toast').textContent };
});
ok('M5 двойная защита: shopClick buy (rod 1) при rodLv[0]<4 — rods/coins не изменились, тост «Сначала улучши…»',
m5d.err === null && m5d.before.rods === m5d.after.rods && m5d.before.coins === m5d.after.coins
&& m5d.toast.includes('Сначала улучши'),
JSON.stringify(m5d));
// (9) покупка апгрейда: 1000 🪙 → 980 (Этап 53: апгрейд 1→2 ур. удочки id0 = 20), rodLv[0]=2, фонарик включился
await page.evaluate(() => {
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.coins = 1000;
renderShop();
});
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="0"]');
const m5u = await page.evaluate(() => ({
coins: save.coins, lv: save.rodLv[0], light: hasLight(),
toast: document.getElementById('toast').textContent
}));
ok('M5 покупка апгрейда: rod 0 1→2 ур. за 20 🪙 (1000→980), hasLight()=true, тост «Улучшено … → 2 ур»',
m5u.coins === 980 && m5u.lv === 2 && m5u.light === true
&& m5u.toast.includes('Улучшено') && m5u.toast.includes('2 ур'),
JSON.stringify(m5u));
// (10) на макс. уровне (4) кнопки upgrade в строке нет
const m5x = await page.evaluate(() => {
save.rodLv[0] = 4; renderShop();
const r0 = document.querySelectorAll('#shopBody .item')[0];
return { hasUpg: r0.querySelector('[data-act="upgrade"]') !== null, txt: r0.textContent };
});
ok('M5 макс. уровень: на 4 ур. у rod 0 кнопки upgrade нет (пипсы ●●●●, «4 ур.»)',
!m5x.hasUpg && m5x.txt.includes('●●●●') && m5x.txt.includes('4 ур.'), m5x.txt);
// (11) не хватает монет: апгрейд не покупается
await page.evaluate(() => { save.rodLv[0] = 1; save.coins = 10; renderShop(); });
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="0"]');
const m5p = await page.evaluate(() => ({
coins: save.coins, lv: save.rodLv[0],
toast: document.getElementById('toast').textContent
}));
ok('M5 не хватает монет: 10 🪙 на апгрейд за 20 — coins=10, уровень не изменился, тост «Не хватает монет»',
m5p.coins === 10 && m5p.lv === 1 && m5p.toast.includes('Не хватает монет'),
JSON.stringify(m5p));
await page.click('#btnCloseShop');
// (12) ночь без фонарика (rodLv[save.rod]=1): клёв не стартует, тост (паттерн §9j(c))
await page.evaluate(() => {
setDayTime(0.8);
save.lures = [0]; save.lure = 0;
save.rodLv[save.rod] = 1;
save.quests = { date: todayStr(), list: [] }; // квестный тост не затирает проверочный
state = 'IDLE'; reel = null; biteFish = null; forceBite = false;
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await sleep(600);
const m5n = await page.evaluate(() => ({
st: state, bf: !!biteFish, bt: biteTimer,
toast: document.getElementById('toast').textContent
}));
ok('M5 ночь без фонарика (rodLv=1): клёв не стартует (WAIT, нет biteFish, biteTimer<0), тост «Ночью нужен фонарик»',
m5n.st === 'WAIT' && !m5n.bf && m5n.bt < 0 && m5n.toast.includes('Ночью нужен фонарик'),
JSON.stringify(m5n));
// (13) ночь с фонариком (rodLv[save.rod]=2): клёв работает (паттерн §9j(d))
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.mouse.move(195, 600);
await page.evaluate(() => { save.rodLv[save.rod] = 2; });
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
ok('M5 ночь с фонариком (rodLv=2): клёв работает (BITE)', true);
// (14) покупка разблокированной снасти стартует с уровня 1
await page.evaluate(() => {
forceBite = false; state = 'IDLE'; reel = null; biteFish = null;
setDayTime(0.3);
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 4 }; save.lureLv = { 0: 1 }; save.coins = 3000;
});
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
await page.click('#shopBody [data-act="buy"][data-type="rod"][data-i="1"]');
const m5b = await page.evaluate(() => ({
rods: save.rods.join(','), lv: save.rodLv[1],
rod: save.rod, coins: save.coins
}));
ok('M5 покупка разблокированной снасти: rod 1 за 150 🪙 — rods=[0,1], rodLv[1]=1, снасть экипирована, coins=2850',
m5b.rods === '0,1' && m5b.lv === 1 && m5b.rod === 1 && m5b.coins === 2850,
JSON.stringify(m5b));
// (14b) апгрейд по формуле: rod 1 (база 150) 1→2 ур. = 150×0.15 = 22.5 → 20 (округление до 10)
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="1"]');
const m5bu = await page.evaluate(() => ({ lv: save.rodLv[1], coins: save.coins }));
ok('M5 апгрейд по формуле: rod 1 1→2 ур. за 20 🪙 (база 150×0.15 = 22.5 → округление до 10) — coins=2850→2830, rodLv[1]=2',
m5bu.lv === 2 && m5bu.coins === 2830, JSON.stringify(m5bu));
await page.click('#btnCloseShop');
// (15) пин цен апгрейдов (Этап 53): id 0 — фикс, id>0 — база×(0.15/0.35/0.6)
const m5c = await page.evaluate(() => ({
r: [0,1,2,3].map(i=>[1,2,3].map(lv=>upgCost('rod',i,lv)).join(',')),
l: [0,1,2,3].map(i=>[1,2,3].map(lv=>upgCost('lure',i,lv)).join(','))
}));
ok('M5 цены апгрейдов: id 0 фикс (20/50/100, 15/40/80); id>0 = база×(0.15/0.35/0.6)',
m5c.r.join('|')==='20,50,100|20,50,90|80,180,300|4500,10500,18000'
&& m5c.l.join('|')==='15,40,80|20,40,60|300,700,1200|3300,7700,13200',
JSON.stringify(m5c));
// --- 10c. M6: варианты рыб (VARIANTS, forceVariant, карточка, full_variants) ---
// (1) данные: таблица VARIANTS и живой gold-шанс (variantChance → SHINY_CHANCE);
// снасть — нейтральная (простая наживка 1 ур.): буст вариантов B=0, шансы базовые
const m6v = await page.evaluate(() => {
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
save.lure = 0; save.lureLv = { 0: 1 };
SHINY_CHANCE = 0.07;
const out = {
n: VARIANTS.length,
mults: VARIANTS.map(v => v.mult).join(','),
copper: VARIANTS.find(v => v.id === 'copper').chance,
silver: VARIANTS.find(v => v.id === 'silver').chance,
quartz: VARIANTS.find(v => v.id === 'quartz').chance,
emerald: VARIANTS.find(v => v.id === 'emerald').chance,
diamond: VARIANTS.find(v => v.id === 'diamond').chance,
rainbow: VARIANTS.find(v => v.id === 'rainbow').chance,
goldLive: variantChance(VARIANTS.find(v => v.id === 'gold')),
boost0: variantBoost()
};
SHINY_CHANCE = 0.06;
save.lure = snap.lure; save.lureLv = snap.lv;
return out;
});
ok('M6 данные: VARIANTS 8 (mult 1/1.5/2/2.5/3/4/4.5/5), chance медь 0.12 / серебро 0.08 / кварц 0.07 / изумруд 0.01 / алмаз 0.007 / радуга 0.005, gold = живая SHINY_CHANCE (0.07); буст снасти у простой наживки 1 ур. = 0',
m6v.n === 8 && m6v.mults === '1,1.5,2,2.5,3,4,4.5,5' && m6v.copper === 0.12 && m6v.silver === 0.08
&& m6v.quartz === 0.07 && m6v.emerald === 0.01 && m6v.diamond === 0.007 && m6v.rainbow === 0.005
&& m6v.goldLive === 0.07 && m6v.boost0 === 0,
JSON.stringify(m6v));
// (2) forceVariant='silver': улов ×2, счётчик sv, бейдж и класс карточки
await page.evaluate(() => {
state = 'IDLE'; reel = null; biteFish = null;
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
save.ach = {}; save.caught = {}; save.coins = 1000;
forceVariant = 'silver';
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__m6W = reel.w; window.__m6Sp = reel.sp.id;
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m6s = await page.evaluate(() => ({
w: window.__m6W,
price: SPECIES.find(s => s.id === window.__m6Sp).price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
rec: save.caught[window.__m6Sp],
badgeHidden: document.getElementById('cardVariant').classList.contains('hidden'),
badge: document.getElementById('cardVariant').textContent,
cls: document.getElementById('card').classList.contains('silver-card'),
btnUpEl: document.getElementById('btnCardUp')
}));
const m6sBase = m6s.w * m6s.price * 2;
ok('M6 forceVariant=silver: монеты = w×price×2 (±5%), caught sv=1/n=1, бейдж «СЕРЕБРЯНАЯ» + silver-card, кнопки улучшения нет',
m6s.val >= Math.max(1, Math.round(m6sBase * 0.9)) && m6s.val <= Math.round(m6sBase * 1.1)
&& m6s.coins === 1000 + m6s.val
&& m6s.rec.n === 1 && m6s.rec.sv === 1 && m6s.rec.sn === 0 && m6s.rec.rb === 0
&& m6s.rec.cp === 0 && m6s.rec.qz === 0 && m6s.rec.em === 0 && m6s.rec.dm === 0
&& m6s.badgeHidden === false && m6s.badge === 'СЕРЕБРЯНАЯ' && m6s.cls === true && m6s.btnUpEl === null,
JSON.stringify(m6s));
await page.click('#btnCardOk');
await sleep(100);
// (3) forceVariant='rainbow': улов ×5, счётчик rb
await page.evaluate(() => {
state = 'IDLE'; reel = null; biteFish = null;
save.ach = {}; save.caught = {}; save.coins = 1000;
forceVariant = 'rainbow';
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__m6W = reel.w; window.__m6Sp = reel.sp.id;
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m6r = await page.evaluate(() => ({
w: window.__m6W,
price: SPECIES.find(s => s.id === window.__m6Sp).price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
rec: save.caught[window.__m6Sp],
badge: document.getElementById('cardVariant').textContent
}));
const m6rBase = m6r.w * m6r.price * 5;
ok('M6 forceVariant=rainbow: монеты = w×price×5 (±5%), caught rb=1/n=1',
m6r.val >= Math.max(1, Math.round(m6rBase * 0.9)) && m6r.val <= Math.round(m6rBase * 1.1)
&& m6r.coins === 1000 + m6r.val
&& m6r.rec.n === 1 && m6r.rec.rb === 1 && m6r.rec.sv === 0 && m6r.rec.sn === 0
&& m6r.badge === 'РАДУЖНАЯ',
JSON.stringify(m6r));
await page.click('#btnCardOk');
await sleep(100);
// (4) босс + forceVariant='rainbow': только ×5 босса (вариант у босса всегда normal),
// sv/rb не растут, бейдж варианта скрыт
await page.evaluate(() => {
state = 'IDLE'; reel = null; biteFish = null;
forceBoss = true; forceVariant = 'rainbow';
window.__m6Pick = pickSpecies;
pickSpecies = () => SPECIES.find(s => s.id === 'sturge');
save.ach = {}; save.caught = {}; save.coins = 1000;
save.quests = { date: todayStr(), list: [] };
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__m6W = reel.w;
bossPhase = 3; // фазовые обрывы (§9k) не суть: проверяем вариант, а не бой
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const m6b = await page.evaluate(() => ({
w: window.__m6W,
price: SPECIES.find(s => s.id === 'sturge').price,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
rec: save.caught.sturge,
bossCard: document.getElementById('card').classList.contains('boss-card'),
badgeHidden: document.getElementById('cardVariant').classList.contains('hidden')
}));
const m6bBase = m6b.w * m6b.price * 5;
ok('M6 босс+forceVariant=rainbow: монеты = w×price×5 (только ×5 босса, без ×5 rainbow), sv/rb не выросли, бейдж варианта скрыт',
m6b.val >= Math.max(1, Math.round(m6bBase * 0.9)) && m6b.val <= Math.round(m6bBase * 1.1)
&& m6b.coins === 1000 + m6b.val
&& m6b.rec.n === 1 && m6b.rec.sv === 0 && m6b.rec.rb === 0 && m6b.rec.sn === 0
&& m6b.badgeHidden === true && m6b.bossCard === true,
JSON.stringify(m6b));
await page.click('#btnCardOk');
await page.evaluate(() => {
forceBoss = false; forceVariant = null;
pickSpecies = window.__m6Pick;
});
await sleep(100);
// (5) границы ролла: rollVariant — независимые роллы по порядку VARIANTS:
// copper (0.12) → silver (0.08) → quartz (0.07) → gold (SHINY_CHANCE) → emerald (0.01) → diamond (0.007) → rainbow (0.005);
// пин Math.random по образцу §9j(b); каждый ролл — своё значение из последовательности
const m6roll = await page.evaluate(() => {
const mr = Math.random;
forceVariant = null;
forceWeather = null; weather = null; // M8: дождь ×2.5 сдвинул бы границы — исключаем
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
save.lure = 0; save.lureLv = { 0: 1 }; // M6+: нейтральная снасть — буст 0, границы базовые
SHINY_CHANCE = 0.06;
const seq = arr => { let i = 0; Math.random = () => arr[Math.min(i++, arr.length - 1)]; };
const out = {
copper: (seq([0.01]), rollVariant().id), // r<0.12 → copper
copperEdge: (seq([0.119]), rollVariant().id), // граница <0.12 → copper
notCopper: (seq([0.12]), rollVariant().id), // r=0.12: не copper (0.12<0.12 ложь) → normal
silver: (seq([0.12, 0.0799]), rollVariant().id), // copper нет, <0.08 → silver
quartz: (seq([0.13, 0.09, 0.03]), rollVariant().id), // copper/silver нет, <0.07 → quartz
gold: (seq([0.13, 0.09, 0.08, 0.05]), rollVariant().id), // copper/silver/quartz нет, <0.06 → gold
emerald: (seq([0.13, 0.09, 0.08, 0.07, 0.005]), rollVariant().id), // выше нет, <0.01 → emerald
diamond: (seq([0.13, 0.09, 0.08, 0.07, 0.02, 0.005]), rollVariant().id), // выше нет, <0.007 → diamond
rainbow: (seq([0.13, 0.09, 0.08, 0.07, 0.02, 0.01, 0.004]), rollVariant().id), // выше нет, <0.005 → rainbow
normal: (seq([0.9]), rollVariant().id) // все выше порогов → normal
};
Math.random = mr;
save.lure = snap.lure; save.lureLv = snap.lv;
return out;
});
ok('M6 границы ролла: r<0.12 → copper; [≥0.12, <0.08] → silver; [.., .., <0.07] → quartz; [.., .., .., <0.06] → gold; +emerald 0.01 / diamond 0.007 / rainbow 0.005; иначе → normal',
m6roll.copper === 'copper' && m6roll.copperEdge === 'copper' && m6roll.notCopper === 'normal'
&& m6roll.silver === 'silver' && m6roll.quartz === 'quartz' && m6roll.gold === 'gold'
&& m6roll.emerald === 'emerald' && m6roll.diamond === 'diamond' && m6roll.rainbow === 'rainbow'
&& m6roll.normal === 'normal',
JSON.stringify(m6roll));
// (6) миграция: старый сейв, в caught[id] нет счётчиков вариантов
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: { carass: { n: 2, best: 0.5, sn: 1 }, perch: { n: 4, best: 1.2 } },
casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m6mig = await page.evaluate(() => ({
coins: save.coins,
carass: save.caught.carass,
perch: save.caught.perch
}));
ok('M6 миграция: старый сейв (caught без счётчиков вариантов) → cp/sv/qz/sn/em/dm/rb=0, n/best сохранены',
m6mig.coins === 77
&& m6mig.carass.n === 2 && m6mig.carass.best === 0.5 && m6mig.carass.sn === 1
&& m6mig.carass.sv === 0 && m6mig.carass.rb === 0
&& m6mig.carass.cp === 0 && m6mig.carass.qz === 0 && m6mig.carass.em === 0 && m6mig.carass.dm === 0
&& m6mig.perch.n === 4 && m6mig.perch.best === 1.2 && m6mig.perch.sn === 0
&& m6mig.perch.sv === 0 && m6mig.perch.rb === 0
&& m6mig.perch.cp === 0 && m6mig.perch.qz === 0 && m6mig.perch.em === 0 && m6mig.perch.dm === 0,
JSON.stringify(m6mig));
// (7) карточка: бейдж и класс по варианту (showCard напрямую, как вызовы функций в §9j)
const m6card = await page.evaluate(() => {
const sp = SPECIES[0];
const res = {};
const probe = id => {
const v = VARIANTS.find(x => x.id === id);
showCard(sp, 1, 100, false, v, false);
const c = document.getElementById('card');
const cls = ['silver', 'gold', 'rainbow', 'copper', 'quartz', 'emerald', 'diamond']
.find(k => c.classList.contains(k + '-card')) || 'none';
res[id] = {
txt: document.getElementById('cardVariant').textContent,
hidden: document.getElementById('cardVariant').classList.contains('hidden'),
cls,
// SVG-портрет: showCard ставит href у #cardFishUse на symbol вида (sp = SPECIES[0] = carass)
href: document.getElementById('cardFishUse').getAttribute('href')
};
};
for (const id of VARIANTS.map(v => v.id)) probe(id);
document.getElementById('card').classList.add('hidden');
return res;
});
ok('M6 карточка: все варианты — бейдж «МЕДНАЯ»/«СЕРЕБРЯНАЯ»/«КВАРЦЕВАЯ»/«ЗОЛОТАЯ»/«ИЗУМРУДНАЯ»/«АЛМАЗНАЯ»/«РАДУЖНАЯ» + класс <id>-card; normal — бейдж скрыт; SVG-портрет: href #cardFishUse = #fish-carass (вид SPECIES[0])',
m6card.copper.hidden === false && m6card.copper.txt === 'МЕДНАЯ' && m6card.copper.cls === 'copper'
&& m6card.silver.hidden === false && m6card.silver.txt === 'СЕРЕБРЯНАЯ' && m6card.silver.cls === 'silver'
&& m6card.quartz.hidden === false && m6card.quartz.txt === 'КВАРЦЕВАЯ' && m6card.quartz.cls === 'quartz'
&& m6card.gold.hidden === false && m6card.gold.txt === 'ЗОЛОТАЯ' && m6card.gold.cls === 'gold'
&& m6card.emerald.hidden === false && m6card.emerald.txt === 'ИЗУМРУДНАЯ' && m6card.emerald.cls === 'emerald'
&& m6card.diamond.hidden === false && m6card.diamond.txt === 'АЛМАЗНАЯ' && m6card.diamond.cls === 'diamond'
&& m6card.rainbow.hidden === false && m6card.rainbow.txt === 'РАДУЖНАЯ' && m6card.rainbow.cls === 'rainbow'
&& m6card.normal.hidden === true && m6card.normal.cls === 'none'
&& Object.values(m6card).every(r => r.href === '#fish-carass'),
JSON.stringify(m6card));
// (8) full_variants: все виды во всех 8 вариантах → +5000 ровно один раз, coinPct, повторно — без выплаты
// Этап 45: в сумму coinPct входит swamp_first (локация 4) — пин 0.16→0.18
const m6fv = await page.evaluate(() => {
const locSnap = save.locations.slice();
save.locations = locSnap.indexOf(4) >= 0 ? locSnap : locSnap.concat([4]); // Этап 45: swamp_first даёт +0.02
save.ach = {};
save.casts = 0; save.earned = 0; save.snaps = 0; save.questsDoneTotal = 0;
save.bossBeaten = false; save.bossCount = 0;
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
save.caught = {};
for (const s of SPECIES) save.caught[s.id] = { n: 8, best: 1, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 1 };
const coinsBefore = save.coins;
checkAchievements();
const a1 = { coins: save.coins, pct: achBonus().coinPct, got: !!save.ach.full_variants };
checkAchievements();
const a2 = { coins: save.coins };
save.locations = locSnap; // не загрязнять следующие тесты
return { coinsBefore, a1, a2 };
});
ok('M6 full_variants: все виды во всех 8 вариантах → +5000 ровно один раз, achBonus().coinPct включает 0.05 (сумма 0.18: +first_silver/first_rainbow, Этап 22, + swamp_first, Этап 45), повторный вызов — без выплаты',
m6fv.a1.got === true
&& m6fv.a1.coins === m6fv.coinsBefore + 5000
&& Math.abs(m6fv.a1.pct - 0.18) < 1e-9
&& m6fv.a2.coins === m6fv.a1.coins,
JSON.stringify(m6fv));
// (9) буст снасти (M6+): тип наживки (rare-стат) и её уровень повышают шанс особых вариантов,
// более редкие — сильнее (множитель ×(1+B·r), r — номер в лестнице); простая наживка 1 ур. — B=0
const m6boost = await page.evaluate(() => {
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
forceWeather = null; weather = null;
SHINY_CHANCE = 0.06;
const ch = id => variantChance(VARIANTS.find(x => x.id === id));
const out = {};
save.lure = 0; save.lureLv = { 0: 1 };
out.hookB = variantBoost(); // простая наживка 1 ур. — 0
out.hookCopper = ch('copper'); // 0.12 — без буста
save.lure = 1; // червь rare 0.2 → B=0.03
out.wormB = variantBoost();
out.wormCopper = ch('copper'); // 0.12×1.03
out.wormRainbow = ch('rainbow'); // 0.005×1.21 — редким больше
out.wormRatio = out.wormRainbow / out.wormCopper / (0.005 / 0.12); // (1+7B)/(1+B) > 1
save.lure = 3; save.lureLv = { 0: 1, 3: 1 }; // блёстка rare 2.0 → B=0.3
out.spinB = variantBoost();
out.spinCopper = ch('copper'); // 0.12×1.3
out.spinGold = ch('gold'); // 0.06×2.2
out.spinRainbow = ch('rainbow'); // 0.005×3.1
out.spinRatio = out.spinRainbow / out.spinCopper / (0.005 / 0.12); // ≈2.38 — растёт с B
save.lureLv = { 0: 1, 3: 4 }; // + 4 ур. наживки → rare 3.0 (+1.0 от 3 ур.), B=0.6
out.spin4B = variantBoost();
out.spin4Rainbow = ch('rainbow'); // 0.005×5.2
// ролл с бустом: червь ловит медью r=0.12 (без буста — normal, см. §(5) notCopper)
const mr = Math.random; let i = 0; Math.random = () => [0.12][Math.min(i++, 0)];
forceVariant = null;
out.rollWorm = rollVariant().id; // copper
Math.random = mr;
save.lure = snap.lure; save.lureLv = snap.lv;
SHINY_CHANCE = 0.06;
return out;
});
ok('M6 буст снасти: простая 1 ур. B=0 (copper 0.12); червь B=0.03 — copper 0.1236, rainbow 0.00605 (редким больше); блёстка B=0.3 — copper 0.156, gold 0.132, rainbow 0.0155; 4 ур. (rare 3.0) B=0.6 — rainbow 0.026; отношение «радуга/медь» растёт с B; ролл r=0.12 с червя → copper',
m6boost.hookB === 0 && Math.abs(m6boost.hookCopper - 0.12) < 1e-9
&& Math.abs(m6boost.wormB - 0.03) < 1e-9 && Math.abs(m6boost.wormCopper - 0.1236) < 1e-9
&& Math.abs(m6boost.wormRainbow - 0.00605) < 1e-9 && m6boost.wormRatio > 1
&& Math.abs(m6boost.spinB - 0.3) < 1e-9 && Math.abs(m6boost.spinCopper - 0.156) < 1e-9
&& Math.abs(m6boost.spinGold - 0.132) < 1e-9 && Math.abs(m6boost.spinRainbow - 0.0155) < 1e-9
&& m6boost.spinRatio > m6boost.wormRatio
&& Math.abs(m6boost.spin4B - 0.6) < 1e-9 && Math.abs(m6boost.spin4Rainbow - 0.026) < 1e-9
&& m6boost.rollWorm === 'copper',
JSON.stringify(m6boost));
// сброс тест-форсов
await page.evaluate(() => { forceBite = false; forceVariant = null; forceBoss = false; SHINY_CHANCE = 0.06; });
// Ревизия «время не идёт под неигровыми экранами»: frame() гоняет updateWeather только
// при !uiOpen, а после reload в §10c(6) игра осталась в MENU (uiOpen=true). Входим в
// геймплей, иначе естественные таймеры погоды (5)-(8) не тикают.
await page.click('#btnPlay');
await sleep(150);
// --- 10d. Погода: дождь и туман ---
// (1) данные: WEATHER и константы таймингов
const w1 = await page.evaluate(() => ({
rm: WEATHER.rain.variantMult, bm: WEATHER.fog.biteMult,
gmin: WEATHER_GAP_MIN, gmax: WEATHER_GAP_MAX, dur: WEATHER_DURATION,
fw: forceWeather, sh: SHINY_CHANCE
}));
ok('M8 данные: WEATHER.rain.variantMult===3, WEATHER.fog.biteMult===1.5, gap 120–240 с, длительность 120 с, forceWeather===null',
w1.rm === 3 && w1.bm === 1.5 && w1.gmin === 120 && w1.gmax === 240 && w1.dur === 120 && w1.fw === null,
JSON.stringify(w1));
await page.evaluate(() => { forceWeather = null; weather = null; }); // естественная погода из сессии не должна мешать
// (2) дождь: все особые варианты ярче (×2.5)
const w2 = await page.evaluate(() => {
forceWeather = 'rain';
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
save.lure = 0; save.lureLv = { 0: 1 }; // M6+: нейтральная снасть — буст 0, пины базовых шансов
SHINY_CHANCE = 0.06;
const ch = id => variantChance(VARIANTS.find(x => x.id === id));
const out = { cp: ch('copper'), s: ch('silver'), qz: ch('quartz'), g: ch('gold'),
em: ch('emerald'), dm: ch('diamond'), r: ch('rainbow'),
toast: t('wx_rain') };
forceWeather = null;
save.lure = snap.lure; save.lureLv = snap.lv;
return out;
});
ok('M8 дождь: variantChance ×3 — copper 0.36, silver 0.24, quartz 0.21, gold 0.18 (SHINY_CHANCE 0.06), emerald 0.03, diamond 0.021, rainbow 0.015; тост «Дождь: шанс особых рыб ×3»',
Math.abs(w2.cp - 0.36) < 1e-9 && Math.abs(w2.s - 0.24) < 1e-9 && Math.abs(w2.qz - 0.21) < 1e-9
&& Math.abs(w2.g - 0.18) < 1e-9 && Math.abs(w2.em - 0.03) < 1e-9 && Math.abs(w2.dm - 0.021) < 1e-9
&& Math.abs(w2.r - 0.015) < 1e-9
&& w2.toast.includes('особых') && w2.toast.includes('×3'),
JSON.stringify(w2));
// (3) туман: клёв чаще ×1.5 (отношение к базовому при том же dayPhase)
const w3 = await page.evaluate(() => {
setDayTime(0.3);
weather = null; forceWeather = null;
const base = biteChance();
forceWeather = 'fog';
const fog = biteChance();
forceWeather = null;
return { base, fog, ratio: fog / base };
});
ok('M8 туман: biteChance к базовому (тот же dayPhase) ровно ×1.5',
w3.base > 0 && Math.abs(w3.ratio - 1.5) < 1e-12,
JSON.stringify(w3));
// (4) weatherActive — единственная точка чтения: форс приоритетнее, иначе естественная
const w4 = await page.evaluate(() => {
forceWeather = 'rain'; weather = null; const a = weatherActive();
forceWeather = null; weather = 'fog'; const b = weatherActive();
forceWeather = null; weather = null; const c = weatherActive();
return { a, b, c };
});
ok('M8 weatherActive: форс → «rain», естественная → «fog», без погоды → null',
w4.a === 'rain' && w4.b === 'fog' && w4.c === null,
JSON.stringify(w4));
// (5) естественный старт: gap 0.5 с → событие, weatherT≈120, тост «Дождь/Туман»
await page.evaluate(() => { forceWeather = null; weather = null; weatherT = 0; weatherGap = 0.5; });
await waitCond(page, () => weather !== null); // событие началось (weatherT=WEATHER_DURATION)
const w5 = await page.evaluate(() => ({ w: weather, t: weatherT, toast: document.getElementById('toast').textContent }));
ok('M8 естественный старт: gap 0.5 с → weather rain|fog, weatherT≈120 (±2), тост «Дождь…/Туман…»',
(w5.w === 'rain' || w5.w === 'fog') && Math.abs(w5.t - 120) <= 2
&& (w5.toast.includes('Дождь') || w5.toast.includes('Туман')),
JSON.stringify(w5));
// (6) естественный конец: weatherT 0.5 с → событие закончилось, новый gap 120–240
await page.evaluate(() => { forceWeather = null; weather = 'rain'; weatherT = 0.5; });
await waitCond(page, () => weather === null); // событие закончилось (weatherGap перезаролен)
const w6 = await page.evaluate(() => ({
w: weather, gap: weatherGap,
inRange: weatherGap >= WEATHER_GAP_MIN - 2 && weatherGap <= WEATHER_GAP_MAX
}));
ok('M8 естественный конец: weather===null, новый weatherGap из диапазона 120–240 с (−2 с на тик вниз)',
w6.w === null && w6.inRange === true,
JSON.stringify(w6));
// (7) форс замораживает таймеры; после снятия форса weatherT тикает
await page.evaluate(() => { forceWeather = 'rain'; weather = 'rain'; weatherT = 120; });
await sleep(1500);
const w7frozen = await page.evaluate(() => weatherT);
await page.evaluate(() => { forceWeather = null; weather = 'rain'; weatherT = 100; });
await waitCond(page, () => weatherT < 99); // без форса таймер пошёл (не ждём «1.5 с» реальным временем)
const w7tick = await page.evaluate(() => weatherT);
ok('M8 таймеры: под форсом weatherT стабильно 120 (1.5 с), без форса тикает вниз (100 → <99)',
w7frozen === 120 && w7tick < 100 && Math.abs(w7tick - 98.5) <= 1.0,
JSON.stringify({ frozen: w7frozen, tick: w7tick }));
// (8) HUD: пилюля #weather — видима под форсом, иконка rain↔fog, скрыта без погоды
await page.evaluate(() => { forceWeather = 'rain'; weather = null; });
await sleep(200);
const w8a = await page.evaluate(() => {
const el = document.getElementById('weather');
return { hidden: el.classList.contains('hidden'), txt: el.textContent, icon: WEATHER.rain.icon };
});
await page.evaluate(() => { forceWeather = 'fog'; });
await sleep(200);
const w8b = await page.evaluate(() => {
const el = document.getElementById('weather');
return { hidden: el.classList.contains('hidden'), txt: el.textContent, icon: WEATHER.fog.icon };
});
await page.evaluate(() => { forceWeather = null; weather = null; });
await sleep(200);
const w8c = await page.evaluate(() => document.getElementById('weather').classList.contains('hidden'));
ok('M8 HUD: force rain — пилюля видима с 🌧️, fog — 🌫️ (иконка переключилась), без погоды — hidden',
w8a.hidden === false && w8a.txt.includes(w8a.icon)
&& w8b.hidden === false && w8b.txt.includes(w8b.icon)
&& w8c === true,
JSON.stringify({ rain: w8a.txt, fog: w8b.txt, hidden: w8c }));
// сброс тест-форсов и состояния погоды
await page.evaluate(() => { forceWeather = null; weather = null; });
// --- 10d2. Этап 58: ночной и погодный декор (чисто визуал) ---
// (1) светлячки/мыши собираются в initScenery по локации: Река/Озёра/Болото — есть, Море/Океан — пусто
const nd1 = await page.evaluate(() => {
const snap = { loc: save.location };
const SWAMP = ['#5ff2ff', '#57ffa8', '#d17bff', '#ffe07a']; // AMBIENT_FISH[4].palette
const out = {};
for (const i of [0, 1, 2, 3, 4]) {
save.location = i; initScenery();
out['loc' + i] = {
fly: fireflies.length, bats: bats.length,
// цвета светлячков: у Болота — из болотной палитры, у Реки/Озёр — тёплые (не из неё)
cols: fireflies.map(f => f.color),
swampCols: fireflies.filter(f => SWAMP.includes(f.color)).length
};
}
save.location = snap.loc; initScenery(); // вернуть сцену игрока
return out;
});
ok('Этап 58 декор: fireflies>0 и bats>0 у Реки/Озёр/Болота (6–14 / 4–8 светлячков, 2–3 мыши), у Моря/Океана — 0/0',
nd1.loc0.fly >= 6 && nd1.loc0.fly <= 10 && nd1.loc0.bats >= 2 && nd1.loc0.bats <= 3
&& nd1.loc1.fly >= 4 && nd1.loc1.fly <= 8 && nd1.loc1.bats >= 2 && nd1.loc1.bats <= 3
&& nd1.loc4.fly >= 8 && nd1.loc4.fly <= 14 && nd1.loc4.bats >= 2 && nd1.loc4.bats <= 3
&& nd1.loc2.fly === 0 && nd1.loc2.bats === 0 && nd1.loc3.fly === 0 && nd1.loc3.bats === 0,
JSON.stringify(nd1));
// Поведенческий гейт: цвета светлячков РАЗЛИЧАЮТ локации (перепутывание Река↔Болото падает,
// хотя диапазоны количества у них пересекаются). Болото — все цвета из болотной палитры, Река — ни один.
ok('Этап 58 декор: цвета светлячков различают локации — Болото все из болотной палитры, Река — ни один',
nd1.loc4.fly > 0 && nd1.loc4.swampCols === nd1.loc4.fly
&& nd1.loc0.fly > 0 && nd1.loc0.swampCols === 0,
JSON.stringify({ loc4: nd1.loc4.cols, loc0: nd1.loc0.cols }));
// (2) бюджет альфы тумана над ватерлинией — именованная константа FOG_TOP_ALPHA ≤ 0.12
const nd2 = await page.evaluate(() => ({ a: FOG_TOP_ALPHA }));
ok('Этап 58 туман: FOG_TOP_ALPHA объявлена и ≤ 0.12 (поплавок/HUD читаемы над водой)',
typeof nd2.a === 'number' && nd2.a > 0 && nd2.a <= 0.12, JSON.stringify(nd2));
// (3) рябь от дождя: под forceWeather='rain' кольца спавнятся, без погоды — нет
const nd3 = await page.evaluate(() => {
forceWeather = 'rain'; weather = null; rainRipple = [];
for (let k = 0; k < 10; k++) updateDecor(0.05);
const rain = rainRipple.length;
forceWeather = null; rainRipple = [];
for (let k = 0; k < 10; k++) updateDecor(0.05);
const none = rainRipple.length;
rainRipple = []; // cleanup
return { rain, none };
});
ok('Этап 58 рябь дождя: forceWeather=rain → rainRipple.length>0, без погоды — 0 (игровые ripples[] не тронуты)',
nd3.rain > 0 && nd3.none === 0, JSON.stringify(nd3));
// --- 10e. Скины лодки и поплавка ---
// (1) данные: BOAT_SKINS и BOBBER_SKINS
const sk1 = await page.evaluate(() => ({
boat: BOAT_SKINS.map(s => s.id + ':' + s.cost).join(','),
bob: BOBBER_SKINS.map(s => s.id + ':' + s.cost).join(',')
}));
ok('скины данные: BOAT_SKINS 5 записей (id 0..4, cost 0/800/2000/6000/18000), BOBBER_SKINS 5 записей (id 0..4, cost 0/500/1500/4000/12000)',
sk1.boat === '0:0,1:800,2:2000,3:6000,4:18000' && sk1.bob === '0:0,1:500,2:1500,3:4000,4:12000',
JSON.stringify(sk1));
// (2) миграция: старый сейв без полей скинов → дефолты
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const sk2 = await page.evaluate(() => ({
bs: save.boatSkins.join(','), bsCur: save.boatSkin,
ob: save.bobberSkins.join(','), obCur: save.bobberSkin
}));
ok('скины миграция: старый сейв (нет boatSkins/boatSkin/bobberSkins/bobberSkin) → массивы [0], экипированные 0/0',
sk2.bs === '0' && sk2.bsCur === 0 && sk2.ob === '0' && sk2.obCur === 0,
JSON.stringify(sk2));
// (3) битый сейв: boatSkin вне 0..4 → 0; экипированный не в купленных → 0
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 1, earned: 10,
boatSkins: [0], boatSkin: 99, bobberSkins: [0], bobberSkin: 1
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const sk3 = await page.evaluate(() => ({
bs: save.boatSkins.join(','), bsCur: save.boatSkin,
ob: save.bobberSkins.join(','), obCur: save.bobberSkin
}));
ok('скины битый сейв: boatSkin=99 → 0; bobberSkin=1 при bobberSkins=[0] (не куплено) → 0',
sk3.bs === '0' && sk3.bsCur === 0 && sk3.ob === '0' && sk3.obCur === 0,
JSON.stringify(sk3));
// (4) покупка: 1000 монет, лавка → вкладка скинов → «Красная лодка» (800)
const skSt0 = await page.evaluate(() => state);
if (skSt0 === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
await page.evaluate(() => { save.coins = 1000; });
await page.click('#btnShop');
await sleep(150);
await page.click('#tabSkins');
await sleep(150);
await page.click('button[data-act="buyskin"][data-type="boat"][data-i="1"]');
const sk4 = await page.evaluate(() => {
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const row = items.find(el => el.querySelector('.itemname').textContent === 'Красная лодка');
const btn = row ? row.querySelector('button') : null;
return {
coins: save.coins,
bs: save.boatSkins.join(','), bsCur: save.boatSkin,
btnTxt: btn ? btn.textContent : '',
btnDis: btn ? btn.disabled : false
};
});
ok('скины покупка: 1000 монет → «Красная лодка» (800) — coins=200, boatSkins=[0,1], авто-экип boatSkin=1, кнопка «Выбрано ✓» disabled',
sk4.coins === 200 && sk4.bs === '0,1' && sk4.bsCur === 1
&& sk4.btnTxt === 'Выбрано ✓' && sk4.btnDis === true,
JSON.stringify(sk4));
// (5) не хватает монет: 100 < 2000 («Лодка-домик») — покупка не прошла, тост
await page.evaluate(() => { save.coins = 100; });
await page.click('button[data-act="buyskin"][data-type="boat"][data-i="2"]');
const sk5 = await page.evaluate(() => ({
coins: save.coins,
bs: save.boatSkins.join(','), bsCur: save.boatSkin,
toast: document.getElementById('toast').textContent
}));
ok('скины не хватает монет: 100 < 2000 («Лодка-домик») — coins=100, boatSkins не изменился, тост «Не хватает монет 🪙»',
sk5.coins === 100 && sk5.bs === '0,1' && sk5.bsCur === 1 && sk5.toast.includes('Не хватает монет 🪙'),
JSON.stringify(sk5));
// (6) выбор: покупка bobber 1 и 2 (авто-экип), затем «Выбрать» id=1 — монеты не тратятся
await page.evaluate(() => { save.coins = 2000; });
await page.click('button[data-act="buyskin"][data-type="bobber"][data-i="1"]');
const sk6a = await page.evaluate(() => ({ coins: save.coins, ob: save.bobberSkins.join(','), obCur: save.bobberSkin }));
await page.click('button[data-act="buyskin"][data-type="bobber"][data-i="2"]');
const sk6b = await page.evaluate(() => ({ coins: save.coins, ob: save.bobberSkins.join(','), obCur: save.bobberSkin }));
await page.click('button[data-act="select"][data-type="bobber"][data-i="1"]');
const sk6 = await page.evaluate(() => ({ coins: save.coins, ob: save.bobberSkins.join(','), obCur: save.bobberSkin }));
ok('скины выбор: bobber 1 (500) → bobberSkin=1, bobber 2 (1500) → bobberSkin=2, «Выбрать» id=1 → bobberSkin=1, coins не изменились',
sk6a.coins === 1500 && sk6a.ob === '0,1' && sk6a.obCur === 1
&& sk6b.coins === 0 && sk6b.ob === '0,1,2' && sk6b.obCur === 2
&& sk6.coins === 0 && sk6.ob === '0,1,2' && sk6.obCur === 1,
JSON.stringify({ a: sk6a, b: sk6b, c: sk6 }));
// (7) вкладка и смоук: #tabSkins, группы «Лодка»/«Поплавок», 10 названий (5 лодок + 5 поплавков, этап 57);
// отрисовка скинов 2 (~30 кадров) не даёт pageerror
const sk7a = await page.evaluate(() => document.getElementById('tabSkins') !== null);
await page.click('#tabSkins');
await sleep(150);
const sk7b = await page.evaluate(() => {
const titles = Array.prototype.slice.call(document.querySelectorAll('#shopBody .section-title')).map(e => e.textContent);
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
// названия — прямо из таблиц (не хардкод: при добавлении скина тест не разъедется)
const names = BOAT_SKINS.map(s => s.name).concat(BOBBER_SKINS.map(s => s.name)).concat(LINE_SKINS.map(s => s.name));
return {
boat: titles.includes('Лодка'), bobber: titles.includes('Поплавок'), line: titles.includes('Леска'),
count: items.length, exp: BOAT_SKINS.length + BOBBER_SKINS.length + LINE_SKINS.length,
found: names.map(n => items.some(el => el.querySelector('.itemname').textContent === n))
};
});
await page.evaluate(() => { closeShop(); save.boatSkin = 2; save.bobberSkin = 2; });
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(400); await page.mouse.up();
await waitState(page, 'WAIT', 4000); // поплавок рисуется только в WAIT/CAST/BITE/REEL
await page.evaluate(() => { biteTimer = 999; }); // без клёва — смоук остаётся в WAIT
const skErrBefore = pageErrors.length;
await sleep(1000); // ~30 кадров: лодка-домик + неоновый поплавок (shadowBlur save/restore)
const sk7c = pageErrors.length;
ok('скины вкладка и смоук: #tabSkins есть, группы «Лодка»/«Поплавок»/«Леска» (этап 59), 13 названий видны; boatSkin=2 + bobberSkin=2 ~1 с — нет pageerror',
sk7a === true && sk7b.boat === true && sk7b.bobber === true && sk7b.line === true && sk7b.count === sk7b.exp
&& sk7b.found.every(x => x === true) && sk7c === skErrBefore,
JSON.stringify({ tab: sk7a, titles: sk7b.boat + '/' + sk7b.bobber + '/' + sk7b.line, count: sk7b.count, exp: sk7b.exp, found: sk7b.found, err: sk7c - skErrBefore }));
// этап 57: смоук всех новых эффектов (dragon/wake/firefly/bell) — по ~0.25 с на каждый
const skErrB2 = pageErrors.length;
for (const [b,bb] of [[3,3],[4,4],[3,4],[4,3]]) {
await page.evaluate(([b,bb]) => { save.boatSkin = b; save.bobberSkin = bb; }, [b,bb]);
await sleep(250);
}
const skErrA2 = pageErrors.length;
ok('Этап 57 смоук новых скинов: boat 3/4 (dragon/wake) + bobber 3/4 (firefly/bell) — нет pageerror',
skErrA2 === skErrB2, JSON.stringify({ err: skErrA2 - skErrB2 }));
// сброс состояния скинов к дефолту
await page.evaluate(() => {
save.boatSkins = [0]; save.boatSkin = 0;
save.bobberSkins = [0]; save.bobberSkin = 0;
persist();
});
// --- 10e2. Этап 63: внешний вид снасти (чистый визуал) — бланк от RODS.look, крючок от LURES.hook ---
// (1) данные: у каждой записи look/hook, цвета — hex-строки; фенки: у rod 3 glint+band, у lure 3 gold,
// у lure 1 bait (червь), у lure 2 bead (бусина-глаз) + Э64 body (тельце воблера),
// у lure 3 Э64 blade (лепесток блёстки); у базовых записей фенки нет
const e63d = await page.evaluate(() => ({
rn: RODS.length, ln: LURES.length,
rodLook: RODS.every(r => r.look && /^#[0-9a-f]{6}$/i.test(r.look.c) && typeof r.look.w === 'number' && r.look.w > 0),
hook: LURES.every(l => l.hook && /^#[0-9a-f]{6}$/i.test(l.hook.c)),
fx: RODS[3].look.fx === 'glint' && RODS[3].look.band === true && RODS[2].look.band === true &&
LURES[3].hook.fx === 'gold' && typeof LURES[1].hook.bait === 'string' && typeof LURES[2].hook.bead === 'string',
e64: /^#[0-9a-f]{6}$/i.test(LURES[2].hook.body) && /^#[0-9a-f]{6}$/i.test(LURES[3].hook.blade) &&
!LURES[1].hook.body && !LURES[1].hook.blade && !LURES[0].hook.body && !LURES[0].hook.blade,
base: !RODS[0].look.fx && !RODS[0].look.band && !LURES[0].hook.fx && !LURES[0].hook.bait && !LURES[0].hook.bead
}));
ok('Э63 данные: у 4 RODS есть look (#hex, w>0), у 4 LURES есть hook (#hex); фенки: rod3 glint+band, rod2 band, lure3 gold, lure1 bait, lure2 bead; Э64: lure2 body, lure3 blade (hex), у остальных нет; у базовых — нет',
e63d.rn === 4 && e63d.ln === 4 && e63d.rodLook && e63d.hook && e63d.fx && e63d.e64 && e63d.base, JSON.stringify(e63d));
// (2) rod()/lure() отдают косметику вместе со статами (Object.assign-копия effRod/effLure)
const e63p = await page.evaluate(() => {
const snap = { rod: save.rod, lure: save.lure };
save.rod = 3; save.lure = 3;
const out = { rodFx: rod().look.fx, hookFx: lure().hook.fx };
save.rod = snap.rod; save.lure = snap.lure;
return out;
});
ok('Э63 rod()/lure(): look/hook проходят через effRod/effLure (rod().look.fx=glint, lure().hook.fx=gold)',
e63p.rodFx === 'glint' && e63p.hookFx === 'gold', JSON.stringify(e63p));
// (3) смоук в WAIT: все 4 бланка и 4 крючка ~0.15 с каждый — нет pageerror; снасть восстанавливается
await waitCond(page, "() => state === 'WAIT'", 4000);
const e63Snap = await page.evaluate(() => ({
rod: save.rod, lure: save.lure, rl: Object.assign({}, save.rodLv), ll: Object.assign({}, save.lureLv)
}));
const e63ErrB = pageErrors.length;
for (let r = 0; r < 4; r++) {
await page.evaluate(([r]) => { save.rod = r; save.lure = r; biteTimer = 999; }, [r]);
await sleep(150);
}
const e63ErrA = pageErrors.length;
await page.evaluate((s) => {
save.rod = s.rod; save.lure = s.lure; save.rodLv = s.rl; save.lureLv = s.ll;
}, e63Snap);
ok('Э63 смоук: rod/lure 0..3 в WAIT (бланк glint/band + крючки bait/bead/gold) ~1.2 с — нет pageerror, снасть восстановлена',
e63ErrA === e63ErrB, JSON.stringify({ err: e63ErrA - e63ErrB }));
// --- 10f. Локации: Озёра + Море + Океан (Этап 35: у каждой локации только свои виды, Этап 36: +Океан) ---
// (1) данные: 38 видов в SPECIES (8 речных + 10 озёрных + 5 морских + 9 океанских + 6 болотных, этапы 45/55), LOCATIONS (5 записей, по 6 hex в water)
const lc1 = await page.evaluate(() => {
const g = id => SPECIES.find(s => s.id === id);
const z = g('zander'), c = g('chuch'), k = g('crayfish');
const b = g('bleak'), r = g('roundel'), e = g('eelpout'), t = g('tench'), n = g('navaga');
const rd = g('rudd'), ci = g('cisco');
const h = g('herring'), m = g('mackerel'), f = g('flounder'), tu = g('tuna'), sw = g('swordfish');
const mo = g('moray'), gr = g('grouper'), turt = g('turtle'), an = g('angler'), oc = g('octopus');
const ma = g('manta'), sh = g('shark'), sq = g('squid'), wh = g('whale');
// Этап 45: болотные виды (в КОНЦЕ SPECIES, у каждого поле glow); Этап 56: +которыбка (6-й, экс-целакант)
const gf = g('glowfin'), mf = g('moonfish'), ef = g('emberfin'), vl = g('violetlure'), sf = g('spiritfin'), mwf = g('meowfish');
const p = s => s ? [s.name, s.rarity, s.price, s.wMin, s.wMax].join(',') : 'нет';
return {
n: SPECIES.length, z: p(z), c: p(c), k: p(k),
b: p(b), r: p(r), e: p(e), t: p(t), n2: p(n),
rd: p(rd), ci: p(ci), h: p(h), m: p(m), f: p(f), tu: p(tu), sw: p(sw),
mo: p(mo), gr: p(gr), turt: p(turt), an: p(an), oc: p(oc),
ma: p(ma), sh: p(sh), sq: p(sq), wh: p(wh),
gf: p(gf), mf: p(mf), ef: p(ef), vl: p(vl), sf: p(sf), mwf: p(mwf),
// порядок: 0..7 (река) не сдвигаются, новые виды — только в КОНЦЕ массива
// (Этап 45: океан — явный slice(23,32), иначе swamp-виды попали бы в oceanOrder)
first8: SPECIES.slice(0, 8).map(s => s.id).join(','),
lakeOrder: SPECIES.slice(8, 18).map(s => s.id).join(','),
seaOrder: SPECIES.slice(18, 23).map(s => s.id).join(','),
oceanOrder: SPECIES.slice(23, 32).map(s => s.id).join(','),
swampOrder: SPECIES.slice(32).map(s => s.id).join(','),
// у каждого вида есть обязательные поля (nameEn — для EN-ветки trName; поле emoji убрано в этапе 42 — портреты из SVG-спрайта)
fieldsOk: SPECIES.every(s => s.id && s.name && s.nameEn && typeof s.rarity === 'number' &&
typeof s.weight === 'number' && typeof s.minDist === 'number' && typeof s.strength === 'number' &&
typeof s.wMin === 'number' && typeof s.wMax === 'number' && s.wMax > s.wMin &&
typeof s.price === 'number' && typeof s.hue === 'number'),
// Этап 45: glow болотных видов — строка #rrggbb; Этап 56: 6 видов, редкости 0,1,2,3,4,4 (два r4 — дух и которыбка)
swampGlow: [gf, mf, ef, vl, sf, mwf].every(s => s && typeof s.glow === 'string' && /^#[0-9a-fA-F]{6}$/.test(s.glow)),
swampRar: [gf, mf, ef, vl, sf, mwf].map(s => s && s.rarity).join(','),
locs: LOCATIONS.length,
costs: LOCATIONS.map(l => l.cost).join(','),
names: LOCATIONS.map(l => l.name).join(','),
water: LOCATIONS.every(l => Array.isArray(l.water) && l.water.length === 6 &&
l.water.every(h2 => typeof h2 === 'string' && /^#[0-9a-fA-F]{6}$/.test(h2))),
// SVG-спрайт портретов (блок FISH-SVG, tools/embed-fish.mjs): по symbol на каждый вид
sprites: document.querySelectorAll('#fishSprites symbol').length,
spriteIdsOk: SPECIES.every(s => document.getElementById('fish-' + s.id) !== null)
};
});
ok('локации данные: SPECIES=38 (8 река + 10 озёра + 5 море + 9 океан + 6 болото, этапы 35/36/45/55) — zander «Судак» r2 8🪙, crayfish r3 40🪙, navaga r3 50🪙; озёрные новинки rudd «Краснопёрка» r1 5🪙, cisco «Сиг» r2 18🪙; морские herring «Сельдь» r0 8🪙, mackerel «Скумбрия» r1 18🪙, flounder «Камбала» r2 33🪙, tuna «Тунец» r3 46🪙, swordfish «Рыба-меч» r4 81🪙; океанские moray «Гигантская мурена» r0 5🪙, grouper «Горбуна» r0 5🪙, turtle «Морская черепаха» r1 9🪙, angler «Удильщик» r2 21🪙, octopus «Гигантский осьминог» r2 23🪙, manta «Скат-манта» r3 29🪙, shark «Большая белая акула» r3 31🪙, squid «Гигантский кальмар» r4 37🪙, whale «Синий кит» r4 67🪙 40–100 кг; болотные glowfin «Светлячок» r0 8🪙, moonfish «Лунная рыбка» r1 13🪙, emberfin «Изумрудный светляк» r2 20🪙, violetlure «Фиалковый огонёк» r3 38🪙, spiritfin «Дух болота» r4 70🪙, meowfish «Которыбка» r4 50🪙 20–80 кг (милая рыба-кот, босс Болота, Этап 56) (glow — #rrggbb; редкости 0,1,2,3,4,4 — два r4, Этап 56); этап 38: лестница веса (Озёра ×8 / Море ×6, меч ×4 / Океан ×4, кит ×2; Река не меняется); этап 39: цены Озёр/Моря/Океана урезаны (Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5 — по дню; Река не меняется); первые 8 id не сдвинуты, новые — в конце; все поля заполнены; LOCATIONS 5 (Река 0 / Озёра 500 / Море 100000 / Океан 300000 / Волшебное болото 10000 — Этап 53), у каждой 6 hex в water; SVG-спрайт: 38 symbol, id на каждый вид',
lc1.n === 38 && lc1.z === 'Судак,2,8,8,48' && lc1.c === 'Хариус,1,6,1.6,16' && lc1.k === 'Гигантский рак,3,40,1.6,8'
&& lc1.b === 'Плотва,0,4,0.4,4.8' && lc1.r === 'Густера,0,5,0.8,7.2' && lc1.e === 'Вьюн,1,11,0.4,3.2'
&& lc1.t === 'Линь,2,16,2.4,32' && lc1.n2 === 'Навага,3,50,2.4,20'
&& lc1.rd === 'Краснопёрка,1,5,0.8,9.6' && lc1.ci === 'Сиг,2,18,4,28'
&& lc1.h === 'Сельдь,0,8,0.6,4.8' && lc1.m === 'Скумбрия,1,18,1.8,15' && lc1.f === 'Камбала,2,33,3,30'
&& lc1.tu === 'Тунец,3,46,12,72' && lc1.sw === 'Рыба-меч,4,81,20,80'
&& lc1.mo === 'Гигантская мурена,0,5,6,24' && lc1.gr === 'Горбуна,0,5,8,40'
&& lc1.turt === 'Морская черепаха,1,9,20,56' && lc1.an === 'Удильщик,2,21,4,16'
&& lc1.oc === 'Гигантский осьминог,2,23,16,48' && lc1.ma === 'Скат-манта,3,29,24,60'
&& lc1.sh === 'Большая белая акула,3,31,20,56' && lc1.sq === 'Гигантский кальмар,4,37,16,48'
&& lc1.wh === 'Синий кит,4,67,40,100'
&& lc1.gf === 'Светлячок,0,8,0.3,3' && lc1.mf === 'Лунная рыбка,1,13,0.8,8'
&& lc1.ef === 'Изумрудный светляк,2,20,1.5,16' && lc1.vl === 'Фиалковый огонёк,3,38,2,24'
&& lc1.sf === 'Дух болота,4,70,0.5,6'
&& lc1.mwf === 'Которыбка,4,50,20,80'
&& lc1.first8 === 'carass,perch,bream,carp,pike,catfish,sturge,gold'
&& lc1.lakeOrder === 'zander,chuch,crayfish,bleak,roundel,eelpout,tench,navaga,rudd,cisco'
&& lc1.seaOrder === 'herring,mackerel,flounder,tuna,swordfish'
&& lc1.oceanOrder === 'moray,grouper,turtle,angler,octopus,manta,shark,squid,whale'
&& lc1.swampOrder === 'glowfin,moonfish,emberfin,violetlure,spiritfin,meowfish'
&& lc1.fieldsOk === true
&& lc1.swampGlow === true && lc1.swampRar === '0,1,2,3,4,4'
&& lc1.locs === 5 && lc1.costs === '0,500,100000,300000,10000' && lc1.names === 'Река,Озёра,Море,Океан,Волшебное болото' && lc1.water === true
&& lc1.sprites === 38 && lc1.spriteIdsOk === true,
JSON.stringify(lc1));
// (2) таблицы: река — ровно старые 8 (порядок), озёра — только свои 10, море — только свои 5, океан — только свои 9, болото — только свои 6 (Этап 55), ночные веса — числа; пересечений нет
const lc2 = await page.evaluate(() => {
const old8 = 'carass,perch,bream,carp,pike,catfish,sturge,gold';
const lakeIds = LAKE_SPECIES.map(s => s.id);
const seaIds = SEA_SPECIES.map(s => s.id);
const oceanIds = OCEAN_SPECIES.map(s => s.id);
const swampIds = SWAMP_SPECIES.map(s => s.id);
return {
river: RIVER_SPECIES.map(s => s.id).join(','),
riverN: RIVER_SPECIES_NIGHT.map(s => s.id).join(','),
lake: LAKE_SPECIES.length, lakeN: LAKE_SPECIES_NIGHT.length,
sea: SEA_SPECIES.length, seaN: SEA_SPECIES_NIGHT.length,
ocean: OCEAN_SPECIES.length, oceanN: OCEAN_SPECIES_NIGHT.length,
swamp: SWAMP_SPECIES.length, swampN: SWAMP_SPECIES_NIGHT.length,
lakeOrder: lakeIds.join(','), seaOrder: seaIds.join(','), oceanOrder: oceanIds.join(','),
swampOrder: swampIds.join(','), swampNOrder: SWAMP_SPECIES_NIGHT.map(s => s.id).join(','),
// пересечения между водами (река∩озёра, река∩море, озёра∩море, океан∩река, океан∩озёра, океан∩море, болото∩всё) пусты
xRL: lakeIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
xRS: seaIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
xLS: seaIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
xOR: oceanIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
xOL: oceanIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
xOS: oceanIds.filter(id => seaIds.indexOf(id) >= 0).join(','),
// Этап 45: пересечения болота с остальными водами
xSR: swampIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
xSL: swampIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
xSS: swampIds.filter(id => seaIds.indexOf(id) >= 0).join(','),
xSO: swampIds.filter(id => oceanIds.indexOf(id) >= 0).join(','),
nightW: SPECIES_NIGHT.length === 38 && SPECIES_NIGHT.every(s => typeof s.weight === 'number' && isFinite(s.weight))
};
});
ok('локации таблицы: RIVER_SPECIES(_NIGHT) — ровно старые 8 (порядок сохранён), LAKE_SPECIES(_NIGHT) — только свои 10 (без речных), SEA_SPECIES(_NIGHT) — только свои 5, OCEAN_SPECIES(_NIGHT) — только свои 9 (этап 36), SWAMP_SPECIES(_NIGHT) — только свои 6 (этапы 45/55, порядок = SWAMP_IDS); пересечений между водами нет; у всех 38 в SPECIES_NIGHT вес — число',
lc2.river === 'carass,perch,bream,carp,pike,catfish,sturge,gold' && lc2.riverN === lc2.river
&& lc2.lake === 10 && lc2.lakeN === 10 && lc2.sea === 5 && lc2.seaN === 5
&& lc2.ocean === 9 && lc2.oceanN === 9
&& lc2.swamp === 6 && lc2.swampN === 6
&& lc2.lakeOrder === 'zander,chuch,crayfish,bleak,roundel,eelpout,tench,navaga,rudd,cisco'
&& lc2.seaOrder === 'herring,mackerel,flounder,tuna,swordfish'
&& lc2.oceanOrder === 'moray,grouper,turtle,angler,octopus,manta,shark,squid,whale'
&& lc2.swampOrder === 'glowfin,moonfish,emberfin,violetlure,spiritfin,meowfish'
&& lc2.swampNOrder === lc2.swampOrder
&& lc2.xRL === '' && lc2.xRS === '' && lc2.xLS === ''
&& lc2.xOR === '' && lc2.xOL === '' && lc2.xOS === ''
&& lc2.xSR === '' && lc2.xSL === '' && lc2.xSS === '' && lc2.xSO === ''
&& lc2.nightW === true,
JSON.stringify(lc2));
// (3) роллы реки: день/ночь по 3000 — только старые 8 (новые виды в реку не заходят)
const lc3 = await page.evaluate(() => {
const old8 = ['carass', 'perch', 'bream', 'carp', 'pike', 'catfish', 'sturge', 'gold'];
save.location = 0;
setDayTime(0.3);
const day = new Set();
for (let i = 0; i < 3000; i++) day.add(pickSpecies().id);
setDayTime(0.85);
const night = new Set();
for (let i = 0; i < 3000; i++) night.add(pickSpecies().id);
save.location = 0;
const onlyOld = s => [...s].every(id => old8.indexOf(id) >= 0);
return { dayOk: onlyOld(day), nightOk: onlyOld(night), dayN: day.size, nightN: night.size };
});
ok('локации роллы реки: 3000 днём и 3000 ночью (location=0) — все id из старых 8, новых видов нет',
lc3.dayOk === true && lc3.nightOk === true,
JSON.stringify(lc3));
// (4) роллы озёр: день/ночь по 3000 — ровно свои 10 озёрных видов (каждый ≥1), речных нет
const lc4 = await page.evaluate(() => {
save.location = 1;
setDayTime(0.3);
const day = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; day[id] = (day[id] || 0) + 1; }
setDayTime(0.85);
const night = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; night[id] = (night[id] || 0) + 1; }
save.location = 0;
const ids = LAKE_IDS;
const riverIds = RIVER_IDS;
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
return {
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
noRiver: [...Object.keys(day), ...Object.keys(night)].every(id => riverIds.indexOf(id) < 0),
dayMin: min(day), nightMin: min(night)
};
});
ok('локации роллы озёр: 3000 днём и 3000 ночью (location=1) — все 10 озёрных видов встречаются (каждый ≥1), речных нет',
lc4.dayAll === true && lc4.nightAll === true && lc4.noRiver === true,
JSON.stringify(lc4));
// (4b) Этап 35: роллы моря — ровно свои 5 морских видов (каждый ≥1), пресноводных нет
const lc4b = await page.evaluate(() => {
save.location = 2;
setDayTime(0.3);
const day = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; day[id] = (day[id] || 0) + 1; }
setDayTime(0.85);
const night = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; night[id] = (night[id] || 0) + 1; }
save.location = 0;
const ids = SEA_IDS;
const fresh = RIVER_IDS.concat(LAKE_IDS);
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
return {
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
onlySea: [...Object.keys(day), ...Object.keys(night)].every(id => fresh.indexOf(id) < 0),
dayMin: min(day), nightMin: min(night)
};
});
ok('локации роллы моря: 3000 днём и 3000 ночью (location=2) — все 5 морских видов встречаются (каждый ≥1), пресноводных нет',
lc4b.dayAll === true && lc4b.nightAll === true && lc4b.onlySea === true,
JSON.stringify(lc4b));
// (4c) Этап 36: роллы океана — ровно свои 9 океанских видов (каждый ≥1), пресноводных/озёрных/морских нет
const lc4c = await page.evaluate(() => {
save.location = 3;
setDayTime(0.3);
const day = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; day[id] = (day[id] || 0) + 1; }
setDayTime(0.85);
const night = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; night[id] = (night[id] || 0) + 1; }
save.location = 0;
const ids = OCEAN_IDS;
const others = RIVER_IDS.concat(LAKE_IDS, SEA_IDS);
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
return {
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
onlyOcean: [...Object.keys(day), ...Object.keys(night)].every(id => others.indexOf(id) < 0),
dayMin: min(day), nightMin: min(night)
};
});
ok('локации роллы океана: 3000 днём и 3000 ночью (location=3) — все 9 океанских видов встречаются (каждый ≥1), речных/озёрных/морских нет',
lc4c.dayAll === true && lc4c.nightAll === true && lc4c.onlyOcean === true,
JSON.stringify(lc4c));
// (4d) Этап 45: роллы болота — ровно свои болотные виды (каждый ≥1; 6 с Этапа 55), видов других вод нет
const lc4d = await page.evaluate(() => {
save.location = 4;
setDayTime(0.3);
const day = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; day[id] = (day[id] || 0) + 1; }
setDayTime(0.85);
const night = {};
for (let i = 0; i < 3000; i++) { const id = pickSpecies().id; night[id] = (night[id] || 0) + 1; }
save.location = 0;
const ids = SWAMP_IDS;
const others = RIVER_IDS.concat(LAKE_IDS, SEA_IDS, OCEAN_IDS);
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
return {
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
onlySwamp: [...Object.keys(day), ...Object.keys(night)].every(id => others.indexOf(id) < 0),
dayMin: min(day), nightMin: min(night)
};
});
ok('локации роллы болота: 3000 днём и 3000 ночью (location=4) — все 6 болотных видов встречаются (каждый ≥1), речных/озёрных/морских/океанских нет',
lc4d.dayAll === true && lc4d.nightAll === true && lc4d.onlySwamp === true,
JSON.stringify(lc4d));
// (5) миграция: старый сейв (без locations/location) → [0]/0; location=5 → 0; location=1 при locations=[0] → 0; Этап 36: Море/Океан не сбрасываются (баг границы >1)
const lc5 = await page.evaluate(() => {
const base = { coins: 50, rod: 0, lure: 0, rods: [0], lures: [0], rodLv: { 0: 1 }, lureLv: { 0: 1 },
caught: {}, muted: true, casts: 0, earned: 0, snaps: 0, ach: {},
quests: null, streak: { last: todayStr(), days: 1, shieldWeek: null }, questsDoneTotal: 0,
bossBeaten: false, bossCount: 0,
boatSkins: [0], boatSkin: 0, bobberSkins: [0], bobberSkin: 0,
dayTime: 0.3, lastSaveMs: Date.now() };
const r = {};
// (a): Object.assign в loadSave не удаляет ключи — JSON «без locations/location»
// не сбрасывает in-memory значения (тест-фантом), чистим вручную
delete save.locations; delete save.location;
localStorage.setItem(SAVE_KEY, JSON.stringify(base));
loadSave();
r.a = { locs: save.locations.slice(), loc: save.location };
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { location: 5 })));
loadSave();
r.b = { locs: save.locations.slice(), loc: save.location };
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0], location: 1 })));
loadSave();
r.c = { locs: save.locations.slice(), loc: save.location };
// (d)(e) Этап 36: Regression-тест бага границы (было save.location>1 — Море сбрасывалось на reload)
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 2], location: 2 })));
loadSave();
r.d = { locs: save.locations.slice(), loc: save.location };
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 3], location: 3 })));
loadSave();
r.e = { locs: save.locations.slice(), loc: save.location };
// (f) Этап 45: Болото (location=4) — граница LOCATIONS.length расширена, не сбрасывается на reload
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 4], location: 4 })));
loadSave();
r.f = { locs: save.locations.slice(), loc: save.location };
save.locations = [0]; save.location = 0; // не загрязнять следующие тесты (lc6 покупает Озёра)
return r;
});
ok('локации миграция: старый сейв (без locations/location) → [0]/0; location=5 (вне 0..4) → 0; location=1 при locations=[0] (не куплено) → 0; location=2 (Море), location=3 (Океан) и location=4 (Болото) не сбрасываются (граница из LOCATIONS.length, баг >1 исправлен)',
lc5.a.locs.join(',') === '0' && lc5.a.loc === 0
&& lc5.b.locs.join(',') === '0' && lc5.b.loc === 0
&& lc5.c.locs.join(',') === '0' && lc5.c.loc === 0
&& lc5.d.locs.join(',') === '0,2' && lc5.d.loc === 2
&& lc5.e.locs.join(',') === '0,3' && lc5.e.loc === 3
&& lc5.f.locs.join(',') === '0,4' && lc5.f.loc === 4,
JSON.stringify(lc5));
// (6) покупка: 1500 монет → «Озёра» (500, Этап 53) — списание, авто-переход, тост
const lcSt0 = await page.evaluate(() => state);
if (lcSt0 === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
await page.evaluate(() => { save.coins = 1500; });
await page.click('#btnShop');
await sleep(150);
await page.click('#tabLocations');
await sleep(150);
await page.click('button[data-act="buylocation"][data-i="1"]');
const lc6 = await page.evaluate(() => ({
coins: save.coins, locs: save.locations.slice(), loc: save.location,
toast: document.getElementById('toast').textContent
}));
ok('локации покупка: 1500 → «Озёра» (500) — coins=1000, locations=[0,1], авто-переход location=1, тост «Добро пожаловать на Озёра!»',
lc6.coins === 1000 && lc6.locs.join(',') === '0,1' && lc6.loc === 1 && lc6.toast.includes('Добро пожаловать на Озёра!'),
JSON.stringify(lc6));
// (7) защиты: не хватает монет; повторная buylocation (без дубля); selectlocation не купленной
await page.evaluate(() => { save.locations = [0]; save.location = 0; save.coins = 100; renderShop(); });
await page.click('button[data-act="buylocation"][data-i="1"]');
const lc7a = await page.evaluate(() => ({
coins: save.coins, locs: save.locations.join(','),
toast: document.getElementById('toast').textContent
}));
await page.evaluate(() => {
save.locations = [0, 1]; save.location = 0; save.coins = 100;
// кнопка buylocation у купленной в DOM не рендерится («Выбрать»/«Выбрана ✓») — синтетический клик
const b = document.createElement('button');
b.setAttribute('data-act', 'buylocation'); b.setAttribute('data-i', '1');
shopClick({ target: b });
});
const lc7b = await page.evaluate(() => ({ coins: save.coins, locs: save.locations.slice() }));
await page.evaluate(() => {
save.locations = [0]; save.location = 0;
const b = document.createElement('button');
b.setAttribute('data-act', 'selectlocation'); b.setAttribute('data-i', '1');
shopClick({ target: b });
});
const lc7c = await page.evaluate(() => save.location);
ok('локации защиты: 100 < 500 — coins/locations не изменились, тост «Не хватает монет»; buylocation при locations=[0,1] — без дубля; selectlocation не купленной — location не изменился',
lc7a.coins === 100 && lc7a.locs === '0' && lc7a.toast.includes('Не хватает монет 🪙')
&& lc7b.coins === 100 && lc7b.locs.join(',') === '0,1' && lc7b.locs.length === 2
&& lc7c === 0,
JSON.stringify({ a: lc7a, b: lc7b, c: lc7c }));
// (8) переключение: location=1 → «Выбрать» Река → 0 (монеты не тратятся) → «Выбрать» Озёра → 1
await page.evaluate(() => { save.locations = [0, 1]; save.location = 1; save.coins = 1234; renderShop(); });
await page.click('button[data-act="selectlocation"][data-i="0"]');
const lc8a = await page.evaluate(() => ({
loc: save.location, coins: save.coins, toast: document.getElementById('toast').textContent
}));
await page.click('button[data-act="selectlocation"][data-i="1"]');
const lc8b = await page.evaluate(() => ({
loc: save.location, coins: save.coins, toast: document.getElementById('toast').textContent
}));
ok('локации переключение: location=1 → «Выбрать» Река → location=0 (coins не изменились), «Выбрать» Озёра → location=1',
lc8a.loc === 0 && lc8a.coins === 1234 && lc8a.toast.includes('Локация: Река')
&& lc8b.loc === 1 && lc8b.coins === 1234 && lc8b.toast.includes('Локация: Озёра'),
JSON.stringify({ a: lc8a, b: lc8b }));
// (8b) Этап 35: покупка Моря: 100500 → списание, авто-переход, тост «Добро пожаловать в Море!»
// (Этап 53: цена Моря 100000)
await page.evaluate(() => { save.coins = 100500; save.locations = [0, 1]; save.location = 0; renderShop(); });
await page.click('#tabLocations');
await sleep(150);
await page.click('button[data-act="buylocation"][data-i="2"]');
const lc8s = await page.evaluate(() => ({
coins: save.coins, locs: save.locations.slice(), loc: save.location,
toast: document.getElementById('toast').textContent
}));
ok('локации покупка моря: 100500 → «Море» (100000) — coins=500, locations=[0,1,2], авто-переход location=2, тост «Добро пожаловать в Море!»',
lc8s.coins === 500 && lc8s.locs.join(',') === '0,1,2' && lc8s.loc === 2
&& lc8s.toast.includes('Добро пожаловать в Море!'),
JSON.stringify(lc8s));
// (8c) Этап 36: покупка Океана: 300500 → списание, авто-переход, тост «Добро пожаловать в Океан!»
// (Этап 53: цена Океана 300000)
await page.evaluate(() => { save.coins = 300500; save.locations = [0, 1, 2]; save.location = 0; renderShop(); });
await page.click('#tabLocations');
await sleep(150);
await page.click('button[data-act="buylocation"][data-i="3"]');
const lc8o = await page.evaluate(() => ({
coins: save.coins, locs: save.locations.slice(), loc: save.location,
toast: document.getElementById('toast').textContent
}));
ok('локации покупка океана: 300500 → «Океан» (300000) — coins=500, locations=[0,1,2,3], авто-переход location=3, тост «Добро пожаловать в Океан!»',
lc8o.coins === 500 && lc8o.locs.join(',') === '0,1,2,3' && lc8o.loc === 3
&& lc8o.toast.includes('Добро пожаловать в Океан!'),
JSON.stringify(lc8o));
// (8d) Этап 45: покупка Волшебного болота: 10500 → списание, авто-переход, тост «Добро пожаловать в Волшебное болото!»
// (Этап 53: цена Болота 10000)
await page.evaluate(() => { save.coins = 10500; save.locations = [0, 1, 2, 3]; save.location = 0; renderShop(); });
await page.click('#tabLocations');
await sleep(150);
await page.click('button[data-act="buylocation"][data-i="4"]');
const lc8w = await page.evaluate(() => ({
coins: save.coins, locs: save.locations.slice(), loc: save.location,
toast: document.getElementById('toast').textContent
}));
ok('локации покупка болота: 10500 → «Волшебное болото» (10000) — coins=500, locations=[0,1,2,3,4], авто-переход location=4, тост «Добро пожаловать в Волшебное болото!»',
lc8w.coins === 500 && lc8w.locs.join(',') === '0,1,2,3,4' && lc8w.loc === 4
&& lc8w.toast.includes('Добро пожаловать в Волшебное болото!'),
JSON.stringify(lc8w));
// (9) дневник: 38 строк (новые виды видны в энциклопедии)
await page.click('#tabJournal');
await sleep(150);
const lc9 = await page.evaluate(() => document.querySelectorAll('#shopBody .jrow').length);
ok('локации дневник: вкладка journal — 38 строк .jrow (новые виды в энциклопедии)',
lc9 === 38, 'rows=' + lc9);
// (10) масштаб ачивок: species_8 — все 38 видов; full_variants — 38×8 вариантов, +5000 разово
const lc10 = await page.evaluate(() => {
const desc = ACHIEVEMENTS.find(a => a.id === 'species_8').desc;
const old8 = ['carass', 'perch', 'bream', 'carp', 'pike', 'catfish', 'sturge', 'gold'];
// Этап 45: 29 новых видов; Этап 56: +которыбка (экс-целакант) → 30
const mid30 = ['zander', 'chuch', 'crayfish', 'bleak', 'roundel', 'eelpout', 'tench', 'navaga', 'rudd', 'cisco', 'herring', 'mackerel', 'flounder', 'tuna', 'swordfish', 'moray', 'grouper', 'turtle', 'angler', 'octopus', 'manta', 'shark', 'squid', 'whale', 'glowfin', 'moonfish', 'emberfin', 'violetlure', 'spiritfin', 'meowfish'];
save.ach = {}; save.caught = {};
for (const id of old8) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 1 };
checkAchievements();
const mid = { s8: !!save.ach.species_8, fv: !!save.ach.full_variants };
for (const id of mid30) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 1 };
const c0 = save.coins;
checkAchievements();
const after = { s8: !!save.ach.species_8, fv: !!save.ach.full_variants, d1: save.coins - c0 };
checkAchievements(); // повтор — второй выплаты не должно быть
after.d2 = save.coins - c0;
return { desc, mid, after };
});
ok('локации ачивки: desc species_8 содержит «все виды»; 8 старых ×8 вариантов — species_8/full_variants НЕ выданы; +30 новых (10 озёрных этапов 33/35 + 5 морских + 9 океанских + 6 болотных, этапы 45/55) → обе выданы, +5000 ровно один раз',
lc10.desc.includes('все виды')
&& lc10.mid.s8 === false && lc10.mid.fv === false
&& lc10.after.s8 === true && lc10.after.fv === true
&& lc10.after.d1 === 5000 && lc10.after.d2 === 5000,
JSON.stringify(lc10));
// (11) Этап 45: данные свечения — у болотных видов glow #rrggbb (цвета РАЗЛИЧНЫ; 6 видов с Этапа 55),
// SWAMP_DAY/NIGHT_WEIGHTS покрывают все SWAMP_IDS числом >0, NIGHT_WEIGHTS — ключи всех id
const lc11 = await page.evaluate(() => {
const ids = SWAMP_IDS.slice();
const glows = ids.map(id => SPECIES.find(s => s.id === id).glow);
const weightsOk = tbl => ids.every(id => typeof tbl[id] === 'number' && isFinite(tbl[id]) && tbl[id] > 0);
const keyOf = tbl => ids.every(id => Object.prototype.hasOwnProperty.call(tbl, id));
return {
glowFmt: glows.every(c => typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c)),
glowUnique: new Set(glows).size === glows.length,
dayW: weightsOk(SWAMP_DAY_WEIGHTS), nightW: weightsOk(SWAMP_NIGHT_WEIGHTS),
dayKeys: keyOf(SWAMP_DAY_WEIGHTS), nightKeys: keyOf(SWAMP_NIGHT_WEIGHTS),
nwKeys: keyOf(NIGHT_WEIGHTS),
nwAll: SPECIES.every(s => typeof NIGHT_WEIGHTS[s.id] === 'number' && isFinite(NIGHT_WEIGHTS[s.id]))
};
});
ok('Этап 45/55 свечение/веса: у 6 болотных видов glow — #rrggbb, все 6 цветов различны; SWAMP_DAY_WEIGHTS/SWAMP_NIGHT_WEIGHTS покрывают все id SWAMP_IDS числами >0; NIGHT_WEIGHTS имеет ключи всех 6 болотных id (и всех 38 видов)',
lc11.glowFmt === true && lc11.glowUnique === true
&& lc11.dayW === true && lc11.nightW === true
&& lc11.dayKeys === true && lc11.nightKeys === true
&& lc11.nwKeys === true && lc11.nwAll === true,
JSON.stringify(lc11));
// сброс локаций к дефолту
await page.evaluate(() => {
save.location = 0; save.locations = [0]; persist();
closeShop();
});
// --- 10g. Этап 22: расширение достижений + тексты shiny ---
// (1) тексты: first_shiny.desc и квест catch_shiny — «золотую рыбу», «shiny» в desc нет
const a22t = await page.evaluate(() => ({
achDesc: ACHIEVEMENTS.find(a => a.id === 'first_shiny').desc,
quest: questText({ tpl: 'catch_shiny', param: 0, goal: 1 }),
noShiny: ACHIEVEMENTS.every(a => a.desc.indexOf('shiny') < 0)
}));
ok('Этап 22 тексты: first_shiny.desc и квест catch_shiny — «Поймай золотую рыбу»; «shiny» в описаниях достижений нет',
a22t.achDesc === 'Поймай золотую рыбу' && a22t.quest === 'Поймай золотую рыбу' && a22t.noShiny,
JSON.stringify(a22t));
// (2) данные: 11 новых достижений (Этап 22) + sea_first/ocean_first (Этап 36) + lake_boss/sea_boss/ocean_boss (Этап 37)
// + swamp_first/swamp_boss (Этап 45) + aq_first/aq_full (Этап 46) + line_first (Этап 59), ACHIEVEMENTS.length===39,
// id уникальны, name/desc/icon/bonus/check (у aq_first/aq_full/line_first bonus:{} — пин achBonus().coinPct не сдвинут)
const a22d = await page.evaluate(() => {
const ids = ['first_silver', 'first_rainbow', 'rain_variant', 'fog_catch', 'skin_first',
'skins_all', 'lake_first', 'gear_max', 'titan', 'weight_1000', 'night_first',
'sea_first', 'ocean_first', 'lake_boss', 'sea_boss', 'ocean_boss',
'swamp_first', 'swamp_boss', 'aq_first', 'aq_full', 'line_first'];
const list = ACHIEVEMENTS.map(a => a.id);
return {
total: ACHIEVEMENTS.length,
all: ids.every(id => list.indexOf(id) >= 0),
unique: new Set(list).size === list.length,
fields: ids.every(id => { const a = ACHIEVEMENTS.find(x => x.id === id);
return a && a.name && a.desc && a.icon && a.bonus && typeof a.check === 'function'; }),
// Этап 46: аквариумные ачивки не двигают экономику — пин achBonus().coinPct (сумма 0.18, §10c(8))
noCoinBonus: ids.filter(id => id === 'aq_first' || id === 'aq_full' || id === 'line_first')
.every(id => Object.keys(ACHIEVEMENTS.find(x => x.id === id).bonus).length === 0)
};
});
ok('Этап 22 данные: 11 новых достижений (варианты/погода/скины/локации/снасти/вес/ночь) + sea_first/ocean_first (Этап 36) + lake_boss/sea_boss/ocean_boss (Этап 37) + swamp_first/swamp_boss (Этап 45) + aq_first/aq_full (Этап 46) + line_first (Этап 59), ACHIEVEMENTS.length===39, id уникальны, поля на месте',
a22d.all === true && a22d.total === 39 && a22d.unique === true && a22d.fields === true
&& a22d.noCoinBonus === true,
JSON.stringify(a22d));
// (3) геймплей: ночной улов в дождь, серебряный вариант — одним уловом проверяются
// крючки счётчиков (kgTotal += w, nightCatch, wxRainV) и 3 достижения
await page.evaluate(() => {
state = 'IDLE'; reel = null; biteFish = null;
save.ach = {}; save.caught = {}; save.coins = 0;
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
save.kgTotal = 0; save.nightCatch = false; save.wxRainV = 0; save.wxFog = 0;
save.rodLv[save.rod] = 2; // фонарик — ночью без него клёв не стартует (WAIT)
setDayTime(0.8); // ночь
forceWeather = 'rain'; // дождь (форс приоритетнее естественной погоды)
forceVariant = 'silver';// вариант != normal
forceBite = true; biteTimer = 0.05;
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
window.__a22W = reel.w;
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
});
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const a22n = await page.evaluate(() => ({
w: window.__a22W,
kg: save.kgTotal,
night: save.nightCatch,
rainV: save.wxRainV,
fog: save.wxFog,
ach: {
silver: !!save.ach.first_silver,
rain: !!save.ach.rain_variant,
night: !!save.ach.night_first,
fogA: !!save.ach.fog_catch
},
phase: dayPhase
}));
ok('Этап 22 ночной дождь: nightCatch=true, wxRainV=1 (wxFog=0), kgTotal вырос ровно на w (первый улов)',
a22n.phase === 'night' && a22n.night === true && a22n.rainV === 1 && a22n.fog === 0
&& a22n.kg === a22n.w,
JSON.stringify(a22n));
ok('Этап 22 ночной дождь: выданы first_silver + rain_variant + night_first (fog_catch нет)',
a22n.ach.silver === true && a22n.ach.rain === true && a22n.ach.night === true && a22n.ach.fogA === false,
JSON.stringify(a22n.ach));
await page.click('#btnCardOk');
await sleep(100);
await page.evaluate(() => {
forceBite = false; forceVariant = null; forceWeather = null; weather = null;
setDayTime(0.3); // обратно к дню
});
// (4) first_rainbow: rb=1 → выдано; silver/gold — нет
const a22rb = await page.evaluate(() => {
save.ach = {};
save.caught = { carass: { n: 1, best: 1, sn: 0, sv: 0, rb: 1 } };
checkAchievements();
const got = { rb: !!save.ach.first_rainbow, sv: !!save.ach.first_silver, gold: !!save.ach.first_shiny };
save.caught = {};
return got;
});
ok('Этап 22 first_rainbow: улов rb=1 → выдано; first_silver/first_shiny — нет',
a22rb.rb === true && a22rb.sv === false && a22rb.gold === false,
JSON.stringify(a22rb));
// (5) fog_catch: wxFog=1 → выдано (счётчик ставится уловом в тумане)
const a22f = await page.evaluate(() => {
save.ach = {};
save.wxRainV = 0; save.wxFog = 1;
checkAchievements();
const got = { fog: !!save.ach.fog_catch, rain: !!save.ach.rain_variant };
save.wxFog = 0;
return got;
});
ok('Этап 22 fog_catch: wxFog=1 → выдано; rain_variant нет (wxRainV=0)',
a22f.fog === true && a22f.rain === false,
JSON.stringify(a22f));
// (6) weight_1000: граница — 999.9 не даёт, 1000 даёт
const a22kg = await page.evaluate(() => {
save.ach = {};
save.kgTotal = 999.9;
checkAchievements();
const notYet = !!save.ach.weight_1000;
save.kgTotal = 1000;
checkAchievements();
const got = !!save.ach.weight_1000;
save.kgTotal = 0;
return { notYet, got };
});
ok('Этап 22 weight_1000: kgTotal 999.9 — не выдано; 1000 — выдано (граница)',
a22kg.notYet === false && a22kg.got === true,
JSON.stringify(a22kg));
// (7) скины (этап 57: 5 лодок + 5 поплавков): [0,1]/[0] → skin_first, skins_all нет;
// все id из таблиц → skins_all
const a22sk = await page.evaluate(() => {
const allBoat = BOAT_SKINS.map(s => s.id), allBob = BOBBER_SKINS.map(s => s.id);
save.ach = {};
save.boatSkins = [0, 1]; save.bobberSkins = [0];
checkAchievements();
const s1 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
// неполный набор: все лодки, но не все поплавки — skins_all быть не должен
save.boatSkins = allBoat.slice(); save.bobberSkins = allBob.slice(0, -1);
checkAchievements();
const s1b = { a: !!save.ach.skins_all };
save.boatSkins = allBoat.slice(); save.bobberSkins = allBob.slice();
checkAchievements();
const s2 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
save.boatSkins = [0]; save.bobberSkins = [0];
return { s1, s1b, s2 };
});
ok('Этап 22/57 скины: boatSkins [0,1] → skin_first, skins_all нет; все лодки, но не все поплавки → skins_all нет; все 10 скинов → skins_all',
a22sk.s1.f === true && a22sk.s1.a === false && a22sk.s1b.a === false
&& a22sk.s2.f === true && a22sk.s2.a === true,
JSON.stringify(a22sk));
// (8) lake_first: locations [0] — не выдано; [0,1] — выдано
const a22lk = await page.evaluate(() => {
save.ach = {};
save.locations = [0];
checkAchievements();
const notYet = !!save.ach.lake_first;
save.locations = [0, 1];
checkAchievements();
const got = !!save.ach.lake_first;
save.locations = [0];
return { notYet, got };
});
ok('Этап 22 lake_first: locations [0] — не выдано; [0,1] (Озёра) — выдано',
a22lk.notYet === false && a22lk.got === true,
JSON.stringify(a22lk));
// (8b) Этап 36: sea_first/ocean_first — locations-based (паттерн lake_first 1:1; ачивки не отзываются —
// каждую проверяем отдельной парой шагов с чисткой save.ach); Этап 45: +swamp_first (location 4)
const a36loc = await page.evaluate(() => {
const r = {};
save.ach = {};
save.locations = [0];
checkAchievements();
r.seaNot = !!save.ach.sea_first;
save.locations = [0, 2];
checkAchievements();
r.seaGot = !!save.ach.sea_first;
save.ach = {};
save.locations = [0];
checkAchievements();
r.oceanNot = !!save.ach.ocean_first;
save.locations = [0, 3];
checkAchievements();
r.oceanGot = !!save.ach.ocean_first;
save.ach = {};
save.locations = [0];
checkAchievements();
r.swampNot = !!save.ach.swamp_first;
save.locations = [0, 4];
checkAchievements();
r.swampGot = !!save.ach.swamp_first;
save.locations = [0];
return r;
});
ok('Этап 36/45 sea_first/ocean_first/swamp_first: locations [0] — не выданы; [0,2] (Море) → sea_first; [0,3] (Океан) → ocean_first; [0,4] (Болото) → swamp_first',
a36loc.seaNot === false && a36loc.seaGot === true
&& a36loc.oceanNot === false && a36loc.oceanGot === true
&& a36loc.swampNot === false && a36loc.swampGot === true,
JSON.stringify(a36loc));
// (9) gear_max/titan: максимум 3 ур. — не выдано; lureLv=4 → gear_max; rods с 3 → titan
const a22g = await page.evaluate(() => {
save.ach = {};
save.rodLv = { 0: 1, 1: 3 }; save.lureLv = { 0: 1 };
save.rods = [0, 1];
checkAchievements();
const notYet = { g: !!save.ach.gear_max, t: !!save.ach.titan };
save.rodLv = { 0: 1, 1: 3 }; save.lureLv = { 0: 4 };
save.rods = [0, 1, 3];
checkAchievements();
const got = { g: !!save.ach.gear_max, t: !!save.ach.titan };
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.rods = [0];
return { notYet, got };
});
ok('Этап 22 снасти: макс 3 ур. — gear_max/titan не выданы; lureLv=4 → gear_max, «Титан» (rods 3) → titan',
a22g.notYet.g === false && a22g.notYet.t === false && a22g.got.g === true && a22g.got.t === true,
JSON.stringify(a22g));
// (10) старый сейв: без новых полей — без ложных выдач и ошибок; дефолты freshSave
const a22m = await page.evaluate(() => {
const s = { coins: 0, caught: {}, ach: {}, casts: 0, earned: 0, snaps: 0,
bossBeaten: false, bossCount: 0, streak: { last: null, days: 0, shieldWeek: null },
questsDoneTotal: 0, rodLv: { 0: 1 }, lureLv: { 0: 1 }, rods: [0], lures: [0],
boatSkins: [0], bobberSkins: [0], locations: [0] };
const granted = ACHIEVEMENTS.filter(a => a.check(s)).map(a => a.id);
const fresh = freshSave(false);
return { granted, fresh: { kg: fresh.kgTotal, night: fresh.nightCatch, rainV: fresh.wxRainV, fog: fresh.wxFog } };
});
ok('Этап 22 старый сейв: сейв без kgTotal/nightCatch/wxRainV/wxFog — ложных выдач нет, ошибок нет; freshSave — дефолты 0/false/0/0',
a22m.granted.length === 0 && a22m.fresh.kg === 0 && a22m.fresh.night === false
&& a22m.fresh.rainV === 0 && a22m.fresh.fog === 0,
JSON.stringify(a22m));
// --- Y2. Реклама (стаб) ---
// В file:// (без ?sdk=1) YG-стаб: showFullscreen → false (реклама не показана —
// мгновенный endAd), showRewarded — синхронная симуляция onOpen→onRewarded→onClose(true).
// Interstitial — на закрытии карточки (btnCardOk → tryInterstitial), гейты: первый
// не раньше AD_FIRST_MS сессии (sessionStartTs), дальше не чаще AD_COOLDOWN_MS
// (lastAdTs). Rewarded «монеты ×2» — кнопка #btnRv на карточке (бонус = rvVal).
// Проверяем счётчики YG.counts (fsAdv/rv), гейты и состояние adShowing/YG.paused.
// ВХОД: IDLE после §10g. Уловы — стандартный цикл (forceBite + pointer), вариант
// форсим 'normal' (детерминированный val без множителей). В конце — сброс в MENU.
{
const y2st = await page.evaluate(() => state);
if (y2st === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
await page.evaluate(() => {
setDayTime(0.3);
save.quests = { date: todayStr(), list: [] }; // квестные монеты не искажают проверки
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
forceVariant = 'normal';
});
// улов до карточки (стандартный цикл)
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
// (1) RV: карточка — btnRv виден и включён, rvVal = цене улова
const y2c0 = await page.evaluate(() => ({
st: state,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
rvVal: rvVal,
hidden: document.getElementById('btnRv').classList.contains('hidden'),
dis: document.getElementById('btnRv').disabled,
txt: document.getElementById('btnRv').textContent,
}));
ok('RV карточка: btnRv виден и включён, текст «×2», rvVal = цене улова',
y2c0.st === 'CARD' && y2c0.hidden === false && y2c0.dis === false
&& y2c0.txt.includes('×2') && y2c0.rvVal === y2c0.val,
JSON.stringify(y2c0));
// (2) RV: клик → бонус ×2 (монеты +val, cardVal удвоен, кнопка disabled, rv=1)
const y2c1b = await page.evaluate(() => save.coins);
const rvBefore = await page.evaluate(() => YG.counts.rv); // rv до клика — проверяем дельту, а не абсолютный пин
await page.click('#btnRv');
const y2c1 = await page.evaluate(() => ({
cardVal: document.getElementById('cardVal').textContent,
coins: save.coins,
dis: document.getElementById('btnRv').disabled,
txt: document.getElementById('btnRv').textContent,
rv: YG.counts.rv, adShowing: adShowing, paused: YG.paused,
}));
ok('RV клик: +val монет (итого ×2), cardVal удвоен, «✓ Бонус получен» disabled, rv=1, adShowing/paused=false',
y2c1.coins === y2c1b + y2c0.val
&& y2c1.cardVal === '+' + (y2c0.val * 2) + ' 🪙'
&& y2c1.dis === true && y2c1.txt.includes('Бонус получен')
&& y2c1.rv - rvBefore === 1 && y2c1.adShowing === false && y2c1.paused === false,
JSON.stringify({ before: y2c1b, val: y2c0.val, after: y2c1 }));
// (3) RV: повторный клик — no-op (rvDone=true, монеты не изменились)
const y2c2b = await page.evaluate(() => save.coins);
await page.click('#btnRv');
const y2c2 = await page.evaluate(() => save.coins);
ok('RV повторный клик: no-op (монеты не изменились)', y2c2 === y2c2b,
'before=' + y2c2b + ' after=' + y2c2);
// (4)+(5) закрытие карточки при СВЕЖЕЙ сессии (<2 мин): state=IDLE,
// гейт AD_FIRST_MS блокирует showFullscreen (fsAdv не изменился)
await page.evaluate(() => { sessionStartTs = Date.now(); lastAdTs = 0; });
const y2i0 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i1 = await page.evaluate(() => ({ st: state, fsAdv: YG.counts.fsAdv }));
ok('RV закрытие карточки: state=IDLE', y2i1.st === 'IDLE', JSON.stringify(y2i1));
ok('interstitial гейт 2 мин: свежая сессия — showFullscreen не вызван (fsAdv не изменился)',
y2i1.fsAdv === y2i0, 'before=' + y2i0 + ' after=' + y2i1.fsAdv);
// (6) сессия старше 2 мин (sessionStartTs=now-130s), lastAdTs=0 → реклама: fsAdv +1
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
await page.evaluate(() => { sessionStartTs = Date.now() - (AD_FIRST_MS + 10000); lastAdTs = 0; });
const y2i2 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i3 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
ok('interstitial после 2 мин: showFullscreen вызван (fsAdv +1), endAd → adShowing/paused=false',
y2i3.fsAdv === y2i2 + 1 && y2i3.adShowing === false && y2i3.paused === false,
JSON.stringify({ before: y2i2, after: y2i3 }));
// (7) кулдаун 3 мин: улов сразу после рекламы (lastAdTs свежий из (6)) → повтор не вызван
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const y2i4 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i5 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv }));
ok('interstitial кулдаун 3 мин: улов сразу после рекламы — showFullscreen не вызван (fsAdv не изменился)',
y2i5.fsAdv === y2i4, 'before=' + y2i4 + ' after=' + y2i5.fsAdv);
// (8) кулдаун истёк (lastAdTs=now-190s) → реклама: fsAdv +1
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
await page.evaluate(() => { lastAdTs = Date.now() - (AD_COOLDOWN_MS + 10000); });
const y2i6 = await page.evaluate(() => YG.counts.fsAdv);
await page.click('#btnCardOk');
const y2i7 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
ok('interstitial после истечения кулдауна: showFullscreen вызван (fsAdv +1), adShowing/paused=false',
y2i7.fsAdv === y2i6 + 1 && y2i7.adShowing === false && y2i7.paused === false,
JSON.stringify({ before: y2i6, after: y2i7 }));
}
// cleanup: сброс тест-форсов, возврат в MENU (пауза → «В меню» → btnReset → btnResetYes),
// adShowing/paused=false
await page.evaluate(() => { forceBite = false; forceVariant = null; });
await toGameMenu(page);
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y2end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
adShowing: adShowing,
paused: YG.paused,
}));
ok('Y2 cleanup: возврат в MENU (меню видно), adShowing=false, YG.paused=false',
y2end.st === 'MENU' && y2end.menu === true && y2end.adShowing === false && y2end.paused === false,
JSON.stringify(y2end));
// --- Y3. Полировка ---
ok('Y3 полировка: .rvbtn min-height 44px (тап-таргет)',
await page.evaluate(() => getComputedStyle(document.getElementById('btnRv')).minHeight === '44px'));
// --- Y5. i18n RU/EN (треб. 2.14) ---
// (1) стаб дефолт RU: LANG=ru, <html lang=ru>, меню на RU, RV-кнопка с «×2»
const y5a = await page.evaluate(() => ({
lang: LANG,
htmlLang: document.documentElement.lang,
menu: document.querySelector('#menu .title').textContent,
rv: document.getElementById('btnRv').textContent
}));
ok('Y5 i18n стаб: дефолт RU (LANG=ru, html lang=ru, меню «РЫБАЛКА», RV-текст с «×2»)',
y5a.lang === 'ru' && y5a.htmlLang === 'ru' && y5a.menu.includes('РЫБАЛКА') && y5a.rv.includes('×2'),
JSON.stringify(y5a));
// (2) forceLang='en' → setLang('en'): язык EN — title, таблицы данных, квесты, RV
const y5b = await page.evaluate(() => {
forceLang = 'en';
setLang('en');
return {
lang: LANG,
htmlLang: document.documentElement.lang,
title: document.title,
rar: trRarity(0),
name0: trName(SPECIES[0]),
quest: questText({ tpl: 'catch_shiny', goal: 1, param: 0 }),
rv: document.getElementById('btnRv').textContent
};
});
ok('Y5 i18n forceLang=en: setLang(en) — LANG=en, html lang=en, title EN',
y5b.lang === 'en' && y5b.htmlLang === 'en' && y5b.title === 'Fishing',
JSON.stringify(y5b));
ok('Y5 i18n EN: trRarity(0)=Common, trName(SPECIES[0])=Crucian carp',
y5b.rar === 'Common' && y5b.name0 === 'Crucian carp',
JSON.stringify({ rar: y5b.rar, name0: y5b.name0 }));
ok('Y5 i18n EN: questText(catch_shiny) = "Catch a golden fish"',
y5b.quest === 'Catch a golden fish', 'got: ' + y5b.quest);
ok('Y5 i18n EN: RV-кнопка переведена (содержит «Ad» и «×2»)',
y5b.rv.includes('Ad') && y5b.rv.includes('×2'), 'got: ' + y5b.rv);
// (3) setLang('ru') — обратно на RU (дефолт)
const y5c = await page.evaluate(() => {
setLang('ru');
const r = {
lang: LANG,
htmlLang: document.documentElement.lang,
title: document.title,
rar: trRarity(0),
rv: document.getElementById('btnRv').textContent
};
forceLang = null; // чистим тест-форс
return r;
});
ok('Y5 i18n setLang(ru): обратно RU (trRarity(0)=Обычная, html lang=ru, title RU, RV «Реклама»)',
y5c.lang === 'ru' && y5c.htmlLang === 'ru' && y5c.title === 'Рыбалка'
&& y5c.rar === 'Обычная' && y5c.rv.includes('Реклама'),
JSON.stringify(y5c));
// --- Y6. Кнопка паузы (треб. Яндекс Игр 6.3) и blur/focus (треб. 1.3) ---
// (1) #btnPause существует; в MENU — hidden; после #btnPlay (IDLE) — виден
// (видимость синхронизирует syncDom() в updateGame — sleep до кадра)
const y6a = await page.evaluate(() => ({
exists: !!document.getElementById('btnPause'),
hiddenMenu: document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Y6 пауза: #btnPause существует, в MENU скрыт',
y6a.exists === true && y6a.hiddenMenu === true, JSON.stringify(y6a));
await page.click('#btnPlay');
await sleep(150);
const y6b = await page.evaluate(() => ({
st: state, ui: uiOpen,
hidden: document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Y6 пауза: после #btnPlay (IDLE) — #btnPause виден',
y6b.st === 'IDLE' && y6b.ui === false && y6b.hidden === false, JSON.stringify(y6b));
// (2) клик #btnPause из IDLE: оверлей открыт, всё на паузе
await page.click('#btnPause');
await sleep(100);
const y6c = await page.evaluate(() => ({
po: pauseOpen, ui: uiOpen, paused: YG.paused,
open: !document.getElementById('pause').classList.contains('hidden'),
btn: document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Y6 пауза: клик #btnPause из IDLE → pauseOpen=true, uiOpen=true, YG.paused=true, #pause виден, #btnPause скрыт',
y6c.po === true && y6c.ui === true && y6c.paused === true && y6c.open === true && y6c.btn === true,
JSON.stringify(y6c));
// (3) пауза замораживает время: frame() под YG.paused не двигает dayTime
const y6d1 = await page.evaluate(() => dayTime);
await sleep(300);
const y6d2 = await page.evaluate(() => dayTime);
ok('Y6 пауза: время заморожено (dayTime не изменился за 300 мс под YG.paused)',
y6d1 === y6d2, JSON.stringify({ d1: y6d1, d2: y6d2 }));
// (4) #btnPauseResume: пауза снята, игра продолжается (IDLE)
await page.click('#btnPauseResume');
await sleep(100);
const y6e = await page.evaluate(() => ({
po: pauseOpen, ui: uiOpen, paused: YG.paused, st: state,
open: !document.getElementById('pause').classList.contains('hidden'),
btn: !document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Y6 пауза: #btnPauseResume → pauseOpen=false, YG.paused=false, #pause скрыт, state=IDLE, uiOpen=false, #btnPause снова виден',
y6e.po === false && y6e.ui === false && y6e.paused === false && y6e.st === 'IDLE'
&& y6e.open === false && y6e.btn === true,
JSON.stringify(y6e));
// (5) «В меню» из паузы: открыт хаб #gamemenu (state сохранён — IDLE), пауза снята
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
const y6f = await page.evaluate(() => ({
st: state,
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
menu: document.getElementById('menu').classList.contains('hidden'),
open: !document.getElementById('pause').classList.contains('hidden'),
po: pauseOpen, paused: YG.paused
}));
ok('Y6 пауза: #btnPause → #btnPauseMenu → #gamemenu виден (state IDLE сохранён), #pause скрыт, YG.paused=false',
y6f.st === 'IDLE' && y6f.gm === true && y6f.menu === true && y6f.open === false && y6f.po === false && y6f.paused === false,
JSON.stringify(y6f));
// (6) blur/focus (треб. 1.3): из IDLE — blur окна паузит, focus возобновляет.
// Путь к #btnPlay: из «Меню» — «Инструкции» (#menu), там кнопка «Играть»
await page.click('#btnGmHowto');
await sleep(100);
await page.click('#btnPlay');
await sleep(150);
const y6g = await page.evaluate(() => {
window.dispatchEvent(new Event('blur'));
const p1 = YG.paused;
window.dispatchEvent(new Event('focus'));
return { st: state, p1, p2: YG.paused };
});
ok('Y6 blur/focus: из IDLE window blur → YG.paused=true, focus → YG.paused=false',
y6g.st === 'IDLE' && y6g.p1 === true && y6g.p2 === false, JSON.stringify(y6g));
// (7) гард pauseOpen: focus при открытой паузе не снимает паузу;
// снимает только #btnPauseResume
await page.click('#btnPause');
await sleep(100);
const y6h = await page.evaluate(() => {
window.dispatchEvent(new Event('focus'));
return { po: pauseOpen, paused: YG.paused };
});
ok('Y6 гард pauseOpen: focus при открытой паузе → YG.paused остаётся true',
y6h.po === true && y6h.paused === true, JSON.stringify(y6h));
await page.click('#btnPauseResume');
await sleep(100);
const y6i = await page.evaluate(() => ({ po: pauseOpen, paused: YG.paused }));
ok('Y6 гард pauseOpen: #btnPauseResume снимает паузу (YG.paused=false)',
y6i.po === false && y6i.paused === false, JSON.stringify(y6i));
// cleanup: возврат в MENU (пауза → «В меню» → «Очистить» → «Сбросить») — состояние до секции
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y6end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
gm: document.getElementById('gamemenu').classList.contains('hidden'),
po: pauseOpen, paused: YG.paused
}));
ok('Y6 cleanup: возврат в MENU (меню видно, #gamemenu скрыт), pauseOpen=false, YG.paused=false',
y6end.st === 'MENU' && y6end.menu === true && y6end.gm === true && y6end.po === false && y6end.paused === false,
JSON.stringify(y6end));
// --- Y8. Монеты → лавка (вкладка «Лавка») и хаб «Меню» (#gamemenu) ---
// (1) HUD: 🗑️/🔊 переехали в «Меню», в HUD только 🛒 и ⏸; клик по #coins — лавка на «Лавке»
await page.click('#btnPlay');
await sleep(150);
const y8hud = await page.evaluate(() => ({
resetInMenu: !!document.getElementById('btnReset').closest('#gamemenu'),
muteInMenu: !!document.getElementById('btnMute').closest('#gamemenu'),
hudBtns: [...document.querySelectorAll('.hudbtns button')].map(b => b.id)
}));
ok('Y8 HUD: 🗑️/🔊 в #gamemenu, в .hudbtns остались только btnShop/btnPause',
y8hud.resetInMenu && y8hud.muteInMenu
&& y8hud.hudBtns.length === 2 && y8hud.hudBtns.includes('btnShop') && y8hud.hudBtns.includes('btnPause'),
JSON.stringify(y8hud));
await page.click('#coins');
await sleep(150);
const y8a = await page.evaluate(() => ({
shop: !document.getElementById('shop').classList.contains('hidden'),
tackle: document.getElementById('tabTackle').classList.contains('active'),
ui: uiOpen
}));
ok('Y8 монеты: клик #coins → лавка открыта ровно на вкладке «Лавка» (tackle active)',
y8a.shop && y8a.tackle && y8a.ui === true, JSON.stringify(y8a));
// (2) × из лавки, открытой из HUD/монет — возврат в игру
await page.click('#btnCloseShop');
await sleep(150);
ok('Y8 монеты: #btnCloseShop → лавка закрыта, uiOpen=false (без возврата в «Меню»)',
await page.$eval('#shop', el => el.classList.contains('hidden'))
&& (await page.evaluate(() => uiOpen)) === false);
// (3) пауза → «В меню»: #gamemenu виден, сессия сохранена (state=IDLE), #pause скрыт
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
const y8b = await page.evaluate(() => ({
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
st: state, ui: uiOpen, po: pauseOpen, paused: YG.paused,
pause: document.getElementById('pause').classList.contains('hidden'),
menu: document.getElementById('menu').classList.contains('hidden')
}));
ok('Y8 меню: «В меню» из паузы → #gamemenu виден, state=IDLE сохранён, #pause скрыт, YG.paused=false',
y8b.gm && y8b.st === 'IDLE' && y8b.ui === true && y8b.po === false && y8b.paused === false
&& y8b.pause && y8b.menu, JSON.stringify(y8b));
// (4) «Магазин» из меню → лавка на «Лавке»; × → шаг назад в «Меню» (uiOpen=true, без start)
const y8s0 = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop }));
await page.click('#btnGmShop');
await sleep(150);
const y8c = await page.evaluate(() => ({
shop: !document.getElementById('shop').classList.contains('hidden'),
gm: document.getElementById('gamemenu').classList.contains('hidden'),
tackle: document.getElementById('tabTackle').classList.contains('active')
}));
ok('Y8 меню: «Магазин» → лавка на вкладке «Лавка», #gamemenu скрыт (не перекрывает лавку)',
y8c.shop && y8c.gm && y8c.tackle, JSON.stringify(y8c));
await page.click('#btnCloseShop');
await sleep(150);
const y8d = await page.evaluate(() => ({
shop: document.getElementById('shop').classList.contains('hidden'),
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen, s: YG.counts.start, t: YG.counts.stop
}));
ok('Y8 меню: × из лавки возвращает в «Меню» (uiOpen=true, геймплей не стартует: stop +1, start +0)',
y8d.shop && y8d.gm && y8d.ui === true && y8d.s === y8s0.s && y8d.t === y8s0.t + 1,
JSON.stringify({ before: y8s0, after: y8d }));
// (5) «Достижения»/«Дневник» из меню → свои вкладки лавки
await page.click('#btnGmAch');
await sleep(150);
const y8e = await page.evaluate(() => ({
shop: !document.getElementById('shop').classList.contains('hidden'),
ach: document.getElementById('tabAch').classList.contains('active')
}));
ok('Y8 меню: «Достижения» → лавка на вкладке достижений', y8e.shop && y8e.ach, JSON.stringify(y8e));
await page.click('#btnCloseShop');
await sleep(100);
await page.click('#btnGmJournal');
await sleep(150);
const y8f = await page.evaluate(() => ({
shop: !document.getElementById('shop').classList.contains('hidden'),
journal: document.getElementById('tabJournal').classList.contains('active')
}));
ok('Y8 меню: «Дневник» → лавка на вкладке дневника', y8f.shop && y8f.journal, JSON.stringify(y8f));
await page.click('#btnCloseShop');
await sleep(100);
// (6) Звук из меню: два клика — muted меняется на каждом, итог = исходный
// (не пинуем абсолютное значение: фикстуры старых сейвов выше могут ставить muted=true)
const y8m0 = await page.evaluate(() => save.muted);
await page.click('#btnMute');
const y8m1 = await page.evaluate(() => save.muted);
await page.click('#btnMute');
const y8m2 = await page.evaluate(() => save.muted);
ok('Y8 меню: #btnMute в «Меню» переключает звук (каждый клик — тогл)',
y8m1 === !y8m0 && y8m2 === y8m0, JSON.stringify({ y8m0, y8m1, y8m2 }));
// (7) «Продолжить» → возврат в игру: сессия на том же месте (IDLE), gameplayStart +1
const y8s1 = await page.evaluate(() => YG.counts.start);
await page.click('#btnGmResume');
await sleep(150);
const y8g = await page.evaluate(() => ({
gm: document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen, st: state, s: YG.counts.start,
btn: document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Y8 меню: «Продолжить» → #gamemenu скрыт, uiOpen=false, state=IDLE, gameplayStart +1, #btnPause виден',
y8g.gm && y8g.ui === false && y8g.st === 'IDLE' && y8g.s === y8s1 + 1 && y8g.btn === false,
JSON.stringify(y8g));
// (7b) мировой таймер (M3 день/ночь + M8 погода) стоит под неигровыми экранами:
// открываем лавку (uiOpen=true, YG.paused=false), ставим погоду — за 300 мс
// dayTime и weatherT не двигаются; после × таймер снова идёт.
await page.click('#btnShop');
await sleep(50);
const y8w0 = await page.evaluate(() => {
forceWeather = null; weather = 'rain'; weatherT = 100;
return { d: dayTime, t: weatherT, ui: uiOpen, shop: !document.getElementById('shop').classList.contains('hidden') };
});
await sleep(300);
const y8w1 = await page.evaluate(() => ({ d: dayTime, t: weatherT, ui: uiOpen }));
ok('Y8 таймер: лавка (неигровой экран, uiOpen) → dayTime и weatherT заморожены',
y8w0.shop === true && y8w0.ui === true && y8w1.ui === true
&& y8w1.d === y8w0.d && y8w1.t === y8w0.t,
JSON.stringify({ before: y8w0, after: y8w1 }));
await page.click('#btnCloseShop');
await sleep(300);
const y8w2 = await page.evaluate(() => ({ d: dayTime, t: weatherT, ui: uiOpen }));
ok('Y8 таймер: закрытие лавки → dayTime и weatherT снова идут',
y8w2.ui === false && y8w2.d !== y8w0.d && y8w2.t < y8w0.t,
JSON.stringify({ before: y8w0, after: y8w2 }));
// (7c) то же для хаба «Меню» (#gamemenu): pauseOpen снят, но uiOpen=true
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
const y8w3 = await page.evaluate(() => ({
d: dayTime, t: weatherT,
gm: !document.getElementById('gamemenu').classList.contains('hidden'), ui: uiOpen
}));
await sleep(300);
const y8w4 = await page.evaluate(() => ({ d: dayTime, t: weatherT }));
ok('Y8 таймер: хаб «Меню» (#gamemenu, uiOpen) → dayTime и weatherT заморожены',
y8w3.gm === true && y8w3.ui === true && y8w4.d === y8w3.d && y8w4.t === y8w3.t,
JSON.stringify({ before: y8w3, after: y8w4 }));
await page.click('#btnGmResume');
await sleep(150);
await page.evaluate(() => { weather = null; weatherT = 0; forceWeather = null; weatherGap = weatherGapNext(); });
// (8) «Инструкции» из меню → начальные инструкции (#menu); «Играть» возвращает в игру
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
await page.click('#btnGmHowto');
await sleep(100);
const y8h = await page.evaluate(() => ({
menu: !document.getElementById('menu').classList.contains('hidden'),
gm: document.getElementById('gamemenu').classList.contains('hidden')
}));
ok('Y8 меню: «Инструкции» → диалог с инструкциями виден, #gamemenu скрыт',
y8h.menu && y8h.gm, JSON.stringify(y8h));
await page.screenshot({ path: 'shot-gamemenu-instructions.png' });
await page.click('#btnPlay');
await sleep(150);
ok('Y8 меню: «Играть» из инструкций → обратно в игру (state=IDLE, #gamemenu скрыт)',
(await page.evaluate(() => state)) === 'IDLE'
&& (await page.$eval('#gamemenu', el => el.classList.contains('hidden'))));
// cleanup: «Очистить» из «Меню» → «Сбросить» → MENU (инструкции), гменю скрыто
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
await page.screenshot({ path: 'shot-gamemenu.png' });
await page.click('#btnReset');
await sleep(150);
await page.click('#btnResetYes');
await sleep(150);
const y8end = await page.evaluate(() => ({
st: state,
menu: !document.getElementById('menu').classList.contains('hidden'),
gm: document.getElementById('gamemenu').classList.contains('hidden')
}));
ok('Y8 cleanup: «Очистить» → «Сбросить» → MENU (инструкции), #gamemenu скрыт',
y8end.st === 'MENU' && y8end.menu && y8end.gm, JSON.stringify(y8end));
// --- Y7. Встроенный emoji-шрифт (этап 28) ---
// Шрифт вшит в index.html как base64 @font-face (субсет Noto Color Emoji, tools/emoji-font.py).
// Проверка: face зарегистрирован и реально резолвит glyph (не молча падает в системный фоллбек).
const y7 = await page.evaluate(async () => {
const loaded = (document.fonts&&document.fonts.load)
? await Promise.race([document.fonts.load('16px "EmojiEmbedded"').then(()=>true).catch(()=>false),
new Promise(r=>setTimeout(()=>r(false),3000))])
: false;
const check = document.fonts ? document.fonts.check('16px "EmojiEmbedded"','🪙') : false;
const families = document.fonts ? [...document.fonts].map(f=>f.family) : [];
return { loaded, check, families };
});
ok('Y7 шрифт: @font-face "EmojiEmbedded" зарегистрирован в document.fonts',
y7.families.includes('EmojiEmbedded'), JSON.stringify(y7));
ok('Y7 шрифт: document.fonts.check("16px EmojiEmbedded","🪙") — встроенный шрифт резолвит glyph',
y7.loaded === true && y7.check === true, JSON.stringify(y7));
// --- X. Закрытие по тапу мимо диалога (backdropClose) ---
// Тап «в молоко» — на подложку оверлея (e.target === сам оверлей, не .panel) — закрывает
// диалог тем же действием, что и кнопка закрытия. Точка (10,10) — в 16px-паддинге .overlay,
// т.е. гарантированно на подложке (панель центрирована, width ≤ 370px).
await page.click('#btnPlay');
await sleep(150);
// (1) Лавка: открыть → тап по подложке → закрытие (как ×)
await page.click('#btnShop');
await sleep(100);
await page.mouse.click(10, 10);
await sleep(100);
const xb1 = await page.evaluate(() => ({
shop: document.getElementById('shop').classList.contains('hidden'),
ui: uiOpen, st: state
}));
ok('X подложка: лавка → тап мимо → закрыта (uiOpen=false, state=IDLE)',
xb1.shop && xb1.ui === false && xb1.st === 'IDLE', JSON.stringify(xb1));
// (2) Пауза: открыть → тап по подложке → «Продолжить»
await page.click('#btnPause');
await sleep(100);
await page.mouse.click(10, 10);
await sleep(100);
const xb2 = await page.evaluate(() => ({
pause: document.getElementById('pause').classList.contains('hidden'),
po: pauseOpen, ui: uiOpen
}));
ok('X подложка: пауза → тап мимо → снята (pauseOpen=false, uiOpen=false)',
xb2.pause && xb2.po === false && xb2.ui === false, JSON.stringify(xb2));
// (3) Хаб «Меню»: из паузы → тап по подложке → «Продолжить» (сессия с места)
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
const xb3s = await page.evaluate(() => YG.counts.start);
await page.mouse.click(10, 10);
await sleep(100);
const xb3 = await page.evaluate(() => ({
gm: document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen, st: state, s: YG.counts.start
}));
ok('X подложка: «Меню» → тап мимо → «Продолжить» (uiOpen=false, state=IDLE, gameplayStart +1)',
xb3.gm && xb3.ui === false && xb3.st === 'IDLE' && xb3.s === xb3s + 1, JSON.stringify(xb3));
// (4) Подтверждение сброса: из «Меню» «Очистить» → тап по подложке → «Отмена» (в «Меню», прогресс цел)
await page.click('#btnPause');
await sleep(100);
await page.click('#btnPauseMenu');
await sleep(100);
await page.click('#btnReset');
await sleep(100);
const xb4pre = await page.evaluate(() => ({
dlg: !document.getElementById('resetDlg').classList.contains('hidden'),
coins: save.coins
}));
await page.mouse.click(10, 10);
await sleep(100);
const xb4 = await page.evaluate(() => ({
dlg: document.getElementById('resetDlg').classList.contains('hidden'),
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen, coins: save.coins
}));
ok('X подложка: сброс → тап мимо → «Отмена» (в «Меню», uiOpen=true, монеты не стёрты)',
xb4pre.dlg && xb4.dlg && xb4.gm && xb4.ui === true && xb4.coins === xb4pre.coins, JSON.stringify(xb4));
await page.click('#btnGmResume');
await sleep(100);
// (5) Карточка улова: showCard напрямую (паттерн §9j(7), state='CARD' — как в catchFish)
// → тап по подложке → карточка ОСТАЁТСЯ открытой (state='CARD'): диалог улова вне backdropClose,
// случайный тап мимо не закрывает его и не дёргает интерстишел. Ждём 400 мс: у #card pop-анимация
// (transform scale, 300 мс) — чтобы тап гарантированно пришёлся на подложку, а не на канвас.
await page.evaluate(() => { state='CARD'; showCard(SPECIES[0], 1, 100, false, VARIANTS[0], false); });
await sleep(400);
const xb5pre = await page.evaluate(() => ({
card: !document.getElementById('card').classList.contains('hidden')
}));
await page.mouse.click(10, 10);
await sleep(150);
const xb5 = await page.evaluate(() => ({
card: !document.getElementById('card').classList.contains('hidden'),
st: state
}));
ok('X подложка: карточка → тап мимо → НЕ закрыта (state=CARD)',
xb5pre.card && xb5.card && xb5.st === 'CARD', JSON.stringify(xb5));
// Закрыть карточку явной кнопкой, чтобы не тащить состояние CARD в §AQ (там reload, но подстраховка).
await page.click('#btnCardOk');
await sleep(100);
// --- AQ. Этап 46: Аквариум; Этап 52: диалог выбора слота (#aqPick) ---
// Глобалы игры (page.evaluate): aqOpen, aqFishRt, aqSel, aqTank, AQ_BASE_SLOTS, AQ_MAX_SLOTS,
// AQ_SLOT_COSTS, AQ_VARIANT_RECOLOR, AQ_VARIANT_CLASS, aqApplyVariant, initAquarium,
// updateAquarium, drawAquarium, aqFishSize, aqFishEls, aqBuildFishEl, aqPlaceFish,
// aqTap, showAqPop, hideAqPop, renderAqBar, aqReleaseAt, openAquarium, closeAquarium,
// syncKeepBtn, cardSpec, save.aqFish/save.aqSlots — имена не переименовывать (тесты молча ломаются).
// Этап 52: aqPickOpen, aqPickPending, openAqPick, closeAqPick, renderAqPick, aqPickPlace,
// aqPickConfirmReplace, aqPickBuySlot, aqPickFinish.
// Э62: aqShopOpen, aqShopTab, openAqShop, closeAqShop, setAqTab, renderAqShop, aqBuySlot,
// aqEquipFloor, aqEquipDeco — имена не переименовывать. Э64: aqShopRelPending (pending выпуска).
// DOM: #btnGmAquarium, #btnKeep, #aqBar, #btnAqBack, #aqCount, #aqPop,
// #aqPopName, #aqPopSub, #aqFishLayer (DOM-слой портретов — ревизия: жильцы
// те же SVG-спрайты #fish-<id>, что на карточке улова; вариант — класс .var<id> + --f1/--f3).
// Э64: в #aqPop кнопок нет (инфо-поповер) — выпуск только из «Слотов» #aqShop.
// Этап 52 DOM: #aqPick, #aqPickTitle, #aqPickList, .aqpickrow(.empty/.replace), #aqPickConfirm,
// #aqPickConfirmText, #btnAqPickConfirmNo, #btnAqPickConfirmYes, #btnAqPickSlot, #btnAqPickCancel.
// Э62 DOM (диалог улучшений): #btnAqShop (пилюля входа), #aqShop, #btnCloseAqShop, #aqShopTitle,
// #tabAqSlots, #tabAqUpg, #aqShopBody; строки [data-act=buyaslot|buyfloor|selfloor|buydeco|seldeco|selnone].
// Э64 DOM: строки жильца с [data-act=askrelease], подтверждение #aqShopConfirm/#aqShopConfirmText/
// #btnAqShopConfirmNo/#btnAqShopConfirmYes. Прежние пилюли #btnAqSlot/#btnAqFloor/#btnAqDeco и
// кнопка выпуска в поповере #btnAqRelease убраны — управление только через #aqShop.
// (1) данные: freshSave() кладёт aqFish:[] и aqSlots:AQ_BASE_SLOTS; кноб слотов (6/12 + лестница цен)
const aqFresh = await page.evaluate(() => {
const f = freshSave(false);
return { arr: Array.isArray(f.aqFish), len: f.aqFish.length, slots: f.aqSlots,
base: AQ_BASE_SLOTS, max: AQ_MAX_SLOTS, costs: AQ_SLOT_COSTS.slice() };
});
ok('Этап 46 данные: freshSave().aqFish=[] и aqSlots=AQ_BASE_SLOTS (6), AQ_MAX_SLOTS=12, в AQ_SLOT_COSTS 6 цен',
aqFresh.arr === true && aqFresh.len === 0 && aqFresh.slots === aqFresh.base
&& aqFresh.base === 6 && aqFresh.max === 12 && aqFresh.costs.length === 6
&& aqFresh.costs.every(c => typeof c === 'number' && c > 0),
JSON.stringify(aqFresh));
// (2) миграция: старый сейв без aqFish/aqSlots → после reload дефолты (паттерн §9b/M6(6))
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 5000, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 0, earned: 0
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const aqMig = await page.evaluate(() => ({
menu: !document.getElementById('menu').classList.contains('hidden'),
arr: Array.isArray(save.aqFish), len: save.aqFish.length,
slots: save.aqSlots, base: AQ_BASE_SLOTS
}));
ok('Этап 46 миграция: старый сейв без aqFish/aqSlots → aqFish=[], aqSlots=AQ_BASE_SLOTS (6)',
aqMig.menu && aqMig.arr === true && aqMig.len === 0 && aqMig.slots === aqMig.base && aqMig.base === 6,
JSON.stringify(aqMig));
// (3) миграция битых значений: неизвестный вид/вес отброшены, битый вариант → normal,
// aqSlots клэмпится к AQ_MAX_SLOTS, хвост списка обрезается по числу слотов
await page.evaluate(() => {
const fish = [{ id: 'pike', v: 'bad', w: 3 }]; // индекс 0: битый вариант → 'normal'
for (let i = 0; i < 15; i++) fish.push({ id: i % 2 ? 'perch' : 'carass', v: 'normal', w: 1 + i });
fish.push({ id: 'nope', v: 'gold', w: 2 }); // неизвестный вид — отбрасывается
fish.push({ id: 'perch', v: 'normal', w: -1 }); // битый вес — отбрасывается
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 1, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 0, earned: 0, aqSlots: 99, aqFish: fish
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const aqMig2 = await page.evaluate(() => ({
slots: save.aqSlots, max: AQ_MAX_SLOTS, len: save.aqFish.length,
first: save.aqFish[0] && { id: save.aqFish[0].id, v: save.aqFish[0].v },
allValid: save.aqFish.every(f => SPECIES.some(s => s.id === f.id)
&& typeof f.w === 'number' && f.w > 0 && VARIANTS.some(v => v.id === f.v))
}));
ok('Этап 46 миграция: битый id/вес отброшены, неизвестный вариант → normal, aqSlots клэмп к AQ_MAX_SLOTS, хвост обрезан',
aqMig2.slots === aqMig2.max && aqMig2.max === 12 && aqMig2.len === aqMig2.max
&& aqMig2.first && aqMig2.first.id === 'pike' && aqMig2.first.v === 'normal' && aqMig2.allValid,
JSON.stringify(aqMig2));
// (4) keep: форс-улов (normal) → CARD → #btnKeep копирует экземпляр (модель C).
// Сброс aq-ачивок: deep-миграция выше могла выдать aq_first/aq_full через applyStreak.
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.aqFish = []; save.aqSlots = AQ_BASE_SLOTS;
if(save.ach){ delete save.ach.aq_first; delete save.ach.aq_full; }
save.quests = { date: todayStr(), list: [] };
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
forceBite = true; forceVariant = 'normal'; forceBoss = false;
persist();
});
const aqCoinsBefore = await page.evaluate(() => save.coins);
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
await waitState(page, 'CARD', 5000);
await page.mouse.up();
const aqCard = await page.evaluate(() => ({
spec: cardSpec ? { id: cardSpec.sp.id, v: cardSpec.variant.id, w: cardSpec.w } : null,
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
coins: save.coins,
keepDisabled: document.getElementById('btnKeep').disabled
}));
ok('Этап 52 keep: карточка даёт cardSpec (id/v/w), #btnKeep всегда включён (есть/нет места — не важно)',
aqCard.spec !== null && aqCard.spec.v === 'normal' && aqCard.keepDisabled === false,
JSON.stringify(aqCard));
await page.click('#btnKeep');
await sleep(150);
const aqPickOpen1 = await page.evaluate(() => ({
open: aqPickOpen,
pickVisible: !document.getElementById('aqPick').classList.contains('hidden'),
cardVisible: !document.getElementById('card').classList.contains('hidden'),
rows: document.querySelectorAll('#aqPickList .aqpickrow').length,
empty: document.querySelectorAll('#aqPickList .aqpickrow.empty').length,
len: save.aqFish.length, slots: save.aqSlots
}));
ok('Этап 52 keep: #btnKeep открывает диалог #aqPick (список aqSlots строк), рыба ещё НЕ поселена, карточка видна',
aqPickOpen1.open === true && aqPickOpen1.pickVisible === true && aqPickOpen1.cardVisible === true
&& aqPickOpen1.len === 0 && aqPickOpen1.rows === aqPickOpen1.slots
&& aqPickOpen1.empty === aqPickOpen1.slots,
JSON.stringify(aqPickOpen1));
await page.click('#aqPickList .aqpickrow.empty');
await sleep(200);
const aqKeep = await page.evaluate(() => ({
len: save.aqFish.length, f: save.aqFish[0] || null, coins: save.coins,
cardHidden: document.getElementById('card').classList.contains('hidden'),
pickHidden: document.getElementById('aqPick').classList.contains('hidden'),
open: aqPickOpen, st: state, aqFirst: !!save.ach.aq_first, aqFull: !!save.ach.aq_full
}));
ok('Этап 52 keep: клик по пустому слоту → 1 копия с верными id/v/w, диалог и карточка закрыты, state=IDLE',
aqKeep.len === 1 && aqKeep.f
&& aqKeep.f.id === aqCard.spec.id && aqKeep.f.v === aqCard.spec.v
&& Math.abs(aqKeep.f.w - aqCard.spec.w) < 1e-9
&& aqKeep.cardHidden === true && aqKeep.pickHidden === true && aqKeep.open === false && aqKeep.st === 'IDLE',
JSON.stringify({ card: aqCard, keep: aqKeep }));
ok('Этап 52 keep: монеты выросли на цену улова (модель C — платит catchFish), keep монет не добавляет; выдан aq_first',
aqCard.coins === aqCoinsBefore + aqCard.val && aqKeep.coins === aqCard.coins
&& aqKeep.aqFirst === true && aqKeep.aqFull === false,
JSON.stringify({ before: aqCoinsBefore, afterCard: aqCard.coins, afterKeep: aqKeep.coins,
first: aqKeep.aqFirst, full: aqKeep.aqFull }));
// (5) полный аквариум: #btnKeep активен, диалог показывает все слоты занятыми; клик по
// занятому слоту → inline-подтверждение; отмена ничего не меняет; подтверждение
// заменяет экземпляр (вытесненная рыба выпускается бесплатно), длина массива та же
await page.evaluate(() => {
state = 'CARD'; reel = null; biteFish = null;
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [];
for (let i = 0; i < save.aqSlots; i++) save.aqFish.push({ id: 'carass', v: 'normal', w: 1 });
save.coins = 7777;
showCard(SPECIES[0], 2.5, 100, false, VARIANTS[0], false); // showCard пересинхронизирует #btnKeep
});
await sleep(200);
const aqLim = await page.evaluate(() => ({
disabled: document.getElementById('btnKeep').disabled,
text: document.getElementById('btnKeep').textContent,
keep: t('aq_keep')
}));
ok('Этап 52 лимит: полный аквариум → #btnKeep НЕ disabled (можно заменить или докупить слот)',
aqLim.disabled === false && aqLim.text === aqLim.keep, JSON.stringify(aqLim));
await page.click('#btnKeep');
await sleep(150);
const aqFullPick = await page.evaluate(() => ({
open: aqPickOpen,
rows: document.querySelectorAll('#aqPickList .aqpickrow').length,
empty: document.querySelectorAll('#aqPickList .aqpickrow.empty').length,
len: save.aqFish.length, slots: save.aqSlots,
confirmHidden: document.getElementById('aqPickConfirm').classList.contains('hidden')
}));
ok('Этап 52 лимит: диалог из полного аквариума — все слоты заняты (пустых нет), подтверждение скрыто',
aqFullPick.open === true && aqFullPick.rows === aqFullPick.slots && aqFullPick.empty === 0
&& aqFullPick.len === aqFullPick.slots && aqFullPick.confirmHidden === true,
JSON.stringify(aqFullPick));
await page.click('#aqPickList .aqpickrow:nth-child(2)'); // второй занятый слот
await sleep(150);
const aqRep0 = await page.evaluate(() => ({
pending: aqPickPending,
confirmVisible: !document.getElementById('aqPickConfirm').classList.contains('hidden'),
text: document.getElementById('aqPickConfirmText').textContent,
marked: document.querySelectorAll('#aqPickList .aqpickrow.replace').length
}));
ok('Этап 52 замена: клик по занятому слоту → inline-подтверждение (aqPickPending=1, строка помечена, есть текст)',
aqRep0.pending === 1 && aqRep0.confirmVisible === true && aqRep0.marked === 1
&& aqRep0.text.length > 0, JSON.stringify(aqRep0));
await page.click('#btnAqPickConfirmNo'); // Отмена — ничего не меняется
await sleep(150);
const aqRepCancel = await page.evaluate(() => ({
pending: aqPickPending, len: save.aqFish.length, open: aqPickOpen, base: AQ_BASE_SLOTS,
f2: save.aqFish[1] && { id: save.aqFish[1].id, w: save.aqFish[1].w },
confirmHidden: document.getElementById('aqPickConfirm').classList.contains('hidden')
}));
ok('Этап 52 замена: «Отмена» снимает подтверждение, список не меняется',
aqRepCancel.pending === -1 && aqRepCancel.len === aqRepCancel.base && aqRepCancel.open === true
&& aqRepCancel.f2 && aqRepCancel.f2.id === 'carass' && aqRepCancel.f2.w === 1
&& aqRepCancel.confirmHidden === true, JSON.stringify(aqRepCancel));
await page.click('#aqPickList .aqpickrow:nth-child(2)');
await sleep(120);
await page.click('#btnAqPickConfirmYes');
await sleep(200);
const aqRep1 = await page.evaluate(() => ({
len: save.aqFish.length, slots: save.aqSlots, base: AQ_BASE_SLOTS, spId: SPECIES[0].id,
f2: save.aqFish[1] || null,
cardHidden: document.getElementById('card').classList.contains('hidden'),
pickHidden: document.getElementById('aqPick').classList.contains('hidden'),
open: aqPickOpen, st: state
}));
ok('Этап 52 замена: подтверждение заменяет экземпляр в слоте (id/v/w), длина не растёт, диалог/карточка закрыты, state=IDLE',
aqRep1.len === aqRep1.base && aqRep1.f2
&& aqRep1.f2.id === aqRep1.spId && aqRep1.f2.v === 'normal' && Math.abs(aqRep1.f2.w - 2.5) < 1e-9
&& aqRep1.cardHidden === true && aqRep1.pickHidden === true && aqRep1.open === false && aqRep1.st === 'IDLE',
JSON.stringify(aqRep1));
// (5b) докупка слота из диалога: монеты списываются по AQ_SLOT_COSTS, появляется пустая строка
await page.evaluate(() => {
state = 'CARD'; reel = null; biteFish = null;
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [];
for (let i = 0; i < save.aqSlots; i++) save.aqFish.push({ id: 'carass', v: 'normal', w: 1 });
save.coins = AQ_SLOT_COSTS[0] + 500;
showCard(SPECIES[0], 1, 100, false, VARIANTS[0], false);
});
await page.click('#btnKeep');
await sleep(150);
await page.click('#btnAqPickSlot');
await sleep(150);
const aqPickBuy = await page.evaluate(() => ({
coins: save.coins, slots: save.aqSlots, base: AQ_BASE_SLOTS, cost: AQ_SLOT_COSTS[0],
rows: document.querySelectorAll('#aqPickList .aqpickrow').length,
empty: document.querySelectorAll('#aqPickList .aqpickrow.empty').length
}));
ok('Этап 52 докупка: #btnAqPickSlot списывает AQ_SLOT_COSTS и добавляет пустую строку в диалоге',
aqPickBuy.slots === aqPickBuy.base + 1
&& aqPickBuy.coins === (aqPickBuy.cost + 500) - aqPickBuy.cost
&& aqPickBuy.rows === aqPickBuy.slots && aqPickBuy.empty === 1,
JSON.stringify(aqPickBuy));
// Отмена диалога: возврат к карточке, ничего не поселено
await page.click('#btnAqPickCancel');
await sleep(150);
const aqPickCancel = await page.evaluate(() => ({
open: aqPickOpen,
pickHidden: document.getElementById('aqPick').classList.contains('hidden'),
cardVisible: !document.getElementById('card').classList.contains('hidden'),
len: save.aqFish.length, base: AQ_BASE_SLOTS
}));
ok('Этап 52 отмена: #btnAqPickCancel закрывает диалог, карточка остаётся, рыба не поселена',
aqPickCancel.open === false && aqPickCancel.pickHidden === true && aqPickCancel.cardVisible === true
&& aqPickCancel.len === aqPickCancel.base, JSON.stringify(aqPickCancel));
// Закрываем карточку штатной кнопкой (рыбу не кладём)
await page.click('#btnCardOk');
await sleep(150);
await page.evaluate(() => { save.aqSlots = AQ_BASE_SLOTS; save.aqFish = []; if(cardSpec) cardSpec = null; persist(); });
// (5c) портрет и вариант в строке диалога — те же SVG-спрайты, что на карточке (#fish-<id> + .var<id>)
await page.evaluate(() => {
state = 'CARD'; reel = null; biteFish = null;
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [{ id: 'pike', v: 'silver', w: 7.4 }];
save.coins = 0;
showCard(SPECIES[0], 1, 100, false, VARIANTS[0], false);
});
await page.click('#btnKeep');
await sleep(150);
const aqPickPort = await page.evaluate(() => {
const row = document.querySelector('#aqPickList .aqpickrow');
const use = row && row.querySelector('.je use');
const sub = row && row.querySelector('.jn .jc');
return {
occupied: !!row && !row.classList.contains('empty'),
href: use ? use.getAttribute('href') : '',
cls: row ? AQ_VARIANT_CLASS.filter(id => row.classList.contains('var' + id)).join(',') : 'x',
hasName: !!row && !!row.querySelector('.jn b'),
hasWeight: !!sub && sub.textContent.indexOf('7.4') >= 0
};
});
ok('Этап 52 портрет: занятая строка диалога — SVG #fish-<id> с вариантом .varsilver, имя и вес',
aqPickPort.occupied && aqPickPort.href === '#fish-pike' && aqPickPort.cls === 'silver'
&& aqPickPort.hasName && aqPickPort.hasWeight, JSON.stringify(aqPickPort));
await page.click('#btnAqPickCancel');
await sleep(120);
await page.click('#btnCardOk');
await sleep(150);
await page.evaluate(() => { save.aqSlots = AQ_BASE_SLOTS; save.aqFish = []; if(cardSpec) cardSpec = null; persist(); });
// (6) сцена: вход из хаба «Меню» (#btnGmAquarium) и возврат «← Назад» (#btnAqBack)
await toGameMenu(page);
await page.click('#btnGmAquarium');
await sleep(200);
const aqSc1 = await page.evaluate(() => ({
open: aqOpen,
bar: !document.getElementById('aqBar').classList.contains('hidden'),
gm: document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen,
pauseHidden: document.getElementById('btnPause').classList.contains('hidden')
}));
ok('Этап 46 сцена: #btnGmAquarium → aqOpen=true, #aqBar виден, #gamemenu и #btnPause скрыты',
aqSc1.open === true && aqSc1.bar === true && aqSc1.gm === true && aqSc1.ui === true
&& aqSc1.pauseHidden === true, JSON.stringify(aqSc1));
await page.click('#btnAqBack');
await sleep(150);
const aqSc2 = await page.evaluate(() => ({
open: aqOpen,
barHidden: document.getElementById('aqBar').classList.contains('hidden'),
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
ui: uiOpen
}));
ok('Этап 46 сцена: #btnAqBack → aqOpen=false, возврат в #gamemenu (uiOpen=true)',
aqSc2.open === false && aqSc2.barHidden === true && aqSc2.gm === true && aqSc2.ui === true,
JSON.stringify(aqSc2));
// (6b) ревизия: жильцы банки — те же SVG-портреты, что на карточке улова (спрайт #fish-<id>),
// вариант — класс .var<id> (silver/rainbow — фильтры, copper/gold/quartz/emerald/diamond — drop-shadow),
// DOM-элемент на каждый save.aqFish, transform следует за aqFishRt (индекс в индекс), слой
// виден только под сценой (body.aq-open) и не перехватывает клики
await page.click('#btnGmAquarium');
await sleep(200);
const aqDom = await page.evaluate(() => {
save.aqFish = [{ id: 'carass', v: 'normal', w: 0.6 }, { id: 'pike', v: 'silver', w: 7.4 },
{ id: 'gold', v: 'rainbow', w: 0.8 }, { id: 'tench', v: 'emerald', w: 3 }];
save.aqSlots = AQ_BASE_SLOTS; persist();
initAquarium();
const layer = document.getElementById('aqFishLayer');
const els = [...layer.querySelectorAll('.aqfish')];
return {
n: els.length, len: save.aqFish.length,
hrefs: els.map(e => e.querySelector('use').getAttribute('href')),
ids: save.aqFish.map(f => '#fish-' + f.id),
variants: els.map(e => AQ_VARIANT_CLASS.filter(id => e.classList.contains('var' + id)).join(',')),
f1: els.map(e => e.style.getPropertyValue('--f1')),
rt: aqFishRt.map(r => [r.x, r.y]),
tr: els.map(e => e.style.transform),
click: getComputedStyle(layer).pointerEvents,
cardHref: document.getElementById('cardFishUse').getAttribute('href')
};
});
const aqDomTrack = await page.evaluate(() => {
const els = [...document.querySelectorAll('#aqFishLayer .aqfish')];
return els.every((e, i) => {
const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(e.style.transform);
return m && aqFishRt[i] && Math.abs(+m[1] - aqFishRt[i].x) < 0.1 && Math.abs(+m[2] - aqFishRt[i].y) < 0.1;
});
});
ok('Этап 46 портреты: на каждый aqFish — .aqfish с <use href="#fish-<id>"> (те же спрайты, что на карточке)',
aqDom.n === aqDom.len && aqDom.len === 4
&& JSON.stringify(aqDom.hrefs) === JSON.stringify(aqDom.ids)
&& aqDom.cardHref.indexOf('#fish-') === 0,
JSON.stringify(aqDom));
ok('Этап 46 портреты: вариант как на карточке — .var<id> по варианту (silver/rainbow/emerald) + --f1 у перекраски; normal — без класса',
aqDom.variants[0] === '' && aqDom.variants[1] === 'silver'
&& aqDom.variants[2] === 'rainbow' && aqDom.variants[3] === 'emerald'
&& aqDom.f1[0] === '' && aqDom.f1[3] !== '' && aqDom.click === 'none',
JSON.stringify(aqDom));
ok('Этап 46 портреты: transform каждого .aqfish следует за aqFishRt (индекс в индекс), слой виден в сцене',
aqDomTrack === true && aqDom.tr.every(t => t.indexOf('translate(') === 0),
JSON.stringify({ track: aqDomTrack, tr: aqDom.tr }));
await page.click('#btnAqBack');
await sleep(150);
const aqDomClosed = await page.evaluate(() => ({
disp: getComputedStyle(document.getElementById('aqFishLayer')).display,
cls: document.body.classList.contains('aq-open')
}));
ok('Этап 46 портреты: вне сцены слой скрыт (display:none, body без aq-open)',
aqDomClosed.disp === 'none' && aqDomClosed.cls === false, JSON.stringify(aqDomClosed));
// === Этап 59: скины лески (LINE_SKINS/SKIN_GROUPS) и декор аквариума (aqDecor/AQ_FLOORS/AQ_DECOS) ===
// (59-i) данные: LINE_SKINS (3 записи, цены строго возрастают, nameEn у всех, fx 'gold' у топ)
const e59data = await page.evaluate(() => ({
line: LINE_SKINS.map(s => s.id + ':' + s.cost).join(','),
grow: LINE_SKINS.every((s, i) => i === 0 || s.cost > LINE_SKINS[i - 1].cost),
en: LINE_SKINS.every(s => typeof s.nameEn === 'string' && s.nameEn.length > 0),
gold: (LINE_SKINS.find(s => s.fx === 'gold') || {}).id,
tex: LINE_SKINS.every(s => typeof s.c === 'string' && typeof s.w === 'number' && s.w > 0),
groups: Object.keys(SKIN_GROUPS).sort().join(','),
floors: AQ_FLOORS.map(f => f.id + ':' + f.cost).join(','),
decos: AQ_DECOS.map(d => d.id + ':' + d.cost).join(','),
lineAch: !!(ACHIEVEMENTS.find(a => a.id === 'line_first') || {}).bonus,
lineAchBonus: JSON.stringify((ACHIEVEMENTS.find(a => a.id === 'line_first') || {}).bonus)
}));
ok('Этап 59 данные: LINE_SKINS 3 записи (0/3000/12000, цены растут, nameEn у всех, fx gold у id=2), SKIN_GROUPS boat/bobber/line, AQ_FLOORS 0/2500/8000, AQ_DECOS 5000/9000/20000, у line_first bonus={}',
e59data.line === '0:0,1:3000,2:12000' && e59data.grow === true && e59data.en === true
&& e59data.gold === 2 && e59data.tex === true
&& e59data.groups === 'boat,bobber,line'
&& e59data.floors === '0:0,1:2500,2:8000' && e59data.decos === '0:5000,1:9000,2:20000'
&& e59data.lineAchBonus === '{}',
JSON.stringify(e59data));
// (59-ii) миграция: старый сейв без новых полей → дефолты (леска [0]/0, декор {floor:0,deco:-1}, floorOwned [0], decoOwned [])
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 4242, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 3, earned: 50
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const e59mig = await page.evaluate(() => ({
ls: (save.lineSkins || []).join(','), lc: save.lineSkin,
ad: JSON.stringify(save.aqDecor),
fo: (save.aqFloorOwned || []).join(','),
do_: (save.aqDecoOwned || []).length,
coins: save.coins
}));
ok('Этап 59 миграция: старый сейв → lineSkins=[0], lineSkin=0, aqDecor={floor:0,deco:-1}, aqFloorOwned=[0], aqDecoOwned=[], монеты не тронуты',
e59mig.ls === '0' && e59mig.lc === 0 && e59mig.ad === '{"floor":0,"deco":-1}'
&& e59mig.fo === '0' && e59mig.do_ === 0 && e59mig.coins === 4242,
JSON.stringify(e59mig));
// (59-iii) битый сейв: lineSkin=99 → 0; lineSkins [0,1] + lineSkin 1 → остаётся 1;
// битый aqDecor {floor:9,deco:7} → {floor:0,deco:-1}; мусор в купленных вычищается
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 1, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 0, earned: 0,
lineSkins: [0], lineSkin: 99, aqDecor: { floor: 9, deco: 7 }, aqFloorOwned: ['x', 0, 77], aqDecoOwned: [5]
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const e59bad1 = await page.evaluate(() => ({
ls: save.lineSkins.join(','), lc: save.lineSkin,
ad: JSON.stringify(save.aqDecor), fo: save.aqFloorOwned.join(','), do_: save.aqDecoOwned.join(',')
}));
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 1, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
caught: {}, casts: 0, earned: 0, lineSkins: [0, 1], lineSkin: 1
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const e59bad2 = await page.evaluate(() => ({ ls: save.lineSkins.join(','), lc: save.lineSkin }));
ok('Этап 59 битый сейв: lineSkin=99 → 0, aqDecor {floor:9,deco:7} → {floor:0,deco:-1}, мусор в купленных вычищен; lineSkins [0,1]+lineSkin 1 → 1',
e59bad1.ls === '0' && e59bad1.lc === 0 && e59bad1.ad === '{"floor":0,"deco":-1}'
&& e59bad1.fo === '0' && e59bad1.do_ === ''
&& e59bad2.ls === '0,1' && e59bad2.lc === 1,
JSON.stringify({ a: e59bad1, b: e59bad2 }));
// (59-iv) леска в лавке: 5000 монет → «Флуоресцент» (3000) — coins=2000, lineSkins=[0,1], lineSkin=1,
// лодка/поплавок не тронуты; ачивка line_first выдана
if (await page.evaluate(() => state) === 'MENU') { await page.click('#btnPlay'); await sleep(150); } // могли быть уже в игре
// Этап 59: чистим line_first (мог быть выдан предыдущими блоками — монеты могли быть ≥5000) —
// покупаем явно из нулевого состояния лески
await page.evaluate(() => {
save.coins = 5000; save.lineSkins = [0]; save.lineSkin = 0;
if (save.ach) delete save.ach.line_first;
persist(); openShop('skins');
});
await sleep(150);
await page.click('button[data-act="buyskin"][data-type="line"][data-i="1"]');
await sleep(150);
const e59buy = await page.evaluate(() => ({
coins: save.coins, ls: save.lineSkins.join(','), lc: save.lineSkin,
bs: save.boatSkins.join(','), bsCur: save.boatSkin,
ob: save.bobberSkins.join(','), obCur: save.bobberSkin,
ach: !!(save.ach && save.ach.line_first),
titles: Array.prototype.slice.call(document.querySelectorAll('#shopBody .section-title')).map(e => e.textContent)
}));
ok('Этап 59 покупка лески: 5000 → «Флуоресцент» (3000) — coins=2000, lineSkins=[0,1], lineSkin=1, лодка/поплавок не тронуты, line_first выдана, секция «Леска» есть',
e59buy.coins === 2000 && e59buy.ls === '0,1' && e59buy.lc === 1
&& e59buy.bs === '0' && e59buy.bsCur === 0 && e59buy.ob === '0' && e59buy.obCur === 0
&& e59buy.ach === true && e59buy.titles.includes('Леска'),
JSON.stringify(e59buy));
// смоук отрисовки золотой лески (fx 'gold': shadowBlur в save/restore) — без pageerror
await page.evaluate(() => { closeShop(); save.lineSkin = 2; persist(); });
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(400); await page.mouse.up();
await waitState(page, 'WAIT', 4000);
await page.evaluate(() => { biteTimer = 999; });
const e59errB = pageErrors.length;
await sleep(700);
const e59errA = pageErrors.length;
ok('Этап 59 леска gold: отрисовка «Золотой нити» (fx gold) ~0.7 с в WAIT — нет pageerror',
e59errA === e59errB, JSON.stringify({ err: e59errA - e59errB }));
await page.evaluate(() => { save.lineSkins = [0]; save.lineSkin = 0; persist(); });
// (59-v) Э62: диалог улучшений аквариума — вход пилюлей #btnAqShop из сцены; вкладка «Апгрейды»:
// покупка грунта 1 (2500) → покупка грунта 2 (8000); декор: покупка сундука (5000),
// «Без декора» (selnone) → повторный выбор сундука (seldeco, бесплатно); без монет —
// покупка декор 1 без изменений + тост. Вкладка «Слоты» и покупка слота — в тесте (7).
await toGameMenu(page);
await page.click('#btnGmAquarium');
await sleep(250);
const e59dec0 = await page.evaluate(() => {
save.aqFish = []; save.aqSlots = AQ_BASE_SLOTS;
save.aqDecor = { floor: 0, deco: -1 }; save.aqFloorOwned = [0]; save.aqDecoOwned = [];
save.coins = 20000;
persist(); renderAqBar();
return { coins: save.coins, cost1: AQ_FLOORS[1].cost, cost2: AQ_FLOORS[2].cost,
dc0: AQ_DECOS[0].cost, max: AQ_MAX_SLOTS };
});
await page.click('#btnAqShop');
await sleep(150);
const e59dlg = await page.evaluate(() => ({
open: aqShopOpen, tab: aqShopTab, max: AQ_MAX_SLOTS,
expLocked: AQ_MAX_SLOTS - AQ_BASE_SLOTS - 1,
hidden: document.getElementById('aqShop').classList.contains('hidden'),
rows: document.querySelectorAll('#aqShopBody .aqpickrow').length,
buy: !!document.querySelector('#aqShopBody [data-act="buyaslot"]'),
locked: document.querySelectorAll('#aqShopBody .aqpickrow.locked').length,
z: +getComputedStyle(document.getElementById('aqShop')).zIndex,
zBar: +getComputedStyle(document.getElementById('aqBar')).zIndex
}));
ok('Э62 диалог: #btnAqShop → aqShopOpen=true, вкладка «Слоты» по умолчанию: 12 строк, покупаемый 7-й и 6 🔒, z-index выше бара',
e59dlg.open === true && e59dlg.tab === 'slots' && e59dlg.hidden === false
&& e59dlg.rows === e59dlg.max && e59dlg.buy === true && e59dlg.locked === e59dlg.expLocked
&& e59dlg.z > e59dlg.zBar, JSON.stringify(e59dlg));
await page.click('#tabAqUpg');
await sleep(150);
const e59upg = await page.evaluate(() => ({
tab: aqShopTab,
titles: [...document.querySelectorAll('#aqShopBody .section-title')].map(e => e.textContent),
icons: [...document.querySelectorAll('#aqShopBody .item .itemicon')].map(e => e.textContent).join(''),
iconsExp: AQ_FLOORS.map(f => f.icon).concat(['🚫'], AQ_DECOS.map(d => d.icon)).join(''),
// deco=-1 (свежее состояние) → строка «Без декора» (4-я по счёту) уже «Выбрано ✓»
noneCur: !!document.querySelectorAll('#aqShopBody .item')[3].querySelector('.buybtn.current')
}));
ok('Э62 вкладка «Апгрейды»: секции «Грунт»/«Декор», иконки строк из таблиц + «🚫 Без декора» выбрана при deco=-1',
e59upg.tab === 'upg' && e59upg.titles.join('|') === 'Грунт|Декор'
&& e59upg.icons === e59upg.iconsExp && e59upg.noneCur === true, JSON.stringify(e59upg));
await page.click('#aqShopBody [data-act="buyfloor"][data-i="1"]');
await sleep(150);
const e59dec1 = await page.evaluate(() => ({
coins: save.coins, ad: JSON.stringify(save.aqDecor), fo: save.aqFloorOwned.join(','),
names: [...document.querySelectorAll('#aqShopBody .itemname')].map(e => e.textContent),
row1Cur: !!document.querySelectorAll('#aqShopBody .item')[1].querySelector('.buybtn.current')
}));
ok('Э62 грунт: покупка «Галька» (2500) → floor=1, fo=0,1, монеты списаны, строка «Выбрано ✓», имя в строке',
e59dec1.coins === e59dec0.coins - e59dec0.cost1
&& JSON.parse(e59dec1.ad).floor === 1 && e59dec1.fo === '0,1'
&& e59dec1.names.includes('Галька') && e59dec1.row1Cur === true,
JSON.stringify(e59dec1));
await page.click('#aqShopBody [data-act="buyfloor"][data-i="2"]');
await sleep(150);
const e59dec2 = await page.evaluate(() => ({
coins: save.coins, ad: JSON.stringify(save.aqDecor), fo: save.aqFloorOwned.join(',')
}));
ok('Э62 грунт: покупка «Тёмный грунт» (8000) → floor=2, fo=0,1,2, монеты списаны',
e59dec2.coins === e59dec1.coins - e59dec0.cost2 && JSON.parse(e59dec2.ad).floor === 2
&& e59dec2.fo === '0,1,2', JSON.stringify(e59dec2));
await page.click('#aqShopBody [data-act="buydeco"][data-i="0"]');
await sleep(150);
const e59dec3 = await page.evaluate(() => ({
coins: save.coins, ad: JSON.stringify(save.aqDecor), do_: save.aqDecoOwned.join(','),
names: [...document.querySelectorAll('#aqShopBody .itemname')].map(e => e.textContent)
}));
ok('Э62 декор: покупка «Сундук» (5000) → deco=0, do_=0, монеты списаны, имя в строке',
e59dec3.coins === e59dec2.coins - e59dec0.dc0 && JSON.parse(e59dec3.ad).deco === 0
&& e59dec3.do_ === '0' && e59dec3.names.includes('Сундук'),
JSON.stringify({ d2: e59dec2, d3: e59dec3 }));
// «Без декора» → обратно сундук (оба выбора бесплатны — уже куплено)
await page.click('#aqShopBody [data-act="selnone"]');
await sleep(120);
const e59none = await page.evaluate(() => ({ coins: save.coins, ad: JSON.stringify(save.aqDecor) }));
await page.click('#aqShopBody [data-act="seldeco"][data-i="0"]');
await sleep(120);
const e59back = await page.evaluate(() => ({ coins: save.coins, ad: JSON.stringify(save.aqDecor) }));
ok('Э62 «Без декора»: selnone → deco=-1, seldeco обратно → deco=0, монеты не менялись',
JSON.parse(e59none.ad).deco === -1 && e59none.coins === e59dec3.coins
&& JSON.parse(e59back.ad).deco === 0 && e59back.coins === e59dec3.coins,
JSON.stringify({ none: e59none, back: e59back }));
// без монет: непокупленный декор 1 → монеты и декор не меняются (тост «Не хватает»)
await page.evaluate(() => { save.coins = 0; persist(); });
await page.click('#aqShopBody [data-act="buydeco"][data-i="1"]');
await sleep(150);
const e59dec4 = await page.evaluate(() => ({
coins: save.coins, ad: JSON.stringify(save.aqDecor), do_: save.aqDecoOwned.join(','),
toast: document.getElementById('toast').textContent
}));
ok('Э62 декор без монет: покупка «Коряга» при 0 🪙 — декор/купленные не изменились, тост «Не хватает монет 🪙»',
e59dec4.coins === 0 && JSON.parse(e59dec4.ad).deco === 0 && e59dec4.do_ === '0'
&& e59dec4.toast.includes('Не хватает монет 🪙'),
JSON.stringify(e59dec4));
// бар (Э62): три элемента — «Назад», счётчик, вход в диалог — влезают в ширину экрана
const e59bar = await page.evaluate(() => [...document.querySelectorAll('#aqBar > *')].map(e => {
const r = e.getBoundingClientRect();
return { id: e.id, right: Math.round(r.right), w: Math.round(r.width), vw: innerWidth };
}));
ok('Э62 бар: элементы #aqBar (#btnAqBack/#aqCount/#btnAqShop) влезают в ширину экрана',
e59bar.length === 3 && e59bar.every(b => b.w > 0 && b.right <= b.vw), JSON.stringify(e59bar));
// закрытие диалога × → сцена остаётся открытой
await page.click('#btnCloseAqShop');
await sleep(150);
const e59cls = await page.evaluate(() => ({
open: aqShopOpen, hidden: document.getElementById('aqShop').classList.contains('hidden'),
aq: aqOpen
}));
ok('Э62 закрытие: #btnCloseAqShop → aqShopOpen=false, #aqShop скрыт, сцена (aqOpen) не тронута',
e59cls.open === false && e59cls.hidden === true && e59cls.aq === true, JSON.stringify(e59cls));
// смоук отрисовки: все грунты × все орнаменты ~0.2 с — без pageerror
const e59errB2 = pageErrors.length;
for (const [fl, dc] of [[0, -1], [1, 0], [2, 1], [2, 2], [0, 2]]) {
await page.evaluate(([fl, dc]) => { save.aqDecor = { floor: fl, deco: dc }; initAquarium(); }, [fl, dc]);
await sleep(220);
}
const e59errA2 = pageErrors.length;
ok('Этап 59 декор смоук: грунт 0/1/2 × орнаменты -1/0/1/2 — нет pageerror', e59errA2 === e59errB2,
JSON.stringify({ err: e59errA2 - e59errB2 }));
// сброс декора и выход из банки в гменю (состояние как до блока §59: #aqBar скрыт, #gamemenu виден)
await page.evaluate(() => {
save.aqDecor = { floor: 0, deco: -1 }; save.aqFloorOwned = [0]; save.aqDecoOwned = [];
persist();
});
await page.click('#btnAqBack');
await sleep(200);
// (7) Э62: слот через диалог — «Слоты» → строка «Купить N 🪙» списывает AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS],
// даёт +1 слот и обновляет счётчик в баре; закрытие тапом по подложке оставляет сцену
await page.click('#btnGmAquarium');
await sleep(200);
const aqSlot0 = await page.evaluate(() => {
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [];
save.coins = AQ_SLOT_COSTS[0] + 1000;
persist(); renderAqBar();
return { coins: save.coins, slots: save.aqSlots, cost: AQ_SLOT_COSTS[0] };
});
await page.click('#btnAqShop');
await sleep(150);
await page.click('#tabAqSlots');
await sleep(150);
const aqSlotRow = await page.evaluate(() => {
const row = document.querySelector('#aqShopBody [data-act="buyaslot"]');
return { txt: row ? row.textContent : '', i: row ? +row.dataset.i : -1 };
});
await page.click('#aqShopBody [data-act="buyaslot"]');
await sleep(200);
const aqSlot1 = await page.evaluate(() => ({
coins: save.coins, slots: save.aqSlots,
count: document.getElementById('aqCount').textContent,
nextBuy: !!document.querySelector('#aqShopBody [data-act="buyaslot"]')
}));
ok('Э62 слот: «Купить» в диалоге → монеты списаны на AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS], aqSlots+1, счётчик N/M в баре обновлён, следующий слот стал покупаемым',
aqSlotRow.i === aqSlot0.slots && aqSlotRow.txt.indexOf(String(aqSlot0.cost)) >= 0
&& aqSlot1.coins === aqSlot0.coins - aqSlot0.cost && aqSlot1.slots === aqSlot0.slots + 1
&& aqSlot1.count === '0/' + aqSlot1.slots && aqSlot1.nextBuy === true,
JSON.stringify({ before: aqSlot0, row: aqSlotRow, after: aqSlot1 }));
// тап по подложке (backdropClose) — диалог закрыт, сцена жива
await page.evaluate(() => document.getElementById('aqShop').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
await sleep(150);
const aqSlotCls = await page.evaluate(() => ({
open: aqShopOpen, hidden: document.getElementById('aqShop').classList.contains('hidden'), aq: aqOpen
}));
ok('Э62 backdrop: тап мимо панели закрывает #aqShop, aqOpen остаётся',
aqSlotCls.open === false && aqSlotCls.hidden === true && aqSlotCls.aq === true, JSON.stringify(aqSlotCls));
// (8) выпуск из банки (Э64 — ревизия): тап по рыбе даёт инфо-поповер БЕЗ кнопок; выпуск — только
// из диалога «Слоты»: [data-act=askrelease] → подтверждение #aqShopConfirm («Отмена»/«Выпустить»);
// без монет (чистая косметика — освобождение слота) и без телепорта остальных
const aqRel0 = await page.evaluate(() => {
save.aqFish = [{ id: 'carass', v: 'gold', w: 2 }, { id: 'perch', v: 'normal', w: 1.2 }];
save.aqSlots = AQ_BASE_SLOTS; save.coins = 500; save.earned = 1234;
persist();
initAquarium(); // пересобрать aqFishRt под 2 экземпляра
const rt = aqFishRt[0];
aqTap(rt.x, rt.y); // тап по рыбе → выбор + инфо-поповер
return { coins: save.coins, earned: save.earned, len: save.aqFish.length, sel: aqSel,
pop: !document.getElementById('aqPop').classList.contains('hidden'),
popName: document.getElementById('aqPopName').textContent,
expName: trName(SPECIES.find(s => s.id === 'carass')),
sub: document.getElementById('aqPopSub').textContent,
popBtns: document.querySelectorAll('#aqPop button').length };
});
ok('Э64 выпуск: aqTap по рыбе → aqSel=0 и поповер #aqPop (имя; подпись без цены), кнопок в поповере нет — выпуск только из диалога',
aqRel0.sel === 0 && aqRel0.pop === true && aqRel0.len === 2
&& aqRel0.popName.indexOf(aqRel0.expName) >= 0 && aqRel0.popBtns === 0
&& aqRel0.sub.indexOf('🪙') < 0,
JSON.stringify(aqRel0));
await page.click('#btnAqShop'); // диалог «Слоты» — на строке жильца кнопка «Выпустить»
await sleep(150);
const aqRelRow = await page.evaluate(() => ({
rel: document.querySelectorAll('#aqShopBody [data-act="askrelease"]').length,
confHidden: document.getElementById('aqShopConfirm').classList.contains('hidden')
}));
ok('Э64 выпуск: во вкладке «Слоты» на каждом жильце [data-act=askrelease] (2 шт.), подтверждение изначально скрыто',
aqRelRow.rel === 2 && aqRelRow.confHidden === true, JSON.stringify(aqRelRow));
await page.click('#aqShopBody [data-act="askrelease"][data-i="0"]');
await sleep(150);
const aqRelQ = await page.evaluate(() => ({
hidden: document.getElementById('aqShopConfirm').classList.contains('hidden'),
txt: document.getElementById('aqShopConfirmText').textContent,
exp: tt('aq_release_q', { n: trName(SPECIES.find(s => s.id === 'carass')) }),
yes: document.getElementById('btnAqShopConfirmYes').textContent, expYes: t('aq_release'),
pend: aqShopRelPending,
repl: !!document.querySelector('#aqShopBody .aqpickrow.replace')
}));
ok('Э64 выпуск: askrelease → #aqShopConfirm с текстом aq_release_q(имя) и кнопкой t(aq_release), aqShopRelPending=0, строка подсвечена',
aqRelQ.hidden === false && aqRelQ.txt === aqRelQ.exp && aqRelQ.yes === aqRelQ.expYes
&& aqRelQ.pend === 0 && aqRelQ.repl === true, JSON.stringify(aqRelQ));
await page.click('#btnAqShopConfirmNo'); // «Отмена» — рыба на месте
await sleep(150);
const aqRelC = await page.evaluate(() => ({
hidden: document.getElementById('aqShopConfirm').classList.contains('hidden'),
len: save.aqFish.length, pend: aqShopRelPending
}));
ok('Э64 выпуск: «Отмена» → подтверждение скрыто, aqShopRelPending=-1, рыб не стало меньше (2)',
aqRelC.hidden === true && aqRelC.len === 2 && aqRelC.pend === -1, JSON.stringify(aqRelC));
await page.click('#aqShopBody [data-act="askrelease"][data-i="0"]');
await sleep(150);
await page.click('#btnAqShopConfirmYes'); // «Выпустить»
await sleep(200);
const aqRel1 = await page.evaluate(() => ({
coins: save.coins, earned: save.earned, len: save.aqFish.length, first: save.aqFish[0].id,
sel: aqSel, pend: aqShopRelPending,
confHidden: document.getElementById('aqShopConfirm').classList.contains('hidden'),
popHidden: document.getElementById('aqPop').classList.contains('hidden')
}));
ok('Э64 выпуск: «Выпустить» → экземпляр удалён (остался окунь), монеты/earned НЕ изменились, pending снят, подтверждение/поповер скрыты (aqSel=-1)',
aqRel1.len === aqRel0.len - 1 && aqRel1.first === 'perch' && aqRel1.coins === aqRel0.coins
&& aqRel1.earned === aqRel0.earned && aqRel1.pend === -1 && aqRel1.confHidden === true
&& aqRel1.sel === -1 && aqRel1.popHidden === true,
JSON.stringify({ before: aqRel0, after: aqRel1 }));
// (8b) телепорт: замер позиций и выпуск в ОДНОМ evaluate (иначе кадры updateAquarium продвигают рыб)
const aqTp = await page.evaluate(() => {
save.aqFish = [{ id: 'carass', v: 'normal', w: 2 }, { id: 'perch', v: 'normal', w: 1.2 }];
save.aqSlots = AQ_BASE_SLOTS; persist();
initAquarium();
aqSel = 0;
const before = aqFishRt.slice(1).map(r => ({ x: r.x, y: r.y }));
const rtLenBefore = aqFishRt.length;
aqReleaseAt(0); // синхронно: между замерами не проходит ни одного кадра
return { before, after: aqFishRt.map(r => ({ x: r.x, y: r.y })),
rtLenBefore, rtLenAfter: aqFishRt.length };
});
ok('Э64 выпуск: без телепорта — позиции остальных рыб (x,y) сохранились, aqFishRt.length−1',
aqTp.rtLenAfter === aqTp.rtLenBefore - 1
&& aqTp.after.length === aqTp.before.length
&& aqTp.after.every((p, i) => p.x === aqTp.before[i].x && p.y === aqTp.before[i].y),
JSON.stringify(aqTp));
await page.click('#btnCloseAqShop'); // диалог закрыт — дальше штатный выход из сцены
await sleep(150);
// cleanup: «← Назад» → «Продолжить» (в игру), сброс сцены/полей/форсов
await page.click('#btnAqBack');
await sleep(150);
await page.click('#btnGmResume');
await sleep(150);
await page.evaluate(() => {
save.aqFish = []; save.aqSlots = AQ_BASE_SLOTS; persist();
forceBite = false; forceVariant = null; forceBoss = false; aqSel = -1;
});
// Этап 59: сцена в §AQ теперь открывается штатно (из #gamemenu) — после «Продолжить»
// state — текущая сессия игры (не обязательно IDLE): проверяем именно закрытие сцены
await waitCond(page, "() => !uiOpen && !aqOpen", 4000);
const aqEnd = await page.evaluate(() => ({
open: aqOpen, st: state, ui: uiOpen,
gm: document.getElementById('gamemenu').classList.contains('hidden'),
bar: document.getElementById('aqBar').classList.contains('hidden'),
shopHidden: document.getElementById('aqShop').classList.contains('hidden')
}));
ok('Этап 46 cleanup: сцена закрыта (#aqBar/#gamemenu/#aqShop скрыты), uiOpen=false (state — текущая сессия), форсы сброшены',
aqEnd.open === false && aqEnd.ui === false
&& aqEnd.gm === true && aqEnd.bar === true && aqEnd.shopHidden === true, JSON.stringify(aqEnd));
// --- 11. Ошибки консоли ---
ok('нет ошибок JS (pageerror)', pageErrors.length === 0, pageErrors.join(' | '));
ok('нет ошибок консоли', consoleErrors.length === 0, consoleErrors.join(' | '));
await browser.close();
const failed = results.filter(r => !r.pass);
console.log('\n===== ИТОГ: ' + (results.length - failed.length) + '/' + results.length + ' =====');
process.exit(failed.length ? 1 : 0);
})().catch(e => { console.error('TEST CRASH:', e.message); process.exit(2); });