'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); } const sleep = ms => new Promise(r => setTimeout(r, ms)); (async () => { 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. Магазин: покупка и экипировка --- await page.evaluate(() => { save.coins = 10000; }); 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 === 9750 && 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 === 9600, 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 === 9600 && 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)) === 9600); 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('cardShiny').classList.contains('hidden'), badgeText: document.getElementById('cardShiny').textContent, goldCard: document.getElementById('card').classList.contains('shiny-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; // исключаем случайный ×3 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; }); 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) ночь с фонариком: клёв работает await page.click('#btnRecall'); await waitState(page, 'IDLE'); await page.mouse.move(195, 600); await page.evaluate(() => { setDayTime(0.8); save.lures=[0,4]; }); 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 ночь с фонариком (save.lures включает 4): клёв работает (BITE)', true); // (e) лавка: «Фонарик» в снастях await page.click('#btnShop'); await sleep(150); await page.click('#tabTackle'); await sleep(150); const m3shop = await page.evaluate(() => { save.lures=[0]; // строка с ценой, а не «Выбрать» renderShop(); const items=Array.prototype.slice.call(document.querySelectorAll('#shopBody .item')); const row=items.find(el=>el.textContent.indexOf('Фонарик')>=0); return row?{txt:row.textContent}:null; }); ok('M3 лавка: «Фонарик» в снастях (500 🪙, пометка «ночь»)', m3shop && m3shop.txt.indexOf('500 🪙')>=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)); // --- 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); });