Files
fishing-game/tests/test.js
T

1133 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* Автотест игры: прогон механик через CDP в headless Chromium */
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
const results = [];
function ok(name, cond, extra = '') {
results.push({ name, pass: !!cond, extra });
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
}
async function waitState(page, st, timeout = 8000) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
cur = await page.evaluate(() => state);
if (cur === st) return cur;
await new Promise(r => setTimeout(r, 40));
}
throw new Error('timeout waiting state "' + st + '", current=' + cur);
}
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. Магазин: покупка и экипировка ---
// 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 === 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) ночь с фонариком: клёв работает (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 удилища (кнопка ⬆ 400 🪙, подпись «фонарик (ночь)»)',
m3shop && m3shop.txt.indexOf('400')>=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<st0 после 2 с удержания)', st1 < st0,
'st0=' + st0.toFixed(3) + ' st1=' + st1.toFixed(3));
// (2) stamina: усталая рыба слабее — fishForce глобальна, tired лачится при st<=0.12
const f = await page.evaluate(() => {
const r = { st: 1, tired: false, ov: 1 };
const F1 = fishForce(r, 0.5, true);
r.st = 0.05;
const F2 = fishForce(r, 0.5, true);
return { F1, F2 };
});
ok('stamina: слабая/уставшая рыба тянет слабее (F(st=0.05) < 0.6·F(st=1))',
f.F2 < f.F1 * 0.6, 'F1=' + f.F1.toFixed(1) + ' F2=' + f.F2.toFixed(1));
await page.evaluate(() => {
reel.d0 = 200; reel.d = 100; reel.tension = 0; reel.line = 500;
reel.phase = 'calm'; reel.phaseT = 9999; reel.st = 0.1; reel.tired = false;
});
await sleep(200); // кадры updateReel зафиксируют tired при st<=0.12
const tiredFlag = await page.evaluate(() => reel.tired);
ok('stamina: при st=0.1 рыба устала (reel.tired===true)', tiredFlag === true,
'tired=' + tiredFlag);
// (3) drag: рывок сильнее drag — леска скользит (d уходит, tension за чертой)
await page.evaluate(() => {
reel.w = 12; reel.ov = 4; reel.aggr = 1; reel.st = 1; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000); // палец всё ещё нажат (holding=true)
const slip = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок сильнее drag — леска скользит (d растёт, tension>drag)',
slip.d > slip.d0 && slip.tension > slip.drag,
JSON.stringify(slip));
// (4) drag: рывок слабее drag — спокойно втягиваем (d падает, tension под чертой)
await page.evaluate(() => {
reel.w = 0.3; reel.ov = 0.1; reel.aggr = 0.2; reel.st = 0.9; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000);
const reelIn = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок слабее drag — втягивание (d падает, tension<drag)',
reelIn.d < reelIn.d0 && reelIn.tension < reelIn.drag,
JSON.stringify(reelIn));
await page.mouse.up(); // отпустили палец
await page.evaluate(() => { state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
// (5) пустой клёв: без forceBite и с Math.random=0.99+ клёва нет
await page.evaluate(() => { setDayTime(0.3); state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => {
window.__mr = Math.random;
Math.random = () => 0.999; // выше любого biteChance (макс ~0.9 днём)
biteTimer = 0.05;
forceBite = false;
});
await sleep(700);
const empty = await page.evaluate(() => {
Math.random = window.__mr;
return { st: state, bf: !!biteFish };
});
ok('пустой клёв: без forceBite и Math.random=0.99+ — клёва нет (WAIT, нет biteFish)',
empty.st === 'WAIT' && !empty.bf, JSON.stringify(empty));
// --- 10b. M5: апгрейды снастей (lv 1..4), фонарик, гейтинг лавки ---
// (1) миграция: старый сейв без rodLv/lureLv, фонарик ещё приманка (lures:[0,4], lure:4)
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 4, rods: [0], lures: [0, 4], muted: false,
caught: {}, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m5m = await page.evaluate(() => ({
rodLv: save.rodLv, lureLv: save.lureLv,
lures: save.lures, lure: save.lure,
rods: save.rods, rod: save.rod
}));
ok('M5 миграция: старый сейв (нет rodLv/lureLv, lures:[0,4], lure:4) → rodLv={0:1}, lureLv={0:1}, lures без 4, lure=0',
m5m.rodLv[0]===1 && Object.keys(m5m.rodLv).length===1
&& m5m.lureLv[0]===1 && Object.keys(m5m.lureLv).length===1
&& m5m.lures.join(',')==='0' && m5m.lure===0
&& m5m.rods.join(',')==='0' && m5m.rod===0,
JSON.stringify(m5m));
// (2) данные: фонарика как приманки id=4 нет, поле nightOnly не используется
const m5t = await page.evaluate(() => ({
n: LURES.length,
ids: LURES.map(l => l.id).join(','),
noNight: LURES.every(l => !('nightOnly' in l))
}));
ok('M5 данные: LURES.length===4 (id 0..3, фонарика id=4 нет) и нет поля nightOnly',
m5t.n===4 && m5t.ids==='0,1,2,3' && m5t.noNight, JSON.stringify(m5t));
// (3) эффективные статы удочки 0 по уровням: точные (база × множители реализации)
const m5r = await page.evaluate(() => {
save.rod = 0;
const b = RODS[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.rodLv[0] = lv;
const r = rod();
out[lv] = { line: r.line, drag: r.drag, reel: r.reel, size: r.size, light: r.light };
}
save.rodLv[0] = 1;
return {
got: out,
exp: {
1: { line: b.line, drag: b.drag, reel: b.reel, size: b.size, light: false },
2: { line: b.line * 1.15, drag: b.drag, reel: b.reel, size: b.size, light: true },
3: { line: b.line * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size, light: true },
4: { line: b.line * 1.15 * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size + 0.10, light: true }
}
};
});
ok('M5 статы удочки 0: Lv1=база (line 100, drag 34, reel 1.0, size 0), Lv2 line≈115 (×1.15), Lv3 drag≈39.1 (×1.15) reel=1.1 (×1.10), Lv4 line≈132.25 (×1.15²) size=0.1',
[1, 2, 3, 4].every(lv => {
const g = m5r.got[lv], e = m5r.exp[lv];
return g.line === e.line && g.drag === e.drag && g.reel === e.reel && g.size === e.size && !!g.light === e.light;
}),
JSON.stringify(m5r.got));
// (4) эффективные статы приманки 0 по уровням
const m5l = await page.evaluate(() => {
save.lure = 0;
const b = LURES[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.lureLv[0] = lv;
const l = lure();
out[lv] = { bite: l.bite, rare: l.rare, size: l.size };
}
save.lureLv[0] = 1;
return {
got: out,
exp: {
1: { bite: b.bite, rare: b.rare, size: b.size },
2: { bite: b.bite * 1.15, rare: b.rare, size: b.size },
3: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size },
4: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size + 0.15 }
}
};
});
ok('M5 статы приманки 0: Lv1=база (bite 1.0, rare 0, size 0), Lv2 bite=1.15 (×1.15), Lv3 rare=1.0 (+1.0), Lv4 size=0.15 (+0.15)',
[1, 2, 3, 4].every(lv => {
const g = m5l.got[lv], e = m5l.exp[lv];
return g.bite === e.bite && g.rare === e.rare && g.size === e.size;
}),
JSON.stringify(m5l.got));
// (5) фонарик = апгрейд 1 удилища: hasLight
const m5h = await page.evaluate(() => {
save.rodLv[save.rod] = 1; const a = hasLight();
save.rodLv[save.rod] = 2; const b = hasLight();
save.rodLv[save.rod] = 4; const c = hasLight();
save.rodLv[save.rod] = 1;
return { a, b, c };
});
ok('M5 hasLight(): false на 1 ур., true на 2 ур. и 4 ур.',
m5h.a === false && m5h.b === true && m5h.c === true, JSON.stringify(m5h));
// (6) лавка: гейтинг — свежий сейв, rod 1 и lure 1 заблокированы
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.coins = 1000;
});
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
const m5g1 = await page.evaluate(() => {
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => ({
locked: el.classList.contains('locked'),
buy: el.querySelector('[data-act="buy"]') !== null,
lock: el.querySelector('button[disabled]') !== null,
desc: el.querySelector('.itemdesc').textContent
});
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: свежий сейв (1 ур.) — строки rod 1 и lure 1: класс locked, 🔒, нет buy, «Откроется: … до 4 ур.»',
m5g1.r1.locked && !m5g1.r1.buy && m5g1.r1.lock
&& m5g1.r1.desc.includes('Откроется') && m5g1.r1.desc.includes('до 4 ур.')
&& m5g1.l1.locked && !m5g1.l1.buy && m5g1.l1.lock && m5g1.l1.desc.includes('Откроется'),
JSON.stringify(m5g1));
// (7) лавка: предыдущая снасть на 4 ур. — buy появляется, locked снимается
const m5g2 = await page.evaluate(() => {
save.rodLv[0] = 4; save.lureLv[0] = 4;
renderShop();
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => {
const b = el.querySelector('[data-act="buy"]');
return { locked: el.classList.contains('locked'), buyTxt: b ? b.textContent : null };
};
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: после rodLv[0]=4 / lureLv[0]=4 — locked снят, кнопки buy «250 🪙» / «150 🪙»',
!m5g2.r1.locked && m5g2.r1.buyTxt === '250 🪙' && !m5g2.l1.locked && m5g2.l1.buyTxt === '150 🪙',
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 🪙 → 600, 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 ур. за 400 🪙 (1000→600), hasLight()=true, тост «Улучшено … → 2 ур»',
m5u.coins === 600 && 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 = 100; 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 не хватает монет: 100 🪙 на апгрейд за 400 — coins=100, уровень не изменился, тост «Не хватает монет»',
m5p.coins === 100 && 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 = 1000;
});
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 за 250 🪙 — rods=[0,1], rodLv[1]=1, снасть экипирована, coins=750',
m5b.rods === '0,1' && m5b.lv === 1 && m5b.rod === 1 && m5b.coins === 750,
JSON.stringify(m5b));
await page.click('#btnCloseShop');
// --- 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); });