diff --git a/AGENTS.md b/AGENTS.md index cf177b1..a89d3f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ npm install # puppeteer-core npm test # = node test.js ``` - Ждать все PASS и exit 0. Сейчас проверок 100. + Ждать все PASS и exit 0. Сейчас проверок 107. 3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI): ```bash node tests/orca-playtest.js # из каталога репо; требует ЗАПУЩЕННЫЙ Orca app и orca-ide (или ORCA_CLI_COMMAND) @@ -22,7 +22,7 @@ ## Подводные камни - `tests/test.js` берёт браузер из `process.env.CHROMIUM || '/usr/bin/chromium-browser'`, а URL игры резолвит динамически от каталога репо (тот же паттерн, что в `shots.js`). Если тест падает на старте — поправьте имя/путь chromium под свой чекаут (переменная `CHROMIUM`). -- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `VARIANT_CHAIN`, `rollVariant()`, `variantChance()`, `cardInfo`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()` и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`. Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите. +- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `VARIANT_CHAIN`, `rollVariant()`, `variantChance()`, `cardInfo`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()`, `BOAT_SKINS`, `BOBBER_SKINS` и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`. Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите. - Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики. - Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми). @@ -33,7 +33,8 @@ - Босс (M4): тест-форс появления `forceBoss`, фазы боя `stateBoss`/`bossPhase`, босс-ветки в `updateReel()` (обрыв фазы) и `SNAP` в `updateGame()` (авто-переход), награда ×5 в `catchFish()`. - Варианты рыб (M6): таблица `VARIANTS` (порядок ролла: gold → silver → rainbow, иначе normal), `variantChance()` — единственная точка вероятностей (дождь M8 уже здесь — ×2,5, см. строку «Погода (M8)»; gold — живая `SHINY_CHANCE`), тест-форс `forceVariant`, босс всегда normal, улучшение на карточке (кнопка `#btnCardUp`, цепочка `VARIANT_CHAIN`, cost = 0.6×Δ ценности, формула — в `cardUpNext()`). - Погода (M8): таблица `WEATHER` (rain — `variantMult` ×2,5; fog — `biteMult` ×1,5), таймеры в реальном времени — `updateWeather()` в `frame()` (gap `WEATHER_GAP_MIN..MAX` 120–240 с, длительность `WEATHER_DURATION` 120 с, под форсом не гоняются), `weatherActive()` — единственная точка чтения (форс приоритетнее), эффекты только в `variantChance()` (rain) и `biteChance()` (fog), тест-форс `forceWeather` ('rain'/'fog'/null, паттерн `forceBoss`); погода не персистится в сейв. Заметка: туман может дать p>1 → насыщение 100% клёва (осознанно). -- Сложность вываживания: `updateReel()`. + - Скины (M-skins): таблицы `BOAT_SKINS`/`BOBBER_SKINS` (лодка: Обычная 0 / «Красная лодка» 800 / «Лодка-домик» 2000 🪙; поплавок: Обычный 0 / Красный 500 / Неоновый 1500 🪙), сейв `save.boatSkin(s)`/`save.bobberSkin(s)` + миграция в `loadSave()` (включая гарантию бесплатного скина 0), вкладка «Скины» 🎨 (`#tabSkins`, строка `skinRow(list,i,owned,equipped,group)`, ветки buyskin/select в `shopClick()`: покупка со списанием и авто-экипировкой, выбор — только купленное), отрисовка `drawBoat()`/`drawBobber()` с clamp 0..2 (скин 2: каюта у лодки, неоновое свечение `shadowBlur` в save/restore у поплавка); чистая косметика — без эффектов на геймплей. + - Сложность вываживания: `updateReel()`. - Физика боя (спринт 4): `fishForce()` (сила рыбы: рывок `(16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov)`, затишье `5+7·ag`, уставшая ×0.45) и `updateReel()` — выносливость `st` (старт `st0`, флаг `tired` при `st≤0.12`: рывки ×0.45, втягивание быстрее до ×1.6), тормоз катушки (черта `drag` в `RODS`: в рывке выше тормоза леска скользит `over·0.22`), натяжение (`10+over·1.1+22·ag`, ×0.35 в затишье), втягивание `(11+3·reel)·(1+0.6·(1−st))`, свободный бег `F·0.32`, escape-бар растёт только при затишье без удержания (отпускание в рывок безопасно), near-miss `1.45·d0`, `d0=45+7·w`. Пустые клёвы: ролл `biteChance()` в ветке WAIT (тест-форс `forceBite`). Тесты (§10) завязаны на эти константы — при ребалансе гонять Monte-Carlo точными формулами (docs/PHYSICS-RESEARCH.md, §4.7) и править тесты в том же коммите. - Награды (M2): шаблоны квестов `QUEST_TPLS`, награды стрика `STREAK_REWARDS`, достижения `ACHIEVEMENTS` — секция «M2. Награды» после сейва. diff --git a/README.md b/README.md index 79a59f2..e53cfcf 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - Босс-рыба — Осётр-босс (шанс при поимке осётра): 3 фазы «рвёт и уходит», награда ×5, достижение «Босс-рыбак» - Варианты рыб: серебряная ×2, золотая ×3, радужная ×5 — бейдж и оформление карточки улова; на карточке — улучшение варианта (⬆) — ценность улова растёт - Погода: дождь (🌧️) — шанс серебряной/золотой/радужной ×2,5; туман (🌫️) — клёв ×1,5; событие идёт 120 с, следующее — раз в 2–4 минуты — ![дождь](screenshots/shot-weather-rain.png) +- Скины лодки и поплавка 🎨 — вкладка «Скины» в лавке: лодка «Красная лодка» 800 🪙 / «Лодка-домик» 2000 🪙, поплавок «Красный» 500 🪙 / «Неоновый» 1500 🪙 — чистая косметика (без эффектов на геймплей) - Квесты дня (3 шт, генерация по дню), стрик входов со «щитом», 18 достижений с бонусами - Дневник рыбака — коллекция, максимальный улов по каждому виду, статистика - Магазин с вкладками: Снасти 🎣, Дневник 📔, Награды 🎯, Достижения 🏅 @@ -60,7 +61,7 @@ python3 -m http.server 8000 ```bash cd tests npm install # ставит puppeteer-core -node test.js # 100 проверок +node test.js # 107 проверок ``` Нужен установленный Chromium (`executablePath` в `test.js` при необходимости поправить). diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 86811c5..319230f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -379,3 +379,20 @@ git add -A && git commit -m "что изменилось" && git push **Заметки (осознанные решения и ловушки):** - Пре-эксзистинг-флейка §9h (~4,5%/прогон): вид рыбы не был закреплён — ролл вида с rarity≥2 выдавал ачивку `rare_fish` (coinPct 0,01→0,02 и +монеты) и ломал точную проверку `achBonus()`/монет. Захарденено пином `pickSpecies = () => SPECIES[0]` (карась, rarity 0) по паттерну §10c(4). - Туман: итоговая вероятность клёва может превысить 1 (p = p_базы×1,5) → насыщение 100% клёва. Осознанно — туман «клевое» событие; `Math.min(1,p)` не добавляли. + +## Этап 19. M-skins: Скины лодки и поплавка + +**Что сделано в `index.html`:** + +1. **Таблицы `BOAT_SKINS`/`BOBBER_SKINS`** — лодка: «Обычная» (0), «Красная лодка» (800 🪙), «Лодка-домик» (2000 🪙); поплавок: «Обычный» (0), «Красный» (500 🪙), «Неоновый» (1500 🪙). Чистая косметика — coin-sink, без эффектов на геймплей (монеты, физика, погода не затронуты). +2. **Сейв и миграция** — `save.boatSkins:[0]`/`save.boatSkin:0`, `save.bobberSkins:[0]`/`save.bobberSkin:0` (купленные id + экипированный). Миграция в `loadSave()`: нет массивов/полей → дефолты `[0]`/`0`; битые значения (скин вне 0..2, экипированный не в купленных) → 0; бесплатный скин 0 гарантирован — при битом сейве (напр. `boatSkins:[1]`) без `0` в купленных дописывается `push(0)`, иначе лавка показывала бы «Купить бесплатно» скин 0, хотя лодка уже рендерится как скин 0. +3. **Вкладка «Скины» 🎨** (`#tabSkins`, `setTab('skins')`) — группы «Лодка»/«Поплавок», строка `skinRow(list,i,owned,equipped,group)` (group передаётся явно: 'boat'/'bobber' — без identity-чека таблиц). `shopClick()`: ветка `buyskin` (защиты: уже куплено / не хватает монет → тост; списание, push, авто-экипировка, persist) и `select` (защита: не куплено — выбрать нельзя; монеты не тратятся). +4. **Отрисовка** — `drawBoat()`: корпус по `save.boatSkin` с clamp 0..2 (скин 1 — красный градиент, скин 2 — синий + каюта с ярко-жёлтым окном); `drawBobber()`: по `save.bobberSkin` (скин 2 — неон `#aaff00` + `shadowBlur=10` в `ctx.save()/restore()`, чтобы свечение не перетекало на другие элементы). + +**Тесты:** +7 проверок в `tests/test.js` (§10e: данные таблиц (id/cost); миграция старого сейва → массивы `[0]`, экипированные 0/0; битый сейв (скин вне 0..2, экипированный не в купленных) → 0; покупка «Красная лодка» за 1000 монет (coins=200, `boatSkins=[0,1]`, авто-экип, «Выбрано ✓» disabled); не хватает монет (тост, без изменений); выбор после покупки (монеты не тратятся); вкладка/группы/6 названий + смоук отрисовки скинов 2 (~1 с, без pageerror)). Итог **107/107 PASS, exit 0** (`node tools/check.mjs` — OK). + +**Monte-Carlo:** не требовался — физика вываживания не тронута; скины влияют только на отрисовку. + +**Заметки (осознанные решения и ловушки):** +- Brute-force проверка «coin-tolerance» ассертов §10e (ищутся ли посторонние источники монет, ломающие точные проверки) — пинить нечего: монеты каждого шага задаются явно. +- Известный suite-level риск: проход границы даты 00:00 за время прогона (~0.2%) может сдвинуть точные ассерты; guard в `tests/test.js` — в бэклоге (отдельная задача). diff --git a/index.html b/index.html index 4797ffa..49cb35b 100644 --- a/index.html +++ b/index.html @@ -234,6 +234,7 @@ button{font-family:inherit;cursor:pointer} +
@@ -353,17 +354,31 @@ const LURE_UPG_COST=[300,900,2200]; const ROD_UPG_NEXT=['фонарик (ночь), леска ×1.15','тормоз ×1.15, катушка ×1.10','леска ещё ×1.15, крупные +0.10']; const LURE_UPG_NEXT=['клёв ×1.15','редкие +1.0','крупные +0.15']; +/* Этап 4: скины лодки и поплавка — чистая косметика (coin-sink), в лавке вкладка «Скины» */ +const BOAT_SKINS=[ + {id:0, name:'Обычная', cost:0}, + {id:1, name:'Красная лодка', cost:800}, + {id:2, name:'Лодка-домик', cost:2000} +]; +const BOBBER_SKINS=[ + {id:0, name:'Обычный', cost:0}, + {id:1, name:'Красный', cost:500}, + {id:2, name:'Неоновый', cost:1500} +]; + /* ========================================================= СОХРАНЕНИЕ ========================================================= */ const SAVE_KEY='fishing-save-v1'; let save={coins:0,rod:0,lure:0,rods:[0],lures:[0],rodLv:{0:1},lureLv:{0:1},caught:{},muted:false,casts:0,earned:0, snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0, - bossBeaten:false,bossCount:0}; + bossBeaten:false,bossCount:0, + boatSkins:[0],boatSkin:0,bobberSkins:[0],bobberSkin:0}; // Этап 4: скины лодки/поплавка function freshSave(muted){ return {coins:0,rod:0,lure:0,rods:[0],lures:[0],rodLv:{0:1},lureLv:{0:1},caught:{},muted:muted,casts:0,earned:0, snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0, - bossBeaten:false,bossCount:0}; + bossBeaten:false,bossCount:0, + boatSkins:[0],boatSkin:0,bobberSkins:[0],bobberSkin:0}; // Этап 4: скины лодки/поплавка } function loadSave(){ try{ const s=localStorage.getItem(SAVE_KEY); if(s) Object.assign(save,JSON.parse(s)); }catch(e){} @@ -411,6 +426,15 @@ function loadSave(){ // M4: босс (Осётр-босс) — новые поля, дефолты для старых сейвов if(typeof save.bossBeaten!=='boolean') save.bossBeaten=false; if(typeof save.bossCount!=='number') save.bossCount=0; + // Этап 4: скины лодки/поплавка — дефолты для старых сейвов и чинка битых значений + if(!save.boatSkins||!Array.isArray(save.boatSkins)) save.boatSkins=[0]; + if(save.boatSkins.indexOf(0)<0) save.boatSkins.push(0); // бесплатный скин 0 гарантирован (битый сейв) + if(!save.bobberSkins||!Array.isArray(save.bobberSkins)) save.bobberSkins=[0]; + if(save.bobberSkins.indexOf(0)<0) save.bobberSkins.push(0); + if(typeof save.boatSkin!=='number'||save.boatSkin<0||save.boatSkin>2) save.boatSkin=0; + if(typeof save.bobberSkin!=='number'||save.bobberSkin<0||save.bobberSkin>2) save.bobberSkin=0; + if(save.boatSkins.indexOf(save.boatSkin)<0) save.boatSkin=0; // экипированного нет в купленных (битый сейв) + if(save.bobberSkins.indexOf(save.bobberSkin)<0) save.bobberSkin=0; // M2: стрик входа и квесты дня (новый день — перегенерация) applyStreak(); refreshQuests(); @@ -1395,14 +1419,23 @@ function drawBoat(){ // блик-отражение под лодкой (визуал) ctx.fillStyle='rgba(0,20,40,0.28)'; ctx.beginPath(); ctx.ellipse(bx,by+17,34,4.5,0,0,7); ctx.fill(); - // лодка + // лодка (Этап 4: скины — градиент корпуса и кромка по save.boatSkin) + const skin=Math.max(0,Math.min(2,save.boatSkin|0)); + const bcols=[['#c9773c','#96521f','#8a4519'],['#c0392b','#7b241c','#6e221a'],['#5d6d7e','#34495e','#2c3a47']][skin]; const bg=ctx.createLinearGradient(bx-32,by,bx+32,by+15); - bg.addColorStop(0,'#c9773c'); bg.addColorStop(1,'#96521f'); + bg.addColorStop(0,bcols[0]); bg.addColorStop(1,bcols[1]); ctx.fillStyle=bg; ctx.beginPath(); ctx.moveTo(bx-32,by); ctx.lineTo(bx+32,by); ctx.lineTo(bx+21,by+15); ctx.lineTo(bx-21,by+15); ctx.closePath(); ctx.fill(); - ctx.fillStyle='#8a4519'; ctx.fillRect(bx-32,by,64,4); + ctx.fillStyle=bcols[2]; ctx.fillRect(bx-32,by,64,4); + // «Лодка-домик»: каюта на корме корпуса (над линией борта) с ярко-жёлтым окном (визуал) + if(skin===2){ + ctx.fillStyle='#2c3a47'; + ctx.fillRect(bx-31,by-11,16,11); + ctx.fillStyle='rgba(255,220,100,0.9)'; + ctx.fillRect(bx-28,by-9,9,5); + } // рыбак ctx.fillStyle='#34495e'; ctx.fillRect(bx-9,by-18,12,12); @@ -1446,15 +1479,21 @@ function drawBobber(){ // тёмная кромка воды под поплавком (визуал) ctx.fillStyle='rgba(0,20,40,0.25)'; ctx.beginPath(); ctx.ellipse(x,y+6,7,1.8,0,0,7); ctx.fill(); - ctx.fillStyle='#fff'; + // Этап 4: скины — поплавок по save.bobberSkin (0 обычный, 1 красный, 2 неоновый) + const skin=Math.max(0,Math.min(2,save.bobberSkin|0)); + const bot=['#fff','#e74c3c','#aaff00'][skin]; + const top=['#e74c3c','#e74c3c','#aaff00'][skin]; + if(skin===2){ ctx.save(); ctx.shadowColor='rgba(170,255,0,0.8)'; ctx.shadowBlur=10; } // неоновое свечение — обязательно save/restore, чтобы не перетечь на другие элементы + ctx.fillStyle=bot; ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.fill(); - ctx.fillStyle='#e74c3c'; + ctx.fillStyle=top; ctx.beginPath(); ctx.arc(x,y,5.5,Math.PI,0); ctx.fill(); // блик (визуал) ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.arc(x-1.8,y-1.8,1.4,0,7); ctx.fill(); - ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5; + ctx.strokeStyle=skin===2?'#77cc00':'rgba(255,255,255,0.9)'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke(); + if(skin===2) ctx.restore(); } function drawLineAndBobber(){ // след поплавка: копим в забросе, чистим в остальных состояниях (визуал) @@ -1753,6 +1792,7 @@ function setTab(t){ shopTab=t; renderShop(); $('tabJournal').classList.toggle('active',t==='journal'); $('tabRewards').classList.toggle('active',t==='rewards'); $('tabAch').classList.toggle('active',t==='ach'); + $('tabSkins').classList.toggle('active',t==='skins'); } function lvPips(lv){ let s=''; for(let k=1;k<=4;k++) s+=k<=lv?'●':'○'; return s; } function itemRow(item,i,type,owned,cur){ @@ -1780,6 +1820,23 @@ function itemRow(item,i,type,owned,cur){ '
'+desc+'
'+ (btns?'
'+btns+'
':'
')+''; } +/* Этап 4: строка скина (стиль как у снастей, но проще): название, цена, одна кнопка + («Купить N 🪙» / «Выбрать» / «Выбрано ✓») */ +function skinRow(list,i,owned,equipped,group){ + const s=list[i]; + const price=s.cost>0?s.cost+' 🪙':'бесплатно'; + let btn; + if(equipped){ + btn=''; + }else if(owned){ + btn=''; + }else{ + btn=''; + } + return '
'+s.name+'
'+ + '
Цена: '+price+'
'+ + '
'+btn+'
'; +} function renderShop(){ const body=$('shopBody'); if(shopTab==='tackle'){ @@ -1841,12 +1898,38 @@ function renderShop(){ (got?'✓':'')+''; } body.innerHTML=h; + }else if(shopTab==='skins'){ + // Этап 4: скины — лодка и поплавок (чистая косметика, coin-sink) + let h='
Лодка
'; + BOAT_SKINS.forEach((s,i)=>{ h+=skinRow(BOAT_SKINS,i,save.boatSkins.indexOf(i)>=0,save.boatSkin===i,'boat'); }); + h+='
Поплавок
'; + BOBBER_SKINS.forEach((s,i)=>{ h+=skinRow(BOBBER_SKINS,i,save.bobberSkins.indexOf(i)>=0,save.bobberSkin===i,'bobber'); }); + body.innerHTML=h; } } function shopClick(e){ const b=e.target.closest('[data-act]'); if(!b) return; const type=b.dataset.type, i=+b.dataset.i; + if(type==='boat'||type==='bobber'){ + // Этап 4: скины — покупка (монеты списать, авто-экипировка) и выбор + const slist=type==='boat'?BOAT_SKINS:BOBBER_SKINS; + const owned=type==='boat'?save.boatSkins:save.bobberSkins; + const item=slist[i]; + if(b.dataset.act==='buyskin'){ + if(!item||owned.indexOf(i)>=0){ sfx('no'); return; } // уже куплено — защита + if(save.coins{ setTab('tackle'); sfx('click'); }) $('tabJournal').addEventListener('click',()=>{ setTab('journal'); sfx('click'); }); $('tabRewards').addEventListener('click',()=>{ setTab('rewards'); sfx('click'); }); $('tabAch').addEventListener('click',()=>{ setTab('ach'); sfx('click'); }); +$('tabSkins').addEventListener('click',()=>{ setTab('skins'); sfx('click'); }); // Этап 4: вкладка скинов $('shopBody').addEventListener('click',shopClick); $('btnMute').addEventListener('click',()=>{ save.muted=!save.muted; persist(); updateHUD(); sfx('click'); diff --git a/tests/test.js b/tests/test.js index c38c924..4cee188 100644 --- a/tests/test.js +++ b/tests/test.js @@ -1555,6 +1555,136 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); // сброс тест-форсов и состояния погоды 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(); + }); + // --- 11. Ошибки консоли --- ok('нет ошибок JS (pageerror)', pageErrors.length === 0, pageErrors.join(' | ')); ok('нет ошибок консоли', consoleErrors.length === 0, consoleErrors.join(' | '));