M5: Этап 1 — апгрейды снастей (lv 1..4, save.rodLv/lureLv + миграция), фонарик → первый апгрейд удилища (LURES id=4 удалён), гейтинг лавки lv(i-1)===4; по ревью: доки/скриншот/индентация/NIT. 82/82, exit 0

This commit is contained in:
2026-09-06 12:04:21 +03:00
parent 73b4ab3bd2
commit 622d203e52
9 changed files with 398 additions and 43 deletions
+3 -3
View File
@@ -11,7 +11,7 @@
npm install # puppeteer-core
npm test # = node test.js
```
Ждать все PASS и exit 0. Сейчас проверок 68.
Ждать все PASS и exit 0. Сейчас проверок 82.
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI):
```bash
node tests/orca-playtest.js # из каталога репо; требует ЗАПУЩЕННЫЙ Orca app и orca-ide (или ORCA_CLI_COMMAND)
@@ -28,8 +28,8 @@
## Где крутить
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта.
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — `LURES id=4` (секция «M3: Цикл дня/ночи»).
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта. Апгрейды снастей (уровни 1..4): `ROD_UPG_COST`/`LURE_UPG_COST`/`ROD_UPG_NEXT`/`LURE_UPG_NEXT` (ДАННЫЕ), уровни `save.rodLv`/`save.lureLv`, эффективные статы — `effRod()`/`effLure()` (глобалы `rod()`/`lure()` отдают эффективные), гейтинг открытия — в `itemRow()`/`shopClick()`.
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — апгрейд 1 любого удилища (`save.rodLv[save.rod]>=2`, проверка `hasLight()` в ветке WAIT, секция «M3: Цикл дня/ночи»).
- Босс (M4): тест-форс появления `forceBoss`, фазы боя `stateBoss`/`bossPhase`, босс-ветки в `updateReel()` (обрыв фазы) и `SNAP` в `updateGame()` (авто-переход), награда ×5 в `catchFish()`.
- Сложность вываживания: `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) и править тесты в том же коммите.
+3 -2
View File
@@ -17,7 +17,7 @@
5. **У удочек есть предел веса** (3–40 кг): зацепишь более тяжёлую — шнур лопнет почти мгновенно. Чем лучше удочка и приманка, тем выше шанс зацепить крупную. Новый максимум по виду отмечен бейджем «🏆 РЕКОРД!» на карточке улова.
6. На монеты — новые удилища и приманки в магазине 🛒.
7. **Награды 🎯**: три квеста дня (уловы, монеты, забросы) с наградой за выполнение; стрик входов — ежедневная награда растёт до дня 7, «щит» прощает один пропуск в неделю; 17 достижений 🏅 с постоянными бонусами (+% к монетам, окно подсечки, нагрузка шнура).
8. **День/ночь**: полный цикл суток ~5 минут (утро → день → вечер → ночь). Ночью свой состав рыб — сом, щука и окунь активнее, но без 🪯 фонарика из лавки клёва не будет.
8. **День/ночь**: полный цикл суток ~5 минут (утро → день → вечер → ночь). Ночью свой состав рыб — сом, щука и окунь активнее, но без 🪯 фонарика клёва не будет — фонарик дают апгрейды удилищ (1-й апгрейд любого удилища).
9. **Осётр-босс**: при поимке осётра есть шанс встретить босса. Бой идёт в 3 фазы — босс «рвёт и уходит», и каждая следующая фаза глубже; удерживай, чтобы втащить. Награда ×5 к цене.
10. **Симулятор боёв (релиз 5)**: у рыбы есть выносливость — тонкая фиолетовая полоска под дистанцией: свежая рыба тянет долго и сильно, а уставшая (💤) вянет — втягивай её именно тогда. У катушек — тормоз (drag): рывок сильнее тормоза — леска скользит. Иногда поплавок не берёт вовсе — пустой клёв, подожди ещё немного.
@@ -27,7 +27,8 @@
- Удилища: предел веса, прочность шнура, скорость подсечки, бонус на крупную рыбу
- Приманки: частота клёва, дальность броска, шанс редких и крупных
- Физика боя: выносливость рыбы (уставшая 💤 вянет и втягивается быстрее), тормоз катушки (drag) — сильнейший рывок проскальзывает, пустые клёвы
- Цикл дня и ночи (~5 мин на сутки): ночью своя таблица рыб (сом/щука/окунь активнее), без 🪯 фонарика ночью клёва нет
- Цикл дня и ночи (~5 мин на сутки): ночью своя таблица рыб (сом/щука/окунь активнее), ночью нужен 🪯 фонарик — апгрейд 1 удилища
- Апгрейды снастей: удилища и приманки улучшаются до 4 уровня (свет фонарика, крепость лески, тормоз, катушка, клёв, редкие и крупные рыбы); следующая снасть в лавке открывается после 4 уровня предыдущей
- Босс-рыба — Осётр-босс (шанс при поимке осётра): 3 фазы «рвёт и уходит», награда ×5, достижение «Босс-рыбак»
- Квесты дня (3 шт, генерация по дню), стрик входов со «щитом», 17 достижений с бонусами
- Дневник рыбака — коллекция, максимальный улов по каждому виду, статистика
+1 -1
View File
@@ -46,7 +46,7 @@ node tests/orca-playtest.js # скриншоты orca-*.png кладутся
| 8 | `SHINY_CHANCE = 1` → полный цикл → скриншот → проверка DOM → `SHINY_CHANCE = 0.03` | бейдж «ШИНИ» показан, золотая карточка |
| 9 | цикл до `REEL` → `reel.d = reel.d0 * 1.4 + 2` → polling `WAIT` **с чтением тоста в том же eval** | тост содержит «Сбежала» |
| 10 | `setDayTime(0.85)` **с чтением `dayPhase` в том же eval** → скриншот | `dayPhase === "night"` |
| 11 | «🛒» → вкладка «Снасти» → поиск «Фонарик» в `#shopBody` | в снастях есть «Фонарик» (500 🪙, ночь) |
| 11 | «🛒» → вкладка «Снасти» → поиск «фонарик» в `#shopBody` | в снастях есть апгрейд-фонарик (⬆ 400 🪙, пометка про ночь) |
| 12 | `forceBoss = true` + `pickSpecies` → осётр → полный цикл: `REEL` (старт боя: `stateBoss`, `bossPhase=1`) → 3 фазы (в каждой `reel.d = 0.4` → `SNAP` **со сбросом `holding` в том же eval** → авто-`REEL` следующей фазы; фаза 3 → `CARD`) → скриншот → `forceBoss = false` в `finally` | старт босс-боя, карточка после 3 фаз (`stateBoss=false`), имя «Осётр-БОСС» с бейджем |
| 13 | `console --limit 100` | нет сообщений уровня `error` |
| 13b | чтение `window.__errs` | нет pageerror за прогон (JS-ошибки и unhandledrejection) |
+21
View File
@@ -319,3 +319,24 @@ git add -A && git commit -m "что изменилось" && git push
| 13 | Стартовый клёв p90 13.4 с и ночной простой ~25% суток без фонарика — на границе комфорта первых минут | T4: бот 100 забросов стартовой снастью | Не блокер — в следующий спринт (NIT) |
**Итог.** Вердикт T4: «Релиз готов» — все рубежи зелёные (68/68 ×3, 47/47 live, 11/11 скриншотов), крашей и pageerror нет, сейв/миграции не трогались (новых персистентных полей нет: `st`/`tired` — поля боя, `drag` — константа RODS, `forceBite` — тест-форс). Балансные находки (камни 10–13) — не блокеры, backlog следующего спринта.
## Этап 16. M5: Апгрейды снастей (уровни 1..4, фонарик, гейтинг лавки)
**Что сделано в `index.html`:**
1. **Уровни снастей 1..4** — новые поля сейва `save.rodLv`/`save.lureLv` (объект «уровень по id»; купленная снасть — уровень 1). Эффективные статы — `effRod()`/`effLure()` (база × множители уровней): удилище Lv2 — леска ×1.15, Lv3 — тормоз ×1.15 и катушка ×1.10, Lv4 — леска ещё ×1.15 и крупные +0.10; приманка Lv2 — клёв ×1.15, Lv3 — редкие +1.0, Lv4 — крупные +0.15. Стоимость переходов 1→2/2→3/3→4: `ROD_UPG_COST=[400,1200,3000]`, `LURE_UPG_COST=[300,900,2200]`; подписи «что даёт следующий уровень» — `ROD_UPG_NEXT`/`LURE_UPG_NEXT`. UI лавки: пипсы ●/○ и «N ур.» у купленной снасти, кнопка ⬆ со стоимостью (на 4 ур. исчезает), у закрытых строк — 🔒.
2. **Фонарик больше не приманка** — `LURES id=4` удалён из данных (`LURES.length===4`, поле `nightOnly` убрано): фонарик — апгрейд 1 любого удилища (2+ уровень): `hasLight()` — `rodLvOf(save.rod)>=2`, проверка в ветке WAIT (ночью без фонарика клёв не стартует, таймер тикает вхолостую, тост разовый — флаг `nightNoLightToast`, сброс новым забросом).
3. **Гейтинг открытия** — снасть №i открывается, когда предыдущая снасть того же типа на 4 ур. (`lv(i−1)===4`): в UI строка `locked` + 🔒 + «Откроется: улучши «…» до 4 ур.», в `shopClick()` buy-ветке — двойная защита (при закрытом гейтинге покупка не проходит). Покупка разблокированной снасти стартует с уровня 1.
4. **Миграция старых сейвов** (`loadSave()`): нет `rodLv`/`lureLv` → купленным присваивается уровень 1; приманка `id=4` вычёркивается из `save.lures`, `save.lure===4` → 0. Старые сейвы читаются (проверяется тестом §10b(1)).
**Тесты:** +14 проверок в `tests/test.js` (§10b: миграция сейва; данные — `LURES.length===4`, без `nightOnly`; точные эффективные статы удочки и приманки на всех 4 уровнях; `hasLight()`; гейтинг лавки (locked/🔒/нет buy → снятие после 4 ур.); двойная защита buy; покупка апгрейда (1000→600 🪙, фонарик включился); макс. уровень (кнопки upgrade нет); не хватает монет; ночь без/с фонариком; покупка разблокированной снасти — уровень 1). Итог **82/82 PASS, exit 0** (`node tools/check.mjs` — OK).
**БЛОК БАЛАНСА (M-1: «принято без ребаланса»).** Ревью подняло вопрос: не тривиализирует ли M5 босса на максимальной снасти (камень #10 этапа 15: «босс на Титане too_easy: 20/20 побед, ни одного опасного состояния»)? Monte-Carlo по точным формулам `updateReel()` (скрипт `/tmp/opencode/boss_mc.mjs`, N=400/сценарий, dt=1/60, боты §4.4 доки) для **босс / Титан lv4 + Блёстка lv4** (line 342.25, drag 100.05, reel 2.53, size 0.95):
| Бот | Исход |
|---|---|
| «умный» (держать в calm / отпускать в pull) | **43.5%** улов / SNAP 0 / сход 56.5% |
| «держать» (всегда держать) | **77.3%** улов / **SNAP 22.8%** |
| пассив | 100% сход |
Модель валидирована на эталоне этапа 15 (босс/Титан lv1): smart 55.8% vs доки 59.5%, hold 67.3/32.8 vs 62/38 — в пределах MC-шума (N=400, σ≈2.5–3 п.п.). Числа lv4-кейса: size-апгрейд (0.95) утяжеляет босса — E[w]≈25.7 кг (против ~20 кг на lv1), d0≈225 px — и именно это режет «умный» улов вдвое (59.5→43.5%), а «держать» остаётся доходным, но рвёт леску в ~23% боёв. **Вывод: 100%-стратегии нет, hold рвётся ~23% — тривиализации нет; M-1 принят без ребаланса.** M5 расширяет разрыв камня #10 (босс с прогрессией снастей стал *ещё* доступнее, но остался честным боем) — принято осознанно: снасти — награда за прогресс, а риск босса держит физика (stamina/drag/near-miss), не гейтинг.
+9 -4
View File
@@ -34,8 +34,9 @@
`price` (🪙/кг). `SPECIES_NIGHT` — клон с ночными весами.
- `RODS` (4 удочки): `maxW` (предел веса), `line` (прочность шнура 100/140/190/260),
`reel` (скорость втягивания 1.0–2.3), `size` (бонус на крупную).
- `LURES` (5 приманок): `cast` (дальность), `bite` (частота клёва), `rare`
(шанс редких/крупных), `nightOnly` (фонарик).
- `LURES` (4 приманки): `cast` (дальность), `bite` (частота клёва), `rare`
(шанс редких/крупных). Поле `nightOnly` удалено (M5): фонарик — не
приманка, а апгрейд 1 любого удилища (2+ уровень, `hasLight()`).
- `SHINY_CHANCE` (3%), `forceBoss` (тест-форс босса).
### 1.2 Бросок
@@ -50,8 +51,11 @@
### 1.3 Клёв и подсечка
- `WAIT`: `biteTimer` тикает; при `≤ 0` — всегда `startBite()` (кроме ночи
без фонарика). **Пустых клёв нет: 100% забросов приводят к рыбе.**
- `WAIT`: `biteTimer` тикает; при `≤ 0` — ролл `forceBite || Math.random() <
biteChance()` → `startBite()`, иначе новый таймер `rand(2.5,5)/lure().bite`
(пустые клёвы ЕСТЬ: «молчание» ~10–35% забросов, ночью тише — `×0.85`).
Ночью без фонарика (M5: апгрейд 1 удилища) таймер тикает вхолостую —
клёв не стартует.
- `startBite()`: `pickSpecies(dist)` (вес вида × редкость-бонус приманки,
штраф `×0.06` за близкую дистанцию); осётр → босс с шансом 30% (`forceBoss`
форсирует). Рыба плывёт к поплавку за 0.45 с (`APPROACH`,
@@ -612,6 +616,7 @@ stamina-дуга не видна. Т2b подогнал числа (только
| сом/Карбон (крупная) | 66% / 0 / 34% — 20.4 с (уловы) | 79% / SNAP 21% | 100% сход, 5.7 с |
| сом/Стекло (крупная, слабая) | 39% / 0 / 62% | 39% / **SNAP 61%** | 100% сход |
| босс/Титан | **59.5%** / 0 / 40% — 42.8 с | 62% / **SNAP 38%** | 100% сход, 6.7 с |
| босс/Титан lv4 + Блёстка lv4 (M5 max) | **43.5%** / 0 / 56.5% — 55.2 с | 77.3% / **SNAP 22.8%** — 25.8 с | 100% сход |
| босс/Карбон | 0% / 0 / 100% (недогрейд) | 40% / SNAP 60% | 100% сход |
Смысл метрик: умный >80% мелочи ✓, крупная 50–80% ✓ (66% на Карбоне;
+94 -19
View File
@@ -91,6 +91,11 @@ button{font-family:inherit;cursor:pointer}
.buybtn.owned:hover{filter:none;background:rgba(255,255,255,.22)}
.buybtn.current{background:#2ecc71;color:#04263d;box-shadow:0 2px 8px rgba(46,204,113,.4)}
.buybtn:disabled{opacity:.45;cursor:default}
.buybtn.upgbt{background:#5c55a8}
.rowbtns{display:flex;gap:6px;flex-shrink:0}
.lvtag{color:#ffd75e;font-weight:800;font-size:12px;margin-left:5px;white-space:nowrap}
.item.locked{opacity:.5}
.item.locked:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.09);transform:none}
.statsline{font-size:13px;color:#9fc3e2;margin-bottom:6px}
.jrow{display:flex;align-items:center;gap:10px;padding:9px 10px;background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.06);border-radius:10px;margin-bottom:6px;font-size:14px;text-align:left;
@@ -279,18 +284,23 @@ const LURES=[
{id:1, name:'Червь', cost:150, cast:1.00, bite:1.3, rare:0.2, size:0.12, desc:'Клёв на 30% чаще, шанс крупной выше'},
{id:2, name:'Воблер', cost:450, cast:1.15, bite:1.2, rare:0.8, size:0.28, desc:'Дальше бросок, приманивает крупных'},
{id:3, name:'Золотая блёстка',cost:1200, cast:1.20, bite:1.4, rare:2.0, size:0.50, desc:'Лучшее, что есть. К ней плывут легенды'},
{id:4, name:'Фонарик', cost:500, cast:1.00, bite:1.0, rare:0.0, size:0.00, nightOnly:true, desc:'Позволяет рыбачить ночью'},
];
/* M5: апгрейды снастей — уровни 1..4 (макс), стоимость переходов 1→2, 2→3, 3→4 */
const ROD_UPG_COST=[400,1200,3000];
const LURE_UPG_COST=[300,900,2200];
/* Что даёт следующий уровень (индекс = текущий уровень − 1) — подпись в лавке */
const ROD_UPG_NEXT=['фонарик (ночь), леска ×1.15','тормоз ×1.15, катушка ×1.10','леска ещё ×1.15, крупные +0.10'];
const LURE_UPG_NEXT=['клёв ×1.15','редкие +1.0','крупные +0.15'];
/* =========================================================
СОХРАНЕНИЕ
========================================================= */
const SAVE_KEY='fishing-save-v1';
let save={coins:0,rod:0,lure:0,rods:[0],lures:[0],caught:{},muted:false,casts:0,earned:0,
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};
function freshSave(muted){
return {coins:0,rod:0,lure:0,rods:[0],lures:[0],caught:{},muted:muted,casts:0,earned:0,
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};
}
@@ -311,8 +321,19 @@ function loadSave(){
if(typeof save.questsDoneTotal!=='number') save.questsDoneTotal=0;
if(!save.quests||typeof save.quests!=='object') save.quests=null;
if(!save.streak||typeof save.streak!=='object') save.streak={last:null,days:0,shieldWeek:null};
// M3: приманки (фонарик) — дефолт для старых сейвов
// M3: приманки — дефолт для старых сейвов
if(!save.lures||!Array.isArray(save.lures)) save.lures=[0];
if(!save.rods||!Array.isArray(save.rods)) save.rods=[0];
// M5: фонарик больше не приманка (id=4) — убираем из старых сейвов
if(save.lures.includes(4)) save.lures=save.lures.filter(x=>x!==4);
if(!save.lures.length) save.lures=[0];
if(save.lure===4) save.lure=0;
// M5: уровни снастей 1..4 — в старых сейвах их нет: купленным — уровень 1
if(!save.rodLv||typeof save.rodLv!=='object') save.rodLv={};
if(!save.lureLv||typeof save.lureLv!=='object') save.lureLv={};
for(const i of save.rods) if(!(save.rodLv[i]>=1)) save.rodLv[i]=1;
for(const i of save.lures) if(!(save.lureLv[i]>=1)) save.lureLv[i]=1;
delete save.lureLv[4];
// M3: цикл дня/ночи — без dayTime старт с утра; день идёт в реальном времени,
// пока игрока не было, сдвиг догоняет (mod 1)
if(typeof save.dayTime!=='number'||save.dayTime<0) save.dayTime=0;
@@ -337,7 +358,7 @@ function persist(){
const DAY_LENGTH=300; // секунд на полный день
let dayTime=0; // 0..1 — позиция в сутках
let dayPhase='morning'; // morning (0-0.25) day (0.25-0.5) evening (0.5-0.75) night (0.75-1.0)
let nightNoLureToast=false; // тост «Ночью нужен фонарик» — разово, не каждый кадр
let nightNoLightToast=false; // тост «Ночью нужен фонарик» — разово, не каждый кадр
function phaseOf(t){ return t<0.25?'morning':(t<0.5?'day':(t<0.75?'evening':'night')); }
function setDayTime(t){ t%=1; if(t<0)t+=1; dayTime=t; dayPhase=phaseOf(dayTime); }
@@ -621,8 +642,29 @@ let clouds=[], sparkles=[];
let particles=[], ripples=[];
let bobberTrail=[]; // визуал: след поплавка при забросе
const rod=()=>RODS[save.rod];
const lure=()=>LURES[save.lure];
/* M5: уровни снастей (1..4) и эффективные статы: базовые + эффекты апгрейдов */
function rodLvOf(i){ const v=save.rodLv&&save.rodLv[i]>=1?save.rodLv[i]:1; return Math.max(1,Math.min(4,v)); }
function lureLvOf(i){ const v=save.lureLv&&save.lureLv[i]>=1?save.lureLv[i]:1; return Math.max(1,Math.min(4,v)); }
function effRod(i){
const b=RODS[i], lv=rodLvOf(i);
const e=Object.assign({},b,{light:lv>=2}); // M5: Lv2 — фонарик (ночная рыбалка)
if(lv>=2) e.line*=1.15; // Lv2: леска крепче
if(lv>=3){ e.drag*=1.15; e.reel*=1.10; } // Lv3: тормоз и катушка
if(lv>=4){ e.line*=1.15; e.size+=0.10; } // Lv4: леска ещё + крупные
return e;
}
function effLure(i){
const b=LURES[i], lv=lureLvOf(i);
const e=Object.assign({},b);
if(lv>=2) e.bite*=1.15; // Lv2: клёв чаще
if(lv>=3) e.rare+=1.0; // Lv3: редкие
if(lv>=4) e.size+=0.15; // Lv4: крупные
return e;
}
const rod=()=>effRod(save.rod);
const lure=()=>effLure(save.lure);
/* M5: фонарик — не приманка, а апгрейд 1 любого удилища (2+ ур.) */
const hasLight=()=>rodLvOf(save.rod)>=2;
const rand=(a,b)=>a+Math.random()*(b-a);
function initFish(){
@@ -902,7 +944,7 @@ function updateGame(dt){
bobber={x:castTo.x,y:waterY-2};
state='WAIT';
biteTimer=rand(4,10)/lure().bite;
nightNoLureToast=false; // новый заброс — тост о фонарике может показаться снова
nightNoLightToast=false; // новый заброс — тост о фонарике может показаться снова
splash(castTo.x,waterY,14,100);
addRipple(castTo.x,waterY,40);
sfx('splash'); vib(10);
@@ -920,9 +962,10 @@ function updateGame(dt){
if(biteFish&&biteFish.mode==='out') updateBiteFishOut(dt);
biteTimer-=dt;
if(biteTimer<=0&&!biteFish){
if(dayPhase==='night'&&!save.lures.includes(4)){
// M3: ночью без фонарика клёва нет — таймер тикает вхолостую
if(!nightNoLureToast){ toast('Ночью нужен фонарик'); nightNoLureToast=true; }
if(dayPhase==='night'&&!hasLight()){
// M5: ночью без фонарика клёва нет — таймер тикает вхолостую
// (фонарик — апгрейд 1 удилища: save.rodLv[save.rod]>=2)
if(!nightNoLightToast){ toast('Ночью нужен фонарик — улучши удилище (первый апгрейд)'); nightNoLightToast=true; }
}else if(forceBite||Math.random()<biteChance()){
startBite();
}else{
@@ -1534,14 +1577,31 @@ function setTab(t){ shopTab=t; renderShop();
$('tabRewards').classList.toggle('active',t==='rewards');
$('tabAch').classList.toggle('active',t==='ach');
}
function lvPips(lv){ let s=''; for(let k=1;k<=4;k++) s+=k<=lv?'●':'○'; return s; }
function itemRow(item,i,type,owned,cur){
let btn;
if(cur) btn='<button class="buybtn current" disabled>✓ Выбрано</button>';
else if(owned) btn='<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">Выбрать</button>';
else btn='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
const tag=item.nightOnly?' <span style="color:#8fb8dd;font-size:12px">ночь</span>':''; // M3: пометка «ночь»
return '<div class="item"><div><div class="itemname">'+item.name+tag+'</div>'+
'<div class="itemdesc">'+item.desc+'</div></div>'+btn+'</div>';
const list=type==='rod'?RODS:LURES;
const lvMap=type==='rod'?(save.rodLv||{}):(save.lureLv||{});
const costMap=type==='rod'?ROD_UPG_COST:LURE_UPG_COST;
const nextTxt=type==='rod'?ROD_UPG_NEXT:LURE_UPG_NEXT;
const lv=owned?Math.max(1,Math.min(4,lvMap[i]>=1?lvMap[i]:1)):1;
// M5: гейтинг — снасть №i открывается, когда снасть №(i−1) того же типа на 4 ур.
const prevLv=i>0?((lvMap[list[i-1].id]>=1)?lvMap[list[i-1].id]:1):0;
const locked=!owned&&i>0&&prevLv<4;
let btns='';
if(owned){
btns+=cur?'<button class="buybtn current" disabled>✓ Выбрано</button>'
:'<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">Выбрать</button>';
if(lv<4) btns+='<button class="buybtn upgbt" data-act="upgrade" data-type="'+type+'" data-i="'+i+'">⬆ '+costMap[lv-1]+' 🪙</button>';
}else if(!locked){
btns='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
}
let desc=item.desc;
if(locked) desc='Откроется: улучши «'+list[i-1].name+'» до 4 ур.';
else if(owned&&lv<4) desc+=' · далее: '+nextTxt[lv-1];
const lvTag=owned?' <span class="lvtag">'+lvPips(lv)+' '+lv+' ур.</span>':'';
return '<div class="item'+(locked?' locked':'')+'"><div><div class="itemname">'+item.name+lvTag+'</div>'+
'<div class="itemdesc">'+desc+'</div></div>'+
(btns?'<div class="rowbtns">'+btns+'</div>':'<div class="rowbtns"><button class="buybtn" disabled>🔒</button></div>')+'</div>';
}
function renderShop(){
const body=$('shopBody');
@@ -1606,14 +1666,29 @@ function shopClick(e){
const type=b.dataset.type, i=+b.dataset.i;
const list=type==='rod'?RODS:LURES;
const owned=type==='rod'?save.rods:save.lures;
if(!save.rodLv||typeof save.rodLv!=='object') save.rodLv={};
if(!save.lureLv||typeof save.lureLv!=='object') save.lureLv={};
const lvMap=type==='rod'?save.rodLv:save.lureLv;
const costMap=type==='rod'?ROD_UPG_COST:LURE_UPG_COST;
const item=list[i];
if(b.dataset.act==='buy'){
if(i>0&&lvMap[list[i-1].id]<4){ sfx('no'); toast('Сначала улучши «'+list[i-1].name+'» до 4 ур.'); return; } // M5: гейтинг
if(save.coins<item.cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=item.cost; owned.push(i);
save.coins-=item.cost; owned.push(i); lvMap[i]=1; // куплено — уровень 1
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Куплено: '+item.name+'!');
checkAchievements();
}else if(b.dataset.act==='upgrade'){
if(owned.indexOf(i)<0){ sfx('no'); return; } // M5: не куплено — апгрейд недоступен (паттерн защиты buy)
const lv=(lvMap[i]>=1)?lvMap[i]:1;
if(lv>=4) return;
const cost=costMap[lv-1];
if(save.coins<cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=cost; lvMap[i]=lv+1; // M5: апгрейд — уровень++
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Улучшено: '+item.name+' → '+(lv+1)+' ур.!');
checkAchievements();
}else{
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('click'); renderShop();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

After

Width:  |  Height:  |  Size: 460 KiB

+5 -4
View File
@@ -325,13 +325,14 @@ async function forceCatch() {
ok('ночь: dayPhase=night при dayTime 0.85', night === 'night', String(night));
await shot('orca-night.png');
// 9c. M3: фонарик в лавке (id=4, пометка «ночь»). Лавка открыта на вкладке,
// где её закрыли в шаге 7 (🏅) — переключаем на Снасти.
// 9c. M5: фонарик в лавке — апгрейд 1 удилища (не приманка id=4): в строке
// удилища кнопка «⬆ 400 🪙» и подпись «фонарик (ночь)». Лавка открыта на
// вкладке, где её закрыли в шаге 7 (🏅) — переключаем на Снасти.
await clickByText('🛒');
await waitCond('!document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop hidden"', 15000, 'лавка открыта');
await clickByID('tabTackle');
await waitCond('document.getElementById("shopBody").textContent.indexOf("Фонарик") >= 0 ? "yes" : "no flask"', 15000, 'фонарик в снастях');
ok('лавка: «Фонарик» в снастях (id=4, пометка «ночь»)', true);
await waitCond('document.getElementById("shopBody").textContent.indexOf("фонарик") >= 0 ? "yes" : "no flask"', 15000, 'апгрейд-фонарик в снастях');
ok('лавка: фонарик — апгрейд 1 удилища (⬆ 400, пометка про ночь)', true);
await clickByText('×');
await waitCond('document.getElementById("shop").classList.contains("hidden") ? "yes" : "shop open"', 15000, 'лавка закрыта');
+262 -10
View File
@@ -151,7 +151,9 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
ok('срыв: рыба ушла -> WAIT', true);
// --- 7. Магазин: покупка и экипировка ---
await page.evaluate(() => { save.coins = 10000; });
// 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')));
@@ -508,32 +510,33 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
m3nb.st==='WAIT' && !m3nb.bf && m3nb.bt<0 && m3nb.toast.includes('Ночью нужен фонарик'),
JSON.stringify(m3nb));
// (d) ночь с фонариком: клёв работает
// (d) ночь с фонариком: клёв работает (M5: фонарик = апгрейд 1 удилища)
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.mouse.move(195, 600);
await page.evaluate(() => { setDayTime(0.8); save.lures=[0,4]; });
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 ночь с фонариком (save.lures включает 4): клёв работает (BITE)', true);
ok('M3 ночь с фонариком (удочка 2+ ур.: save.rodLv[save.rod]>=2): клёв работает (BITE)', true);
// (e) лавка: «Фонарик» в снастях
// (e) лавка: фонарик — не приманка, а апгрейд 1 удилища (кнопка ⬆ 400 🪙,
// в подписи «фонарик (ночь)»); уровень 1 форсим, чтобы строка показывала именно его
await page.click('#btnShop');
await sleep(150);
await page.click('#tabTackle');
await sleep(150);
const m3shop = await page.evaluate(() => {
save.lures=[0]; // строка с ценой, а не «Выбрать»
save.rodLv[0]=1;
renderShop();
const items=Array.prototype.slice.call(document.querySelectorAll('#shopBody .item'));
const row=items.find(el=>el.textContent.indexOf('Фонарик')>=0);
const row=items.find(el=>el.querySelector('[data-act="upgrade"][data-type="rod"]'));
return row?{txt:row.textContent}:null;
});
ok('M3 лавка: «Фонарик» в снастях (500 🪙, пометка «ночь»)',
m3shop && m3shop.txt.indexOf('500 🪙')>=0 && m3shop.txt.indexOf('ноч')>=0,
m3shop?m3shop.txt:'нет строки');
ok('M5 лавка: фонарик = апгрейд 1 удилища (кнопка ⬆ 400 🪙, подпись «фонарик (ночь)»)',
m3shop && m3shop.txt.indexOf('400')>=0 && m3shop.txt.indexOf('фонарик')>=0 && m3shop.txt.indexOf('ноч')>=0,
m3shop?m3shop.txt:'нет строки апгрейда');
await page.click('#btnCloseShop');
// (f) миграция: старый сейв без dayTime/lastSaveMs
@@ -868,6 +871,255 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
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 «250 🪙» / «150 🪙»',
!m5g2.r1.locked && m5g2.r1.buyTxt === '250 🪙' && !m5g2.l1.locked && m5g2.l1.buyTxt === '150 🪙',
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 🪙 → 600, 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 ур. за 400 🪙 (1000→600), hasLight()=true, тост «Улучшено … → 2 ур»',
m5u.coins === 600 && 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 = 100; 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 не хватает монет: 100 🪙 на апгрейд за 400 — coins=100, уровень не изменился, тост «Не хватает монет»',
m5p.coins === 100 && 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 = 1000;
});
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 за 250 🪙 — rods=[0,1], rodLv[1]=1, снасть экипирована, coins=750',
m5b.rods === '0,1' && m5b.lv === 1 && m5b.rod === 1 && m5b.coins === 750,
JSON.stringify(m5b));
await page.click('#btnCloseShop');
// --- 11. Ошибки консоли ---
ok('нет ошибок JS (pageerror)', pageErrors.length === 0, pageErrors.join(' | '));
ok('нет ошибок консоли', consoleErrors.length === 0, consoleErrors.join(' | '));