1851 lines
99 KiB
JavaScript
1851 lines
99 KiB
JavaScript
'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); });
|