3527 lines
201 KiB
JavaScript
3527 lines
201 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));
|
||
// 🗑️/🔊 переехали в хаб «Меню» (#gamemenu): из игры он открывается через паузу —
|
||
// ⏸ → «В меню». Работает из игрового состояния (state!=='MENU', без оверлеев).
|
||
async function toGameMenu(p) {
|
||
await p.click('#btnPause');
|
||
await sleep(100);
|
||
await p.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
}
|
||
|
||
(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(() => {});
|
||
const snapToast = await page.evaluate(() => document.getElementById('toast').textContent);
|
||
await page.mouse.up();
|
||
await waitState(page, 'IDLE', 3000);
|
||
ok('обрыв: SNAP -> IDLE', true);
|
||
ok('обрыв: в тосте рыба с весом («💥 …» + «кг»)',
|
||
snapToast.startsWith('💥') && snapToast.includes('кг'),
|
||
snapToast);
|
||
|
||
// --- 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 toGameMenu(page);
|
||
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);
|
||
|
||
// --- Y1. SDK (стаб) ---
|
||
// YG — обёртка SDK Яндекс Игр: в file:// (без ?sdk=1) скрипт /sdk.js не создаётся —
|
||
// YG в стабе (sdk=null, active=false), геймплейная разметка дёргает только счётчики
|
||
// YG.counts. На входе — MENU (гарантия секции 9: сброс сейва). Порядок важен: btnPlay
|
||
// до магазина — в MENU оверлей #menu (z-20) выше #hud (z-10), координатный клик по
|
||
// #btnShop не долетает до кнопки (после btnPlay HUD без оверлея).
|
||
// (1) стаб: YG активен, SDK недоступен в file://
|
||
const y1stub = await page.evaluate(() => ({
|
||
active: YG.active, sdk: YG.sdk,
|
||
gs: typeof YG.gameplayStart, gp: typeof YG.gameplayStop
|
||
}));
|
||
ok('Y1 стаб: YG.active===false, YG.sdk===null, gameplayStart/gameplayStop — функции',
|
||
y1stub.active === false && y1stub.sdk === null && y1stub.gs === 'function' && y1stub.gp === 'function',
|
||
JSON.stringify(y1stub));
|
||
|
||
// (2) LoadingAPI: ready() — ровно один раз, в первом кадре frame()
|
||
const y1ready = await page.evaluate(() => ({ readySent: YG.readySent, ready: YG.counts.ready }));
|
||
ok('Y1 LoadingAPI: readySent=true, counts.ready===1 (первый кадр frame)',
|
||
y1ready.readySent === true && y1ready.ready === 1, JSON.stringify(y1ready));
|
||
|
||
// (3) геймплей-разметка: btnPlay из MENU → start, state=IDLE
|
||
// (на входе state=MENU; страховка — флоу сброса: пауза → «В меню» → btnReset → btnResetYes)
|
||
{
|
||
if ((await page.evaluate(() => state)) !== 'MENU') {
|
||
await toGameMenu(page);
|
||
await page.click('#btnReset');
|
||
await sleep(150);
|
||
await page.click('#btnResetYes');
|
||
await sleep(150);
|
||
}
|
||
}
|
||
const y1pB = await page.evaluate(() => ({ s: YG.counts.start, st: state }));
|
||
await page.click('#btnPlay');
|
||
await sleep(100);
|
||
const y1p = await page.evaluate(() => ({
|
||
s: YG.counts.start, st: state,
|
||
menu: document.getElementById('menu').classList.contains('hidden')
|
||
}));
|
||
ok('Y1 геймплей-разметка: #btnPlay из MENU → counts.start +1, state=IDLE, меню скрыто',
|
||
y1pB.st === 'MENU' && y1p.s === y1pB.s + 1 && y1p.st === 'IDLE' && y1p.menu === true,
|
||
JSON.stringify({ before: y1pB, after: y1p }));
|
||
|
||
// (4) геймплей-разметка: btnShop → stop, btnCloseShop → start (из IDLE — HUD без оверлея)
|
||
const y1sB = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop }));
|
||
await page.click('#btnShop');
|
||
await sleep(100);
|
||
const y1sM = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
|
||
await page.click('#btnCloseShop');
|
||
await sleep(100);
|
||
const y1sE = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop, open: uiOpen }));
|
||
ok('Y1 геймплей-разметка: #btnShop → counts.stop +1 (uiOpen=true), #btnCloseShop → counts.start +1 (uiOpen=false)',
|
||
y1sM.t === y1sB.t + 1 && y1sM.s === y1sB.s && y1sM.open === true
|
||
&& y1sE.s === y1sM.s + 1 && y1sE.t === y1sM.t && y1sE.open === false,
|
||
JSON.stringify({ before: y1sB, mid: y1sM, after: y1sE }));
|
||
|
||
// (5) пауза: YG.onPause() → paused=true, AC.suspend()
|
||
// жест для AudioContext — клики выше (sfx→ac): до паузы AC в running;
|
||
// AC.suspend() асинхронен — polling до 'suspended' (в IDLE updateGame без sfx —
|
||
// контекст сам не возобновится)
|
||
const y1paB = await page.evaluate(() => {
|
||
ac();
|
||
return { ac: AC ? AC.state : null, t: YG.counts.stop };
|
||
});
|
||
await page.evaluate(() => YG.onPause());
|
||
await waitCond(page, () => AC && AC.state === 'suspended');
|
||
const y1pa = await page.evaluate(() => ({ paused: YG.paused, ac: AC ? AC.state : null, t: YG.counts.stop }));
|
||
ok('Y1 пауза: YG.onPause() → paused=true, AC.state="suspended", counts.stop +1',
|
||
y1pa.paused === true && y1pa.ac === 'suspended' && y1pa.t === y1paB.t + 1,
|
||
JSON.stringify({ before: y1paB, after: y1pa }));
|
||
|
||
// (6) visibilitychange-путь: hidden → onPause, visible → onResume
|
||
// сначала сбрасываем паузу из (5) (иначе проверка hidden тривиальна);
|
||
// в конце возвращаем document.hidden (маску — свойство — снимаем: нативный
|
||
// getter в тестовой вкладке → false)
|
||
const y1v = await page.evaluate(() => {
|
||
const set = h => {
|
||
Object.defineProperty(document, 'hidden', { value: h, configurable: true });
|
||
document.dispatchEvent(new Event('visibilitychange'));
|
||
};
|
||
set(false);
|
||
const pre = YG.paused;
|
||
set(true);
|
||
const hid = YG.paused;
|
||
set(false);
|
||
const shw = YG.paused;
|
||
delete document.hidden;
|
||
return { pre, hid, shw, doc: document.hidden };
|
||
});
|
||
ok('Y1 visibilitychange: visible→hidden → paused=true, hidden→visible → paused=false; document.hidden возвращён (false)',
|
||
y1v.pre === false && y1v.hid === true && y1v.shw === false && y1v.doc === false,
|
||
JSON.stringify(y1v));
|
||
|
||
// AC после (5) остаётся suspended (YG.onResume звук не возобновляет — по следующему
|
||
// жесту): два клика по 🔊 — sfx→ac→resume, muted при этом не меняется (false→true→false).
|
||
// Клик по canvas нельзя: в IDLE это заброс (save.casts/стрик до секции 9a).
|
||
// 🔊 теперь в хабе «Меню» — открыть паузой → «В меню».
|
||
await toGameMenu(page);
|
||
await page.click('#btnMute');
|
||
await page.click('#btnMute');
|
||
|
||
// (7) возврат в MENU: секция 9a заходит в игрой кликом по #btnPlay, а он лежит внутри
|
||
// меню (в IDLE меню hidden — кнопка не кликабельна). «Меню» уже открыто (после
|
||
// mute-кликов) — флоу сброса btnReset → btnResetYes (паттерн секции 9): сейв уже
|
||
// обнулён — данные не ломаются, квесты дня перегенерируются (9a их сразу чистит)
|
||
await page.click('#btnReset');
|
||
await sleep(150);
|
||
await page.click('#btnResetYes');
|
||
await sleep(150);
|
||
const y1end = await page.evaluate(() => ({
|
||
st: state,
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
paused: YG.paused,
|
||
muted: save.muted,
|
||
doc: document.hidden
|
||
}));
|
||
ok('Y1 cleanup: возврат в MENU (меню видно, paused=false, muted=false, document.hidden=false)',
|
||
y1end.st === 'MENU' && y1end.menu === true && y1end.paused === false
|
||
&& y1end.muted === false && y1end.doc === false,
|
||
JSON.stringify(y1end));
|
||
|
||
// --- 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; forceVariant = 'gold'; }); // M6: forceVariant — медь/кварц и пр. не перехватят ролл
|
||
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.06; forceVariant = null; });
|
||
|
||
// --- 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: сбежавшая рыба не даёт наград (утешение убрано) ---
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
await page.evaluate(() => {
|
||
save.quests = { date: todayStr(), list: [] }; // пин: квестовая награда не искажает монеты
|
||
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
|
||
});
|
||
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.phase = 'pull'; reel.phaseT = 9999;
|
||
reel.d = reel.d0 * 1.38;
|
||
});
|
||
await waitState(page, 'WAIT', 15000);
|
||
const nm = await page.evaluate(() => ({
|
||
coins: save.coins,
|
||
toast: document.getElementById('toast').textContent,
|
||
}));
|
||
ok('near-miss: сбежавшая рыба монеты не даёт',
|
||
nm.coins === coinsBeforeNm,
|
||
JSON.stringify(nm) + ' before=' + coinsBeforeNm);
|
||
ok('near-miss: в DOM тост «Сбежала» с весом, без монет',
|
||
nm.toast.startsWith('Сбежала!') && 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.06; 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.06;
|
||
pickSpecies = window.__m4Pick;
|
||
});
|
||
|
||
// --- 9k2. Этап 37: босс в каждой локации — Озёра (рак) / Море (рыба-меч) / Океан (кит) / Болото (дух, Этап 45) ---
|
||
// Паттерн §9k: monkey-patch pickSpecies + forceBoss + save.location; единое правило веса:
|
||
// любой босс 20–50 кг (hook), награда ×5, save.bosses[id]=true, пер-босс ачивка (без coinPct)
|
||
const b37list = [
|
||
{ loc: 1, id: 'crayfish', ach: 'lake_boss', name: 'Гигантский рак' },
|
||
{ loc: 2, id: 'swordfish', ach: 'sea_boss', name: 'Рыба-меч' },
|
||
{ loc: 3, id: 'whale', ach: 'ocean_boss', name: 'Синий кит' },
|
||
{ loc: 4, id: 'spiritfin', ach: 'swamp_boss', name: 'Дух болота' }, // Этап 45: Волшебное болото
|
||
];
|
||
await page.evaluate(() => { window.__b37Pick = pickSpecies; });
|
||
for (const b37c of b37list) {
|
||
await page.evaluate((c) => {
|
||
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
|
||
forceBoss = true; SHINY_CHANCE = 0;
|
||
pickSpecies = () => SPECIES.find(s => s.id === c.id);
|
||
save.location = c.loc;
|
||
// ВАЖНО: save.locations не трогаем — doCast() зовёт checkAchievements(), и «купленная»
|
||
// локация выдала бы lake/sea/ocean/swamp_first (+2% coinPct) ещё до catchFish →
|
||
// проверка награды босса (±10%) поплыла бы. Боссу достаточно save.location (forceBoss).
|
||
save.ach = {}; save.bosses = {}; save.bossBeaten = false; save.bossCount = 0;
|
||
save.caught = {};
|
||
save.quests = { date: todayStr(), list: [] };
|
||
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
|
||
}, b37c);
|
||
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 b37a = await page.evaluate(() => ({
|
||
stateBoss, bossPhase, boss: reel.boss, spId: reel.sp.id,
|
||
w: reel.w, d0: reel.d0,
|
||
}));
|
||
ok('Этап 37 ' + b37c.name + ' (локация ' + b37c.loc + '): forceBoss → старт босс-боя (stateBoss=true, bossPhase=1, reel.boss, sp.id=' + b37c.id + '), вес 20–50 кг',
|
||
b37a.stateBoss === true && b37a.bossPhase === 1 && b37a.boss === true && b37a.spId === b37c.id
|
||
&& b37a.w >= 20 && b37a.w <= 50,
|
||
JSON.stringify(b37a));
|
||
await page.evaluate(() => {
|
||
window.__b37W = reel.w;
|
||
bossPhase = 3; // фазовые обрывы (§9k) не суть — сразу в улов (паттерн M6(4))
|
||
reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5;
|
||
});
|
||
await page.mouse.down();
|
||
await waitState(page, 'CARD', 5000);
|
||
await page.mouse.up();
|
||
const b37b = await page.evaluate((c) => ({
|
||
w: window.__b37W,
|
||
price: SPECIES.find(s => s.id === c.id).price,
|
||
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
|
||
bosses: save.bosses, bossBeaten: save.bossBeaten, bossCount: save.bossCount,
|
||
ach: !!save.ach[c.ach],
|
||
name: document.getElementById('cardName').textContent,
|
||
badgeHidden: document.getElementById('cardBoss').classList.contains('hidden'),
|
||
bossCard: document.getElementById('card').classList.contains('boss-card'),
|
||
}), b37c);
|
||
const b37base = b37b.w * b37b.price * 5;
|
||
ok('Этап 37 ' + b37c.name + ': монеты = w×price×5 (±10%), save.bosses.' + b37c.id + '=true, bossBeaten=true, bossCount=1, ачивка ' + b37c.ach,
|
||
b37b.val >= Math.max(1, Math.round(b37base * 0.9)) && b37b.val <= Math.round(b37base * 1.1)
|
||
&& b37b.bosses && b37b.bosses[b37c.id] === true && b37b.bossBeaten === true && b37b.bossCount === 1
|
||
&& b37b.ach === true,
|
||
JSON.stringify(b37b));
|
||
ok('Этап 37 ' + b37c.name + ': карточка «' + b37c.name + '-БОСС», бейдж cardBoss виден, класс boss-card',
|
||
b37b.name === b37c.name + '-БОСС' && b37b.badgeHidden === false && b37b.bossCard === true,
|
||
JSON.stringify({ name: b37b.name, badgeHidden: b37b.badgeHidden, bossCard: b37b.bossCard }));
|
||
await page.click('#btnCardOk');
|
||
await sleep(100);
|
||
}
|
||
// (4) миграция: старый сейв без save.bosses → {} (паттерн lc5: Object.assign не удаляет ключи —
|
||
// в-памяти значение чиним вручную перед loadSave)
|
||
const b37m = await page.evaluate(() => {
|
||
delete save.bosses; // «тест-фантом» из прогона выше
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
||
coins: 77, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
|
||
caught: {}, casts: 1, earned: 10, bossBeaten: false, bossCount: 0
|
||
}));
|
||
loadSave();
|
||
return {
|
||
isObj: save.bosses !== undefined && typeof save.bosses === 'object' && Object.keys(save.bosses).length === 0
|
||
};
|
||
});
|
||
ok('Этап 37 миграция: старый сейв без save.bosses → {} (loadSave)',
|
||
b37m.isObj === true,
|
||
JSON.stringify(b37m));
|
||
// полный сброс тест-форсов и состояния (паттерн соседних блоков)
|
||
await page.evaluate(() => {
|
||
forceBoss = false; SHINY_CHANCE = 0.06; forceBite = false;
|
||
pickSpecies = window.__b37Pick;
|
||
state = 'IDLE'; reel = null; stateBoss = false; bossPhase = 0; biteFish = null;
|
||
save.location = 0;
|
||
save.bosses = {}; save.bossBeaten = false; save.bossCount = 0;
|
||
save.ach = {}; save.caught = {};
|
||
save.coins = 0;
|
||
});
|
||
|
||
// --- 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: сход монет не даёт — HUD в согласии с сейвом
|
||
await page.mouse.move(195, 600);
|
||
const nmHudBefore = 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.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('near-miss: сход монет не даёт, HUD в согласии с сейвом',
|
||
nmHud.coins === nmHudBefore && nmHud.hud === '🪙 ' + nmHud.coins,
|
||
JSON.stringify(nmHud) + ' before=' + nmHudBefore);
|
||
|
||
// (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.06; 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);
|
||
// снасть — нейтральная (простая наживка 1 ур.): буст вариантов B=0, шансы базовые
|
||
const m6v = await page.evaluate(() => {
|
||
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
|
||
save.lure = 0; save.lureLv = { 0: 1 };
|
||
SHINY_CHANCE = 0.07;
|
||
const out = {
|
||
n: VARIANTS.length,
|
||
mults: VARIANTS.map(v => v.mult).join(','),
|
||
copper: VARIANTS.find(v => v.id === 'copper').chance,
|
||
silver: VARIANTS.find(v => v.id === 'silver').chance,
|
||
quartz: VARIANTS.find(v => v.id === 'quartz').chance,
|
||
emerald: VARIANTS.find(v => v.id === 'emerald').chance,
|
||
diamond: VARIANTS.find(v => v.id === 'diamond').chance,
|
||
rainbow: VARIANTS.find(v => v.id === 'rainbow').chance,
|
||
goldLive: variantChance(VARIANTS.find(v => v.id === 'gold')),
|
||
boost0: variantBoost()
|
||
};
|
||
SHINY_CHANCE = 0.06;
|
||
save.lure = snap.lure; save.lureLv = snap.lv;
|
||
return out;
|
||
});
|
||
ok('M6 данные: VARIANTS 8 (mult 1/1.5/2/2.5/3/4/4.5/5), chance медь 0.12 / серебро 0.08 / кварц 0.07 / изумруд 0.01 / алмаз 0.007 / радуга 0.005, gold = живая SHINY_CHANCE (0.07); буст снасти у простой наживки 1 ур. = 0',
|
||
m6v.n === 8 && m6v.mults === '1,1.5,2,2.5,3,4,4.5,5' && m6v.copper === 0.12 && m6v.silver === 0.08
|
||
&& m6v.quartz === 0.07 && m6v.emerald === 0.01 && m6v.diamond === 0.007 && m6v.rainbow === 0.005
|
||
&& m6v.goldLive === 0.07 && m6v.boost0 === 0,
|
||
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.rec.cp === 0 && m6s.rec.qz === 0 && m6s.rec.em === 0 && m6s.rec.dm === 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 — независимые роллы по порядку VARIANTS:
|
||
// copper (0.12) → silver (0.08) → quartz (0.07) → gold (SHINY_CHANCE) → emerald (0.01) → diamond (0.007) → rainbow (0.005);
|
||
// пин Math.random по образцу §9j(b); каждый ролл — своё значение из последовательности
|
||
const m6roll = await page.evaluate(() => {
|
||
const mr = Math.random;
|
||
forceVariant = null;
|
||
forceWeather = null; weather = null; // M8: дождь ×2.5 сдвинул бы границы — исключаем
|
||
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
|
||
save.lure = 0; save.lureLv = { 0: 1 }; // M6+: нейтральная снасть — буст 0, границы базовые
|
||
SHINY_CHANCE = 0.06;
|
||
const seq = arr => { let i = 0; Math.random = () => arr[Math.min(i++, arr.length - 1)]; };
|
||
const out = {
|
||
copper: (seq([0.01]), rollVariant().id), // r<0.12 → copper
|
||
copperEdge: (seq([0.119]), rollVariant().id), // граница <0.12 → copper
|
||
notCopper: (seq([0.12]), rollVariant().id), // r=0.12: не copper (0.12<0.12 ложь) → normal
|
||
silver: (seq([0.12, 0.0799]), rollVariant().id), // copper нет, <0.08 → silver
|
||
quartz: (seq([0.13, 0.09, 0.03]), rollVariant().id), // copper/silver нет, <0.07 → quartz
|
||
gold: (seq([0.13, 0.09, 0.08, 0.05]), rollVariant().id), // copper/silver/quartz нет, <0.06 → gold
|
||
emerald: (seq([0.13, 0.09, 0.08, 0.07, 0.005]), rollVariant().id), // выше нет, <0.01 → emerald
|
||
diamond: (seq([0.13, 0.09, 0.08, 0.07, 0.02, 0.005]), rollVariant().id), // выше нет, <0.007 → diamond
|
||
rainbow: (seq([0.13, 0.09, 0.08, 0.07, 0.02, 0.01, 0.004]), rollVariant().id), // выше нет, <0.005 → rainbow
|
||
normal: (seq([0.9]), rollVariant().id) // все выше порогов → normal
|
||
};
|
||
Math.random = mr;
|
||
save.lure = snap.lure; save.lureLv = snap.lv;
|
||
return out;
|
||
});
|
||
ok('M6 границы ролла: r<0.12 → copper; [≥0.12, <0.08] → silver; [.., .., <0.07] → quartz; [.., .., .., <0.06] → gold; +emerald 0.01 / diamond 0.007 / rainbow 0.005; иначе → normal',
|
||
m6roll.copper === 'copper' && m6roll.copperEdge === 'copper' && m6roll.notCopper === 'normal'
|
||
&& m6roll.silver === 'silver' && m6roll.quartz === 'quartz' && m6roll.gold === 'gold'
|
||
&& m6roll.emerald === 'emerald' && m6roll.diamond === 'diamond' && m6roll.rainbow === 'rainbow'
|
||
&& m6roll.normal === 'normal',
|
||
JSON.stringify(m6roll));
|
||
|
||
// (6) миграция: старый сейв, в caught[id] нет счётчиков вариантов
|
||
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 без счётчиков вариантов) → cp/sv/qz/sn/em/dm/rb=0, n/best сохранены',
|
||
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.carass.cp === 0 && m6mig.carass.qz === 0 && m6mig.carass.em === 0 && m6mig.carass.dm === 0
|
||
&& m6mig.perch.n === 4 && m6mig.perch.best === 1.2 && m6mig.perch.sn === 0
|
||
&& m6mig.perch.sv === 0 && m6mig.perch.rb === 0
|
||
&& m6mig.perch.cp === 0 && m6mig.perch.qz === 0 && m6mig.perch.em === 0 && m6mig.perch.dm === 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');
|
||
const cls = ['silver', 'gold', 'rainbow', 'copper', 'quartz', 'emerald', 'diamond']
|
||
.find(k => c.classList.contains(k + '-card')) || 'none';
|
||
res[id] = {
|
||
txt: document.getElementById('cardVariant').textContent,
|
||
hidden: document.getElementById('cardVariant').classList.contains('hidden'),
|
||
cls,
|
||
// SVG-портрет: showCard ставит href у #cardFishUse на symbol вида (sp = SPECIES[0] = carass)
|
||
href: document.getElementById('cardFishUse').getAttribute('href')
|
||
};
|
||
};
|
||
for (const id of VARIANTS.map(v => v.id)) probe(id);
|
||
document.getElementById('card').classList.add('hidden');
|
||
return res;
|
||
});
|
||
ok('M6 карточка: все варианты — бейдж «МЕДНАЯ»/«СЕРЕБРЯНАЯ»/«КВАРЦЕВАЯ»/«ЗОЛОТАЯ»/«ИЗУМРУДНАЯ»/«АЛМАЗНАЯ»/«РАДУЖНАЯ» + класс <id>-card; normal — бейдж скрыт; SVG-портрет: href #cardFishUse = #fish-carass (вид SPECIES[0])',
|
||
m6card.copper.hidden === false && m6card.copper.txt === 'МЕДНАЯ' && m6card.copper.cls === 'copper'
|
||
&& m6card.silver.hidden === false && m6card.silver.txt === 'СЕРЕБРЯНАЯ' && m6card.silver.cls === 'silver'
|
||
&& m6card.quartz.hidden === false && m6card.quartz.txt === 'КВАРЦЕВАЯ' && m6card.quartz.cls === 'quartz'
|
||
&& m6card.gold.hidden === false && m6card.gold.txt === 'ЗОЛОТАЯ' && m6card.gold.cls === 'gold'
|
||
&& m6card.emerald.hidden === false && m6card.emerald.txt === 'ИЗУМРУДНАЯ' && m6card.emerald.cls === 'emerald'
|
||
&& m6card.diamond.hidden === false && m6card.diamond.txt === 'АЛМАЗНАЯ' && m6card.diamond.cls === 'diamond'
|
||
&& m6card.rainbow.hidden === false && m6card.rainbow.txt === 'РАДУЖНАЯ' && m6card.rainbow.cls === 'rainbow'
|
||
&& m6card.normal.hidden === true && m6card.normal.cls === 'none'
|
||
&& Object.values(m6card).every(r => r.href === '#fish-carass'),
|
||
JSON.stringify(m6card));
|
||
|
||
// (8) full_variants: все виды во всех 8 вариантах → +5000 ровно один раз, coinPct, повторно — без выплаты
|
||
// Этап 45: в сумму coinPct входит swamp_first (локация 4) — пин 0.16→0.18
|
||
const m6fv = await page.evaluate(() => {
|
||
const locSnap = save.locations.slice();
|
||
save.locations = locSnap.indexOf(4) >= 0 ? locSnap : locSnap.concat([4]); // Этап 45: swamp_first даёт +0.02
|
||
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: 8, best: 1, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 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 };
|
||
save.locations = locSnap; // не загрязнять следующие тесты
|
||
return { coinsBefore, a1, a2 };
|
||
});
|
||
ok('M6 full_variants: все виды во всех 8 вариантах → +5000 ровно один раз, achBonus().coinPct включает 0.05 (сумма 0.18: +first_silver/first_rainbow, Этап 22, + swamp_first, Этап 45), повторный вызов — без выплаты',
|
||
m6fv.a1.got === true
|
||
&& m6fv.a1.coins === m6fv.coinsBefore + 5000
|
||
&& Math.abs(m6fv.a1.pct - 0.18) < 1e-9
|
||
&& m6fv.a2.coins === m6fv.a1.coins,
|
||
JSON.stringify(m6fv));
|
||
|
||
// (9) буст снасти (M6+): тип наживки (rare-стат) и её уровень повышают шанс особых вариантов,
|
||
// более редкие — сильнее (множитель ×(1+B·r), r — номер в лестнице); простая наживка 1 ур. — B=0
|
||
const m6boost = await page.evaluate(() => {
|
||
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
|
||
forceWeather = null; weather = null;
|
||
SHINY_CHANCE = 0.06;
|
||
const ch = id => variantChance(VARIANTS.find(x => x.id === id));
|
||
const out = {};
|
||
save.lure = 0; save.lureLv = { 0: 1 };
|
||
out.hookB = variantBoost(); // простая наживка 1 ур. — 0
|
||
out.hookCopper = ch('copper'); // 0.12 — без буста
|
||
save.lure = 1; // червь rare 0.2 → B=0.03
|
||
out.wormB = variantBoost();
|
||
out.wormCopper = ch('copper'); // 0.12×1.03
|
||
out.wormRainbow = ch('rainbow'); // 0.005×1.21 — редким больше
|
||
out.wormRatio = out.wormRainbow / out.wormCopper / (0.005 / 0.12); // (1+7B)/(1+B) > 1
|
||
save.lure = 3; save.lureLv = { 0: 1, 3: 1 }; // блёстка rare 2.0 → B=0.3
|
||
out.spinB = variantBoost();
|
||
out.spinCopper = ch('copper'); // 0.12×1.3
|
||
out.spinGold = ch('gold'); // 0.06×2.2
|
||
out.spinRainbow = ch('rainbow'); // 0.005×3.1
|
||
out.spinRatio = out.spinRainbow / out.spinCopper / (0.005 / 0.12); // ≈2.38 — растёт с B
|
||
save.lureLv = { 0: 1, 3: 4 }; // + 4 ур. наживки → rare 3.0 (+1.0 от 3 ур.), B=0.6
|
||
out.spin4B = variantBoost();
|
||
out.spin4Rainbow = ch('rainbow'); // 0.005×5.2
|
||
// ролл с бустом: червь ловит медью r=0.12 (без буста — normal, см. §(5) notCopper)
|
||
const mr = Math.random; let i = 0; Math.random = () => [0.12][Math.min(i++, 0)];
|
||
forceVariant = null;
|
||
out.rollWorm = rollVariant().id; // copper
|
||
Math.random = mr;
|
||
save.lure = snap.lure; save.lureLv = snap.lv;
|
||
SHINY_CHANCE = 0.06;
|
||
return out;
|
||
});
|
||
ok('M6 буст снасти: простая 1 ур. B=0 (copper 0.12); червь B=0.03 — copper 0.1236, rainbow 0.00605 (редким больше); блёстка B=0.3 — copper 0.156, gold 0.132, rainbow 0.0155; 4 ур. (rare 3.0) B=0.6 — rainbow 0.026; отношение «радуга/медь» растёт с B; ролл r=0.12 с червя → copper',
|
||
m6boost.hookB === 0 && Math.abs(m6boost.hookCopper - 0.12) < 1e-9
|
||
&& Math.abs(m6boost.wormB - 0.03) < 1e-9 && Math.abs(m6boost.wormCopper - 0.1236) < 1e-9
|
||
&& Math.abs(m6boost.wormRainbow - 0.00605) < 1e-9 && m6boost.wormRatio > 1
|
||
&& Math.abs(m6boost.spinB - 0.3) < 1e-9 && Math.abs(m6boost.spinCopper - 0.156) < 1e-9
|
||
&& Math.abs(m6boost.spinGold - 0.132) < 1e-9 && Math.abs(m6boost.spinRainbow - 0.0155) < 1e-9
|
||
&& m6boost.spinRatio > m6boost.wormRatio
|
||
&& Math.abs(m6boost.spin4B - 0.6) < 1e-9 && Math.abs(m6boost.spin4Rainbow - 0.026) < 1e-9
|
||
&& m6boost.rollWorm === 'copper',
|
||
JSON.stringify(m6boost));
|
||
|
||
// сброс тест-форсов
|
||
await page.evaluate(() => { forceBite = false; forceVariant = null; forceBoss = false; SHINY_CHANCE = 0.06; });
|
||
|
||
// --- 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===3, WEATHER.fog.biteMult===1.5, gap 120–240 с, длительность 120 с, forceWeather===null',
|
||
w1.rm === 3 && 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) дождь: все особые варианты ярче (×2.5)
|
||
const w2 = await page.evaluate(() => {
|
||
forceWeather = 'rain';
|
||
const snap = { lure: save.lure, lv: Object.assign({}, save.lureLv) };
|
||
save.lure = 0; save.lureLv = { 0: 1 }; // M6+: нейтральная снасть — буст 0, пины базовых шансов
|
||
SHINY_CHANCE = 0.06;
|
||
const ch = id => variantChance(VARIANTS.find(x => x.id === id));
|
||
const out = { cp: ch('copper'), s: ch('silver'), qz: ch('quartz'), g: ch('gold'),
|
||
em: ch('emerald'), dm: ch('diamond'), r: ch('rainbow'),
|
||
toast: t('wx_rain') };
|
||
forceWeather = null;
|
||
save.lure = snap.lure; save.lureLv = snap.lv;
|
||
return out;
|
||
});
|
||
ok('M8 дождь: variantChance ×3 — copper 0.36, silver 0.24, quartz 0.21, gold 0.18 (SHINY_CHANCE 0.06), emerald 0.03, diamond 0.021, rainbow 0.015; тост «Дождь: шанс особых рыб ×3»',
|
||
Math.abs(w2.cp - 0.36) < 1e-9 && Math.abs(w2.s - 0.24) < 1e-9 && Math.abs(w2.qz - 0.21) < 1e-9
|
||
&& Math.abs(w2.g - 0.18) < 1e-9 && Math.abs(w2.em - 0.03) < 1e-9 && Math.abs(w2.dm - 0.021) < 1e-9
|
||
&& Math.abs(w2.r - 0.015) < 1e-9
|
||
&& w2.toast.includes('особых') && w2.toast.includes('×3'),
|
||
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. Локации: Озёра + Море + Океан (Этап 35: у каждой локации только свои виды, Этап 36: +Океан) ---
|
||
// (1) данные: 37 видов в SPECIES (8 речных + 10 озёрных + 5 морских + 9 океанских + 5 болотных, Этап 45), LOCATIONS (5 записей, по 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 b = g('bleak'), r = g('roundel'), e = g('eelpout'), t = g('tench'), n = g('navaga');
|
||
const rd = g('rudd'), ci = g('cisco');
|
||
const h = g('herring'), m = g('mackerel'), f = g('flounder'), tu = g('tuna'), sw = g('swordfish');
|
||
const mo = g('moray'), gr = g('grouper'), turt = g('turtle'), an = g('angler'), oc = g('octopus');
|
||
const ma = g('manta'), sh = g('shark'), sq = g('squid'), wh = g('whale');
|
||
// Этап 45: болотные виды (5 — в КОНЦЕ SPECIES, у каждого поле glow)
|
||
const gf = g('glowfin'), mf = g('moonfish'), ef = g('emberfin'), vl = g('violetlure'), sf = g('spiritfin');
|
||
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),
|
||
b: p(b), r: p(r), e: p(e), t: p(t), n2: p(n),
|
||
rd: p(rd), ci: p(ci), h: p(h), m: p(m), f: p(f), tu: p(tu), sw: p(sw),
|
||
mo: p(mo), gr: p(gr), turt: p(turt), an: p(an), oc: p(oc),
|
||
ma: p(ma), sh: p(sh), sq: p(sq), wh: p(wh),
|
||
gf: p(gf), mf: p(mf), ef: p(ef), vl: p(vl), sf: p(sf),
|
||
// порядок: 0..7 (река) не сдвигаются, новые виды — только в КОНЦЕ массива
|
||
// (Этап 45: океан — явный slice(23,32), иначе swamp-виды попали бы в oceanOrder)
|
||
first8: SPECIES.slice(0, 8).map(s => s.id).join(','),
|
||
lakeOrder: SPECIES.slice(8, 18).map(s => s.id).join(','),
|
||
seaOrder: SPECIES.slice(18, 23).map(s => s.id).join(','),
|
||
oceanOrder: SPECIES.slice(23, 32).map(s => s.id).join(','),
|
||
swampOrder: SPECIES.slice(32).map(s => s.id).join(','),
|
||
// у каждого вида есть обязательные поля (nameEn — для EN-ветки trName; поле emoji убрано в этапе 42 — портреты из SVG-спрайта)
|
||
fieldsOk: SPECIES.every(s => s.id && s.name && s.nameEn && typeof s.rarity === 'number' &&
|
||
typeof s.weight === 'number' && typeof s.minDist === 'number' && typeof s.strength === 'number' &&
|
||
typeof s.wMin === 'number' && typeof s.wMax === 'number' && s.wMax > s.wMin &&
|
||
typeof s.price === 'number' && typeof s.hue === 'number'),
|
||
// Этап 45: у новых болотных видов glow — строка #rrggbb, редкости ровно 0..4 по одной
|
||
swampGlow: [gf, mf, ef, vl, sf].every(s => s && typeof s.glow === 'string' && /^#[0-9a-fA-F]{6}$/.test(s.glow)),
|
||
swampRar: [gf, mf, ef, vl, sf].map(s => s && s.rarity).join(','),
|
||
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(h2 => typeof h2 === 'string' && /^#[0-9a-fA-F]{6}$/.test(h2))),
|
||
// SVG-спрайт портретов (блок FISH-SVG, tools/embed-fish.mjs): по symbol на каждый вид
|
||
sprites: document.querySelectorAll('#fishSprites symbol').length,
|
||
spriteIdsOk: SPECIES.every(s => document.getElementById('fish-' + s.id) !== null)
|
||
};
|
||
});
|
||
ok('локации данные: SPECIES=37 (8 река + 10 озёра + 5 море + 9 океан + 5 болото, этапы 35/36/45) — zander «Судак» r2 8🪙, crayfish r3 40🪙, navaga r3 50🪙; озёрные новинки rudd «Краснопёрка» r1 5🪙, cisco «Сиг» r2 18🪙; морские herring «Сельдь» r0 8🪙, mackerel «Скумбрия» r1 18🪙, flounder «Камбала» r2 33🪙, tuna «Тунец» r3 46🪙, swordfish «Рыба-меч» r4 81🪙; океанские moray «Гигантская мурена» r0 5🪙, grouper «Горбуна» r0 5🪙, turtle «Морская черепаха» r1 9🪙, angler «Удильщик» r2 21🪙, octopus «Гигантский осьминог» r2 23🪙, manta «Скат-манта» r3 29🪙, shark «Большая белая акула» r3 31🪙, squid «Гигантский кальмар» r4 37🪙, whale «Синий кит» r4 67🪙 40–100 кг; болотные glowfin «Светлячок» r0 8🪙, moonfish «Лунная рыбка» r1 13🪙, emberfin «Изумрудный светляк» r2 20🪙, violetlure «Фиалковый огонёк» r3 38🪙, spiritfin «Дух болота» r4 70🪙 (редкости 0..4 по одной, glow — #rrggbb); этап 38: лестница веса (Озёра ×8 / Море ×6, меч ×4 / Океан ×4, кит ×2; Река не меняется); этап 39: цены Озёр/Моря/Океана урезаны (Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5 — по дню; Река не меняется); первые 8 id не сдвинуты, новые — в конце; все поля заполнены; LOCATIONS 5 (Река 0 / Озёра 65536 / Море 131072 / Океан 196608 / Волшебное болото 90000), у каждой 6 hex в water; SVG-спрайт: 37 symbol, id на каждый вид',
|
||
lc1.n === 37 && lc1.z === 'Судак,2,8,8,48' && lc1.c === 'Хариус,1,6,1.6,16' && lc1.k === 'Гигантский рак,3,40,1.6,8'
|
||
&& lc1.b === 'Плотва,0,4,0.4,4.8' && lc1.r === 'Густера,0,5,0.8,7.2' && lc1.e === 'Вьюн,1,11,0.4,3.2'
|
||
&& lc1.t === 'Линь,2,16,2.4,32' && lc1.n2 === 'Навага,3,50,2.4,20'
|
||
&& lc1.rd === 'Краснопёрка,1,5,0.8,9.6' && lc1.ci === 'Сиг,2,18,4,28'
|
||
&& lc1.h === 'Сельдь,0,8,0.6,4.8' && lc1.m === 'Скумбрия,1,18,1.8,15' && lc1.f === 'Камбала,2,33,3,30'
|
||
&& lc1.tu === 'Тунец,3,46,12,72' && lc1.sw === 'Рыба-меч,4,81,20,80'
|
||
&& lc1.mo === 'Гигантская мурена,0,5,6,24' && lc1.gr === 'Горбуна,0,5,8,40'
|
||
&& lc1.turt === 'Морская черепаха,1,9,20,56' && lc1.an === 'Удильщик,2,21,4,16'
|
||
&& lc1.oc === 'Гигантский осьминог,2,23,16,48' && lc1.ma === 'Скат-манта,3,29,24,60'
|
||
&& lc1.sh === 'Большая белая акула,3,31,20,56' && lc1.sq === 'Гигантский кальмар,4,37,16,48'
|
||
&& lc1.wh === 'Синий кит,4,67,40,100'
|
||
&& lc1.gf === 'Светлячок,0,8,0.3,3' && lc1.mf === 'Лунная рыбка,1,13,0.8,8'
|
||
&& lc1.ef === 'Изумрудный светляк,2,20,1.5,16' && lc1.vl === 'Фиалковый огонёк,3,38,2,24'
|
||
&& lc1.sf === 'Дух болота,4,70,0.5,6'
|
||
&& lc1.first8 === 'carass,perch,bream,carp,pike,catfish,sturge,gold'
|
||
&& lc1.lakeOrder === 'zander,chuch,crayfish,bleak,roundel,eelpout,tench,navaga,rudd,cisco'
|
||
&& lc1.seaOrder === 'herring,mackerel,flounder,tuna,swordfish'
|
||
&& lc1.oceanOrder === 'moray,grouper,turtle,angler,octopus,manta,shark,squid,whale'
|
||
&& lc1.swampOrder === 'glowfin,moonfish,emberfin,violetlure,spiritfin'
|
||
&& lc1.fieldsOk === true
|
||
&& lc1.swampGlow === true && lc1.swampRar === '0,1,2,3,4'
|
||
&& lc1.locs === 5 && lc1.costs === '0,65536,131072,196608,90000' && lc1.names === 'Река,Озёра,Море,Океан,Волшебное болото' && lc1.water === true
|
||
&& lc1.sprites === 37 && lc1.spriteIdsOk === true,
|
||
JSON.stringify(lc1));
|
||
|
||
// (2) таблицы: река — ровно старые 8 (порядок), озёра — только свои 10, море — только свои 5, океан — только свои 9, болото — только свои 5, ночные веса — числа; пересечений нет
|
||
const lc2 = await page.evaluate(() => {
|
||
const old8 = 'carass,perch,bream,carp,pike,catfish,sturge,gold';
|
||
const lakeIds = LAKE_SPECIES.map(s => s.id);
|
||
const seaIds = SEA_SPECIES.map(s => s.id);
|
||
const oceanIds = OCEAN_SPECIES.map(s => s.id);
|
||
const swampIds = SWAMP_SPECIES.map(s => s.id);
|
||
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,
|
||
sea: SEA_SPECIES.length, seaN: SEA_SPECIES_NIGHT.length,
|
||
ocean: OCEAN_SPECIES.length, oceanN: OCEAN_SPECIES_NIGHT.length,
|
||
swamp: SWAMP_SPECIES.length, swampN: SWAMP_SPECIES_NIGHT.length,
|
||
lakeOrder: lakeIds.join(','), seaOrder: seaIds.join(','), oceanOrder: oceanIds.join(','),
|
||
swampOrder: swampIds.join(','), swampNOrder: SWAMP_SPECIES_NIGHT.map(s => s.id).join(','),
|
||
// пересечения между водами (река∩озёра, река∩море, озёра∩море, океан∩река, океан∩озёра, океан∩море, болото∩всё) пусты
|
||
xRL: lakeIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
|
||
xRS: seaIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
|
||
xLS: seaIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
|
||
xOR: oceanIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
|
||
xOL: oceanIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
|
||
xOS: oceanIds.filter(id => seaIds.indexOf(id) >= 0).join(','),
|
||
// Этап 45: пересечения болота с остальными водами
|
||
xSR: swampIds.filter(id => old8.split(',').indexOf(id) >= 0).join(','),
|
||
xSL: swampIds.filter(id => lakeIds.indexOf(id) >= 0).join(','),
|
||
xSS: swampIds.filter(id => seaIds.indexOf(id) >= 0).join(','),
|
||
xSO: swampIds.filter(id => oceanIds.indexOf(id) >= 0).join(','),
|
||
nightW: SPECIES_NIGHT.length === 37 && SPECIES_NIGHT.every(s => typeof s.weight === 'number' && isFinite(s.weight))
|
||
};
|
||
});
|
||
ok('локации таблицы: RIVER_SPECIES(_NIGHT) — ровно старые 8 (порядок сохранён), LAKE_SPECIES(_NIGHT) — только свои 10 (без речных), SEA_SPECIES(_NIGHT) — только свои 5, OCEAN_SPECIES(_NIGHT) — только свои 9 (этап 36), SWAMP_SPECIES(_NIGHT) — только свои 5 (этап 45, порядок = SWAMP_IDS); пересечений между водами нет; у всех 37 в SPECIES_NIGHT вес — число',
|
||
lc2.river === 'carass,perch,bream,carp,pike,catfish,sturge,gold' && lc2.riverN === lc2.river
|
||
&& lc2.lake === 10 && lc2.lakeN === 10 && lc2.sea === 5 && lc2.seaN === 5
|
||
&& lc2.ocean === 9 && lc2.oceanN === 9
|
||
&& lc2.swamp === 5 && lc2.swampN === 5
|
||
&& lc2.lakeOrder === 'zander,chuch,crayfish,bleak,roundel,eelpout,tench,navaga,rudd,cisco'
|
||
&& lc2.seaOrder === 'herring,mackerel,flounder,tuna,swordfish'
|
||
&& lc2.oceanOrder === 'moray,grouper,turtle,angler,octopus,manta,shark,squid,whale'
|
||
&& lc2.swampOrder === 'glowfin,moonfish,emberfin,violetlure,spiritfin'
|
||
&& lc2.swampNOrder === lc2.swampOrder
|
||
&& lc2.xRL === '' && lc2.xRS === '' && lc2.xLS === ''
|
||
&& lc2.xOR === '' && lc2.xOL === '' && lc2.xOS === ''
|
||
&& lc2.xSR === '' && lc2.xSL === '' && lc2.xSS === '' && lc2.xSO === ''
|
||
&& 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 — ровно свои 10 озёрных видов (каждый ≥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 = LAKE_IDS;
|
||
const riverIds = RIVER_IDS;
|
||
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
|
||
return {
|
||
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
|
||
noRiver: [...Object.keys(day), ...Object.keys(night)].every(id => riverIds.indexOf(id) < 0),
|
||
dayMin: min(day), nightMin: min(night)
|
||
};
|
||
});
|
||
ok('локации роллы озёр: 3000 днём и 3000 ночью (location=1) — все 10 озёрных видов встречаются (каждый ≥1), речных нет',
|
||
lc4.dayAll === true && lc4.nightAll === true && lc4.noRiver === true,
|
||
JSON.stringify(lc4));
|
||
|
||
// (4b) Этап 35: роллы моря — ровно свои 5 морских видов (каждый ≥1), пресноводных нет
|
||
const lc4b = await page.evaluate(() => {
|
||
save.location = 2;
|
||
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 = SEA_IDS;
|
||
const fresh = RIVER_IDS.concat(LAKE_IDS);
|
||
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
|
||
return {
|
||
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
|
||
onlySea: [...Object.keys(day), ...Object.keys(night)].every(id => fresh.indexOf(id) < 0),
|
||
dayMin: min(day), nightMin: min(night)
|
||
};
|
||
});
|
||
ok('локации роллы моря: 3000 днём и 3000 ночью (location=2) — все 5 морских видов встречаются (каждый ≥1), пресноводных нет',
|
||
lc4b.dayAll === true && lc4b.nightAll === true && lc4b.onlySea === true,
|
||
JSON.stringify(lc4b));
|
||
|
||
// (4c) Этап 36: роллы океана — ровно свои 9 океанских видов (каждый ≥1), пресноводных/озёрных/морских нет
|
||
const lc4c = await page.evaluate(() => {
|
||
save.location = 3;
|
||
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 = OCEAN_IDS;
|
||
const others = RIVER_IDS.concat(LAKE_IDS, SEA_IDS);
|
||
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
|
||
return {
|
||
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
|
||
onlyOcean: [...Object.keys(day), ...Object.keys(night)].every(id => others.indexOf(id) < 0),
|
||
dayMin: min(day), nightMin: min(night)
|
||
};
|
||
});
|
||
ok('локации роллы океана: 3000 днём и 3000 ночью (location=3) — все 9 океанских видов встречаются (каждый ≥1), речных/озёрных/морских нет',
|
||
lc4c.dayAll === true && lc4c.nightAll === true && lc4c.onlyOcean === true,
|
||
JSON.stringify(lc4c));
|
||
|
||
// (4d) Этап 45: роллы болота — ровно свои 5 болотных видов (каждый ≥1), видов других вод нет
|
||
const lc4d = await page.evaluate(() => {
|
||
save.location = 4;
|
||
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 = SWAMP_IDS;
|
||
const others = RIVER_IDS.concat(LAKE_IDS, SEA_IDS, OCEAN_IDS);
|
||
const min = o => Math.min.apply(null, ids.map(id => o[id] || 0));
|
||
return {
|
||
dayAll: ids.every(id => day[id] > 0), nightAll: ids.every(id => night[id] > 0),
|
||
onlySwamp: [...Object.keys(day), ...Object.keys(night)].every(id => others.indexOf(id) < 0),
|
||
dayMin: min(day), nightMin: min(night)
|
||
};
|
||
});
|
||
ok('локации роллы болота: 3000 днём и 3000 ночью (location=4) — все 5 болотных видов встречаются (каждый ≥1), речных/озёрных/морских/океанских нет',
|
||
lc4d.dayAll === true && lc4d.nightAll === true && lc4d.onlySwamp === true,
|
||
JSON.stringify(lc4d));
|
||
|
||
// (5) миграция: старый сейв (без locations/location) → [0]/0; location=5 → 0; location=1 при locations=[0] → 0; Этап 36: Море/Океан не сбрасываются (баг границы >1)
|
||
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 };
|
||
// (d)(e) Этап 36: Regression-тест бага границы (было save.location>1 — Море сбрасывалось на reload)
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 2], location: 2 })));
|
||
loadSave();
|
||
r.d = { locs: save.locations.slice(), loc: save.location };
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 3], location: 3 })));
|
||
loadSave();
|
||
r.e = { locs: save.locations.slice(), loc: save.location };
|
||
// (f) Этап 45: Болото (location=4) — граница LOCATIONS.length расширена, не сбрасывается на reload
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify(Object.assign({}, base, { locations: [0, 4], location: 4 })));
|
||
loadSave();
|
||
r.f = { locs: save.locations.slice(), loc: save.location };
|
||
save.locations = [0]; save.location = 0; // не загрязнять следующие тесты (lc6 покупает Озёра)
|
||
return r;
|
||
});
|
||
ok('локации миграция: старый сейв (без locations/location) → [0]/0; location=5 (вне 0..4) → 0; location=1 при locations=[0] (не куплено) → 0; location=2 (Море), location=3 (Океан) и location=4 (Болото) не сбрасываются (граница из LOCATIONS.length, баг >1 исправлен)',
|
||
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
|
||
&& lc5.d.locs.join(',') === '0,2' && lc5.d.loc === 2
|
||
&& lc5.e.locs.join(',') === '0,3' && lc5.e.loc === 3
|
||
&& lc5.f.locs.join(',') === '0,4' && lc5.f.loc === 4,
|
||
JSON.stringify(lc5));
|
||
|
||
// (6) покупка: 66536 монет → «Озёра» (65536) — списание, авто-переход, тост
|
||
const lcSt0 = await page.evaluate(() => state);
|
||
if (lcSt0 === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
|
||
await page.evaluate(() => { save.coins = 66536; });
|
||
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('локации покупка: 66536 → «Озёра» (65536) — 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 < 65536 — 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 }));
|
||
|
||
// (8b) Этап 35: покупка Моря: 131072 → списание, авто-переход, тост «Добро пожаловать в Море!»
|
||
await page.evaluate(() => { save.coins = 131072; save.locations = [0, 1]; save.location = 0; renderShop(); });
|
||
await page.click('#tabLocations');
|
||
await sleep(150);
|
||
await page.click('button[data-act="buylocation"][data-i="2"]');
|
||
const lc8s = await page.evaluate(() => ({
|
||
coins: save.coins, locs: save.locations.slice(), loc: save.location,
|
||
toast: document.getElementById('toast').textContent
|
||
}));
|
||
ok('локации покупка моря: 131072 → «Море» (131072) — coins=0, locations=[0,1,2], авто-переход location=2, тост «Добро пожаловать в Море!»',
|
||
lc8s.coins === 0 && lc8s.locs.join(',') === '0,1,2' && lc8s.loc === 2
|
||
&& lc8s.toast.includes('Добро пожаловать в Море!'),
|
||
JSON.stringify(lc8s));
|
||
|
||
// (8c) Этап 36: покупка Океана: 196608 → списание, авто-переход, тост «Добро пожаловать в Океан!»
|
||
await page.evaluate(() => { save.coins = 196608; save.locations = [0, 1, 2]; save.location = 0; renderShop(); });
|
||
await page.click('#tabLocations');
|
||
await sleep(150);
|
||
await page.click('button[data-act="buylocation"][data-i="3"]');
|
||
const lc8o = await page.evaluate(() => ({
|
||
coins: save.coins, locs: save.locations.slice(), loc: save.location,
|
||
toast: document.getElementById('toast').textContent
|
||
}));
|
||
ok('локации покупка океана: 196608 → «Океан» (196608) — coins=0, locations=[0,1,2,3], авто-переход location=3, тост «Добро пожаловать в Океан!»',
|
||
lc8o.coins === 0 && lc8o.locs.join(',') === '0,1,2,3' && lc8o.loc === 3
|
||
&& lc8o.toast.includes('Добро пожаловать в Океан!'),
|
||
JSON.stringify(lc8o));
|
||
|
||
// (8d) Этап 45: покупка Волшебного болота: 90000 → списание, авто-переход, тост «Добро пожаловать в Волшебное болото!»
|
||
await page.evaluate(() => { save.coins = 90000; save.locations = [0, 1, 2, 3]; save.location = 0; renderShop(); });
|
||
await page.click('#tabLocations');
|
||
await sleep(150);
|
||
await page.click('button[data-act="buylocation"][data-i="4"]');
|
||
const lc8w = await page.evaluate(() => ({
|
||
coins: save.coins, locs: save.locations.slice(), loc: save.location,
|
||
toast: document.getElementById('toast').textContent
|
||
}));
|
||
ok('локации покупка болота: 90000 → «Волшебное болото» (90000) — coins=0, locations=[0,1,2,3,4], авто-переход location=4, тост «Добро пожаловать в Волшебное болото!»',
|
||
lc8w.coins === 0 && lc8w.locs.join(',') === '0,1,2,3,4' && lc8w.loc === 4
|
||
&& lc8w.toast.includes('Добро пожаловать в Волшебное болото!'),
|
||
JSON.stringify(lc8w));
|
||
|
||
// (9) дневник: 37 строк (новые виды видны в энциклопедии)
|
||
await page.click('#tabJournal');
|
||
await sleep(150);
|
||
const lc9 = await page.evaluate(() => document.querySelectorAll('#shopBody .jrow').length);
|
||
ok('локации дневник: вкладка journal — 37 строк .jrow (новые виды в энциклопедии)',
|
||
lc9 === 37, 'rows=' + lc9);
|
||
|
||
// (10) масштаб ачивок: species_8 — все 37 видов; full_variants — 37×8 вариантов, +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'];
|
||
// Этап 45: 29 новых видов (10 озёрных этапа 33/35 + 5 морских этапа 35 + 9 океанских этапа 36 + 5 болотных)
|
||
const mid29 = ['zander', 'chuch', 'crayfish', 'bleak', 'roundel', 'eelpout', 'tench', 'navaga', 'rudd', 'cisco', 'herring', 'mackerel', 'flounder', 'tuna', 'swordfish', 'moray', 'grouper', 'turtle', 'angler', 'octopus', 'manta', 'shark', 'squid', 'whale', 'glowfin', 'moonfish', 'emberfin', 'violetlure', 'spiritfin'];
|
||
save.ach = {}; save.caught = {};
|
||
for (const id of old8) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 1 };
|
||
checkAchievements();
|
||
const mid = { s8: !!save.ach.species_8, fv: !!save.ach.full_variants };
|
||
for (const id of mid29) save.caught[id] = { n: 1, best: 0, sn: 1, sv: 1, rb: 1, cp: 1, qz: 1, em: 1, dm: 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 старых ×8 вариантов — species_8/full_variants НЕ выданы; +29 новых (10 озёрных этапов 33/35 + 5 морских + 9 океанских + 5 болотных, Этап 45) → обе выданы, +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));
|
||
|
||
// (11) Этап 45: данные свечения — у 5 болотных видов glow #rrggbb (5 цветов РАЗЛИЧНЫ),
|
||
// SWAMP_DAY/NIGHT_WEIGHTS покрывают все SWAMP_IDS числом >0, NIGHT_WEIGHTS — ключи всех 5 id
|
||
const lc11 = await page.evaluate(() => {
|
||
const ids = SWAMP_IDS.slice();
|
||
const glows = ids.map(id => SPECIES.find(s => s.id === id).glow);
|
||
const weightsOk = tbl => ids.every(id => typeof tbl[id] === 'number' && isFinite(tbl[id]) && tbl[id] > 0);
|
||
const keyOf = tbl => ids.every(id => Object.prototype.hasOwnProperty.call(tbl, id));
|
||
return {
|
||
glowFmt: glows.every(c => typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c)),
|
||
glowUnique: new Set(glows).size === glows.length,
|
||
dayW: weightsOk(SWAMP_DAY_WEIGHTS), nightW: weightsOk(SWAMP_NIGHT_WEIGHTS),
|
||
dayKeys: keyOf(SWAMP_DAY_WEIGHTS), nightKeys: keyOf(SWAMP_NIGHT_WEIGHTS),
|
||
nwKeys: keyOf(NIGHT_WEIGHTS),
|
||
nwAll: SPECIES.every(s => typeof NIGHT_WEIGHTS[s.id] === 'number' && isFinite(NIGHT_WEIGHTS[s.id]))
|
||
};
|
||
});
|
||
ok('Этап 45 свечение/веса: у 5 болотных видов glow — #rrggbb, все 5 цветов различны; SWAMP_DAY_WEIGHTS/SWAMP_NIGHT_WEIGHTS покрывают все id SWAMP_IDS числами >0; NIGHT_WEIGHTS имеет ключи всех 5 болотных id (и всех 37 видов)',
|
||
lc11.glowFmt === true && lc11.glowUnique === true
|
||
&& lc11.dayW === true && lc11.nightW === true
|
||
&& lc11.dayKeys === true && lc11.nightKeys === true
|
||
&& lc11.nwKeys === true && lc11.nwAll === true,
|
||
JSON.stringify(lc11));
|
||
|
||
// сброс локаций к дефолту
|
||
await page.evaluate(() => {
|
||
save.location = 0; save.locations = [0]; persist();
|
||
closeShop();
|
||
});
|
||
|
||
// --- 10g. Этап 22: расширение достижений + тексты shiny ---
|
||
// (1) тексты: first_shiny.desc и квест catch_shiny — «золотую рыбу», «shiny» в desc нет
|
||
const a22t = await page.evaluate(() => ({
|
||
achDesc: ACHIEVEMENTS.find(a => a.id === 'first_shiny').desc,
|
||
quest: questText({ tpl: 'catch_shiny', param: 0, goal: 1 }),
|
||
noShiny: ACHIEVEMENTS.every(a => a.desc.indexOf('shiny') < 0)
|
||
}));
|
||
ok('Этап 22 тексты: first_shiny.desc и квест catch_shiny — «Поймай золотую рыбу»; «shiny» в описаниях достижений нет',
|
||
a22t.achDesc === 'Поймай золотую рыбу' && a22t.quest === 'Поймай золотую рыбу' && a22t.noShiny,
|
||
JSON.stringify(a22t));
|
||
|
||
// (2) данные: 11 новых достижений (Этап 22) + sea_first/ocean_first (Этап 36) + lake_boss/sea_boss/ocean_boss (Этап 37)
|
||
// + swamp_first/swamp_boss (Этап 45) + aq_first/aq_full (Этап 46), ACHIEVEMENTS.length===38, id уникальны,
|
||
// name/desc/icon/bonus/check (у aq_first/aq_full bonus:{} — пин achBonus().coinPct не сдвинут)
|
||
const a22d = await page.evaluate(() => {
|
||
const ids = ['first_silver', 'first_rainbow', 'rain_variant', 'fog_catch', 'skin_first',
|
||
'skins_all', 'lake_first', 'gear_max', 'titan', 'weight_1000', 'night_first',
|
||
'sea_first', 'ocean_first', 'lake_boss', 'sea_boss', 'ocean_boss',
|
||
'swamp_first', 'swamp_boss', 'aq_first', 'aq_full'];
|
||
const list = ACHIEVEMENTS.map(a => a.id);
|
||
return {
|
||
total: ACHIEVEMENTS.length,
|
||
all: ids.every(id => list.indexOf(id) >= 0),
|
||
unique: new Set(list).size === list.length,
|
||
fields: ids.every(id => { const a = ACHIEVEMENTS.find(x => x.id === id);
|
||
return a && a.name && a.desc && a.icon && a.bonus && typeof a.check === 'function'; }),
|
||
// Этап 46: аквариумные ачивки не двигают экономику — пин achBonus().coinPct (сумма 0.18, §10c(8))
|
||
noCoinBonus: ids.filter(id => id === 'aq_first' || id === 'aq_full')
|
||
.every(id => Object.keys(ACHIEVEMENTS.find(x => x.id === id).bonus).length === 0)
|
||
};
|
||
});
|
||
ok('Этап 22 данные: 11 новых достижений (варианты/погода/скины/локации/снасти/вес/ночь) + sea_first/ocean_first (Этап 36) + lake_boss/sea_boss/ocean_boss (Этап 37) + swamp_first/swamp_boss (Этап 45) + aq_first/aq_full (Этап 46), ACHIEVEMENTS.length===38, id уникальны, поля на месте',
|
||
a22d.all === true && a22d.total === 38 && a22d.unique === true && a22d.fields === true
|
||
&& a22d.noCoinBonus === true,
|
||
JSON.stringify(a22d));
|
||
|
||
// (3) геймплей: ночной улов в дождь, серебряный вариант — одним уловом проверяются
|
||
// крючки счётчиков (kgTotal += w, nightCatch, wxRainV) и 3 достижения
|
||
await page.evaluate(() => {
|
||
state = 'IDLE'; reel = null; biteFish = null;
|
||
save.ach = {}; save.caught = {}; save.coins = 0;
|
||
save.quests = { date: todayStr(), list: [] };
|
||
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
|
||
save.kgTotal = 0; save.nightCatch = false; save.wxRainV = 0; save.wxFog = 0;
|
||
save.rodLv[save.rod] = 2; // фонарик — ночью без него клёв не стартует (WAIT)
|
||
setDayTime(0.8); // ночь
|
||
forceWeather = 'rain'; // дождь (форс приоритетнее естественной погоды)
|
||
forceVariant = 'silver';// вариант != normal
|
||
forceBite = true; biteTimer = 0.05;
|
||
});
|
||
await page.mouse.move(195, 600);
|
||
await page.mouse.down(); await sleep(450); await page.mouse.up();
|
||
await waitState(page, 'WAIT');
|
||
await page.evaluate(() => { biteTimer = 0.05; });
|
||
await waitState(page, 'BITE', 4000);
|
||
await page.mouse.down(); await page.mouse.up();
|
||
await waitState(page, 'REEL');
|
||
await page.evaluate(() => {
|
||
window.__a22W = 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 a22n = await page.evaluate(() => ({
|
||
w: window.__a22W,
|
||
kg: save.kgTotal,
|
||
night: save.nightCatch,
|
||
rainV: save.wxRainV,
|
||
fog: save.wxFog,
|
||
ach: {
|
||
silver: !!save.ach.first_silver,
|
||
rain: !!save.ach.rain_variant,
|
||
night: !!save.ach.night_first,
|
||
fogA: !!save.ach.fog_catch
|
||
},
|
||
phase: dayPhase
|
||
}));
|
||
ok('Этап 22 ночной дождь: nightCatch=true, wxRainV=1 (wxFog=0), kgTotal вырос ровно на w (первый улов)',
|
||
a22n.phase === 'night' && a22n.night === true && a22n.rainV === 1 && a22n.fog === 0
|
||
&& a22n.kg === a22n.w,
|
||
JSON.stringify(a22n));
|
||
ok('Этап 22 ночной дождь: выданы first_silver + rain_variant + night_first (fog_catch нет)',
|
||
a22n.ach.silver === true && a22n.ach.rain === true && a22n.ach.night === true && a22n.ach.fogA === false,
|
||
JSON.stringify(a22n.ach));
|
||
await page.click('#btnCardOk');
|
||
await sleep(100);
|
||
await page.evaluate(() => {
|
||
forceBite = false; forceVariant = null; forceWeather = null; weather = null;
|
||
setDayTime(0.3); // обратно к дню
|
||
});
|
||
|
||
// (4) first_rainbow: rb=1 → выдано; silver/gold — нет
|
||
const a22rb = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.caught = { carass: { n: 1, best: 1, sn: 0, sv: 0, rb: 1 } };
|
||
checkAchievements();
|
||
const got = { rb: !!save.ach.first_rainbow, sv: !!save.ach.first_silver, gold: !!save.ach.first_shiny };
|
||
save.caught = {};
|
||
return got;
|
||
});
|
||
ok('Этап 22 first_rainbow: улов rb=1 → выдано; first_silver/first_shiny — нет',
|
||
a22rb.rb === true && a22rb.sv === false && a22rb.gold === false,
|
||
JSON.stringify(a22rb));
|
||
|
||
// (5) fog_catch: wxFog=1 → выдано (счётчик ставится уловом в тумане)
|
||
const a22f = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.wxRainV = 0; save.wxFog = 1;
|
||
checkAchievements();
|
||
const got = { fog: !!save.ach.fog_catch, rain: !!save.ach.rain_variant };
|
||
save.wxFog = 0;
|
||
return got;
|
||
});
|
||
ok('Этап 22 fog_catch: wxFog=1 → выдано; rain_variant нет (wxRainV=0)',
|
||
a22f.fog === true && a22f.rain === false,
|
||
JSON.stringify(a22f));
|
||
|
||
// (6) weight_1000: граница — 999.9 не даёт, 1000 даёт
|
||
const a22kg = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.kgTotal = 999.9;
|
||
checkAchievements();
|
||
const notYet = !!save.ach.weight_1000;
|
||
save.kgTotal = 1000;
|
||
checkAchievements();
|
||
const got = !!save.ach.weight_1000;
|
||
save.kgTotal = 0;
|
||
return { notYet, got };
|
||
});
|
||
ok('Этап 22 weight_1000: kgTotal 999.9 — не выдано; 1000 — выдано (граница)',
|
||
a22kg.notYet === false && a22kg.got === true,
|
||
JSON.stringify(a22kg));
|
||
|
||
// (7) скины: boatSkins=[0,1] → skin_first (skins_all нет); все 6 → skins_all
|
||
const a22sk = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.boatSkins = [0, 1]; save.bobberSkins = [0];
|
||
checkAchievements();
|
||
const s1 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
|
||
save.boatSkins = [0, 1, 2]; save.bobberSkins = [0, 1, 2];
|
||
checkAchievements();
|
||
const s2 = { f: !!save.ach.skin_first, a: !!save.ach.skins_all };
|
||
save.boatSkins = [0]; save.bobberSkins = [0];
|
||
return { s1, s2 };
|
||
});
|
||
ok('Этап 22 скины: boatSkins [0,1] → skin_first, skins_all нет; все 6 скинов → skins_all',
|
||
a22sk.s1.f === true && a22sk.s1.a === false && a22sk.s2.f === true && a22sk.s2.a === true,
|
||
JSON.stringify(a22sk));
|
||
|
||
// (8) lake_first: locations [0] — не выдано; [0,1] — выдано
|
||
const a22lk = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.locations = [0];
|
||
checkAchievements();
|
||
const notYet = !!save.ach.lake_first;
|
||
save.locations = [0, 1];
|
||
checkAchievements();
|
||
const got = !!save.ach.lake_first;
|
||
save.locations = [0];
|
||
return { notYet, got };
|
||
});
|
||
ok('Этап 22 lake_first: locations [0] — не выдано; [0,1] (Озёра) — выдано',
|
||
a22lk.notYet === false && a22lk.got === true,
|
||
JSON.stringify(a22lk));
|
||
|
||
// (8b) Этап 36: sea_first/ocean_first — locations-based (паттерн lake_first 1:1; ачивки не отзываются —
|
||
// каждую проверяем отдельной парой шагов с чисткой save.ach); Этап 45: +swamp_first (location 4)
|
||
const a36loc = await page.evaluate(() => {
|
||
const r = {};
|
||
save.ach = {};
|
||
save.locations = [0];
|
||
checkAchievements();
|
||
r.seaNot = !!save.ach.sea_first;
|
||
save.locations = [0, 2];
|
||
checkAchievements();
|
||
r.seaGot = !!save.ach.sea_first;
|
||
save.ach = {};
|
||
save.locations = [0];
|
||
checkAchievements();
|
||
r.oceanNot = !!save.ach.ocean_first;
|
||
save.locations = [0, 3];
|
||
checkAchievements();
|
||
r.oceanGot = !!save.ach.ocean_first;
|
||
save.ach = {};
|
||
save.locations = [0];
|
||
checkAchievements();
|
||
r.swampNot = !!save.ach.swamp_first;
|
||
save.locations = [0, 4];
|
||
checkAchievements();
|
||
r.swampGot = !!save.ach.swamp_first;
|
||
save.locations = [0];
|
||
return r;
|
||
});
|
||
ok('Этап 36/45 sea_first/ocean_first/swamp_first: locations [0] — не выданы; [0,2] (Море) → sea_first; [0,3] (Океан) → ocean_first; [0,4] (Болото) → swamp_first',
|
||
a36loc.seaNot === false && a36loc.seaGot === true
|
||
&& a36loc.oceanNot === false && a36loc.oceanGot === true
|
||
&& a36loc.swampNot === false && a36loc.swampGot === true,
|
||
JSON.stringify(a36loc));
|
||
|
||
// (9) gear_max/titan: максимум 3 ур. — не выдано; lureLv=4 → gear_max; rods с 3 → titan
|
||
const a22g = await page.evaluate(() => {
|
||
save.ach = {};
|
||
save.rodLv = { 0: 1, 1: 3 }; save.lureLv = { 0: 1 };
|
||
save.rods = [0, 1];
|
||
checkAchievements();
|
||
const notYet = { g: !!save.ach.gear_max, t: !!save.ach.titan };
|
||
save.rodLv = { 0: 1, 1: 3 }; save.lureLv = { 0: 4 };
|
||
save.rods = [0, 1, 3];
|
||
checkAchievements();
|
||
const got = { g: !!save.ach.gear_max, t: !!save.ach.titan };
|
||
save.rodLv = { 0: 1 }; save.lureLv = { 0: 1 }; save.rods = [0];
|
||
return { notYet, got };
|
||
});
|
||
ok('Этап 22 снасти: макс 3 ур. — gear_max/titan не выданы; lureLv=4 → gear_max, «Титан» (rods 3) → titan',
|
||
a22g.notYet.g === false && a22g.notYet.t === false && a22g.got.g === true && a22g.got.t === true,
|
||
JSON.stringify(a22g));
|
||
|
||
// (10) старый сейв: без новых полей — без ложных выдач и ошибок; дефолты freshSave
|
||
const a22m = await page.evaluate(() => {
|
||
const s = { coins: 0, caught: {}, ach: {}, casts: 0, earned: 0, snaps: 0,
|
||
bossBeaten: false, bossCount: 0, streak: { last: null, days: 0, shieldWeek: null },
|
||
questsDoneTotal: 0, rodLv: { 0: 1 }, lureLv: { 0: 1 }, rods: [0], lures: [0],
|
||
boatSkins: [0], bobberSkins: [0], locations: [0] };
|
||
const granted = ACHIEVEMENTS.filter(a => a.check(s)).map(a => a.id);
|
||
const fresh = freshSave(false);
|
||
return { granted, fresh: { kg: fresh.kgTotal, night: fresh.nightCatch, rainV: fresh.wxRainV, fog: fresh.wxFog } };
|
||
});
|
||
ok('Этап 22 старый сейв: сейв без kgTotal/nightCatch/wxRainV/wxFog — ложных выдач нет, ошибок нет; freshSave — дефолты 0/false/0/0',
|
||
a22m.granted.length === 0 && a22m.fresh.kg === 0 && a22m.fresh.night === false
|
||
&& a22m.fresh.rainV === 0 && a22m.fresh.fog === 0,
|
||
JSON.stringify(a22m));
|
||
|
||
// --- Y2. Реклама (стаб) ---
|
||
// В file:// (без ?sdk=1) YG-стаб: showFullscreen → false (реклама не показана —
|
||
// мгновенный endAd), showRewarded — синхронная симуляция onOpen→onRewarded→onClose(true).
|
||
// Interstitial — на закрытии карточки (btnCardOk → tryInterstitial), гейты: первый
|
||
// не раньше AD_FIRST_MS сессии (sessionStartTs), дальше не чаще AD_COOLDOWN_MS
|
||
// (lastAdTs). Rewarded «монеты ×2» — кнопка #btnRv на карточке (бонус = rvVal).
|
||
// Проверяем счётчики YG.counts (fsAdv/rv), гейты и состояние adShowing/YG.paused.
|
||
// ВХОД: IDLE после §10g. Уловы — стандартный цикл (forceBite + pointer), вариант
|
||
// форсим 'normal' (детерминированный val без множителей). В конце — сброс в MENU.
|
||
{
|
||
const y2st = await page.evaluate(() => state);
|
||
if (y2st === 'MENU') { await page.click('#btnPlay'); await sleep(150); }
|
||
await page.evaluate(() => {
|
||
setDayTime(0.3);
|
||
save.quests = { date: todayStr(), list: [] }; // квестные монеты не искажают проверки
|
||
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
|
||
forceVariant = 'normal';
|
||
});
|
||
|
||
// улов до карточки (стандартный цикл)
|
||
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 = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
|
||
await page.mouse.down();
|
||
await waitState(page, 'CARD', 5000);
|
||
await page.mouse.up();
|
||
|
||
// (1) RV: карточка — btnRv виден и включён, rvVal = цене улова
|
||
const y2c0 = await page.evaluate(() => ({
|
||
st: state,
|
||
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
|
||
rvVal: rvVal,
|
||
hidden: document.getElementById('btnRv').classList.contains('hidden'),
|
||
dis: document.getElementById('btnRv').disabled,
|
||
txt: document.getElementById('btnRv').textContent,
|
||
}));
|
||
ok('RV карточка: btnRv виден и включён, текст «×2», rvVal = цене улова',
|
||
y2c0.st === 'CARD' && y2c0.hidden === false && y2c0.dis === false
|
||
&& y2c0.txt.includes('×2') && y2c0.rvVal === y2c0.val,
|
||
JSON.stringify(y2c0));
|
||
|
||
// (2) RV: клик → бонус ×2 (монеты +val, cardVal удвоен, кнопка disabled, rv=1)
|
||
const y2c1b = await page.evaluate(() => save.coins);
|
||
const rvBefore = await page.evaluate(() => YG.counts.rv); // rv до клика — проверяем дельту, а не абсолютный пин
|
||
await page.click('#btnRv');
|
||
const y2c1 = await page.evaluate(() => ({
|
||
cardVal: document.getElementById('cardVal').textContent,
|
||
coins: save.coins,
|
||
dis: document.getElementById('btnRv').disabled,
|
||
txt: document.getElementById('btnRv').textContent,
|
||
rv: YG.counts.rv, adShowing: adShowing, paused: YG.paused,
|
||
}));
|
||
ok('RV клик: +val монет (итого ×2), cardVal удвоен, «✓ Бонус получен» disabled, rv=1, adShowing/paused=false',
|
||
y2c1.coins === y2c1b + y2c0.val
|
||
&& y2c1.cardVal === '+' + (y2c0.val * 2) + ' 🪙'
|
||
&& y2c1.dis === true && y2c1.txt.includes('Бонус получен')
|
||
&& y2c1.rv - rvBefore === 1 && y2c1.adShowing === false && y2c1.paused === false,
|
||
JSON.stringify({ before: y2c1b, val: y2c0.val, after: y2c1 }));
|
||
|
||
// (3) RV: повторный клик — no-op (rvDone=true, монеты не изменились)
|
||
const y2c2b = await page.evaluate(() => save.coins);
|
||
await page.click('#btnRv');
|
||
const y2c2 = await page.evaluate(() => save.coins);
|
||
ok('RV повторный клик: no-op (монеты не изменились)', y2c2 === y2c2b,
|
||
'before=' + y2c2b + ' after=' + y2c2);
|
||
|
||
// (4)+(5) закрытие карточки при СВЕЖЕЙ сессии (<2 мин): state=IDLE,
|
||
// гейт AD_FIRST_MS блокирует showFullscreen (fsAdv не изменился)
|
||
await page.evaluate(() => { sessionStartTs = Date.now(); lastAdTs = 0; });
|
||
const y2i0 = await page.evaluate(() => YG.counts.fsAdv);
|
||
await page.click('#btnCardOk');
|
||
const y2i1 = await page.evaluate(() => ({ st: state, fsAdv: YG.counts.fsAdv }));
|
||
ok('RV закрытие карточки: state=IDLE', y2i1.st === 'IDLE', JSON.stringify(y2i1));
|
||
ok('interstitial гейт 2 мин: свежая сессия — showFullscreen не вызван (fsAdv не изменился)',
|
||
y2i1.fsAdv === y2i0, 'before=' + y2i0 + ' after=' + y2i1.fsAdv);
|
||
|
||
// (6) сессия старше 2 мин (sessionStartTs=now-130s), lastAdTs=0 → реклама: fsAdv +1
|
||
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 = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
|
||
await page.mouse.down();
|
||
await waitState(page, 'CARD', 5000);
|
||
await page.mouse.up();
|
||
await page.evaluate(() => { sessionStartTs = Date.now() - (AD_FIRST_MS + 10000); lastAdTs = 0; });
|
||
const y2i2 = await page.evaluate(() => YG.counts.fsAdv);
|
||
await page.click('#btnCardOk');
|
||
const y2i3 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
|
||
ok('interstitial после 2 мин: showFullscreen вызван (fsAdv +1), endAd → adShowing/paused=false',
|
||
y2i3.fsAdv === y2i2 + 1 && y2i3.adShowing === false && y2i3.paused === false,
|
||
JSON.stringify({ before: y2i2, after: y2i3 }));
|
||
|
||
// (7) кулдаун 3 мин: улов сразу после рекламы (lastAdTs свежий из (6)) → повтор не вызван
|
||
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 = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
|
||
await page.mouse.down();
|
||
await waitState(page, 'CARD', 5000);
|
||
await page.mouse.up();
|
||
const y2i4 = await page.evaluate(() => YG.counts.fsAdv);
|
||
await page.click('#btnCardOk');
|
||
const y2i5 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv }));
|
||
ok('interstitial кулдаун 3 мин: улов сразу после рекламы — showFullscreen не вызван (fsAdv не изменился)',
|
||
y2i5.fsAdv === y2i4, 'before=' + y2i4 + ' after=' + y2i5.fsAdv);
|
||
|
||
// (8) кулдаун истёк (lastAdTs=now-190s) → реклама: fsAdv +1
|
||
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 = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
|
||
await page.mouse.down();
|
||
await waitState(page, 'CARD', 5000);
|
||
await page.mouse.up();
|
||
await page.evaluate(() => { lastAdTs = Date.now() - (AD_COOLDOWN_MS + 10000); });
|
||
const y2i6 = await page.evaluate(() => YG.counts.fsAdv);
|
||
await page.click('#btnCardOk');
|
||
const y2i7 = await page.evaluate(() => ({ fsAdv: YG.counts.fsAdv, adShowing: adShowing, paused: YG.paused }));
|
||
ok('interstitial после истечения кулдауна: showFullscreen вызван (fsAdv +1), adShowing/paused=false',
|
||
y2i7.fsAdv === y2i6 + 1 && y2i7.adShowing === false && y2i7.paused === false,
|
||
JSON.stringify({ before: y2i6, after: y2i7 }));
|
||
}
|
||
// cleanup: сброс тест-форсов, возврат в MENU (пауза → «В меню» → btnReset → btnResetYes),
|
||
// adShowing/paused=false
|
||
await page.evaluate(() => { forceBite = false; forceVariant = null; });
|
||
await toGameMenu(page);
|
||
await page.click('#btnReset');
|
||
await sleep(150);
|
||
await page.click('#btnResetYes');
|
||
await sleep(150);
|
||
const y2end = await page.evaluate(() => ({
|
||
st: state,
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
adShowing: adShowing,
|
||
paused: YG.paused,
|
||
}));
|
||
ok('Y2 cleanup: возврат в MENU (меню видно), adShowing=false, YG.paused=false',
|
||
y2end.st === 'MENU' && y2end.menu === true && y2end.adShowing === false && y2end.paused === false,
|
||
JSON.stringify(y2end));
|
||
|
||
// --- Y3. Полировка ---
|
||
ok('Y3 полировка: .rvbtn min-height 44px (тап-таргет)',
|
||
await page.evaluate(() => getComputedStyle(document.getElementById('btnRv')).minHeight === '44px'));
|
||
|
||
// --- Y5. i18n RU/EN (треб. 2.14) ---
|
||
// (1) стаб дефолт RU: LANG=ru, <html lang=ru>, меню на RU, RV-кнопка с «×2»
|
||
const y5a = await page.evaluate(() => ({
|
||
lang: LANG,
|
||
htmlLang: document.documentElement.lang,
|
||
menu: document.querySelector('#menu .title').textContent,
|
||
rv: document.getElementById('btnRv').textContent
|
||
}));
|
||
ok('Y5 i18n стаб: дефолт RU (LANG=ru, html lang=ru, меню «РЫБАЛКА», RV-текст с «×2»)',
|
||
y5a.lang === 'ru' && y5a.htmlLang === 'ru' && y5a.menu.includes('РЫБАЛКА') && y5a.rv.includes('×2'),
|
||
JSON.stringify(y5a));
|
||
|
||
// (2) forceLang='en' → setLang('en'): язык EN — title, таблицы данных, квесты, RV
|
||
const y5b = await page.evaluate(() => {
|
||
forceLang = 'en';
|
||
setLang('en');
|
||
return {
|
||
lang: LANG,
|
||
htmlLang: document.documentElement.lang,
|
||
title: document.title,
|
||
rar: trRarity(0),
|
||
name0: trName(SPECIES[0]),
|
||
quest: questText({ tpl: 'catch_shiny', goal: 1, param: 0 }),
|
||
rv: document.getElementById('btnRv').textContent
|
||
};
|
||
});
|
||
ok('Y5 i18n forceLang=en: setLang(en) — LANG=en, html lang=en, title EN',
|
||
y5b.lang === 'en' && y5b.htmlLang === 'en' && y5b.title === 'Fishing',
|
||
JSON.stringify(y5b));
|
||
ok('Y5 i18n EN: trRarity(0)=Common, trName(SPECIES[0])=Crucian carp',
|
||
y5b.rar === 'Common' && y5b.name0 === 'Crucian carp',
|
||
JSON.stringify({ rar: y5b.rar, name0: y5b.name0 }));
|
||
ok('Y5 i18n EN: questText(catch_shiny) = "Catch a golden fish"',
|
||
y5b.quest === 'Catch a golden fish', 'got: ' + y5b.quest);
|
||
ok('Y5 i18n EN: RV-кнопка переведена (содержит «Ad» и «×2»)',
|
||
y5b.rv.includes('Ad') && y5b.rv.includes('×2'), 'got: ' + y5b.rv);
|
||
|
||
// (3) setLang('ru') — обратно на RU (дефолт)
|
||
const y5c = await page.evaluate(() => {
|
||
setLang('ru');
|
||
const r = {
|
||
lang: LANG,
|
||
htmlLang: document.documentElement.lang,
|
||
title: document.title,
|
||
rar: trRarity(0),
|
||
rv: document.getElementById('btnRv').textContent
|
||
};
|
||
forceLang = null; // чистим тест-форс
|
||
return r;
|
||
});
|
||
ok('Y5 i18n setLang(ru): обратно RU (trRarity(0)=Обычная, html lang=ru, title RU, RV «Реклама»)',
|
||
y5c.lang === 'ru' && y5c.htmlLang === 'ru' && y5c.title === 'Рыбалка'
|
||
&& y5c.rar === 'Обычная' && y5c.rv.includes('Реклама'),
|
||
JSON.stringify(y5c));
|
||
|
||
// --- Y6. Кнопка паузы (треб. Яндекс Игр 6.3) и blur/focus (треб. 1.3) ---
|
||
// (1) #btnPause существует; в MENU — hidden; после #btnPlay (IDLE) — виден
|
||
// (видимость синхронизирует syncDom() в updateGame — sleep до кадра)
|
||
const y6a = await page.evaluate(() => ({
|
||
exists: !!document.getElementById('btnPause'),
|
||
hiddenMenu: document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Y6 пауза: #btnPause существует, в MENU скрыт',
|
||
y6a.exists === true && y6a.hiddenMenu === true, JSON.stringify(y6a));
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
const y6b = await page.evaluate(() => ({
|
||
st: state, ui: uiOpen,
|
||
hidden: document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Y6 пауза: после #btnPlay (IDLE) — #btnPause виден',
|
||
y6b.st === 'IDLE' && y6b.ui === false && y6b.hidden === false, JSON.stringify(y6b));
|
||
|
||
// (2) клик #btnPause из IDLE: оверлей открыт, всё на паузе
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
const y6c = await page.evaluate(() => ({
|
||
po: pauseOpen, ui: uiOpen, paused: YG.paused,
|
||
open: !document.getElementById('pause').classList.contains('hidden'),
|
||
btn: document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Y6 пауза: клик #btnPause из IDLE → pauseOpen=true, uiOpen=true, YG.paused=true, #pause виден, #btnPause скрыт',
|
||
y6c.po === true && y6c.ui === true && y6c.paused === true && y6c.open === true && y6c.btn === true,
|
||
JSON.stringify(y6c));
|
||
|
||
// (3) пауза замораживает время: frame() под YG.paused не двигает dayTime
|
||
const y6d1 = await page.evaluate(() => dayTime);
|
||
await sleep(300);
|
||
const y6d2 = await page.evaluate(() => dayTime);
|
||
ok('Y6 пауза: время заморожено (dayTime не изменился за 300 мс под YG.paused)',
|
||
y6d1 === y6d2, JSON.stringify({ d1: y6d1, d2: y6d2 }));
|
||
|
||
// (4) #btnPauseResume: пауза снята, игра продолжается (IDLE)
|
||
await page.click('#btnPauseResume');
|
||
await sleep(100);
|
||
const y6e = await page.evaluate(() => ({
|
||
po: pauseOpen, ui: uiOpen, paused: YG.paused, st: state,
|
||
open: !document.getElementById('pause').classList.contains('hidden'),
|
||
btn: !document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Y6 пауза: #btnPauseResume → pauseOpen=false, YG.paused=false, #pause скрыт, state=IDLE, uiOpen=false, #btnPause снова виден',
|
||
y6e.po === false && y6e.ui === false && y6e.paused === false && y6e.st === 'IDLE'
|
||
&& y6e.open === false && y6e.btn === true,
|
||
JSON.stringify(y6e));
|
||
|
||
// (5) «В меню» из паузы: открыт хаб #gamemenu (state сохранён — IDLE), пауза снята
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
const y6f = await page.evaluate(() => ({
|
||
st: state,
|
||
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
|
||
menu: document.getElementById('menu').classList.contains('hidden'),
|
||
open: !document.getElementById('pause').classList.contains('hidden'),
|
||
po: pauseOpen, paused: YG.paused
|
||
}));
|
||
ok('Y6 пауза: #btnPause → #btnPauseMenu → #gamemenu виден (state IDLE сохранён), #pause скрыт, YG.paused=false',
|
||
y6f.st === 'IDLE' && y6f.gm === true && y6f.menu === true && y6f.open === false && y6f.po === false && y6f.paused === false,
|
||
JSON.stringify(y6f));
|
||
|
||
// (6) blur/focus (треб. 1.3): из IDLE — blur окна паузит, focus возобновляет.
|
||
// Путь к #btnPlay: из «Меню» — «Инструкции» (#menu), там кнопка «Играть»
|
||
await page.click('#btnGmHowto');
|
||
await sleep(100);
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
const y6g = await page.evaluate(() => {
|
||
window.dispatchEvent(new Event('blur'));
|
||
const p1 = YG.paused;
|
||
window.dispatchEvent(new Event('focus'));
|
||
return { st: state, p1, p2: YG.paused };
|
||
});
|
||
ok('Y6 blur/focus: из IDLE window blur → YG.paused=true, focus → YG.paused=false',
|
||
y6g.st === 'IDLE' && y6g.p1 === true && y6g.p2 === false, JSON.stringify(y6g));
|
||
|
||
// (7) гард pauseOpen: focus при открытой паузе не снимает паузу;
|
||
// снимает только #btnPauseResume
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
const y6h = await page.evaluate(() => {
|
||
window.dispatchEvent(new Event('focus'));
|
||
return { po: pauseOpen, paused: YG.paused };
|
||
});
|
||
ok('Y6 гард pauseOpen: focus при открытой паузе → YG.paused остаётся true',
|
||
y6h.po === true && y6h.paused === true, JSON.stringify(y6h));
|
||
await page.click('#btnPauseResume');
|
||
await sleep(100);
|
||
const y6i = await page.evaluate(() => ({ po: pauseOpen, paused: YG.paused }));
|
||
ok('Y6 гард pauseOpen: #btnPauseResume снимает паузу (YG.paused=false)',
|
||
y6i.po === false && y6i.paused === false, JSON.stringify(y6i));
|
||
|
||
// cleanup: возврат в MENU (пауза → «В меню» → «Очистить» → «Сбросить») — состояние до секции
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
await page.click('#btnReset');
|
||
await sleep(150);
|
||
await page.click('#btnResetYes');
|
||
await sleep(150);
|
||
const y6end = await page.evaluate(() => ({
|
||
st: state,
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
po: pauseOpen, paused: YG.paused
|
||
}));
|
||
ok('Y6 cleanup: возврат в MENU (меню видно, #gamemenu скрыт), pauseOpen=false, YG.paused=false',
|
||
y6end.st === 'MENU' && y6end.menu === true && y6end.gm === true && y6end.po === false && y6end.paused === false,
|
||
JSON.stringify(y6end));
|
||
|
||
// --- Y8. Монеты → лавка (вкладка «Лавка») и хаб «Меню» (#gamemenu) ---
|
||
// (1) HUD: 🗑️/🔊 переехали в «Меню», в HUD только 🛒 и ⏸; клик по #coins — лавка на «Лавке»
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
const y8hud = await page.evaluate(() => ({
|
||
resetInMenu: !!document.getElementById('btnReset').closest('#gamemenu'),
|
||
muteInMenu: !!document.getElementById('btnMute').closest('#gamemenu'),
|
||
hudBtns: [...document.querySelectorAll('.hudbtns button')].map(b => b.id)
|
||
}));
|
||
ok('Y8 HUD: 🗑️/🔊 в #gamemenu, в .hudbtns остались только btnShop/btnPause',
|
||
y8hud.resetInMenu && y8hud.muteInMenu
|
||
&& y8hud.hudBtns.length === 2 && y8hud.hudBtns.includes('btnShop') && y8hud.hudBtns.includes('btnPause'),
|
||
JSON.stringify(y8hud));
|
||
await page.click('#coins');
|
||
await sleep(150);
|
||
const y8a = await page.evaluate(() => ({
|
||
shop: !document.getElementById('shop').classList.contains('hidden'),
|
||
tackle: document.getElementById('tabTackle').classList.contains('active'),
|
||
ui: uiOpen
|
||
}));
|
||
ok('Y8 монеты: клик #coins → лавка открыта ровно на вкладке «Лавка» (tackle active)',
|
||
y8a.shop && y8a.tackle && y8a.ui === true, JSON.stringify(y8a));
|
||
// (2) × из лавки, открытой из HUD/монет — возврат в игру
|
||
await page.click('#btnCloseShop');
|
||
await sleep(150);
|
||
ok('Y8 монеты: #btnCloseShop → лавка закрыта, uiOpen=false (без возврата в «Меню»)',
|
||
await page.$eval('#shop', el => el.classList.contains('hidden'))
|
||
&& (await page.evaluate(() => uiOpen)) === false);
|
||
|
||
// (3) пауза → «В меню»: #gamemenu виден, сессия сохранена (state=IDLE), #pause скрыт
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
const y8b = await page.evaluate(() => ({
|
||
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
|
||
st: state, ui: uiOpen, po: pauseOpen, paused: YG.paused,
|
||
pause: document.getElementById('pause').classList.contains('hidden'),
|
||
menu: document.getElementById('menu').classList.contains('hidden')
|
||
}));
|
||
ok('Y8 меню: «В меню» из паузы → #gamemenu виден, state=IDLE сохранён, #pause скрыт, YG.paused=false',
|
||
y8b.gm && y8b.st === 'IDLE' && y8b.ui === true && y8b.po === false && y8b.paused === false
|
||
&& y8b.pause && y8b.menu, JSON.stringify(y8b));
|
||
|
||
// (4) «Магазин» из меню → лавка на «Лавке»; × → шаг назад в «Меню» (uiOpen=true, без start)
|
||
const y8s0 = await page.evaluate(() => ({ s: YG.counts.start, t: YG.counts.stop }));
|
||
await page.click('#btnGmShop');
|
||
await sleep(150);
|
||
const y8c = await page.evaluate(() => ({
|
||
shop: !document.getElementById('shop').classList.contains('hidden'),
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
tackle: document.getElementById('tabTackle').classList.contains('active')
|
||
}));
|
||
ok('Y8 меню: «Магазин» → лавка на вкладке «Лавка», #gamemenu скрыт (не перекрывает лавку)',
|
||
y8c.shop && y8c.gm && y8c.tackle, JSON.stringify(y8c));
|
||
await page.click('#btnCloseShop');
|
||
await sleep(150);
|
||
const y8d = await page.evaluate(() => ({
|
||
shop: document.getElementById('shop').classList.contains('hidden'),
|
||
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen, s: YG.counts.start, t: YG.counts.stop
|
||
}));
|
||
ok('Y8 меню: × из лавки возвращает в «Меню» (uiOpen=true, геймплей не стартует: stop +1, start +0)',
|
||
y8d.shop && y8d.gm && y8d.ui === true && y8d.s === y8s0.s && y8d.t === y8s0.t + 1,
|
||
JSON.stringify({ before: y8s0, after: y8d }));
|
||
|
||
// (5) «Достижения»/«Дневник» из меню → свои вкладки лавки
|
||
await page.click('#btnGmAch');
|
||
await sleep(150);
|
||
const y8e = await page.evaluate(() => ({
|
||
shop: !document.getElementById('shop').classList.contains('hidden'),
|
||
ach: document.getElementById('tabAch').classList.contains('active')
|
||
}));
|
||
ok('Y8 меню: «Достижения» → лавка на вкладке достижений', y8e.shop && y8e.ach, JSON.stringify(y8e));
|
||
await page.click('#btnCloseShop');
|
||
await sleep(100);
|
||
await page.click('#btnGmJournal');
|
||
await sleep(150);
|
||
const y8f = await page.evaluate(() => ({
|
||
shop: !document.getElementById('shop').classList.contains('hidden'),
|
||
journal: document.getElementById('tabJournal').classList.contains('active')
|
||
}));
|
||
ok('Y8 меню: «Дневник» → лавка на вкладке дневника', y8f.shop && y8f.journal, JSON.stringify(y8f));
|
||
await page.click('#btnCloseShop');
|
||
await sleep(100);
|
||
|
||
// (6) Звук из меню: два клика — muted меняется на каждом, итог = исходный
|
||
// (не пинуем абсолютное значение: фикстуры старых сейвов выше могут ставить muted=true)
|
||
const y8m0 = await page.evaluate(() => save.muted);
|
||
await page.click('#btnMute');
|
||
const y8m1 = await page.evaluate(() => save.muted);
|
||
await page.click('#btnMute');
|
||
const y8m2 = await page.evaluate(() => save.muted);
|
||
ok('Y8 меню: #btnMute в «Меню» переключает звук (каждый клик — тогл)',
|
||
y8m1 === !y8m0 && y8m2 === y8m0, JSON.stringify({ y8m0, y8m1, y8m2 }));
|
||
|
||
// (7) «Продолжить» → возврат в игру: сессия на том же месте (IDLE), gameplayStart +1
|
||
const y8s1 = await page.evaluate(() => YG.counts.start);
|
||
await page.click('#btnGmResume');
|
||
await sleep(150);
|
||
const y8g = await page.evaluate(() => ({
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen, st: state, s: YG.counts.start,
|
||
btn: document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Y8 меню: «Продолжить» → #gamemenu скрыт, uiOpen=false, state=IDLE, gameplayStart +1, #btnPause виден',
|
||
y8g.gm && y8g.ui === false && y8g.st === 'IDLE' && y8g.s === y8s1 + 1 && y8g.btn === false,
|
||
JSON.stringify(y8g));
|
||
|
||
// (8) «Инструкции» из меню → начальные инструкции (#menu); «Играть» возвращает в игру
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
await page.click('#btnGmHowto');
|
||
await sleep(100);
|
||
const y8h = await page.evaluate(() => ({
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden')
|
||
}));
|
||
ok('Y8 меню: «Инструкции» → диалог с инструкциями виден, #gamemenu скрыт',
|
||
y8h.menu && y8h.gm, JSON.stringify(y8h));
|
||
await page.screenshot({ path: 'shot-gamemenu-instructions.png' });
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
ok('Y8 меню: «Играть» из инструкций → обратно в игру (state=IDLE, #gamemenu скрыт)',
|
||
(await page.evaluate(() => state)) === 'IDLE'
|
||
&& (await page.$eval('#gamemenu', el => el.classList.contains('hidden'))));
|
||
|
||
// cleanup: «Очистить» из «Меню» → «Сбросить» → MENU (инструкции), гменю скрыто
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
await page.screenshot({ path: 'shot-gamemenu.png' });
|
||
await page.click('#btnReset');
|
||
await sleep(150);
|
||
await page.click('#btnResetYes');
|
||
await sleep(150);
|
||
const y8end = await page.evaluate(() => ({
|
||
st: state,
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden')
|
||
}));
|
||
ok('Y8 cleanup: «Очистить» → «Сбросить» → MENU (инструкции), #gamemenu скрыт',
|
||
y8end.st === 'MENU' && y8end.menu && y8end.gm, JSON.stringify(y8end));
|
||
|
||
// --- Y7. Встроенный emoji-шрифт (этап 28) ---
|
||
// Шрифт вшит в index.html как base64 @font-face (субсет Noto Color Emoji, tools/emoji-font.py).
|
||
// Проверка: face зарегистрирован и реально резолвит glyph (не молча падает в системный фоллбек).
|
||
const y7 = await page.evaluate(async () => {
|
||
const loaded = (document.fonts&&document.fonts.load)
|
||
? await Promise.race([document.fonts.load('16px "EmojiEmbedded"').then(()=>true).catch(()=>false),
|
||
new Promise(r=>setTimeout(()=>r(false),3000))])
|
||
: false;
|
||
const check = document.fonts ? document.fonts.check('16px "EmojiEmbedded"','🪙') : false;
|
||
const families = document.fonts ? [...document.fonts].map(f=>f.family) : [];
|
||
return { loaded, check, families };
|
||
});
|
||
ok('Y7 шрифт: @font-face "EmojiEmbedded" зарегистрирован в document.fonts',
|
||
y7.families.includes('EmojiEmbedded'), JSON.stringify(y7));
|
||
ok('Y7 шрифт: document.fonts.check("16px EmojiEmbedded","🪙") — встроенный шрифт резолвит glyph',
|
||
y7.loaded === true && y7.check === true, JSON.stringify(y7));
|
||
|
||
// --- X. Закрытие по тапу мимо диалога (backdropClose) ---
|
||
// Тап «в молоко» — на подложку оверлея (e.target === сам оверлей, не .panel) — закрывает
|
||
// диалог тем же действием, что и кнопка закрытия. Точка (10,10) — в 16px-паддинге .overlay,
|
||
// т.е. гарантированно на подложке (панель центрирована, width ≤ 370px).
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
|
||
// (1) Лавка: открыть → тап по подложке → закрытие (как ×)
|
||
await page.click('#btnShop');
|
||
await sleep(100);
|
||
await page.mouse.click(10, 10);
|
||
await sleep(100);
|
||
const xb1 = await page.evaluate(() => ({
|
||
shop: document.getElementById('shop').classList.contains('hidden'),
|
||
ui: uiOpen, st: state
|
||
}));
|
||
ok('X подложка: лавка → тап мимо → закрыта (uiOpen=false, state=IDLE)',
|
||
xb1.shop && xb1.ui === false && xb1.st === 'IDLE', JSON.stringify(xb1));
|
||
|
||
// (2) Пауза: открыть → тап по подложке → «Продолжить»
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.mouse.click(10, 10);
|
||
await sleep(100);
|
||
const xb2 = await page.evaluate(() => ({
|
||
pause: document.getElementById('pause').classList.contains('hidden'),
|
||
po: pauseOpen, ui: uiOpen
|
||
}));
|
||
ok('X подложка: пауза → тап мимо → снята (pauseOpen=false, uiOpen=false)',
|
||
xb2.pause && xb2.po === false && xb2.ui === false, JSON.stringify(xb2));
|
||
|
||
// (3) Хаб «Меню»: из паузы → тап по подложке → «Продолжить» (сессия с места)
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
const xb3s = await page.evaluate(() => YG.counts.start);
|
||
await page.mouse.click(10, 10);
|
||
await sleep(100);
|
||
const xb3 = await page.evaluate(() => ({
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen, st: state, s: YG.counts.start
|
||
}));
|
||
ok('X подложка: «Меню» → тап мимо → «Продолжить» (uiOpen=false, state=IDLE, gameplayStart +1)',
|
||
xb3.gm && xb3.ui === false && xb3.st === 'IDLE' && xb3.s === xb3s + 1, JSON.stringify(xb3));
|
||
|
||
// (4) Подтверждение сброса: из «Меню» «Очистить» → тап по подложке → «Отмена» (в «Меню», прогресс цел)
|
||
await page.click('#btnPause');
|
||
await sleep(100);
|
||
await page.click('#btnPauseMenu');
|
||
await sleep(100);
|
||
await page.click('#btnReset');
|
||
await sleep(100);
|
||
const xb4pre = await page.evaluate(() => ({
|
||
dlg: !document.getElementById('resetDlg').classList.contains('hidden'),
|
||
coins: save.coins
|
||
}));
|
||
await page.mouse.click(10, 10);
|
||
await sleep(100);
|
||
const xb4 = await page.evaluate(() => ({
|
||
dlg: document.getElementById('resetDlg').classList.contains('hidden'),
|
||
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen, coins: save.coins
|
||
}));
|
||
ok('X подложка: сброс → тап мимо → «Отмена» (в «Меню», uiOpen=true, монеты не стёрты)',
|
||
xb4pre.dlg && xb4.dlg && xb4.gm && xb4.ui === true && xb4.coins === xb4pre.coins, JSON.stringify(xb4));
|
||
await page.click('#btnGmResume');
|
||
await sleep(100);
|
||
|
||
// (5) Карточка улова: showCard напрямую (паттерн §9j(7), state='CARD' — как в catchFish)
|
||
// → тап по подложке → «Отлично!». Ждём 400 мс: у #card pop-анимация (transform scale, 300 мс),
|
||
// пока она идёт — hit-area оверлея сужена, тап мимо бы ушёл на канвас.
|
||
await page.evaluate(() => { state='CARD'; showCard(SPECIES[0], 1, 100, false, VARIANTS[0], false); });
|
||
await sleep(400);
|
||
const xb5pre = await page.evaluate(() => ({
|
||
card: !document.getElementById('card').classList.contains('hidden')
|
||
}));
|
||
await page.mouse.click(10, 10);
|
||
await sleep(150);
|
||
const xb5 = await page.evaluate(() => ({
|
||
card: document.getElementById('card').classList.contains('hidden'),
|
||
st: state
|
||
}));
|
||
ok('X подложка: карточка → тап мимо → закрыта («Отлично!», state=IDLE)',
|
||
xb5pre.card && xb5.card && xb5.st === 'IDLE', JSON.stringify(xb5));
|
||
|
||
// --- AQ. Этап 46: Аквариум (миграция / «В аквариум» / лимит / слот / сцена / выпуск) ---
|
||
// Глобалы игры (page.evaluate): aqOpen, aqFishRt, aqSel, aqTank, AQ_BASE_SLOTS, AQ_MAX_SLOTS,
|
||
// AQ_SLOT_COSTS, AQ_VARIANT_RECOLOR, AQ_VARIANT_CLASS, aqApplyVariant, initAquarium,
|
||
// updateAquarium, drawAquarium, aqFishSize, aqFishEls, aqBuildFishEl, aqPlaceFish,
|
||
// aqTap, showAqPop, hideAqPop, renderAqBar, aqReleaseSel, openAquarium, closeAquarium,
|
||
// syncKeepBtn, cardSpec, save.aqFish/save.aqSlots — имена не переименовывать (тесты молча ломаются).
|
||
// DOM: #btnGmAquarium, #btnKeep, #aqBar, #btnAqBack, #aqCount, #btnAqSlot, #aqPop,
|
||
// #aqPopName, #aqPopSub, #btnAqRelease, #aqFishLayer (DOM-слой портретов — ревизия: жильцы
|
||
// те же SVG-спрайты #fish-<id>, что на карточке улова; вариант — класс .var<id> + --f1/--f3).
|
||
|
||
// (1) данные: freshSave() кладёт aqFish:[] и aqSlots:AQ_BASE_SLOTS; кноб слотов (6/12 + лестница цен)
|
||
const aqFresh = await page.evaluate(() => {
|
||
const f = freshSave(false);
|
||
return { arr: Array.isArray(f.aqFish), len: f.aqFish.length, slots: f.aqSlots,
|
||
base: AQ_BASE_SLOTS, max: AQ_MAX_SLOTS, costs: AQ_SLOT_COSTS.slice() };
|
||
});
|
||
ok('Этап 46 данные: freshSave().aqFish=[] и aqSlots=AQ_BASE_SLOTS (6), AQ_MAX_SLOTS=12, в AQ_SLOT_COSTS 6 цен',
|
||
aqFresh.arr === true && aqFresh.len === 0 && aqFresh.slots === aqFresh.base
|
||
&& aqFresh.base === 6 && aqFresh.max === 12 && aqFresh.costs.length === 6
|
||
&& aqFresh.costs.every(c => typeof c === 'number' && c > 0),
|
||
JSON.stringify(aqFresh));
|
||
|
||
// (2) миграция: старый сейв без aqFish/aqSlots → после reload дефолты (паттерн §9b/M6(6))
|
||
await page.evaluate(() => {
|
||
localStorage.setItem('fishing-save-v1', JSON.stringify({
|
||
coins: 5000, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
|
||
caught: {}, casts: 0, earned: 0
|
||
}));
|
||
});
|
||
await page.reload({ waitUntil: 'load' });
|
||
await sleep(500);
|
||
const aqMig = await page.evaluate(() => ({
|
||
menu: !document.getElementById('menu').classList.contains('hidden'),
|
||
arr: Array.isArray(save.aqFish), len: save.aqFish.length,
|
||
slots: save.aqSlots, base: AQ_BASE_SLOTS
|
||
}));
|
||
ok('Этап 46 миграция: старый сейв без aqFish/aqSlots → aqFish=[], aqSlots=AQ_BASE_SLOTS (6)',
|
||
aqMig.menu && aqMig.arr === true && aqMig.len === 0 && aqMig.slots === aqMig.base && aqMig.base === 6,
|
||
JSON.stringify(aqMig));
|
||
|
||
// (3) миграция битых значений: неизвестный вид/вес отброшены, битый вариант → normal,
|
||
// aqSlots клэмпится к AQ_MAX_SLOTS, хвост списка обрезается по числу слотов
|
||
await page.evaluate(() => {
|
||
const fish = [{ id: 'pike', v: 'bad', w: 3 }]; // индекс 0: битый вариант → 'normal'
|
||
for (let i = 0; i < 15; i++) fish.push({ id: i % 2 ? 'perch' : 'carass', v: 'normal', w: 1 + i });
|
||
fish.push({ id: 'nope', v: 'gold', w: 2 }); // неизвестный вид — отбрасывается
|
||
fish.push({ id: 'perch', v: 'normal', w: -1 }); // битый вес — отбрасывается
|
||
localStorage.setItem('fishing-save-v1', JSON.stringify({
|
||
coins: 1, rod: 0, lure: 0, rods: [0], lures: [0], muted: false,
|
||
caught: {}, casts: 0, earned: 0, aqSlots: 99, aqFish: fish
|
||
}));
|
||
});
|
||
await page.reload({ waitUntil: 'load' });
|
||
await sleep(500);
|
||
const aqMig2 = await page.evaluate(() => ({
|
||
slots: save.aqSlots, max: AQ_MAX_SLOTS, len: save.aqFish.length,
|
||
first: save.aqFish[0] && { id: save.aqFish[0].id, v: save.aqFish[0].v },
|
||
allValid: save.aqFish.every(f => SPECIES.some(s => s.id === f.id)
|
||
&& typeof f.w === 'number' && f.w > 0 && VARIANTS.some(v => v.id === f.v))
|
||
}));
|
||
ok('Этап 46 миграция: битый id/вес отброшены, неизвестный вариант → normal, aqSlots клэмп к AQ_MAX_SLOTS, хвост обрезан',
|
||
aqMig2.slots === aqMig2.max && aqMig2.max === 12 && aqMig2.len === aqMig2.max
|
||
&& aqMig2.first && aqMig2.first.id === 'pike' && aqMig2.first.v === 'normal' && aqMig2.allValid,
|
||
JSON.stringify(aqMig2));
|
||
|
||
// (4) keep: форс-улов (normal) → CARD → #btnKeep копирует экземпляр (модель C).
|
||
// Сброс aq-ачивок: deep-миграция выше могла выдать aq_first/aq_full через applyStreak.
|
||
await page.click('#btnPlay');
|
||
await sleep(150);
|
||
await page.evaluate(() => {
|
||
save.aqFish = []; save.aqSlots = AQ_BASE_SLOTS;
|
||
if(save.ach){ delete save.ach.aq_first; delete save.ach.aq_full; }
|
||
save.quests = { date: todayStr(), list: [] };
|
||
save.streak = { last: todayStr(), days: 1, shieldWeek: null };
|
||
forceBite = true; forceVariant = 'normal'; forceBoss = false;
|
||
persist();
|
||
});
|
||
const aqCoinsBefore = await page.evaluate(() => save.coins);
|
||
await page.mouse.move(195, 600);
|
||
await page.mouse.down(); await sleep(450); await page.mouse.up();
|
||
await waitState(page, 'WAIT');
|
||
await page.evaluate(() => { 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 aqCard = await page.evaluate(() => ({
|
||
spec: cardSpec ? { id: cardSpec.sp.id, v: cardSpec.variant.id, w: cardSpec.w } : null,
|
||
val: parseInt(document.getElementById('cardVal').textContent.replace(/[^\d]/g, ''), 10),
|
||
coins: save.coins,
|
||
keepDisabled: document.getElementById('btnKeep').disabled
|
||
}));
|
||
ok('Этап 46 keep: карточка даёт cardSpec (id/v/w), #btnKeep включён (есть слот)',
|
||
aqCard.spec !== null && aqCard.spec.v === 'normal' && aqCard.keepDisabled === false,
|
||
JSON.stringify(aqCard));
|
||
await page.click('#btnKeep');
|
||
await sleep(200);
|
||
const aqKeep = await page.evaluate(() => ({
|
||
len: save.aqFish.length, f: save.aqFish[0] || null, coins: save.coins,
|
||
cardHidden: document.getElementById('card').classList.contains('hidden'),
|
||
st: state, aqFirst: !!save.ach.aq_first, aqFull: !!save.ach.aq_full
|
||
}));
|
||
ok('Этап 46 keep: клик #btnKeep → 1 копия с верными id/v/w, карточка закрыта, state=IDLE',
|
||
aqKeep.len === 1 && aqKeep.f
|
||
&& aqKeep.f.id === aqCard.spec.id && aqKeep.f.v === aqCard.spec.v
|
||
&& Math.abs(aqKeep.f.w - aqCard.spec.w) < 1e-9
|
||
&& aqKeep.cardHidden === true && aqKeep.st === 'IDLE',
|
||
JSON.stringify({ card: aqCard, keep: aqKeep }));
|
||
ok('Этап 46 keep: монеты выросли на цену улова (модель C — платит catchFish), keep монет не добавляет; выдан aq_first',
|
||
aqCard.coins === aqCoinsBefore + aqCard.val && aqKeep.coins === aqCard.coins
|
||
&& aqKeep.aqFirst === true && aqKeep.aqFull === false,
|
||
JSON.stringify({ before: aqCoinsBefore, afterCard: aqCard.coins, afterKeep: aqKeep.coins,
|
||
first: aqKeep.aqFirst, full: aqKeep.aqFull }));
|
||
|
||
// (5) лимит: aqFish заполнен до aqSlots → #btnKeep disabled + «Нет места» (подпись и title)
|
||
await page.evaluate(() => {
|
||
state = 'CARD'; reel = null; biteFish = null;
|
||
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [];
|
||
for (let i = 0; i < save.aqSlots; i++) save.aqFish.push({ id: 'carass', v: 'normal', w: 1 });
|
||
showCard(SPECIES[0], 1, 100, false, VARIANTS[0], false); // showCard пересинхронизирует #btnKeep
|
||
});
|
||
await sleep(200);
|
||
const aqLim = await page.evaluate(() => ({
|
||
disabled: document.getElementById('btnKeep').disabled,
|
||
text: document.getElementById('btnKeep').textContent,
|
||
title: document.getElementById('btnKeep').title,
|
||
noRoom: t('aq_no_room'),
|
||
len: save.aqFish.length, slots: save.aqSlots
|
||
}));
|
||
ok('Этап 46 лимит: aqFish заполнен до aqSlots → #btnKeep disabled + подпись/title «Нет места»',
|
||
aqLim.disabled === true && aqLim.text === aqLim.noRoom && aqLim.title === aqLim.noRoom
|
||
&& aqLim.len === aqLim.slots, JSON.stringify(aqLim));
|
||
await page.click('#btnCardOk');
|
||
await sleep(150);
|
||
|
||
// (6) сцена: вход из хаба «Меню» (#btnGmAquarium) и возврат «← Назад» (#btnAqBack)
|
||
await toGameMenu(page);
|
||
await page.click('#btnGmAquarium');
|
||
await sleep(200);
|
||
const aqSc1 = await page.evaluate(() => ({
|
||
open: aqOpen,
|
||
bar: !document.getElementById('aqBar').classList.contains('hidden'),
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen,
|
||
pauseHidden: document.getElementById('btnPause').classList.contains('hidden')
|
||
}));
|
||
ok('Этап 46 сцена: #btnGmAquarium → aqOpen=true, #aqBar виден, #gamemenu и #btnPause скрыты',
|
||
aqSc1.open === true && aqSc1.bar === true && aqSc1.gm === true && aqSc1.ui === true
|
||
&& aqSc1.pauseHidden === true, JSON.stringify(aqSc1));
|
||
await page.click('#btnAqBack');
|
||
await sleep(150);
|
||
const aqSc2 = await page.evaluate(() => ({
|
||
open: aqOpen,
|
||
barHidden: document.getElementById('aqBar').classList.contains('hidden'),
|
||
gm: !document.getElementById('gamemenu').classList.contains('hidden'),
|
||
ui: uiOpen
|
||
}));
|
||
ok('Этап 46 сцена: #btnAqBack → aqOpen=false, возврат в #gamemenu (uiOpen=true)',
|
||
aqSc2.open === false && aqSc2.barHidden === true && aqSc2.gm === true && aqSc2.ui === true,
|
||
JSON.stringify(aqSc2));
|
||
|
||
// (6b) ревизия: жильцы банки — те же SVG-портреты, что на карточке улова (спрайт #fish-<id>),
|
||
// вариант — класс .var<id> (silver/rainbow — фильтры, copper/gold/quartz/emerald/diamond — drop-shadow),
|
||
// DOM-элемент на каждый save.aqFish, transform следует за aqFishRt (индекс в индекс), слой
|
||
// виден только под сценой (body.aq-open) и не перехватывает клики
|
||
await page.click('#btnGmAquarium');
|
||
await sleep(200);
|
||
const aqDom = await page.evaluate(() => {
|
||
save.aqFish = [{ id: 'carass', v: 'normal', w: 0.6 }, { id: 'pike', v: 'silver', w: 7.4 },
|
||
{ id: 'gold', v: 'rainbow', w: 0.8 }, { id: 'tench', v: 'emerald', w: 3 }];
|
||
save.aqSlots = AQ_BASE_SLOTS; persist();
|
||
initAquarium();
|
||
const layer = document.getElementById('aqFishLayer');
|
||
const els = [...layer.querySelectorAll('.aqfish')];
|
||
return {
|
||
n: els.length, len: save.aqFish.length,
|
||
hrefs: els.map(e => e.querySelector('use').getAttribute('href')),
|
||
ids: save.aqFish.map(f => '#fish-' + f.id),
|
||
variants: els.map(e => AQ_VARIANT_CLASS.filter(id => e.classList.contains('var' + id)).join(',')),
|
||
f1: els.map(e => e.style.getPropertyValue('--f1')),
|
||
rt: aqFishRt.map(r => [r.x, r.y]),
|
||
tr: els.map(e => e.style.transform),
|
||
click: getComputedStyle(layer).pointerEvents,
|
||
cardHref: document.getElementById('cardFishUse').getAttribute('href')
|
||
};
|
||
});
|
||
const aqDomTrack = await page.evaluate(() => {
|
||
const els = [...document.querySelectorAll('#aqFishLayer .aqfish')];
|
||
return els.every((e, i) => {
|
||
const m = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(e.style.transform);
|
||
return m && aqFishRt[i] && Math.abs(+m[1] - aqFishRt[i].x) < 0.1 && Math.abs(+m[2] - aqFishRt[i].y) < 0.1;
|
||
});
|
||
});
|
||
ok('Этап 46 портреты: на каждый aqFish — .aqfish с <use href="#fish-<id>"> (те же спрайты, что на карточке)',
|
||
aqDom.n === aqDom.len && aqDom.len === 4
|
||
&& JSON.stringify(aqDom.hrefs) === JSON.stringify(aqDom.ids)
|
||
&& aqDom.cardHref.indexOf('#fish-') === 0,
|
||
JSON.stringify(aqDom));
|
||
ok('Этап 46 портреты: вариант как на карточке — .var<id> по варианту (silver/rainbow/emerald) + --f1 у перекраски; normal — без класса',
|
||
aqDom.variants[0] === '' && aqDom.variants[1] === 'silver'
|
||
&& aqDom.variants[2] === 'rainbow' && aqDom.variants[3] === 'emerald'
|
||
&& aqDom.f1[0] === '' && aqDom.f1[3] !== '' && aqDom.click === 'none',
|
||
JSON.stringify(aqDom));
|
||
ok('Этап 46 портреты: transform каждого .aqfish следует за aqFishRt (индекс в индекс), слой виден в сцене',
|
||
aqDomTrack === true && aqDom.tr.every(t => t.indexOf('translate(') === 0),
|
||
JSON.stringify({ track: aqDomTrack, tr: aqDom.tr }));
|
||
await page.click('#btnAqBack');
|
||
await sleep(150);
|
||
const aqDomClosed = await page.evaluate(() => ({
|
||
disp: getComputedStyle(document.getElementById('aqFishLayer')).display,
|
||
cls: document.body.classList.contains('aq-open')
|
||
}));
|
||
ok('Этап 46 портреты: вне сцены слой скрыт (display:none, body без aq-open)',
|
||
aqDomClosed.disp === 'none' && aqDomClosed.cls === false, JSON.stringify(aqDomClosed));
|
||
|
||
// (7) слот: при достатке монет #btnAqSlot списывает AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS] и даёт +1 слот
|
||
await page.click('#btnGmAquarium');
|
||
await sleep(200);
|
||
const aqSlot0 = await page.evaluate(() => {
|
||
save.aqSlots = AQ_BASE_SLOTS; save.aqFish = [];
|
||
save.coins = AQ_SLOT_COSTS[0] + 1000;
|
||
persist(); renderAqBar();
|
||
return { coins: save.coins, slots: save.aqSlots, cost: AQ_SLOT_COSTS[0] };
|
||
});
|
||
await page.click('#btnAqSlot');
|
||
await sleep(200);
|
||
const aqSlot1 = await page.evaluate(() => ({
|
||
coins: save.coins, slots: save.aqSlots,
|
||
count: document.getElementById('aqCount').textContent
|
||
}));
|
||
ok('Этап 46 слот: #btnAqSlot → монеты списаны на AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS], aqSlots+1, счётчик N/M обновлён',
|
||
aqSlot1.coins === aqSlot0.coins - aqSlot0.cost && aqSlot1.slots === aqSlot0.slots + 1
|
||
&& aqSlot1.count === '0/' + aqSlot1.slots, JSON.stringify({ before: aqSlot0, after: aqSlot1 }));
|
||
|
||
// (8) выпуск из банки: тап по рыбе выделяет экземпляр, #btnAqRelease (aqReleaseSel)
|
||
// удаляет его БЕЗ монет (чистая косметика — освобождение слота) и без телепорта остальных
|
||
const aqRel0 = await page.evaluate(() => {
|
||
save.aqFish = [{ id: 'carass', v: 'gold', w: 2 }, { id: 'perch', v: 'normal', w: 1.2 }];
|
||
save.aqSlots = AQ_BASE_SLOTS; save.coins = 500; save.earned = 1234;
|
||
persist();
|
||
initAquarium(); // пересобрать aqFishRt под 2 экземпляра
|
||
const rt = aqFishRt[0];
|
||
aqTap(rt.x, rt.y); // тап по рыбе → выбор + поповер
|
||
return { coins: save.coins, earned: save.earned, len: save.aqFish.length, sel: aqSel,
|
||
pop: !document.getElementById('aqPop').classList.contains('hidden'),
|
||
popName: document.getElementById('aqPopName').textContent,
|
||
expName: trName(SPECIES.find(s => s.id === 'carass')),
|
||
sub: document.getElementById('aqPopSub').textContent,
|
||
btnText: document.getElementById('btnAqRelease').textContent,
|
||
expBtn: t('aq_release') };
|
||
});
|
||
ok('Этап 46 выпуск: aqTap по рыбе → aqSel=0 и поповер #aqPop (имя; подпись без цены), кнопка #btnAqRelease = t(aq_release)',
|
||
aqRel0.sel === 0 && aqRel0.pop === true && aqRel0.len === 2
|
||
&& aqRel0.popName.indexOf(aqRel0.expName) >= 0 && aqRel0.btnText === aqRel0.expBtn
|
||
&& aqRel0.sub.indexOf('🪙') < 0,
|
||
JSON.stringify(aqRel0));
|
||
await page.click('#btnAqRelease');
|
||
await sleep(200);
|
||
const aqRel1 = await page.evaluate(() => ({
|
||
coins: save.coins, earned: save.earned, len: save.aqFish.length, sel: aqSel,
|
||
popHidden: document.getElementById('aqPop').classList.contains('hidden')
|
||
}));
|
||
ok('Этап 46 выпуск: #btnAqRelease (aqReleaseSel) → экземпляр удалён, монеты/earned НЕ изменились, выбор снят, поповер скрыт',
|
||
aqRel1.len === aqRel0.len - 1 && aqRel1.coins === aqRel0.coins && aqRel1.earned === aqRel0.earned
|
||
&& aqRel1.sel === -1 && aqRel1.popHidden === true,
|
||
JSON.stringify({ before: aqRel0, after: aqRel1 }));
|
||
// (8b) телепорт: замер позиций и выпуск в ОДНОМ evaluate (иначе кадры updateAquarium продвигают рыб)
|
||
const aqTp = await page.evaluate(() => {
|
||
save.aqFish = [{ id: 'carass', v: 'normal', w: 2 }, { id: 'perch', v: 'normal', w: 1.2 }];
|
||
save.aqSlots = AQ_BASE_SLOTS; persist();
|
||
initAquarium();
|
||
aqSel = 0;
|
||
const before = aqFishRt.slice(1).map(r => ({ x: r.x, y: r.y }));
|
||
const rtLenBefore = aqFishRt.length;
|
||
aqReleaseSel(); // синхронно: между замерами не проходит ни одного кадра
|
||
return { before, after: aqFishRt.map(r => ({ x: r.x, y: r.y })),
|
||
rtLenBefore, rtLenAfter: aqFishRt.length };
|
||
});
|
||
ok('Этап 46 выпуск: без телепорта — позиции остальных рыб (x,y) сохранились, aqFishRt.length−1',
|
||
aqTp.rtLenAfter === aqTp.rtLenBefore - 1
|
||
&& aqTp.after.length === aqTp.before.length
|
||
&& aqTp.after.every((p, i) => p.x === aqTp.before[i].x && p.y === aqTp.before[i].y),
|
||
JSON.stringify(aqTp));
|
||
|
||
// cleanup: «← Назад» → «Продолжить» (в игру), сброс сцены/полей/форсов
|
||
await page.click('#btnAqBack');
|
||
await sleep(150);
|
||
await page.click('#btnGmResume');
|
||
await sleep(150);
|
||
await page.evaluate(() => {
|
||
save.aqFish = []; save.aqSlots = AQ_BASE_SLOTS; persist();
|
||
forceBite = false; forceVariant = null; forceBoss = false; aqSel = -1;
|
||
});
|
||
const aqEnd = await page.evaluate(() => ({
|
||
open: aqOpen, st: state, ui: uiOpen,
|
||
gm: document.getElementById('gamemenu').classList.contains('hidden'),
|
||
bar: document.getElementById('aqBar').classList.contains('hidden')
|
||
}));
|
||
ok('Этап 46 cleanup: сцена закрыта (#aqBar/#gamemenu скрыты), state=IDLE, uiOpen=false, форсы сброшены',
|
||
aqEnd.open === false && aqEnd.st === 'IDLE' && aqEnd.ui === false
|
||
&& aqEnd.gm === true && aqEnd.bar === true, JSON.stringify(aqEnd));
|
||
|
||
// --- 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); });
|