'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)); (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(() => {}); await page.mouse.up(); await waitState(page, 'IDLE', 3000); ok('обрыв: SNAP -> IDLE', true); // --- 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 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); // --- 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; }); 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.03; }); // --- 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: сбежавшая рыба даёт 10% своей цены утешением --- await page.click('#btnPlay'); await sleep(150); 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.w = 5; // фиксируем вес — утешение детерминированно window.__testPrice = reel.sp.price; 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, exp: Math.floor(5 * window.__testPrice * 0.1), toast: document.getElementById('toast').textContent, })); const nmDelta = nm.coins - coinsBeforeNm; ok('near-miss: сбежавшая рыба даёт +~10% цены (диапазон)', nm.exp > 0 && Math.abs(nmDelta - nm.exp) <= 1, JSON.stringify(nm) + ' delta=' + nmDelta); ok('near-miss: в DOM тост «Сбежала» с весом и +N 🪙', nm.toast.startsWith('Сбежала!') && nm.toast.includes('кг') && 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.03; 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.03; pickSpecies = window.__m4Pick; }); // --- 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: утешение +10% цены тоже начисляется вне catchFish — HUD актуален 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.w = 5; // фиксируем вес — утешение детерминированно и больше нуля 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('багфикс HUD near-miss: после «Сбежала! +N 🪙» счётчик монет актуален', nmHud.hud === '🪙 ' + nmHud.coins, JSON.stringify(nmHud)); // (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.03; 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) const m6v = await page.evaluate(() => { SHINY_CHANCE = 0.07; const out = { n: VARIANTS.length, mults: VARIANTS.map(v => v.mult).join(','), silver: VARIANTS.find(v => v.id === 'silver').chance, rainbow: VARIANTS.find(v => v.id === 'rainbow').chance, goldLive: variantChance(VARIANTS.find(v => v.id === 'gold')) }; SHINY_CHANCE = 0.03; return out; }); ok('M6 данные: VARIANTS 4 (mult 1/2/3/5), chance silver 0.05 / rainbow 0.005, gold = живая SHINY_CHANCE (0.07)', m6v.n === 4 && m6v.mults === '1,2,3,5' && m6v.silver === 0.05 && m6v.rainbow === 0.005 && m6v.goldLive === 0.07, 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.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 — независимые роллы gold (SHINY_CHANCE) → silver (0.05) → rainbow (0.005); // пин Math.random по образцу §9j(b); rainbow нужен последовательностью [≥0.03, ≥0.05, <0.005] const m6roll = await page.evaluate(() => { const mr = Math.random; forceVariant = null; SHINY_CHANCE = 0.03; const seq = arr => { let i = 0; Math.random = () => arr[Math.min(i++, arr.length - 1)]; }; const out = { gold: (seq([0.01]), rollVariant().id), // r<0.03 → gold goldEdge: (seq([0.029]), rollVariant().id), // граница <0.03 → gold silverEdge: (seq([0.03]), rollVariant().id), // r=0.03: не gold (0.03<0.03 ложь) → silver silver: (seq([0.04]), rollVariant().id), // ≥0.03, <0.05 → silver rainbow: (seq([0.04, 0.06, 0.001]), rollVariant().id), // gold нет, silver нет, <0.005 → rainbow normal: (seq([0.9]), rollVariant().id), // все выше порогов → normal normalEdge: (seq([0.05]), rollVariant().id) // r=0.05: не silver (0.05<0.05 ложь) → normal }; Math.random = mr; return out; }); ok('M6 границы ролла: r<0.03 → gold; 0.03≤r<0.05 → silver; [≥0.03, ≥0.05, <0.005] → rainbow; иначе → normal', m6roll.gold === 'gold' && m6roll.goldEdge === 'gold' && m6roll.silverEdge === 'silver' && m6roll.silver === 'silver' && m6roll.rainbow === 'rainbow' && m6roll.normal === 'normal' && m6roll.normalEdge === 'normal', JSON.stringify(m6roll)); // (6) миграция: старый сейв, в caught[id] нет sv/rb 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 без sv/rb) → sv=0, rb=0, n/best/sn сохранены', 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.perch.n === 4 && m6mig.perch.best === 1.2 && m6mig.perch.sn === 0 && m6mig.perch.sv === 0 && m6mig.perch.rb === 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'); res[id] = { txt: document.getElementById('cardVariant').textContent, hidden: document.getElementById('cardVariant').classList.contains('hidden'), cls: c.classList.contains('silver-card') ? 'silver' : c.classList.contains('gold-card') ? 'gold' : c.classList.contains('rainbow-card') ? 'rainbow' : 'none' }; }; probe('silver'); probe('gold'); probe('rainbow'); probe('normal'); document.getElementById('card').classList.add('hidden'); return res; }); ok('M6 карточка: silver/gold/rainbow — бейдж «СЕРЕБРЯНАЯ»/«ЗОЛОТАЯ»/«РАДУЖНАЯ» + silver-card/gold-card/rainbow-card; normal — бейдж скрыт', m6card.silver.hidden === false && m6card.silver.txt === 'СЕРЕБРЯНАЯ' && m6card.silver.cls === 'silver' && m6card.gold.hidden === false && m6card.gold.txt === 'ЗОЛОТАЯ' && m6card.gold.cls === 'gold' && m6card.rainbow.hidden === false && m6card.rainbow.txt === 'РАДУЖНАЯ' && m6card.rainbow.cls === 'rainbow' && m6card.normal.hidden === true && m6card.normal.cls === 'none', JSON.stringify(m6card)); // (8) full_variants: все 8 видов × 4 варианта → +5000 ровно один раз, coinPct +0.05, повторно — без выплаты const m6fv = await page.evaluate(() => { 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: 4, best: 1, sn: 1, sv: 1, rb: 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 }; return { coinsBefore, a1, a2 }; }); ok('M6 full_variants: все 8 видов во всех вариантах → +5000 ровно один раз, achBonus().coinPct включает 0.05 (сумма 0.13), повторный вызов — без выплаты', m6fv.a1.got === true && m6fv.a1.coins === m6fv.coinsBefore + 5000 && Math.abs(m6fv.a1.pct - 0.13) < 1e-9 && m6fv.a2.coins === m6fv.a1.coins, JSON.stringify(m6fv)); // сброс тест-форсов await page.evaluate(() => { forceBite = false; forceVariant = null; forceBoss = false; SHINY_CHANCE = 0.03; }); // --- 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===2.5, WEATHER.fog.biteMult===1.5, gap 120–240 с, длительность 120 с, forceWeather===null', w1.rm === 2.5 && 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) дождь: варианты ярче (silver/gold/rainbow ×2.5) const w2 = await page.evaluate(() => { forceWeather = 'rain'; const v = id => VARIANTS.find(x => x.id === id); return { s: variantChance(v('silver')), g: variantChance(v('gold')), r: variantChance(v('rainbow')), sh: SHINY_CHANCE }; }); ok('M8 дождь: variantChance ×2.5 — silver 0.125, gold 0.075 (SHINY_CHANCE 0.03), rainbow 0.0125', w2.s === 0.125 && w2.g === 0.075 && w2.r === 0.0125 && w2.sh === 0.03, 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. Локация: Озёра --- // (1) данные: 3 новых вида в SPECIES (всего 11) и параметры, LOCATIONS (2 записи, по 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 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), 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(h => typeof h === 'string' && /^#[0-9a-fA-F]{6}$/.test(h))) }; }); ok('локации данные: SPECIES=11 — zander «Судак» r2 40🪙 1.0–6.0, chuch «Хариус» r1 30🪙 0.2–2.0, crayfish «Гигантский рак» r3 200🪙 0.2–1.0; LOCATIONS 2 (Река 0 / Озёра 100000), у каждой 6 hex в water', lc1.n === 11 && lc1.z === 'Судак,2,40,1,6' && lc1.c === 'Хариус,1,30,0.2,2' && lc1.k === 'Гигантский рак,3,200,0.2,1' && lc1.locs === 2 && lc1.costs === '0,100000' && lc1.names === 'Река,Озёра' && lc1.water === true, JSON.stringify(lc1)); // (2) таблицы: река — ровно старые 8 (порядок), озёра — все 11, ночные веса — числа const lc2 = await page.evaluate(() => { const old8 = 'carass,perch,bream,carp,pike,catfish,sturge,gold'; 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, nightW: SPECIES_NIGHT.length === 11 && SPECIES_NIGHT.every(s => typeof s.weight === 'number' && isFinite(s.weight)) }; }); ok('локации таблицы: RIVER_SPECIES(_NIGHT) — ровно старые 8 (порядок сохранён), LAKE_SPECIES(_NIGHT) — по 11, у всех 11 в SPECIES_NIGHT вес — число', lc2.river === 'carass,perch,bream,carp,pike,catfish,sturge,gold' && lc2.riverN === lc2.river && lc2.lake === 11 && lc2.lakeN === 11 && 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 — ровно все 11 видов (каждый ≥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 = SPECIES.map(s => s.id); const min = o => Math.min.apply(null, ids.map(id => o[id])); return { dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0), dayMin: min(day), nightMin: min(night) }; }); ok('локации роллы озёр: 3000 днём и 3000 ночью (location=1) — все 11 видов встречаются (каждый ≥1)', lc4.dayAll === true && lc4.nightAll === true, JSON.stringify(lc4)); // (5) миграция: старый сейв (без locations/location) → [0]/0; location=5 → 0; location=1 при locations=[0] → 0 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 }; return r; }); ok('локации миграция: старый сейв (без locations/location) → [0]/0; location=5 → 0; location=1 при locations=[0] (не куплено) → 0', 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, JSON.stringify(lc5)); // (6) покупка: 101000 монет → «Озёра» (100000) — списание, авто-переход, тост const lcSt0 = await page.evaluate(() => state); if (lcSt0 === 'MENU') { await page.click('#btnPlay'); await sleep(150); } await page.evaluate(() => { save.coins = 101000; }); 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('локации покупка: 101000 → «Озёра» (100000) — 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 < 100000 — 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 })); // (9) дневник: 11 строк (новые виды видны в энциклопедии) await page.click('#tabJournal'); await sleep(150); const lc9 = await page.evaluate(() => document.querySelectorAll('#shopBody .jrow').length); ok('локации дневник: вкладка journal — 11 строк .jrow (новые виды в энциклопедии)', lc9 === 11, 'rows=' + lc9); // (10) масштаб ачивок: species_8 — все 11 видов; full_variants — 11×4 варианта, +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']; save.ach = {}; save.caught = {}; for (const id of old8) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 1 }; checkAchievements(); const mid = { s8: !!save.ach.species_8, fv: !!save.ach.full_variants }; for (const id of ['zander', 'chuch', 'crayfish']) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 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 старых ×4 варианта — species_8/full_variants НЕ выданы; +3 новых → обе выданы, +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)); // сброс локаций к дефолту await page.evaluate(() => { save.location = 0; save.locations = [0]; persist(); closeShop(); }); // --- 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); });