Files
fishing-game/tests/test.js
T
andrey a168e830cc Этап 46 ревизия: жильцы аквариума — те же SVG-портреты, что на карточке улова. (1) Слой #aqFishLayer (body, после canvas): position:fixed/inset:0/z-index:5/pointer-events:none, включается только под сценой (body.aq-open) — тапы по рыбам по-прежнему ловит canvas (aqTap). (2) aqBuildFishEl(i) — <div class=aqfish><svg viewBox="0 0 160 100"><use href="#fish-<id>"/></svg></div>, тот же спрайт FISH-SVG, что у дневника/карточки; размер от прежнего aqFishSize (длина 2·size, высота по 160:100); aqPlaceFish(el,rt) каждый кадр из updateAquarium ставит translate(x,y)+translate(-50%,-50%)+rotate(±4° по tail)+scale(dir,1). (3) Вариант — 1:1 с карточкой: перекраска --f1/--f3 inline (AQ_VARIANT_RECOLOR, цвета из CSS #card.<variant>-card) + класс .var<id> (AQ_VARIANT_CLASS/aqApplyVariant; silver — grayscale+brightness, rainbow — aqrainbowhue, остальные — цветной drop-shadow). Вместо hue-хака AQ_VARIANT_FX/AQ_VARIANT_HUE (удалены) и прежней искры. (4) initAquarium() пересобирает слой с clip-path по банке (index aqFishEls = index save.aqFish, битый вид → null без сдвига индексов); aqReleaseSel() удаляет DOM-элемент точечно (без телепорта остальных); на canvas остались вода/грунт/растения/пузыри/стекло + аура болотных (уходит ЗА портрет) и рамка выбора. drawFishShape в банке больше не используется (в сценах ловли не тронут). Геймплей/баланс/сейв/экономика не менялись. (5) Тесты 218→222: блок «портреты» в §AQ (use href = #fish-<id> на каждый экземпляр; .var<id>/--f1 по варианту; transform следует за aqFishRt индекс-в-индекс; вне сцены display:none/body без aq-open, pointer-events:none) + обновлён список глобалов в шапке §AQ. (6) Скриншоты: перегенерированы только кадры aquarium (6 PNG, оба набора). (7) Доки: AGENTS.md (пункт «Аквариум» + глобалы), KODA.md (код-карта/глобалы/грабли), README.md, docs/PLAN-AQUARIUM.md (риск №1 закрыт), DEVLOG (подраздел «Ревизия Этапа 46»). Проверено: 222/222 PASS exit 0, check.mjs OK, balance.mjs — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7), pack.mjs 5/5 + pack-smoke 6/6.
2026-09-22 23:02:00 +03:00

3527 lines
201 KiB
JavaScript
Raw 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 === 9125 && 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 === 8500, 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 === 8500 && 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)) === 8500);
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));
// --- 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 удилища (кнопка ⬆ 400 🪙,
// в подписи «фонарик (ночь)»); уровень 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 удилища (кнопка ⬆ 70 🪙, подпись «фонарик (ночь)»)',
m3shop && m3shop.txt.indexOf('70 🪙')>=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: босс в каждой локации — Озёра (рак) / Море (рыба-меч) / Океан (кит) / Болото (дух, Этап 45) ---
// Паттерн §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: 'spiritfin', ach: 'swamp_boss', name: 'Дух болота' }, // Этап 45: Волшебное болото
];
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 «875 🪙» / «625 🪙»',
!m5g2.r1.locked && m5g2.r1.buyTxt === '875 🪙' && !m5g2.l1.locked && m5g2.l1.buyTxt === '625 🪙',
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 🪙 → 930, 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 ур. за 70 🪙 (1000→930), hasLight()=true, тост «Улучшено … → 2 ур»',
m5u.coins === 930 && 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 = 50; 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 не хватает монет: 50 🪙 на апгрейд за 70 — coins=50, уровень не изменился, тост «Не хватает монет»',
m5p.coins === 50 && 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 за 875 🪙 — rods=[0,1], rodLv[1]=1, снасть экипирована, coins=2125',
m5b.rods === '0,1' && m5b.lv === 1 && m5b.rod === 1 && m5b.coins === 2125,
JSON.stringify(m5b));
// (14b) апгрейд по формуле: rod 1 (база 875) 1→2 ур. = 875×0.2 = 175 → 180 (округление до 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 ур. за 180 🪙 (база 875×0.2 = 175 → округление до 10) — coins=2125→1945, rodLv[1]=2',
m5bu.lv === 2 && m5bu.coins === 1945, JSON.stringify(m5bu));
await page.click('#btnCloseShop');
// (15) пин цен апгрейдов: id 0 — фикс, id>0 — база×(0.2/0.6/1.2)
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 фикс (70/210/500, 60/160/400); id>0 = база×(0.2/0.6/1.2)',
m5c.r.join('|')==='70,210,500|180,530,1050|900,2700,5400|3000,9000,18000'
&& m5c.l.join('|')==='60,160,400|130,380,750|600,1800,3600|2250,6750,13500',
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; });
// --- 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; });
// --- 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 3 записи (id 0..2, cost 0/800/2000), BOBBER_SKINS 3 записи (id 0..2, cost 0/500/1500)',
sk1.boat === '0:0,1:800,2:2000' && sk1.bob === '0:0,1:500,2:1500',
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..2 → 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: 5, 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=5 → 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, группы «Лодка»/«Поплавок», 6 названий;
// отрисовка скинов 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 = ['Обычная', 'Красная лодка', 'Лодка-домик', 'Обычный', 'Красный', 'Неоновый'];
return {
boat: titles.includes('Лодка'), bobber: titles.includes('Поплавок'),
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 есть, группы «Лодка»/«Поплавок», 6 названий видны; boatSkin=2 + bobberSkin=2 ~1 с — нет pageerror',
sk7a === true && sk7b.boat === true && sk7b.bobber === true
&& sk7b.found.every(x => x === true) && sk7c === skErrBefore,
JSON.stringify({ tab: sk7a, titles: sk7b.boat + '/' + sk7b.bobber, found: sk7b.found, err: sk7c - skErrBefore }));
// сброс состояния скинов к дефолту
await page.evaluate(() => {
save.boatSkins = [0]; save.boatSkin = 0;
save.bobberSkins = [0]; save.bobberSkin = 0;
persist();
});
// --- 10f. Локации: Озёра + Море + Океан (Этап 35: у каждой локации только свои виды, Этап 36: +Океан) ---
// (1) данные: 37 видов в SPECIES (8 речных + 10 озёрных + 5 морских + 9 океанских + 5 болотных, Этап 45), 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: болотные виды (5 — в КОНЦЕ SPECIES, у каждого поле glow)
const gf = g('glowfin'), mf = g('moonfish'), ef = g('emberfin'), vl = g('violetlure'), sf = g('spiritfin');
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),
// порядок: 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, редкости ровно 0..4 по одной
swampGlow: [gf, mf, ef, vl, sf].every(s => s && typeof s.glow === 'string' && /^#[0-9a-fA-F]{6}$/.test(s.glow)),
swampRar: [gf, mf, ef, vl, sf].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=37 (8 река + 10 озёра + 5 море + 9 океан + 5 болото, этапы 35/36/45) — 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🪙 (редкости 0..4 по одной, glow — #rrggbb); этап 38: лестница веса (Озёра ×8 / Море ×6, меч ×4 / Океан ×4, кит ×2; Река не меняется); этап 39: цены Озёр/Моря/Океана урезаны (Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5 — по дню; Река не меняется); первые 8 id не сдвинуты, новые — в конце; все поля заполнены; LOCATIONS 5 (Река 0 / Озёра 65536 / Море 131072 / Океан 196608 / Волшебное болото 90000), у каждой 6 hex в water; SVG-спрайт: 37 symbol, id на каждый вид',
lc1.n === 37 && 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.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'
&& lc1.fieldsOk === true
&& lc1.swampGlow === true && lc1.swampRar === '0,1,2,3,4'
&& lc1.locs === 5 && lc1.costs === '0,65536,131072,196608,90000' && lc1.names === 'Река,Озёра,Море,Океан,Волшебное болото' && lc1.water === true
&& lc1.sprites === 37 && lc1.spriteIdsOk === true,
JSON.stringify(lc1));
// (2) таблицы: река — ровно старые 8 (порядок), озёра — только свои 10, море — только свои 5, океан — только свои 9, болото — только свои 5, ночные веса — числа; пересечений нет
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 === 37 && 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) — только свои 5 (этап 45, порядок = SWAMP_IDS); пересечений между водами нет; у всех 37 в 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 === 5 && lc2.swampN === 5
&& 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'
&& 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: роллы болота — ровно свои 5 болотных видов (каждый ≥1), видов других вод нет
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) — все 5 болотных видов встречаются (каждый ≥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) покупка: 66536 монет → «Озёра» (65536) — списание, авто-переход, тост
const lcSt0 = await page.evaluate(() => state);
if (lcSt0 === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
await page.evaluate(() => { save.coins = 66536; });
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('локации покупка: 66536 → «Озёра» (65536) — 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 < 65536 — 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: покупка Моря: 131072 → списание, авто-переход, тост «Добро пожаловать в Море!»
await page.evaluate(() => { save.coins = 131072; 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('локации покупка моря: 131072 → «Море» (131072) — coins=0, locations=[0,1,2], авто-переход location=2, тост «Добро пожаловать в Море!»',
lc8s.coins === 0 && lc8s.locs.join(',') === '0,1,2' && lc8s.loc === 2
&& lc8s.toast.includes('Добро пожаловать в Море!'),
JSON.stringify(lc8s));
// (8c) Этап 36: покупка Океана: 196608 → списание, авто-переход, тост «Добро пожаловать в Океан!»
await page.evaluate(() => { save.coins = 196608; 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('локации покупка океана: 196608 → «Океан» (196608) — coins=0, locations=[0,1,2,3], авто-переход location=3, тост «Добро пожаловать в Океан!»',
lc8o.coins === 0 && lc8o.locs.join(',') === '0,1,2,3' && lc8o.loc === 3
&& lc8o.toast.includes('Добро пожаловать в Океан!'),
JSON.stringify(lc8o));
// (8d) Этап 45: покупка Волшебного болота: 90000 → списание, авто-переход, тост «Добро пожаловать в Волшебное болото!»
await page.evaluate(() => { save.coins = 90000; 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('локации покупка болота: 90000 → «Волшебное болото» (90000) — coins=0, locations=[0,1,2,3,4], авто-переход location=4, тост «Добро пожаловать в Волшебное болото!»',
lc8w.coins === 0 && lc8w.locs.join(',') === '0,1,2,3,4' && lc8w.loc === 4
&& lc8w.toast.includes('Добро пожаловать в Волшебное болото!'),
JSON.stringify(lc8w));
// (9) дневник: 37 строк (новые виды видны в энциклопедии)
await page.click('#tabJournal');
await sleep(150);
const lc9 = await page.evaluate(() => document.querySelectorAll('#shopBody .jrow').length);
ok('локации дневник: вкладка journal — 37 строк .jrow (новые виды в энциклопедии)',
lc9 === 37, 'rows=' + lc9);
// (10) масштаб ачивок: species_8 — все 37 видов; full_variants — 37×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 новых видов (10 озёрных этапа 33/35 + 5 морских этапа 35 + 9 океанских этапа 36 + 5 болотных)
const mid29 = ['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'];
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 mid29) 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 НЕ выданы; +29 новых (10 озёрных этапов 33/35 + 5 морских + 9 океанских + 5 болотных, Этап 45) → обе выданы, +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: данные свечения — у 5 болотных видов glow #rrggbb (5 цветов РАЗЛИЧНЫ),
// SWAMP_DAY/NIGHT_WEIGHTS покрывают все SWAMP_IDS числом >0, NIGHT_WEIGHTS — ключи всех 5 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 свечение/веса: у 5 болотных видов glow — #rrggbb, все 5 цветов различны; SWAMP_DAY_WEIGHTS/SWAMP_NIGHT_WEIGHTS покрывают все id SWAMP_IDS числами >0; NIGHT_WEIGHTS имеет ключи всех 5 болотных id (и всех 37 видов)',
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), ACHIEVEMENTS.length===38, id уникальны,
// name/desc/icon/bonus/check (у aq_first/aq_full 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'];
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')
.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), ACHIEVEMENTS.length===38, id уникальны, поля на месте',
a22d.all === true && a22d.total === 38 && 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) скины: boatSkins=[0,1] → skin_first (skins_all нет); все 6 → skins_all
const a22sk = await page.evaluate(() => {
save.ach = {};
save.boatSkins = [0, 1]; save.bobberSkins = [0];
checkAchievements();
const s1 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
save.boatSkins = [0, 1, 2]; save.bobberSkins = [0, 1, 2];
checkAchievements();
const s2 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
save.boatSkins = [0]; save.bobberSkins = [0];
return { s1, s2 };
});
ok('Этап 22 скины: boatSkins [0,1] → skin_first, skins_all нет; все 6 скинов → skins_all',
a22sk.s1.f === true && a22sk.s1.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));
// (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)
// → тап по подложке → «Отлично!». Ждём 400 мс: у #card pop-анимация (transform scale, 300 мс),
// пока она идёт — hit-area оверлея сужена, тап мимо бы ушёл на канвас.
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=IDLE)',
xb5pre.card && xb5.card && xb5.st === 'IDLE', JSON.stringify(xb5));
// --- AQ. Этап 46: Аквариум (миграция / «В аквариум» / лимит / слот / сцена / выпуск) ---
// Глобалы игры (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, aqReleaseSel, openAquarium, closeAquarium,
// syncKeepBtn, cardSpec, save.aqFish/save.aqSlots — имена не переименовывать (тесты молча ломаются).
// DOM: #btnGmAquarium, #btnKeep, #aqBar, #btnAqBack, #aqCount, #btnAqSlot, #aqPop,
// #aqPopName, #aqPopSub, #btnAqRelease, #aqFishLayer (DOM-слой портретов — ревизия: жильцы
// те же SVG-спрайты #fish-<id>, что на карточке улова; вариант — класс .var<id> + --f1/--f3).
// (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('Этап 46 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(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'),
st: state, aqFirst: !!save.ach.aq_first, aqFull: !!save.ach.aq_full
}));
ok('Этап 46 keep: клик #btnKeep → 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.st === 'IDLE',
JSON.stringify({ card: aqCard, keep: aqKeep }));
ok('Этап 46 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) лимит: aqFish заполнен до aqSlots → #btnKeep disabled + «Нет места» (подпись и title)
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 });
showCard(SPECIES[0], 1, 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,
title: document.getElementById('btnKeep').title,
noRoom: t('aq_no_room'),
len: save.aqFish.length, slots: save.aqSlots
}));
ok('Этап 46 лимит: aqFish заполнен до aqSlots → #btnKeep disabled + подпись/title «Нет места»',
aqLim.disabled === true && aqLim.text === aqLim.noRoom && aqLim.title === aqLim.noRoom
&& aqLim.len === aqLim.slots, JSON.stringify(aqLim));
await page.click('#btnCardOk');
await sleep(150);
// (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));
// (7) слот: при достатке монет #btnAqSlot списывает 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('#btnAqSlot');
await sleep(200);
const aqSlot1 = await page.evaluate(() => ({
coins: save.coins, slots: save.aqSlots,
count: document.getElementById('aqCount').textContent
}));
ok('Этап 46 слот: #btnAqSlot → монеты списаны на AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS], aqSlots+1, счётчик N/M обновлён',
aqSlot1.coins === aqSlot0.coins - aqSlot0.cost && aqSlot1.slots === aqSlot0.slots + 1
&& aqSlot1.count === '0/' + aqSlot1.slots, JSON.stringify({ before: aqSlot0, after: aqSlot1 }));
// (8) выпуск из банки: тап по рыбе выделяет экземпляр, #btnAqRelease (aqReleaseSel)
// удаляет его БЕЗ монет (чистая косметика — освобождение слота) и без телепорта остальных
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,
btnText: document.getElementById('btnAqRelease').textContent,
expBtn: t('aq_release') };
});
ok('Этап 46 выпуск: aqTap по рыбе → aqSel=0 и поповер #aqPop (имя; подпись без цены), кнопка #btnAqRelease = t(aq_release)',
aqRel0.sel === 0 && aqRel0.pop === true && aqRel0.len === 2
&& aqRel0.popName.indexOf(aqRel0.expName) >= 0 && aqRel0.btnText === aqRel0.expBtn
&& aqRel0.sub.indexOf('🪙') < 0,
JSON.stringify(aqRel0));
await page.click('#btnAqRelease');
await sleep(200);
const aqRel1 = await page.evaluate(() => ({
coins: save.coins, earned: save.earned, len: save.aqFish.length, sel: aqSel,
popHidden: document.getElementById('aqPop').classList.contains('hidden')
}));
ok('Этап 46 выпуск: #btnAqRelease (aqReleaseSel) → экземпляр удалён, монеты/earned НЕ изменились, выбор снят, поповер скрыт',
aqRel1.len === aqRel0.len - 1 && aqRel1.coins === aqRel0.coins && aqRel1.earned === aqRel0.earned
&& 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;
aqReleaseSel(); // синхронно: между замерами не проходит ни одного кадра
return { before, after: aqFishRt.map(r => ({ x: r.x, y: r.y })),
rtLenBefore, rtLenAfter: aqFishRt.length };
});
ok('Этап 46 выпуск: без телепорта — позиции остальных рыб (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));
// 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;
});
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')
}));
ok('Этап 46 cleanup: сцена закрыта (#aqBar/#gamemenu скрыты), state=IDLE, uiOpen=false, форсы сброшены',
aqEnd.open === false && aqEnd.st === 'IDLE' && aqEnd.ui === false
&& aqEnd.gm === true && aqEnd.bar === 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); });