Files
fishing-game/tests/test.js
T

1851 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* Автотест игры: прогон механик через CDP в headless Chromium */
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
const results = [];
function ok(name, cond, extra = '') {
results.push({ name, pass: !!cond, extra });
console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (extra ? ' | ' + extra : ''));
}
async function waitState(page, st, timeout = 8000) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
cur = await page.evaluate(() => state);
if (cur === st) return cur;
await new Promise(r => setTimeout(r, 40));
}
throw new Error('timeout waiting state "' + st + '", current=' + cur);
}
// Ждём условие в странице (паттерн waitState для произвольного выражения): фиксированные sleep
// хрупки при fps<20 — dt игры клампится (dt=min(0.05,…)), реальные 1.5 с ≠ 1.5 с игрового
async function waitCond(page, expr, timeout = 5000) {
const t0 = Date.now();
let cur;
while (Date.now() - t0 < timeout) {
cur = await page.evaluate(expr);
if (cur) return cur;
await new Promise(r => setTimeout(r, 40));
}
throw new Error('timeout waiting condition, last=' + JSON.stringify(cur));
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
(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<st0 после 2 с удержания)', st1 < st0,
'st0=' + st0.toFixed(3) + ' st1=' + st1.toFixed(3));
// (2) stamina: усталая рыба слабее — fishForce глобальна, tired лачится при st<=0.12
const f = await page.evaluate(() => {
const r = { st: 1, tired: false, ov: 1 };
const F1 = fishForce(r, 0.5, true);
r.st = 0.05;
const F2 = fishForce(r, 0.5, true);
return { F1, F2 };
});
ok('stamina: слабая/уставшая рыба тянет слабее (F(st=0.05) < 0.6·F(st=1))',
f.F2 < f.F1 * 0.6, 'F1=' + f.F1.toFixed(1) + ' F2=' + f.F2.toFixed(1));
await page.evaluate(() => {
reel.d0 = 200; reel.d = 100; reel.tension = 0; reel.line = 500;
reel.phase = 'calm'; reel.phaseT = 9999; reel.st = 0.1; reel.tired = false;
});
await sleep(200); // кадры updateReel зафиксируют tired при st<=0.12
const tiredFlag = await page.evaluate(() => reel.tired);
ok('stamina: при st=0.1 рыба устала (reel.tired===true)', tiredFlag === true,
'tired=' + tiredFlag);
// (3) drag: рывок сильнее drag — леска скользит (d уходит, tension за чертой)
await page.evaluate(() => {
reel.w = 12; reel.ov = 4; reel.aggr = 1; reel.st = 1; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000); // палец всё ещё нажат (holding=true)
const slip = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок сильнее drag — леска скользит (d растёт, tension>drag)',
slip.d > slip.d0 && slip.tension > slip.drag,
JSON.stringify(slip));
// (4) drag: рывок слабее drag — спокойно втягиваем (d падает, tension под чертой)
await page.evaluate(() => {
reel.w = 0.3; reel.ov = 0.1; reel.aggr = 0.2; reel.st = 0.9; reel.tired = false;
reel.phase = 'pull'; reel.phaseT = 9999; reel.tension = 0; reel.line = 500;
reel.d0 = 100; reel.d = 100;
});
await sleep(1000);
const reelIn = await page.evaluate(() => ({ d: reel.d, d0: reel.d0, tension: reel.tension, drag: rod().drag }));
ok('drag: рывок слабее drag — втягивание (d падает, tension<drag)',
reelIn.d < reelIn.d0 && reelIn.tension < reelIn.drag,
JSON.stringify(reelIn));
await page.mouse.up(); // отпустили палец
await page.evaluate(() => { state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
// (5) пустой клёв: без forceBite и с Math.random=0.99+ клёва нет
await page.evaluate(() => { setDayTime(0.3); state = 'IDLE'; reel = null; biteFish = null; forceBite = false; });
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => {
window.__mr = Math.random;
Math.random = () => 0.999; // выше любого biteChance (макс ~0.9 днём)
biteTimer = 0.05;
forceBite = false;
});
await sleep(700);
const empty = await page.evaluate(() => {
Math.random = window.__mr;
return { st: state, bf: !!biteFish };
});
ok('пустой клёв: без forceBite и Math.random=0.99+ — клёва нет (WAIT, нет biteFish)',
empty.st === 'WAIT' && !empty.bf, JSON.stringify(empty));
// --- 10b. M5: апгрейды снастей (lv 1..4), фонарик, гейтинг лавки ---
// (1) миграция: старый сейв без rodLv/lureLv, фонарик ещё приманка (lures:[0,4], lure:4)
await page.evaluate(() => {
localStorage.setItem('fishing-save-v1', JSON.stringify({
coins: 77, rod: 0, lure: 4, rods: [0], lures: [0, 4], muted: false,
caught: {}, casts: 1, earned: 10
}));
});
await page.reload({ waitUntil: 'load' });
await sleep(500);
const m5m = await page.evaluate(() => ({
rodLv: save.rodLv, lureLv: save.lureLv,
lures: save.lures, lure: save.lure,
rods: save.rods, rod: save.rod
}));
ok('M5 миграция: старый сейв (нет rodLv/lureLv, lures:[0,4], lure:4) → rodLv={0:1}, lureLv={0:1}, lures без 4, lure=0',
m5m.rodLv[0]===1 && Object.keys(m5m.rodLv).length===1
&& m5m.lureLv[0]===1 && Object.keys(m5m.lureLv).length===1
&& m5m.lures.join(',')==='0' && m5m.lure===0
&& m5m.rods.join(',')==='0' && m5m.rod===0,
JSON.stringify(m5m));
// (2) данные: фонарика как приманки id=4 нет, поле nightOnly не используется
const m5t = await page.evaluate(() => ({
n: LURES.length,
ids: LURES.map(l => l.id).join(','),
noNight: LURES.every(l => !('nightOnly' in l))
}));
ok('M5 данные: LURES.length===4 (id 0..3, фонарика id=4 нет) и нет поля nightOnly',
m5t.n===4 && m5t.ids==='0,1,2,3' && m5t.noNight, JSON.stringify(m5t));
// (3) эффективные статы удочки 0 по уровням: точные (база × множители реализации)
const m5r = await page.evaluate(() => {
save.rod = 0;
const b = RODS[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.rodLv[0] = lv;
const r = rod();
out[lv] = { line: r.line, drag: r.drag, reel: r.reel, size: r.size, light: r.light };
}
save.rodLv[0] = 1;
return {
got: out,
exp: {
1: { line: b.line, drag: b.drag, reel: b.reel, size: b.size, light: false },
2: { line: b.line * 1.15, drag: b.drag, reel: b.reel, size: b.size, light: true },
3: { line: b.line * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size, light: true },
4: { line: b.line * 1.15 * 1.15, drag: b.drag * 1.15, reel: b.reel * 1.10, size: b.size + 0.10, light: true }
}
};
});
ok('M5 статы удочки 0: Lv1=база (line 100, drag 34, reel 1.0, size 0), Lv2 line≈115 (×1.15), Lv3 drag≈39.1 (×1.15) reel=1.1 (×1.10), Lv4 line≈132.25 (×1.15²) size=0.1',
[1, 2, 3, 4].every(lv => {
const g = m5r.got[lv], e = m5r.exp[lv];
return g.line === e.line && g.drag === e.drag && g.reel === e.reel && g.size === e.size && !!g.light === e.light;
}),
JSON.stringify(m5r.got));
// (4) эффективные статы приманки 0 по уровням
const m5l = await page.evaluate(() => {
save.lure = 0;
const b = LURES[0], out = {};
for (const lv of [1, 2, 3, 4]) {
save.lureLv[0] = lv;
const l = lure();
out[lv] = { bite: l.bite, rare: l.rare, size: l.size };
}
save.lureLv[0] = 1;
return {
got: out,
exp: {
1: { bite: b.bite, rare: b.rare, size: b.size },
2: { bite: b.bite * 1.15, rare: b.rare, size: b.size },
3: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size },
4: { bite: b.bite * 1.15, rare: b.rare + 1.0, size: b.size + 0.15 }
}
};
});
ok('M5 статы приманки 0: Lv1=база (bite 1.0, rare 0, size 0), Lv2 bite=1.15 (×1.15), Lv3 rare=1.0 (+1.0), Lv4 size=0.15 (+0.15)',
[1, 2, 3, 4].every(lv => {
const g = m5l.got[lv], e = m5l.exp[lv];
return g.bite === e.bite && g.rare === e.rare && g.size === e.size;
}),
JSON.stringify(m5l.got));
// (5) фонарик = апгрейд 1 удилища: hasLight
const m5h = await page.evaluate(() => {
save.rodLv[save.rod] = 1; const a = hasLight();
save.rodLv[save.rod] = 2; const b = hasLight();
save.rodLv[save.rod] = 4; const c = hasLight();
save.rodLv[save.rod] = 1;
return { a, b, c };
});
ok('M5 hasLight(): false на 1 ур., true на 2 ур. и 4 ур.',
m5h.a === false && m5h.b === true && m5h.c === true, JSON.stringify(m5h));
// (6) лавка: гейтинг — свежий сейв, rod 1 и lure 1 заблокированы
await page.click('#btnPlay');
await sleep(150);
await page.evaluate(() => {
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.coins = 1000;
});
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
const m5g1 = await page.evaluate(() => {
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => ({
locked: el.classList.contains('locked'),
buy: el.querySelector('[data-act="buy"]') !== null,
lock: el.querySelector('button[disabled]') !== null,
desc: el.querySelector('.itemdesc').textContent
});
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: свежий сейв (1 ур.) — строки rod 1 и lure 1: класс locked, 🔒, нет buy, «Откроется: … до 4 ур.»',
m5g1.r1.locked && !m5g1.r1.buy && m5g1.r1.lock
&& m5g1.r1.desc.includes('Откроется') && m5g1.r1.desc.includes('до 4 ур.')
&& m5g1.l1.locked && !m5g1.l1.buy && m5g1.l1.lock && m5g1.l1.desc.includes('Откроется'),
JSON.stringify(m5g1));
// (7) лавка: предыдущая снасть на 4 ур. — buy появляется, locked снимается
const m5g2 = await page.evaluate(() => {
save.rodLv[0] = 4; save.lureLv[0] = 4;
renderShop();
const items = Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const byName = n => items.find(el => el.querySelector('.itemname').textContent.includes(n));
const info = el => {
const b = el.querySelector('[data-act="buy"]');
return { locked: el.classList.contains('locked'), buyTxt: b ? b.textContent : null };
};
return { r1: info(byName('Стеклопластик')), l1: info(byName('Червь')) };
});
ok('M5 гейтинг лавки: после rodLv[0]=4 / lureLv[0]=4 — locked снят, кнопки buy «875 🪙» / «625 🪙»',
!m5g2.r1.locked && m5g2.r1.buyTxt === '875 🪙' && !m5g2.l1.locked && m5g2.l1.buyTxt === '625 🪙',
JSON.stringify(m5g2));
// (8) двойная защита: buy-ветка shopClick при закрытом гейтинге не покупает
const m5d = await page.evaluate(() => {
save.rodLv[0] = 1; save.coins = 1000;
const before = { rods: save.rods.join(','), coins: save.coins };
const el = document.createElement('button');
el.dataset.act = 'buy'; el.dataset.type = 'rod'; el.dataset.i = '1';
document.body.appendChild(el);
let err = null;
try { shopClick({ target: el }); } catch (e) { err = String(e); }
document.body.removeChild(el);
return { err, before,
after: { rods: save.rods.join(','), coins: save.coins },
toast: document.getElementById('toast').textContent };
});
ok('M5 двойная защита: shopClick buy (rod 1) при rodLv[0]<4 — rods/coins не изменились, тост «Сначала улучши…»',
m5d.err === null && m5d.before.rods === m5d.after.rods && m5d.before.coins === m5d.after.coins
&& m5d.toast.includes('Сначала улучши'),
JSON.stringify(m5d));
// (9) покупка апгрейда: 1000 🪙 → 930, rodLv[0]=2, фонарик включился
await page.evaluate(() => {
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.coins = 1000;
renderShop();
});
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="0"]');
const m5u = await page.evaluate(() => ({
coins: save.coins, lv: save.rodLv[0], light: hasLight(),
toast: document.getElementById('toast').textContent
}));
ok('M5 покупка апгрейда: rod 0 1→2 ур. за 70 🪙 (1000→930), hasLight()=true, тост «Улучшено … → 2 ур»',
m5u.coins === 930 && m5u.lv === 2 && m5u.light === true
&& m5u.toast.includes('Улучшено') && m5u.toast.includes('2 ур'),
JSON.stringify(m5u));
// (10) на макс. уровне (4) кнопки upgrade в строке нет
const m5x = await page.evaluate(() => {
save.rodLv[0] = 4; renderShop();
const r0 = document.querySelectorAll('#shopBody .item')[0];
return { hasUpg: r0.querySelector('[data-act="upgrade"]') !== null, txt: r0.textContent };
});
ok('M5 макс. уровень: на 4 ур. у rod 0 кнопки upgrade нет (пипсы ●●●●, «4 ур.»)',
!m5x.hasUpg && m5x.txt.includes('●●●●') && m5x.txt.includes('4 ур.'), m5x.txt);
// (11) не хватает монет: апгрейд не покупается
await page.evaluate(() => { save.rodLv[0] = 1; save.coins = 50; renderShop(); });
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="0"]');
const m5p = await page.evaluate(() => ({
coins: save.coins, lv: save.rodLv[0],
toast: document.getElementById('toast').textContent
}));
ok('M5 не хватает монет: 50 🪙 на апгрейд за 70 — coins=50, уровень не изменился, тост «Не хватает монет»',
m5p.coins === 50 && m5p.lv === 1 && m5p.toast.includes('Не хватает монет'),
JSON.stringify(m5p));
await page.click('#btnCloseShop');
// (12) ночь без фонарика (rodLv[save.rod]=1): клёв не стартует, тост (паттерн §9j(c))
await page.evaluate(() => {
setDayTime(0.8);
save.lures = [0]; save.lure = 0;
save.rodLv[save.rod] = 1;
save.quests = { date: todayStr(), list: [] }; // квестный тост не затирает проверочный
state = 'IDLE'; reel = null; biteFish = null; forceBite = false;
});
await page.mouse.move(195, 600);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await sleep(600);
const m5n = await page.evaluate(() => ({
st: state, bf: !!biteFish, bt: biteTimer,
toast: document.getElementById('toast').textContent
}));
ok('M5 ночь без фонарика (rodLv=1): клёв не стартует (WAIT, нет biteFish, biteTimer<0), тост «Ночью нужен фонарик»',
m5n.st === 'WAIT' && !m5n.bf && m5n.bt < 0 && m5n.toast.includes('Ночью нужен фонарик'),
JSON.stringify(m5n));
// (13) ночь с фонариком (rodLv[save.rod]=2): клёв работает (паттерн §9j(d))
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.mouse.move(195, 600);
await page.evaluate(() => { save.rodLv[save.rod] = 2; });
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
ok('M5 ночь с фонариком (rodLv=2): клёв работает (BITE)', true);
// (14) покупка разблокированной снасти стартует с уровня 1
await page.evaluate(() => {
forceBite = false; state = 'IDLE'; reel = null; biteFish = null;
setDayTime(0.3);
save.rods = [0]; save.lures = [0]; save.rod = 0; save.lure = 0;
save.rodLv = { 0: 4 }; save.lureLv = { 0: 1 }; save.coins = 3000;
});
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
await page.click('#shopBody [data-act="buy"][data-type="rod"][data-i="1"]');
const m5b = await page.evaluate(() => ({
rods: save.rods.join(','), lv: save.rodLv[1],
rod: save.rod, coins: save.coins
}));
ok('M5 покупка разблокированной снасти: rod 1 за 875 🪙 — rods=[0,1], rodLv[1]=1, снасть экипирована, coins=2125',
m5b.rods === '0,1' && m5b.lv === 1 && m5b.rod === 1 && m5b.coins === 2125,
JSON.stringify(m5b));
// (14b) апгрейд по формуле: rod 1 (база 875) 1→2 ур. = 875×0.2 = 175 → 180 (округление до 10)
await page.click('#shopBody [data-act="upgrade"][data-type="rod"][data-i="1"]');
const m5bu = await page.evaluate(() => ({ lv: save.rodLv[1], coins: save.coins }));
ok('M5 апгрейд по формуле: rod 1 1→2 ур. за 180 🪙 (база 875×0.2 = 175 → округление до 10) — coins=2125→1945, rodLv[1]=2',
m5bu.lv === 2 && m5bu.coins === 1945, JSON.stringify(m5bu));
await page.click('#btnCloseShop');
// (15) пин цен апгрейдов: id 0 — фикс, id>0 — база×(0.2/0.6/1.2)
const m5c = await page.evaluate(() => ({
r: [0,1,2,3].map(i=>[1,2,3].map(lv=>upgCost('rod',i,lv)).join(',')),
l: [0,1,2,3].map(i=>[1,2,3].map(lv=>upgCost('lure',i,lv)).join(','))
}));
ok('M5 цены апгрейдов: id 0 фикс (70/210/500, 60/160/400); id>0 = база×(0.2/0.6/1.2)',
m5c.r.join('|')==='70,210,500|180,530,1050|900,2700,5400|3000,9000,18000'
&& m5c.l.join('|')==='60,160,400|130,380,750|600,1800,3600|2250,6750,13500',
JSON.stringify(m5c));
// --- 10c. M6: варианты рыб (VARIANTS, forceVariant, карточка, full_variants) ---
// (1) данные: таблица VARIANTS и живой gold-шанс (variantChance → SHINY_CHANCE)
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); });