M-skins: Этап 4 — скины лодки (Красная 800 / Лодка-домик 2000) и поплавка (Красный 500 / Неоновый 1500), вкладка «Скины» 🎨, чистая косметика; по ревью: доки/миграция бесплатного скина/NIT. 107/107, exit 0
This commit is contained in:
@@ -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. Награды» после сейва.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
- Босс-рыба — Осётр-босс (шанс при поимке осётра): 3 фазы «рвёт и уходит», награда ×5, достижение «Босс-рыбак»
|
||||
- Варианты рыб: серебряная ×2, золотая ×3, радужная ×5 — бейдж и оформление карточки улова; на карточке — улучшение варианта (⬆) — ценность улова растёт
|
||||
- Погода: дождь (🌧️) — шанс серебряной/золотой/радужной ×2,5; туман (🌫️) — клёв ×1,5; событие идёт 120 с, следующее — раз в 2–4 минуты — 
|
||||
- Скины лодки и поплавка 🎨 — вкладка «Скины» в лавке: лодка «Красная лодка» 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` при необходимости поправить).
|
||||
|
||||
@@ -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
@@ -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
@@ -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(' | '));
|
||||
|
||||
Reference in New Issue
Block a user