M-skins: Этап 4 — скины лодки (Красная 800 / Лодка-домик 2000) и поплавка (Красный 500 / Неоновый 1500), вкладка «Скины» 🎨, чистая косметика; по ревью: доки/миграция бесплатного скина/NIT. 107/107, exit 0

This commit is contained in:
2026-09-06 17:47:01 +03:00
parent 6b723545c1
commit 89c6680ae5
5 changed files with 245 additions and 12 deletions
+4 -3
View File
@@ -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. Награды» после сейва.
+2 -1
View File
@@ -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` при необходимости поправить).
+17
View File
@@ -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` — в бэклоге (отдельная задача).
+92 -8
View File
@@ -234,6 +234,7 @@ button{font-family:inherit;cursor:pointer}
<button id="tabJournal">Дневник</button>
<button id="tabRewards" title="Награды">🎯</button>
<button id="tabAch" title="Достижения">🏅</button>
<button id="tabSkins" title="Скины">🎨</button>
</div>
<div id="shopBody"></div>
</div>
@@ -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){
'<div class="itemdesc">'+desc+'</div></div>'+
(btns?'<div class="rowbtns">'+btns+'</div>':'<div class="rowbtns"><button class="buybtn" disabled>🔒</button></div>')+'</div>';
}
/* Этап 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='<button class="buybtn current" disabled>Выбрано ✓</button>';
}else if(owned){
btn='<button class="buybtn owned" data-act="select" data-type="'+group+'" data-i="'+i+'">Выбрать</button>';
}else{
btn='<button class="buybtn" data-act="buyskin" data-type="'+group+'" data-i="'+i+'">Купить '+price+'</button>';
}
return '<div class="item"><div><div class="itemname">'+s.name+'</div>'+
'<div class="itemdesc">Цена: '+price+'</div></div>'+
'<div class="rowbtns">'+btn+'</div></div>';
}
function renderShop(){
const body=$('shopBody');
if(shopTab==='tackle'){
@@ -1841,12 +1898,38 @@ function renderShop(){
(got?'<span class="jc">✓</span>':'')+'</div>';
}
body.innerHTML=h;
}else if(shopTab==='skins'){
// Этап 4: скины — лодка и поплавок (чистая косметика, coin-sink)
let h='<div class="section-title">Лодка</div>';
BOAT_SKINS.forEach((s,i)=>{ h+=skinRow(BOAT_SKINS,i,save.boatSkins.indexOf(i)>=0,save.boatSkin===i,'boat'); });
h+='<div class="section-title">Поплавок</div>';
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<item.cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=item.cost; owned.push(i);
if(type==='boat') save.boatSkin=i; else save.bobberSkin=i; // авто-экипировка
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Куплено: '+item.name+'!');
}else{
if(!item||owned.indexOf(i)<0){ sfx('no'); return; } // не куплено — выбрать нельзя (паттерн защиты buy)
if(type==='boat') save.boatSkin=i; else save.bobberSkin=i;
persist(); updateHUD(); sfx('click'); renderShop();
}
return;
}
const list=type==='rod'?RODS:LURES;
const owned=type==='rod'?save.rods:save.lures;
if(!save.rodLv||typeof save.rodLv!=='object') save.rodLv={};
@@ -1938,6 +2021,7 @@ $('tabTackle').addEventListener('click',()=>{ 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');
+130
View File
@@ -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(' | '));