'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 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 { 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 карточка: все варианты — бейдж «МЕДНАЯ»/«СЕРЕБРЯНАЯ»/«КВАРЦЕВАЯ»/«ЗОЛОТАЯ»/«ИЗУМРУДНАЯ»/«АЛМАЗНАЯ»/«РАДУЖНАЯ» + класс -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, , меню на 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-, что на карточке улова; вариант — класс .var + --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-), // вариант — класс .var (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 с (те же спрайты, что на карточке)', 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 по варианту (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); });