Files
andrey 7437240cae Этап 48: размер рыбы в аквариуме — от абсолютного веса (общая шкала вываживания) + десктоп ×2. (1) aqFishSize(sp,w) → aqFishSize(w): вместо w/wMax+rarity читает общую с drawHookedFish кривую Этапа 41 — fishSizeFromW(w) (узловая таблица FISH_SIZE_KNOTS, log2 веса) — так «крупнее вес → крупнее рыба» работает в банке как в воде. Формула: clamp(fishSizeFromW(w)·AQ_FISH_SCALE·k, AQ_FISH_SIZE_MIN·k, min(W,H)·AQ_FISH_SIZE_REL·k); константы AQ_FISH_SCALE=0.7, AQ_FISH_SIZE_MIN=7, AQ_FISH_SIZE_REL=0.16. (2) Ревизия по просьбе владельца: мобильный вид одобрен как есть, поэтому десктопный множитель k=AQ_DESKTOP_SCALE=2 включается только при min(W,H)>=AQ_DESKTOP_MIN=600 (иначе k=1) — телефон 390×844 и mobile-landscape 800×450 остаются 1×, десктоп 1280×720 получает жильцов вдвое крупнее (щука 7.4 кг: 40→79 px DOM, кит 100 кг: 125→293 px). Оба клампа (MIN и REL) масштабируются на k вместе с телом — иначе на десктопе мелкая рыба застревала бы на мобильном минимуме, а верхний кламп резал бы увеличенных гигантов. Порог по min(W,H), а не IS_TOUCH: не ломает headless-скриншоты и работает при resize (initAquarium). (3) Обновлены 3 вызова — aqBuildFishEl/drawAquarium/aqTap (sp больше не нужен как аргумент; в drawAquarium sp остался для sp.glow); радиус тапа (size*1.2+10), рамка выбора и ореол свечения читают ту же size — согласованы автоматически. Таблицу/кривую НЕ дублируем (fishSizeFromW объявлен в «ОТРИСОВКА» раньше секции аквариума). Имя aqFishSize сохранено (глобал тестов/AGENTS). (4) Не тронуто: FISH_SIZE_KNOTS/fishSizeFromW/drawHookedFish (вода), wMin/wMax, формат/миграция save.aqFish, баланс/роллы/физика. (5) Тесты не правились — §AQ размеры не пинит (проверяются .aqfish/use href/варианты/transform; aqTap жмёт центр): 222/222 PASS, exit 0. check.mjs OK; balance.mjs — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7). (6) Скриншоты: перегенерированы только кадры aquarium (6 PNG, оба набора — паттерн ревизии Этапа 47); остальные 84 кадра возвращены (набор не побитово-детерминирован из-за анимаций). (7) Доки: AGENTS.md (пункт «Аквариум» — формула + константы + десктоп-множитель), README.md (размер по весу, на десктопе ×2), DEVLOG (Этап 48 с таблицей мобильный/десктоп).
2026-09-23 18:02:56 +03:00

944 lines
210 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Процесс разработки
Как игру «Рыбалка» спроектировали, собрали, проверили и опубликовали. Документ описывает этапы, инструменты и повторяемые команды — чтобы любой (включая вас через полгода) мог воспроизвести и продолжить процесс.
```
ТЗ → макет-прототип → среда проверки → автотесты → визуал → баги → публ. → поддержка
1 2 3 4 5 6 7 8
```
---
## Этап 1. Требования и дизайн (ТЗ)
**Исходное ТЗ:** браузерная игра про рыбалку, играбельная на телефоне.
Ключевые решения:
| Решение | Зачем |
|---|---|
| Один самодостаточный `index.html` (HTML+CSS+JS) | Нет сборки, зависимостей и бэкенда. Файл можно открыть где угодно: телефон, десктоп, статический хостинг |
| Вся графика на Canvas, без внешних ассетов | Игра работает офлайн, грузится мгновенно |
| Портретная ориентация, управление одним пальцем | Все действия — тап/удержание по экрану, больших тач-мишеней (кнопки ≥ 44 px) |
| HTML-оверлеи для меню/магазина/карточек, Canvas — только мир игры | Кнопки и текст резкие и доступные, игровой мир — 60 fps |
| WebAudio для звуков (без файлов), `navigator.vibrate` для тактильного отклика | Снова ноль внешних ресурсов; на телефоне — вибрация на клёв/рывки/обрыв |
| Прогресс в `localStorage` | Мобильная игра без аккаунтов и сервера |
Механика (единый цикл):
```
бросок (удержание = мощность) → ожидание → клёв («!») → подсечка (тап в окно)
→ вываживание (тяни/отпускай) → {улов / обрыв / срыв} → монеты → магазин
```
## Этап 2. Реализация
Структура `index.html` (все секции помечены комментариями в файле):
1. **Данные** — `SPECIES` (8 рыб: сила, редкость, цена, мин. дистанция), `RODS`, `LURES`. *Баланс игры крутится здесь.*
2. **Сохранение** — `save` + `localStorage` (монеты, снасти, коллекция, статистика).
3. **Холст** — масштабирование под `devicePixelRatio`, пересчёт layout'а при `resize`.
4. **Звук** — генеративный WebAudio: 12 эффектов из осцилляторов и шума.
5. **Состояния** — машина из 9 состояний: `MENU → IDLE → CHARGE → CAST → WAIT → APPROACH → BITE → REEL → SNAP/CARD → (IDLE)`.
6. **Физика вываживания** — `updateReel()`: напряжение шнура, дистанция, фазы «тянет/отдыхает». *Сложность крутится здесь.*
7. **Отрисовка** — небо, вода, рыбы-силуэты, лодка, поплавок, частицы, риплы, метры.
8. **Ввод** — Pointer Events (`pointerdown/up`), работают и палец, и мышь (важно для автотестов!).
9. **UI** — магазин, дневник, карточка улова.
## Этап 3. Среда проверки
Игра проверена в контейнере **Alpine Linux 3.24**. Минимальный набор инструментов:
| Инструмент | Версия (в нашем прогоне) | Зачем | Установка |
|---|---|---|---|
| Node.js | v24.18.1 | Статический анализ JS, запуск автотестов | `apk add nodejs npm` |
| Chromium | 151.0.7922 | Headless-браузер для настоящего прогона игры | `apk add chromium` |
| puppeteer-core | 25.9.0 | Управление Chromium через CDP (клики, скриншоты, чтение состояний) | `npm i puppeteer-core` (в `tests/`) |
| Noto Color Emoji | — | Чтобы в headless-скриншотах эмодзи рендерились как на телефоне | `apk add font-noto-emoji` |
| git | 2.54.0 | Публикация | `apk add git` |
| curl | — | Проверка API Gitea, smoke-запросы | уже есть в Alpine |
> В браузере на телефоне для *игры* не нужен ни один инструмент из таблицы — только сам браузер.
## Этап 4. Статическая проверка
Первый рубеж — компиляция кода настоящим движком (V8):
```bash
# вытащить JS из HTML и проверить синтаксис
node --check game.js
```
Ловит опечатки, несбалансированные скобки, висячие конструкции — до запуска в браузере.
## Этап 5. Автотесты (плейтест через CDP)
`tests/test.js` — сценарный автотест: headless Chromium загружает игру, а «игрок» управляет мышью (в браузере мышь порождает те же Pointer Events, что и палец). Тест читает и подталкивает внутренние состояния игры (`state`, `biteTimer`, `reel.d`) через `page.evaluate`, чтобы покрыть все ветки без ожидания случайностей.
**Что покрыто (54 проверки):**
- меню → старт → зарядка → бросок → ожидание
- клёв (ускорен через `biteTimer`) → подсечка → `REEL`
- **все три исхода вываживания**: улов → карточка, обрыв шнура (`SNAP`), срыв рыбы
- магазин: открытие, покупка удочки и приманки (с проверкой баланса и экипировки)
- дневник: рендер, пойманная рыба в коллекции
- **сохранение после перезагрузки страницы** (coins/снасти/статистика)
- shiny: ×3 к цене, счётчик `sn`, бейдж; near-miss: 10% цены за сошедшую рыбу; миграция старого сейва
- **M2 (9 проверок)**: квест выполняется уловом (+reward), перегенерация квестов на новый день, стрик (день+1, награда дня), «щит» стрика (прощение пропуска и контр-кейс), достижения (бонус 1% к монетам), вкладки лавки 🎯/🏅
- **M3 (6 проверок)**: фазы суток по `dayTime`, ночная таблица рыб (тот же ролл → другая рыба), ночь без фонарика (клёва нет, тост разовый), ночь с фонариком (клёв работает), «Фонарик» в лавке, миграция сейва (`dayTime`/`lastSaveMs`)
- **M4 (5 проверок)**: старт босс-боя (`stateBoss`/`bossPhase`), 3 фазы боя (обрывы фаз без штрафа, дистанция растёт по фазам), награда ×5 + `bossBeaten`/`bossCount`, достижение `boss_first`, босс-карточка
- ноль ошибок в консоли и `pageerror`
Запуск:
```bash
cd tests
npm install
node test.js # при необходимости: CHROMIUM=/путь/к/хромью
```
## Этап 6. Визуальная проверка
`tests/shots.js` — та же техника, но с фиксацией скриншотов каждого состояния (`screenshots/shot-*.png`). Скриншоты просматривает разработчик: ловит проблемы, которые не видны по коду и логам (сбившиеся метки, отсутствующие глифы, нечитаемые метры).
## Этап 7. Найденные баги (ради чего всё это)
Автотест + скриншоты поймали то, что ручное «посмотрел глазами» пропустило:
| # | Баг | Как нашли | Лечение |
|---|---|---|---|
| 1 | **Критический:** после поимки рыбы игра вешалась навсегда. `catchFish()` ставил `reel=null`, но кейс `REEL` в `updateGame()` на следующей строке читал `reel.holding` → исключение убивало цикл `requestAnimationFrame` | Автотест: второй бросок не происходил; `pageerror` показал точную строку | `if(state==='REEL'&&reel){...}` |
| 2 | Точка крепления лески висела в углу экрана в меню/карточке | Визуальный осмотр скриншотов | Условие отрисовки по состояниям |
| 3 | Рыбка-маркер не стояла на краю полосы «дистанция» | Визуальный осмотр | Полоса = оставшаяся дистанция, маркер на её конце |
| 4 | Глиф `✕` (U+2715) отсутствовал в некоторых шрифтах | Скриншот магазина | Заменён на `×` (U+00D7) |
**Вывод:** для игр с таймерами и случайностями автотест, который *управляет* временем и состоянием, покрывает за минуту то, что вручную ищут часами.
## Этап 8. Публикация в Gitea
Использовано: Gitea API (создание репозитория) + обычный `git push`.
```bash
# 1. Создать репозиторий (токен: Настройки → Приложения → Токены, права: Repository)
curl -X POST -H "Authorization: token <ТОКЕН>" -H "Content-Type: application/json" \
-d '{"name":"fishing-game","private":false,"auto_init":false}' \
https://git.shstk.ru/api/v1/user/repos
# 2. Наполнить ветку и запушить
git init -b main
git add -A
git commit -m "..."
git remote add origin https://git.shstk.ru/andrey/fishing-game.git
git push -u origin main # git попросит логин/пароль (или токен в URL — см. ниже)
```
Игра доступна анонимно по сырой ссылке:
`https://git.shstk.ru/andrey/fishing-game/raw/branch/main/index.html`
### 🔐 Гигиена секретов (проверено на себе)
1. **Токен — одноразовый.** Для публикации достаточно кратковременного токена с правами на репозиторий; после push его **отзываем** (в нашем случае — отозван, старый токен теперь отдаёт `401`).
2. **Токен не попадает в историю и конфиг.** Если передавать токен в URL (`https://user:TOKEN@host/...`), после push удалите его из конфига:
`git remote set-url origin https://git.shstk.ru/andrey/fishing-game.git`
3. **В репозиторий и в документы — только плейсхолдеры** (`<ТОКЕН>`), реальные секреты нигде не фиксируются.
## Этап 9. Поддержка и развитие
**Крутить баланс:** константы `SPECIES` / `RODS` / `LURES` в начале `index.html`; физика вываживания — в `updateReel()`.
**Цикл изменений:**
```bash
# 1. поправить index.html
# 2. прогнать автотест
cd tests && node test.js # ждём 54/54
# 3. (опционально) свежие скриншоты
node shots.js
# 4. закоммитить и запушить
git add -A && git commit -m "что изменилось" && git push
```
**Идеи развития** (по нарастающей сложности):
- продажи улова / рыбный прайс по редкости
- мировая таблица лидеров (нужен простой бэкенд или Gitea-Actions с webhook'ом)
- офлайн-режим PWA (service worker в тот же файл)
---
## Шпаргалка: минимум инструментов по этапам
| Этап | Инструменты |
|---|---|
| Игра (конечный пользователь) | Любой браузер на телефоне/десктопе |
| Написание/изменение кода | Текстовый редактор (или LLM-сессия) |
| Статическая проверка | Node.js |
| Автотесты | Node.js + npm + Chromium + puppeteer-core |
| Визуальная проверка | Скриншоты из автотеста + Noto Color Emoji в headless |
| Публикация | git + curl + Gitea (токен с минимальными правами, отзываемый) |
---
## Этап 10. M1: shiny-рыба, near-miss и починка тест-запуска
Короткий лог по M1:
1. **Починка запуска автотестов** — `tests/test.js` хардкодил `executablePath: '/usr/bin/chromium'` и URL `file:///fishing/index.html`; в этом чекауте их нет. Теперь, как в `shots.js`: `process.env.CHROMIUM || '/usr/bin/chromium-browser'`, а путь к игре резолвится динамически — `'file://' + path.resolve(__dirname, '..', 'index.html')`.
2. **Shiny-рыба** — каждый улов роллит шанс `SHINY_CHANCE` (3%): золотая версия вида продаётся за `SHINY_MULT` (×3) к цене с тем же разбросом ±5%, на карточке — бейдж «✨ ШИНИ!» и золотое оформление (рамка/свечение), в дневнике у вида появляется метка «✨ N» (счётчик `sn`). Формат сейва: `caught[id] = {n, best, sn}`; миграция в `loadSave()` дописывает `sn = 0` старым записям — числовые и `{n,best}`-форматы читаются без потерь.
3. **Near-miss при сходе** — если рыба сбежала в вываживании (ветка `d ≥ d0×1.4` в `updateReel()`), игрок получает утешение: 10% её цены (`floor(вес × цена/кг × 0.1)`, если > 0) и тост «Сбежала! …». Обрыв (SNAP) выплаты не даёт.
4. **Тесты** — добавлены 4 проверки: shiny (монеты ×3 в диапазоне, `sn = 1`, бейдж в DOM), миграция старого сейва (число и `{n,best}` без `sn`), near-miss (монеты ≈10% цены и тост «Сбежала»). Итог: **34/34 PASS, exit 0** (число в README и этом документе обновлено).
## Этап 11. Инструмент браузерного плейтеста (Orca CLI)
**Что.** `tests/orca-playtest.js` — второй слой плейтестов: тот же полный геймплей, но в **настоящей вкладке браузера Orca app** (не в headless Chromium). Управление — командами Orca CLI (`tab create` / `eval` / `screenshot` / `console` / `tab close`), только встроенные модули Node. Проверяет 31 ok(): состояния игры, DOM карточки/лавки/дневника, бейдж «ШИНИ», тост near-miss, чистоту консоли; скриншоты `orca-*.png` — в каталог запуска. Полное описание, таблица шагов и камешки: `docs/BROWSER-TESTING.md`.
**Почему.** Headless puppeteer-тест — главный рубеж, но он живёт в своей среде. Вкладка браузера Orca троттлит `requestAnimationFrame` (фоновая вкладка), имеет свой CLI-roundtrip и свою консоль: плейтест «вживую» ловит именно эти отличия и гарантирует, что игра играбельна в том браузере, которым пользуются люди.
**Найденные камешки (ради чего писали этот слой):**
| # | Камень | Как нашли | Лечение в скрипте |
|---|---|---|---|
| 1 | **rAF троттлинг фоновой вкладки**: игровое время идёт медленнее реального (кадр ≤ 0.05 c игрового) | Фиксированные sleep не доходили до нужного состояния | Все ожидания — polling с таймаутом (шаг 300 мс, таймауты 10–30 c); фиксированные sleep — только «удержание кнопки» |
| 2 | **Окно подсечки (0.35–0.8 c) короче CLI-roundtrip (~0.3–0.5 c)** — тап опаздывает | Подсечка случайно промахивалась | `biteTimer = 0.05` → ждём BITE → `biteT = 5` (расширяем окно) → СНАЧАЛА скриншот, потом тап |
| 3 | **Цитирование**: JS с кавычками, переданный одной строкой через shell (PTY), ломается | Повреждённые выражения в `eval` | Только `child_process.execFile` с массивом аргументов, без shell |
| 4 | **Transient-зависания CLI** (единичный hang `eval` на ~1 мин, проходит повтором) | Прогон вешался на одном вызове | Таймаут `execFile` 20 c + ретраи ×3; `tab create` ретраев не имеет (дубль вкладки) |
| 5 | **Тосты живут 1.6 c** — отдельный вызов для чтения текста опаздывает | Пустой тост в `orca-escape` | Текст тоста читаем в ТОМ ЖЕ eval, что и ожидание WAIT |
**Как использовать.** Запущенный Orca app + `orca-ide` в PATH (или `ORCA_CLI_COMMAND`); из каталога репо: `node tests/orca-playtest.js`. Если Orca не запущен — пропустить и не блокировать (главный рубеж — `tests/test.js`). В AGENTS.md — пункт 3 раздела «Проверка».
## Этап 12. M2: награды — квесты дня, стрик входов, достижения
**Что добавлено в `index.html` (секция «M2. Награды» после сейва):**
1. **Квесты дня** — `save.quests = {date, list:[3]}`. Три квеста генерируются по дню (seed — FNV-1a от `YYYY-MM-DD`, шаблоны без повторов): «поймай N вида», «заработай N 🪙», «улов ≥ N кг», «N забросов», «N рыб редкости R», «поймай шини». Прогресс растёт в `questHit()` (вызывается из `doCast()`/`catchFish()`); выполненный квест выдаёт награду один раз (`save.questsDoneTotal` — счётчик для достижения). Новый день (первый заход после смены даты) — перегенерация.
2. **Стрик входов со «щитом»** — `save.streak = {last, days, shieldWeek}`. При загрузке `applyStreak()`: вчерашний визит → день+1; пропуск в середине недели → день+1, если `shieldWeek` ещё не равен текущей ISO-неделе (ISO — `isoWeek()`, «YYYY-Www», неделя с понедельника); иначе стрик сбрасывается в день 1. Награда дня 1–7: 25/50/75/100/125/150/200 🪙. Первый визит (визитов не было) запускает стрик с дня 1 **без** награды — иначе миграция старых сейвов (без поля `streak`) давала бы «воздушные» монеты.
3. **Достижения** — 16 штук в `ACHIEVEMENTS` (первый улов, 10/100 забросов, заработано 1k/10k, 3/8 видов, редкая/легендарная рыба, первый шини, 10/20 кг, 10/50 обрывов — для этого `save.snaps++` в ветке SNAP, стрик 7, выполнено 5 квестов). `checkAchievements()` — по событиям (бросок/улов/обрыв/покупка/пополнение кошелька); выданные дают постоянные бонусы через `achBonus()`: +1–2% к монетам, +0.02–0.06 с к окну подсечки, +5–10 к нагрузке шнура.
4. **UI** — в лавке две вкладки: 🎯 «Награды» (полоса стрика с точками дня 1–7 + квесты дня с прогрессом и наградой) и 🏅 «Достижения» (выданные — яркие с ✓, остальные приглушённые с бонусом).
5. **Сейв** — новые поля только через миграцию/дефолты в `loadSave()`; старые сейвы читаются (проверяется тестом миграции — он не тронут).
**Тесты:** +9 проверок в `tests/test.js` (итого **43/43 PASS, exit 0**), +2 шага (🎯/🏅) в `tests/orca-playtest.js` (итого **35/35 PASS, exit 0**). Точные проверки монет (M1 shiny, M1 near-miss) получили «гвард»: на время проверки квесты/стрик/счётчики фиксируются в eval, чтобы случайная награда не исказила баланс.
**Найденные камешки:**
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | Ветка `else if(shopTab==='journal')` терялась при вставке новых вкладок — журнал дублировался, новые вкладки недостижимы | Проваленный прогон: вкладки 🎯/🏅 рендерили «Снасти» | Явные `else if` по каждой вкладке + дубликат удалён |
| 2 | `h='…'` без `let` в новых ветках `renderShop()` → ReferenceError в strict mode | `pageerror: h is not defined` | `let h` в каждой ветке |
| 3 | Стрик давал «воздушные» +25 при миграции старых сейвов (без поля `streak`) — ломал точную проверку монет M1-теста | Провал `coins===77` после reload | Первый визит (last=null) — стрик стартует без награды |
| 4 | `page.click('#btnRecall')` утаскивает мышь на кнопку → следующий `mouse.down()` в puppeteer-тесте падал на кнопку, а не на canvas | Тест вешался на `WAIT` (state=IDLE) | `page.mouse.move(195, 600)` после клика по кнопке |
| 5 | На **первопланарной** вкладке Okno BITE (0.35–0.8 c реального времени) закрывалось до применения отдельного eval `biteT = 5` | Плейтест упал на `REEL` (state=WAIT) | Расширение окна — в ТОМ ЖЕ eval, что детекция BITE (`state === "BITE" ? (biteT = 5, "yes") : …`) |
## Этап 13. Спринт 2: ночная рыбалка + босс-рыба (M3+M4)
**M3 — день/ночь, что добавлено в `index.html` (секция «M3: ЦИКЛ ДНЯ/НОЧИ»):**
1. **Цикл суток** — `DAY_LENGTH = 300` (секунд на полные сутки), глобалы `dayTime` (0..1) и `dayPhase` (утро 0–0.25 → день 0.25–0.5 → вечер 0.5–0.75 → ночь 0.75–1). Время идёт в реальном времени: в `frame()` каждый кадр `setDayTime(dayTime + dt/DAY_LENGTH)`. Пока игрока не было, сутки догоняются при загрузке (`dayTime + (Date.now() - lastSaveMs)`, mod 1).
2. **Визуал фаз** — `SKY_PHASES`: свой градиент неба на каждую фазу (утро — оранжевый, день — голубой, вечер — закатный, ночь — тёмно-синий); солнце/луна ходят по дуге (положение из `dayTime`, ночью — полумесяц), облака ночью приглушены, на воду ложится тёмный оверлей (прозрачность 0–0.3 по фазе).
3. **Ночной клёв** — `SPECIES_NIGHT` (клон `SPECIES` со смещёнными весами спавна: сом/щука/окунь активнее, дневные виды реже). `pickSpecies()` берёт ночную таблицу вечером и ночью. Ночью **без фонарика клёв не стартует**: `biteTimer` тикает вхолостую, разово показывается тост «Ночью нужен фонарик» (флаг `nightNoLureToast`, сброс — новым забросом).
4. **Фонарик** — новая приманка `LURES id=4` (500 🪙, флаг `nightOnly`, в лавке — пометка «ночь»): на клёв не влияет, но разрешает ночную рыбалку (проверка `save.lures.includes(4)`).
5. **Сейв** — `persist()` пишет `save.dayTime`/`save.lastSaveMs`; миграция в `loadSave()`: старый сейв стартует с утра (`dayTime≈0`), `lastSaveMs` проставляется — старые сейвы читаются (есть тест).
**M4 — босс-рыба (Осётр-босс):**
1. **Появление** — при клёве осётра шанс 30% (проверка в `startBite()` — где рождается клёв; воркер M4 отклонился от «в `pickSpecies()`», отклонение согласовано): рыба помечается `sp.boss = true` клоном объекта — таблица `SPECIES` не мутируется. Для тестов — глобал `forceBoss`. Босс отличается: окно подсечки короче (0.25–0.5 c, ветка в `biteWindow()`), «злость» выше (сила 0.95), силуэт рыбы ×1.5.
2. **Бой в 3 фазы** — глобалы `stateBoss`/`bossPhase`; босс-ветка в `updateReel()`: у босса в фазах 1–2 `d ≤ 0` — «босс рвёт леску» (`SNAP` **без штрафа**: `save.snaps` не растёт) и без ввода игрока начинается следующая фаза глубже (`d = 0.6·d0`, затем `1.0·d0`); в фазе 3 — обычный улов. Настоящий обрыв (по натяжению) и near-miss завершают босс-бой (`stateBoss=false`).
3. **Награда** — ×5 к цене улова (множитель в `catchFish()`); поля `save.bossBeaten`/`save.bossCount` (дефолты для старых сейвов в `loadSave()`); тост «🏆 БОСС ПОБЕЖДЁН! +N 🪙»; всплеск и частицы крупнее.
4. **Достижения** — 17-е достижение `boss_first` «Босс-рыбак» (+3% к монетам).
5. **UI** — босс-карточка: имя «Осётр-БОСС», бейдж «🏆 БОСС!», золотое оформление (`boss-card`); во время боя над метрами — строка «⚠️ БОСС-РЫБА · ФАЗА N/3».
**Тесты:** +11 проверок в `tests/test.js` (M3 — 6: фазы суток, ночная таблица, ночь без/с фонариком, «Фонарик» в лавке, миграция сейва; M4 — 5: старт боя, 3 фазы, награда ×5, достижение, карточка). Итог **54/54 PASS, exit 0**. В `tests/orca-playtest.js` +3 шага (ночь `dayTime=0.85` + скриншот, «Фонарик» в лавке, босс-бой до карточки со сбросом `forceBoss` в `finally`) — итого **46/46 PASS, exit 0**. В `tests/shots.js` добавлены `shot-night.png` (ночь, `dayTime=0.85`) и `shot-boss.png` (босс-карточка, полный цикл через `forceBoss`), плюс недостающие `shot-wait.png`/`shot-reset.png` — всего 11 кадров, скопированы в `screenshots/`.
**Найденные камешки:**
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | Тост «Ночью нужен фонарик» показывался бы на каждом кадре: ночью ветка «клёв не стартует» срабатывает на каждом тике `biteTimer ≤ 0` | Ревью при написании M3 | Флаг `nightNoLureToast` — тост разовый; сброс новым забросом |
| 2 | Босс-флаг «залипал» бы в общей таблице: пометить объект осётра в `SPECIES` как босса — все следующие осётры тоже боссы | Ревью при написании M4 | В `startBite()` босс-осётр клонируется (`Object.assign`), таблица не трогается |
| 3 | Настоящий обрыв (по натяжению) не завершал бы босс-бой: `stateBoss` оставался бы `true`, и логика фаз ездила бы по уже кончившемуся бою | Тест M4 (проверки после обрывов фаз) | `stateBoss=false; bossPhase=0` в ветках натяжения и near-miss |
| 4 | В состоянии `SNAP` отпускание пальца не сбрасывает `reel.holding` (`onUp` слушает только `CHARGE`/`REEL`) — после авто-перехода в следующую фазу босса игра «втягивает» сама, как будто палец ещё на экране | Тест M4: дистанция фазы 2 не сходилась с 0.6·d0 | В тестах `holding=false` в том же eval, что детекция `SNAP`; для игрока некритично (окно 0.6 c), в будущем — чистить `holding` при выходе из `SNAP` |
| 5 | `console`-ошибок нет, но квестные тосты затирали проверочный тост фонарика в тесте | Прогон M3-тестов | На время проверки квесты очищаются (`save.quests={list:[]}`), как в M2 |
## Этап 14. Спринт 3: параллельные worktree — баги, визуал, тест-инфра, порядок, баланс
**Новый процесс.** Впервые задачи гонялись не последовательно в одном чекауте, а параллельно: 5 потоков (T1–T5), каждый — свой воркер (opencode через Orca orchestration) в собственном worktree и своей ветке, мерж в main — по очереди после приёмки. Чтобы мержи были бесконфликтными, зоны правок заранее разведены: `index.html` одновременно правили максимум два потока (логика vs CSS/рендер), `tests/test.js` закреплён за одним, `README.md`/`AGENTS.md` — разделены.
**Что сделано по потокам:**
1. **T4 «Порядок»** (`sprint3-techdebt`): корневой `.gitignore` (мусор плейтестов больше не светится в `git status`), `tools/check.mjs` — автоматизация проверки синтаксиса JS из `index.html` (с номером строки при ошибке), `npm test` в `tests/package.json` теперь реально запускает плейтест (раньше был заглушкой — см. AGENTS.md), актуализация «Проверка».
2. **T1 «Баг-хант»** (`sprint3-bugs`): аудит логики с puppeteer-пробниками; починено 4 бага — (1) квест «Сделай N забросов» падал TypeError'ом (`questHit('casts')` без `condFn`) и молча съедал `persist()`/достижения после заброса; (2) фазовый обрыв босса не сбрасывал `reel.tension` — «обрыв без штрафа» превращался в настоящий; (3)–(4) HUD монет не обновлялся после квестовой награды на забросе и near-miss. +4 регресс-теста (секция 9l) → **58**.
3. **T3 «Тест-инфра»** (`sprint3-testing`): харденинг `orca-playtest.js` — pageerror-хуки (47-я проверка), ретраи CLI с бэкоффом, диагностика кнопок, закрытие вкладки в `finally`; `shots.js` — ожидание отрисовки через `waitForFunction`+двойной rAF вместо sleep'ов, выбор кадров через argv (`node shots.js --list`, подмножество); актуализация BROWSER-TESTING.md/README.
4. **T2 «Визуал»** (`sprint3-visual`): плавные переходы неба день/ночь со звёздами, интерполированная вода с дорожкой бликов, анимация хвостов и след поплавка, ночной фонарь на лодке, полировка CSS панелей/кнопок/карточек/тостов, адаптив узких экранов. 11 скриншотов перегенерированы. Механика не тронута (54/54 без правки тестов — критерий). Один затык: воркер завис на 25 минут в ожидании умершего tool-call — оживлен пинком в терминал, доработал без рестарта.
5. **T5 «Играбельность/сложность»** (`sprint3-playability`): 4 живые сессии во встроенном браузере Orca (бот с реакцией 140 мс, 620 забросов): день с нуля, ночь без/с фонариком, босс `forceBoss`, контрольный замер после баланса. **Вердикты: клёв, вываживание, экономика и босс — too_easy, прогрессия — ok** (клёв p50 4.8 c и 0 пустых забросов; 0 сходов на 329 уловов; 192–979 мон/мин; босс 18/18 без шанса проиграть). Баланс-коммит только числами: тайминги клёва `rand(2.5,7)→(4,10)` и аналоги, веса щуки 12→9, сома 7→5, осётра 3.5→2.5 (цена 80→65), ночные веса притушены; константы, зафиксированные тестами (карась 100/40, цены удочки/приманки), не тронуты. После правки: живой ретест p50 клёва 8.1 c, 58/58 ×6 прогонов.
**Интеграция:** мержи в main по очереди (techdebt FF → testing → bugs → visual → playability), автослияние `index.html` прошло чисто на обоих пересечениях, после каждого мержа — полный прогон (58/58) + `node tools/check.mjs`.
**Найденные камешки:**
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | `worker-start` при 4 одновременных запусках: 3 из 4 промптов не принялись (`agent_prompt_stalled`) — TUI opencode не успели | Гейт PO по receipt'ам (state=failed) | Ретрай `--retry-of` с `--terminal` на уже прогретой TUI + `--worktree path:…` (без него — `terminal_worktree_mismatch`) |
| 2 | Worktree создаётся от дефолтной базы репо (`origin/main`), а она отстала от локального main | Вопрос T4 координатору + сверка `git log` всех 5 worktree | Воркеру — `git merge --ff-only 5c95309`; пушить main почаще или держать origin актуальным |
| 3 | `commit.gpgsign=true` ронял коммиты воркеров (pinentry некому вводить в headless) | ask от T3 | Коммиты воркеров — `--no-gpg-sign` (локальные ветки, без пуша) |
| 4 | Воркер T2 завис на 25 мин: tool-call умер, агент вечно «ждал результатов» | Чекпоинт: замороженный счётчик токенов в TUI | Пинок `terminal send` с инструкцией прервать (esc) и продолжить — рестарт не понадобился |
| 5 | Живая игра слишком легка: клёв мгновенный, сходов нет, босс без риска — головой по числам это не видно, только плейтестом | T5: замеры в живом браузере | Баланс числами (см. выше); системно — вываживание и ×5 босса требуют правки логики, оставлено как рекомендации |
## Этап 15. Спринт 4: симуляторская физика
**План спринта.** Вываживание из «держалки с таймерами» превратить в перетягивание: у рыбы появляется выносливость, у катушки — тормоз, силы сторон честные, а отпускание в рывке — осознанная стратегия, а не промах. Исследование и план с инвариантами: `docs/PHYSICS-RESEARCH.md` (коммит `fafa0ad`). Поток: T2 — P0-физика (`70ff964`), T3 — независимое ревью с Monte-Carlo (`«к мержу после фиксов»`, 2 MAJOR), T2b — ребаланс по ревью (`60f3755`), T4 — тесты + живой плейтест + замеры ботами (`cc6238a`, вердикт «Релиз готов»), T5 — релиз.
**Что добавлено (механики по пунктам):**
1. **Выносливость рыбы (stamina)** — `reel.st`: стартовое `st0 = 0.65+0.35·min(1,w/wMax)` (крупная выносливее), у босса `1` (свежий). Убывает весь бой в `updateReel()`: быстрее в рывке, при удержании и при «сдаче в рывок» (pull без holding). Свежая рыба тянет дольше (рывок `(0.8+2.2·st)·(0.6+0.7·ag)·rand(0.8,1.25)` с), уставшая — короче и слабее: при `st ≤ 0.12` флаг `tired` — сила рывков ×0.45 (`fishForce`), втягивание быстрее до ×1.6. UI: тонкая фиолетовая полоска под дистанцией (затухает к нулю), при усталости — 💤 и статус «💤 Рыба устала — втягивай!»; силуэт уставшей рыбы вялый (амплитуда ×0.35).
2. **Тормоз катушки (drag)** — новая черта удилищ в `RODS` (`drag` 34/47/63/87): в рывок сила рыбы `fishForce()` перетягивается против тормоза — если рывок сильнее (`over = F−drag > 0`), леска **скользит** (дистанция растёт `over·0.22`) и нагрузка растёт быстрее; ниже тормоза — нормальное втягивание. UI: отметка drag на жёлтой полосе нагрузки («выше — леска скользит»).
3. **Честный баланс сил (T2b)** — сила рыбы `fishForce(r,ag,pull)`: в рывке `(16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov)`, в затишье `5+7·ag` (уставшая ×0.45); натяжение `10+max(0,over)·1.1+22·ag` (в затишье ×0.35); втягивание `(11+3·reel)·(1+0.6·(1−st))`; свободный бег в рывке `F·0.32`; старт боя `d0 = 45+7·w`; near-miss-порог `1.45·d0` (утешение 10% цены).
4. **Escape: отпускать в рывок безопасно** — фикс MAJOR-1: escape-бар больше не растёт при `pull && !holding` (стоимость отпускания — только дистанция, «сдался рыбе»), а растёт только при зависании в затишье без удержания (`0.2+ag·0.4`/с, гашение 0.5/с при holding) — пассивная игра (не держать кнопку) = гарантированный сход. Подсказка «⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡» теперь соответствует механике (MAJOR-2).
5. **Пустые клёвы** — поплавок молчит: по таймеру в WAIT ролл `biteChance()` (приманка/дистанция/ночь), промах — новый таймер `rand(2.5,5)/bite`. Тест-форс — глобал `forceBite` (let, паттерн `forceBoss`), сброс — в btnResetYes.
**Тесты (числа):**
- `tests/test.js`: +7 проверок (61→**68**): ст-инициализация, ст-убывание, ослабление `fishForce` (F2<0.6·F1), tired-флаг, drag-скольжение (d растёт, tension>drag), drag-втягивание (d падает, tension<drag), пустой клёв. T4: **68/68 PASS ×3 подряд**, exit 0.
- `tests/orca-playtest.js`: харденинг против гонки T2b (атомарный hold, см. камешки) — **47/47 live PASS** в браузере Orca, без retry'ев.
- `tests/shots.js`: **11/11** кадров, свежие в `screenshots/`.
- Monte-Carlo приёмка по точным формулам (N=400/сценарий, dt=1/60, боты доки §4.4): «умный» (держать в calm/отпускать в pull) — карась 98%/13.0 с, окунь 85%, лещ 92%, сом/Карбон 66%/20.4 с, **босс/Титан 59.5%/42.8 с** (босс ≠ 100% даже на топ-удочке); «всегда держать» — мелочь 100%, сом/Стекло 39% + SNAP 61%, босс/Титан 62% + SNAP 38%; «пассив» — 100% сходов за 3.5–6.7 с. Stamina-дуга видна: st при улове 0.2–0.5 против старта 0.7–1.0.
**Найденные камешки:**
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | **MAJOR-1 (T3):** skill-curve инвертирован — «правильная» стратегия (держать в calm / отпускать в pull) теряла рыбу: свежий рывок 2.5–3.9 с длиннее escape-окна 1.7–2.3 с (ag≥0.6) → «умный» босс 98–100% сходов; «всегда держать» = 100% уловов впритык и босса на Титане/Карбоне (peakT p90 25–80%); бои 1.3–7.2 с против целей 8–50 с; stamina-дуга не видна | Monte-Carlo (N=400) точных формул из `index.html` с тремя ботами доки §4.4 | Т2b-ребаланс: escape не растёт при `pull&&!holding`, drain медленнее ×~4 (дуга на бой), `d0=45+7·w`, натяжение `10+over·1.1+22·ag`, втягивание `(11+3·reel)·(1+0.6(1−st))`, free-run `F·0.32`; после: босс/Титан 59.5%, бои 10–45 с |
| 2 | **MAJOR-2 (T3):** подсказка «⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡» противоречила механике — отпускание в рывок гарантировало «Сбежала!» | Т3-ревью: сопоставление UI-текста с формулами | Лечено вместе с MAJOR-1: отпускание стало безопасным и выигрышным, текст сохранён |
| 3 | МИНОР (T3): AGENTS.md не обновлён — новые тест-глобалы `forceBite`/`fishForce` не в списке защищённых имён, счётчик проверок устарел | Т3-чеклист ограничений | Обновление AGENTS.md в коммите T2b (имена в списке, 68 проверок, секция «Физика боя») |
| 4 | МИНОР (T3): инвариант доки «втягивание в calm при st=1 = ровно 30·reel px/с» неверен — формула даёт 30 только для rod 0, rod 1–3 медленнее на 17–41% | Сверка доки §4.2 с кодом | Исправлена дока (код — источник истины); учтено в T2b-числах |
| 5 | НИТ (T3): полоска stamina перекрывала верх эмодзи ⛵ на HUD | Скриншот `shot-reel.png` | T2b: полоска начинается после эмодзи (dx+18) |
| 6 | НИТ (T3): `forceBite`/`forceBoss` не сбрасывались в btnResetYes | Т3-ревью (паттерн предшествовал коммиту) | T2b: сброс добавлен |
| 7 | НИТ (T3): тесты §10 завязаны на абсолютные константы физики (запасы 18–80%) | Т3-ревью покрытия | После T2b пороги не упёрлись (запасы ×2.4–×3.1 проверены прогоном); в AGENTS.md — правило гонять Monte-Carlo при ребалансе |
| 8 | НИТ (T3): stale `reel` после SNAP/escape/near-miss (не null до следующего hook()) | Т3-аудит утечек состояния | Пререксизинг с main, все потребители под guard'ами `state!=='REEL'` — оставлено как есть |
| 9 | Гонка в `orca-playtest.js`: CLI-roundtrip (reset → pointerdown ≈ 1.5–2.5 c игрового времени) длиннее роста escape-бара (1.67 с) — бот «пассивен в затишье» между eval'ами, босс-шаг падал сходом; headless-стресс той же последовательности: 14/60 боёв кончались сходом | T4: прогон 1 упал на босс-шаге (`timeout: SNAP фазы 1, state=WAIT`); диагностика стрессом | Т2b-атомарный hold в тесте: сброс+удержание в ОДНОМ eval, гашение escape-бара в check-eval и при SNAP (паттерн `forceBite`); после: 0/60 в стрессе (даже с гэпом 1.2 с), 47/47 live. Код игры не тронут — хрупок был тест |
| 10 | Босс на Титане после подсечки не создаёт угрозы: 20/20 побед, ни одного опасного состояния нагрузки/дистанции (too_easy на адекватной снасти) | T4: живой плейтест + бот (pump, 20 боёв) и трассировки | Не блокер — передано в следующий спринт (усилить рывки/нагрузку на эндгир-удочке или осознанно оставить «наградой за прогресс») |
| 11 | 0 настоящих обрывов лески в 259 обычных боёв — ось «прочность шнура» не ощущается игроками-«держателями» | T4: трассировки (макс. нагрузка 80/100) | Не блокер — в следующий спринт (MINOR) |
| 12 | 43–47% раннего дохода — утешительные выплаты за сходы (+10% цены): фармить сходы выгоднее осторожной игры (1786 монет за сходы против 953 за уловы) | T4: экономические замеры ботом (~340 забросов) | Не блокер — в следующий спринт (ограничить утешение, напр. только для рыб дороже N) |
| 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), не гейтинг.
## Этап 17. M6: Варианты рыб (серебряная/золотая/радужная) и «Улучшить вариант»
**Что сделано в `index.html`:**
1. **Таблица `VARIANTS`** — 4 варианта улова: normal ×1, silver ×2 (5%), gold ×3 (живая `SHINY_CHANCE`, по умолчанию 3%), rainbow ×5 (0.5%); E[mult]≈1.13. Ролл — `rollVariant()`: порядок gold → silver → rainbow, иначе normal; тест-форс — `forceVariant` (let, паттерн `forceBoss`, сброс в btnResetYes). Константа `SHINY_MULT` из M1 удалена: ×3 теперь — множитель gold-варианта.
2. **`catchFish()`** — ценность улова умножается на `variant.mult` (босс ×5 не складывается с вариантом: босс всегда normal — `variant = boss ? VARIANTS[0] : rollVariant()`). Семантика «shiny» = gold-вариант (квест `catch_shiny`, счётчик `sn`); частицы улова у варианта ярче и многочисленнее.
3. **Счётчики и миграция** — `save.caught[id]` теперь `{n, best, sn, sv, rb}` (gold/silver/rainbow); миграция M6 в `loadSave()`: старым записям без `sv`/`rb` дописываются 0 — старые сейвы читаются (тест §10c(6)).
4. **Карточка улова** — бейдж варианта `#cardVariant` (имя заглавными, скрыт у normal) и оформление карточки `silver-card`/`gold-card`/`rainbow-card`; кнопка «Улучшить вариант» `#btnCardUp` во второй кнопке карточки: цепочка `VARIANT_CHAIN` normal → silver → gold → rainbow, у rainbow и у босса кнопки нет. Формулы — в едином хелпере `cardUpNext()`: `vNext = round(vCur·nextMult/curMult)`, `cost = round(0.6·(vNext−vCur))` (cost = 0.6×Δ ценности). Клик: итог улова становится `value_final−Σcost` (`coins += (vNext−vCur)−cost`, `earned += (vNext−vCur)`), счётчики вида переводятся со старого варианта на новый, карточка/бейдж/HUD обновляются; защита в глубину — `if(!cost) return;` (бесплатно нельзя) и `if(!rec) return;` (нет записи вида).
5. **Дневник** — у каждого вида 4 мини-счётчика вариантов: обычная (n−sv−sn−rb) / серебряная / золотая / радужная; не пойманный вариант — «—».
6. **Достижение `full_variants` «Полная коллекция»** (18-е) — поймать все виды во всех вариантах: check по `SPECIES.every` (масштабируется до 11 видов этапа 5), награда +5000 🪙 разово и +5% к монетам перманентно (`coinPct 0.05` в `ACHIEVEMENTS`, выплата в `checkAchievements()`).
7. **`variantChance()`** — единственная точка вероятностей всех вариантов (gold — живая `SHINY_CHANCE`); сюда встанет дождь M8.
**Тесты:** +10 проверок в `tests/test.js` (§10c: данные `VARIANTS` и живой gold-шанс; `forceVariant=silver` — ×2 + счётчик `sv` + бейдж/класс; `forceVariant=rainbow` — ×5 + `rb`, кнопки «Улучшить» нет; босс+`forceVariant` — только ×5 босса, босс всегда normal; границы ролла `rollVariant()` с закреплённым `Math.random`; миграция сейва (`sv`/`rb` → 0, `n/best/sn` сохранены); бейдж/класс карточки по варианту (`showCard`); улучшение normal→silver (`vNext = vCur×2`, дельта монет, счётчик `sv`, кнопка стала «ЗОЛОТАЯ»); цепочка до rainbow (итог `value_final−Σcost`, `rb=1`, кнопка исчезла); `full_variants` (+5000 ровно один раз, `achBonus().coinPct = 0.13`, повтор — без выплаты)). Итог **92/92 PASS, exit 0** (`node tools/check.mjs` — OK). В `tests/orca-playtest.js` шаг 8 переведён на M6-селекторы: бейдж `#cardVariant` «ЗОЛОТАЯ», класс карточки `gold-card` (ранее `#cardShiny`/`shiny-card`/«ШИНИ»).
**Monte-Carlo:** не требовался — физика вываживания не тронута (формулы/константы боя без изменений); варианты влияют только на ценность улова, счётчики и UI.
**Заметки (осознанные решения и ловушки):**
- Улучшение улова до gold инкрементирует счётчик `sn` → ачивка `first_shiny` («Золотая жила») достижима покупкой варианта, а не только удачным роллом. Осознанно: рыба буквально становится золотой.
- Ловушка этапа 5 (при 11 видах): check ачивки `species_8` = `SPECIES.every` — потребует все 11 видов, а desc говорит про 8. Разберёмся в этапе 5.
## Этап 18. M8: Погода — дождь и туман
**Что сделано в `index.html`:**
1. **Таблица `WEATHER`** — два события: rain 🌧️ (`variantMult:2.5` — варианты silver/gold/rainbow ×2,5) и fog 🌫️ (`biteMult:1.5` — клёв чаще); у каждого иконка и тосты старта/конца («☀️ Погода прояснила»). Тест-форс — `forceWeather` (let, значения 'rain'/'fog'/null, паттерн `forceBoss` — «активна всегда», сброс в btnResetYes).
2. **Таймеры — в реальном времени** (`updateWeather()` в `frame()`, как день/ночь): между событиями случайный gap `WEATHER_GAP_MIN..WEATHER_GAP_MAX` (120–240 с), событие длится `WEATHER_DURATION` (120 с), rain/fog 50/50; под форсом таймеры не гоняются. Погода session-only — в сейв не персистится: на чистом старте (загрузка/сброс) — `weather=null` и новый случайный gap.
3. **`weatherActive()`** — единственная точка чтения погоды (форс приоритетнее, иначе естественное состояние): эффекты только в `variantChance()` (rain: silver/gold/rainbow ×2,5; normal не трогаем, босс всегда normal) и `biteChance()` (fog: клёв ×1,5).
4. **HUD-пилюля `#weather`** рядом с монетами: иконка + остаток секунд («🌧️ 97с»); под форсом — полная длительность; без погоды — скрыта (сравнение целым text — смена иконки rain↔fog не мигает).
5. **Canvas-визуал** (`drawWeather()` — слой в конце draw-цикла): дождь — 40 косых полосок (частицы `rain`, `initRain()`, обновление в `updateDecor()`); туман — полупрозрачный слой + 2 полосы глубже с дрейфом; rect тумана расширен на 8px по периметру — слой рисуется внутри shake-транса, при обрыве фона по краям не просвечивает.
6. **Скриншот** — кадр `weather-rain` в `tests/shots.js` (паттерн форса night-кадра: `forceWeather='rain'`, ждём инициализацию частиц, сброс), `screenshots/shot-weather-rain.png` для README.
**Тесты:** +8 проверок в `tests/test.js` (§10d: данные — множители `WEATHER` и константы таймингов; дождь — `variantChance` ×2,5 (silver 0.125, gold 0.075, rainbow 0.0125); туман — `biteChance` ровно ×1,5 к базовому; `weatherActive()` — форс/естественная/null; естественный старт (gap 0,5 с → событие, `weatherT≈120`, тост); естественный конец (новый gap 120–240 с); таймеры — заморозка под форсом / тик без; HUD-пилюля rain↔fog↔hidden). Итог **100/100 PASS, exit 0** (`node tools/check.mjs` — OK). Ожидание естественных переходов — polling условия (паттерн `waitState`), а не фиксированные sleep: dt игры клампится (`dt=min(0.05,…)`) и при fps<20 игровое и реальное время расходятся.
**Monte-Carlo:** не требовался — физика вываживания не тронута; погода влияет только на вероятности (варианты/клёв), формулы и константы боя без изменений.
**Заметки (осознанные решения и ловушки):**
- Пре-эксзистинг-флейка §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` — в бэклоге (отдельная задача).
## Этап 20. Локации: Озёра
**Что сделано в `index.html`:**
1. **3 новых вида в `SPECIES` (всего 11)** — zander «Судак» (r2, 40 🪙/кг, 1.0–6.0 кг), chuch «Хариус» (r1, 30 🪙/кг, 0.2–2.0 кг), crayfish «Гигантский рак» (r3, 200 🪙/кг, 0.2–1.0 кг) — жильцы Озёр, в реку не заходят (роллы реки не меняются).
2. **4 таблицы ролла + `pickSpecies()`** — `RIVER_SPECIES(_NIGHT)` (фильтр `SPECIES`/`SPECIES_NIGHT` по `RIVER_IDS` — ровно старые 8, порядок и веса как прежде) и `LAKE_SPECIES(_NIGHT)` (все 11, свои веса `LAKE_DAY_WEIGHTS`/`LAKE_NIGHT_WEIGHTS`: днём «рыбное» озеро, ночью смещение к хищникам). `pickSpecies()` выбирает таблицу по `save.location` × dayPhase (вечер/ночь) — логика самого ролла не тронута.
3. **`LOCATIONS` + `drawWater()` по локации** — таблица: Река (0, бесплатно) / Озёра (25000 🪙); у каждой `water` — 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot]: градиент воды берёт базовые цвета текущей локации (clamp 0..1), nightMix-логика прежняя.
4. **Сейв и миграция** — `save.locations:[0]`/`save.location:0`; миграция в `loadSave()` (гарантия бесплатной Реки, битые значения → 0, паттерн скинов) — старые сейвы читаются (тест §10f(5)).
5. **Вкладка «Локации» 🗺️** (`#tabLocations`, строка `locationRow`) — `shopClick()`: ветка `buylocation` (защиты: уже куплено / не хватает монет; списание, push, АВТО-ПЕРЕХОД `save.location`, тост «Добро пожаловать на Озёра!») и `selectlocation` (выбрать только купленное, монеты не тратятся).
6. **Ачивка `species_8`** — desc теперь «Поймай все виды» (честно: check = `SPECIES.every` — все 11); id не тронут — совместимость старых сейвов.
**Тесты:** +10 проверок в `tests/test.js` (§10f: данные/таблицы/роллы реки 3000×2/роллы Озёр 3000×2/миграция/покупка/защиты/переключение/дневник 11/масштаб ачивок). Итог **117/117 PASS, exit 0** (`node tools/check.mjs` — OK).
**Monte-Carlo:** не требовался — физика вываживания не тронута (формулы/константы боя без изменений).
**Заметки (осознанные решения и ловушки):**
- Разбор «ловушки этапа 17»: при 11 видах check ачивки `species_8` (`SPECIES.every`) честно требует все 11 видов — desc обновлён («Поймай все виды»), id не тронут ради старых сейвов.
- Экономика: 25000 🪙 ≈ 250–400 средних озерных уловов — честный end-game coin-sink. Озёра — цель разнообразия/коллекции (гейт на `species_8` и `full_variants` 11×4); рак (40–200 🪙 за улов, шанс ~0.7–1%) — low-frequency бонус, а не чит на монеты.
## Этап 21. Убрано «Улучшить вариант» — рыбу нельзя апгрейдить
**Что сделано в `index.html`:**
1. **Кнопка «Улучшить вариант» полностью удалена** — `#btnCardUp` из разметки карточки, CSS (`#btnCardUp` и ставший мёртвым `.big:disabled`), функции `cardUpNext()`/`renderCardUpBtn()`, клик-обработчик, глобал `cardInfo`, константа `VARIANT_CHAIN`. Карточка улова теперь — бейдж/оформление по варианту + кнопка «Отлично!».
2. **Что осталось без изменений** — таблица `VARIANTS`, роллы (`rollVariant()`/`variantChance()`, включая дождь M8), тест-форс `forceVariant`, счётчики `sv/sn/rb` и их миграция, дневник, ачивка `full_variants`. Формат сейва не менялся — миграция не нужна, старые сейвы читаются.
**Тесты:** −2 проверки в `tests/test.js` (§10c: удалены «улучшение normal→silver» и «цепочка до rainbow»; из данных-теста убран чек цепочки; тест silver теперь проверяет, что элемента улучшения в DOM нет; rainbow/босс-тесты переименованы без упоминаний `#btnCardUp`). Итог **115/115 PASS, exit 0** (`node tools/check.mjs` — OK). Скриншоты перегенерированы (кадр `card` без кнопки), свежие скопированы в `screenshots/`.
**Monte-Carlo:** не требовался — физика вываживания не тронута; удаление касается только UI карточки.
**Заметки (осознанные решения и ловушки):**
- Глобалы `cardInfo` и `VARIANT_CHAIN` убраны из игры — тесты и AGENTS.md обновлены в том же коммите.
- `sv/sn/rb` в сейве остались: дневник и `full_variants` работают как прежде, старые сейвы без миграции.
## Этап 22. Расширение достижений + тексты «shiny» → «золотая рыба»
**Что сделано в `index.html`:**
1. **Тексты** — «shiny» из видимых мест убран (M6: «shiny» = gold-вариант): квест `catch_shiny` → «Поймай золотую рыбу», desc ачивки `first_shiny` «Золотая жила» → «Поймай золотую рыбу». Идентификаторы `first_shiny`/`catch_shiny` и глобал `SHINY_CHANCE` не тронуты (тесты, старые сейвы); README/BROWSER-TESTING/лейблы orca-playtest приведены к «золотая рыба» (скрипт-файл `orca-shiny.png` имя не менял).
2. **11 новых достижений (18 → 29)** — по итогам Этапов 1–5:
- варианты (M6): `first_silver` «Серебро» (🥈, +1%), `first_rainbow` «Радужный улов» (🌈, +2%);
- погода (M8): `rain_variant` «Дождливый везунчик» — особая (≠normal) рыба в дождь (🌧️, +1%), `fog_catch` «Туманный улов» — любой улов в тумане (🌫️, +1%);
- скины (M-skins): `skin_first` «Красуйся» — куплен любой скин (🎨, +1%), `skins_all` «Стилист» — все 6 (🎭, +2%);
- локации (Этап 5): `lake_first` «Новые берега» — открыты Озёра (🗺️, +2%);
- снасти (M5): `gear_max` «Максимальная снасть» — снасть 4 ур. (⚒️, +1%), `titan` «Титан» — куплена удочка «Титан» (💪, +2%);
- новое: `weight_1000` «Тонна рыбы» — суммарно 1000+ кг (🚢, +2%), `night_first` «Ночной клёв» — первый ночной улов (🌙, +1%).
Все бонусы — `coinPct` (монеты); суммарная инфляция при всех ачивках ~+15% к монетам.
3. **Новые поля сейва** — `save.kgTotal` (сумма кг уловов), `save.nightCatch` (bool), `save.wxRainV`/`save.wxFog` (счётчики уловов в дождь с особым вариантом / в тумане). Дефолты в `save`/`freshSave()`; **миграция не нужна** — все чтения через `||0`/флаг, старые сейвы читаются (тест §10g(10)).
4. **Крючок в `catchFish()`** — до `checkAchievements()`: `kgTotal += w`; `dayPhase==='night'` → `nightCatch=true`; `weatherActive()==='rain' && variant!=='normal'` → `wxRainV++` (босс всегда normal — не заходит), `weatherActive()==='fog'` → `wxFog++`.
**Тесты:** +11 проверок в `tests/test.js` (§10g: тексты (desc/квест, «shiny» в desc нет); данные (11 id, `ACHIEVEMENTS.length===29`, уникальность, поля); геймплей — ночной улов в дождь с серебряным: крючки счётчиков (`nightCatch`, `wxRainV`, `kgTotal` ровно на w) и выдачи `first_silver`+`rain_variant`+`night_first` (туманного нет); `first_rainbow` по `rb=1`; `fog_catch` по `wxFog=1`; граница `weight_1000` (999.9/1000); скины (1 купленный → `skin_first`, все 6 → `skins_all`); `lake_first` ([0]→[0,1]); снасти (макс 3 ур. — нет, 4 ур./«Титан» — да); старый сейв без новых полей — без ложных выдач и ошибок, дефолты `freshSave`). Пин §10c(8) `full_variants` обновлён: сумма `achBonus().coinPct` 0.13 → **0.16** (туда попали `first_silver`+`first_rainbow`). Итог **128/128 PASS, exit 0** (`node tools/check.mjs` — OK).
**Monte-Carlo:** не требовался — физика вываживания не тронута; изменения — только тексты, достижения и счётчики сейва.
**Заметки (осознанные решения и ловушки):**
- Геймплей-тест ночного улова требует фонарика: `save.rodLv[save.rod]=2` (ветка WAIT: ночью без `hasLight()` клёв не стартует — таймер тикает вхолостую).
- «Все рыбы во всех вариантах» — отдельная ачивка не добавлялась: это существующая `full_variants` «Полная коллекция» (11×4).
- Порядок записей в `ACHIEVEMENTS` — тематический (снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации у магазинных); тесты не зависят от порядка (только `ACHIEVEMENTS.length` и id).
## Этап 23. Ушедшие рыбы без наград + рыба в тосте обрыва
**Что сделано в `index.html`:**
1. **Сходы без выплат** — из обеих веток схода в `updateReel()` убрано «утешение» 10% цены (`floor(вес × цена/кг × 0.1)` + `save.coins+=nm; persist(); updateHUD()`): escape-бар (`reel.escape>=1`) и near-miss (`reel.d>=reel.d0*1.45`). Общий хвост (сброс `stateBoss`/`bossPhase`, тост → `WAIT` → `biteTimer` → `sfx('escape')`) вынесен в `fishEscaped(sp)`; тост один: «Сбежала! <emoji> <имя> <вес> кг». Устаревший комментарий near-miss-ветки удалён. Формат сейва не менялся — миграция не нужна.
2. **Рыба в тосте обрыва** — тост SNAP теперь с указанием рыбы: «💥 Линия лопнула! <emoji> <имя> <вес> кг» / «💥 Рыба крупнее предела удочки! <emoji> <имя> <вес> кг» (разбор `ov>1.15` сохранён). Босс-обрыв фазы («⚠️ Босс рвёт леску! Фаза N/3») не тронут — это не настоящий обрыв, а рыба и так известна.
**Тесты:** переписаны на месте §9c (`tests/test.js`: пин `save.quests`/стрика перед прогоном — монеты не изменились; тост «Сбежала!» с весом, без «🪙») и (2b) (§9l: сход монет не даёт, HUD в согласии с сейвом); +1 проверка в тесте «обрыв: SNAP -> IDLE» — тост начинается с «💥» и содержит «кг» (тост читается в следующем eval сразу после `waitState('SNAP')`: тост живёт 1.6 с, SNAP — 1.0 с, окна хватает). Комментарий шага 9 в `tests/orca-playtest.js`: «сход с утешением» → «сход без выплат». Итог **129/129 PASS, exit 0** (`node tools/check.mjs` — OK). Счётчики проверок в README/AGENTS.md: 128 → 129.
**Monte-Carlo:** не требовался — физика вываживания не тронута; убраны только выплаты.
**Заметки (осознанные решения и ловушки):**
- Закрывает камень #12 из Этапа T4: утешительные выплаты за сходы давали 43–47% раннего дохода (фармить сходы было выгоднее осторожной игры).
- `fishEscaped(sp)` — единственная точка исхода «рыба ушла»: обе ветки сходятся в неё, новые тексты/звук — править в одном месте.
## Этап 24. Y1: SDK Яндекс Игр
**Что сделано в `index.html`:**
1. **Обёртка YG** — секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ», единственная точка контакта с платформой: стаб при file:// без `?sdk=1` (скрипт `/sdk.js` не создаётся — чистая консоль; no-op'ы + счётчики вызовов `YG.counts` для тестов) и http(s) — динамическая загрузка `/sdk.js` (один ретраи, warn, 3-сек slow-warn), апгрейд стаб → реальный SDK в `YG.init()` (однократно, catch-up `ready()`, если SDK доинициализировался позже).
2. **`LoadingAPI.ready()`** — первым кадром `frame()` + catch-up в `init()`.
3. **`GameplayAPI.start/stop`** — btnPlay/openShop/closeShop/btnReset/btnResetNo; карточка улова — без stop (часть цикла улова, осознанно).
4. **Пауза платформы** — `game_api_pause/resume` + `visibilitychange` → `YG.onPause/onResume` (`AC.suspend()`; при `YG.paused` в `frame()` заморожено всё игровое время).
**Тесты:** +7 проверок в `tests/test.js` (§Y1: стаб в file:// — SDK не создан и счётчики на месте, `ready()` ровно один раз первым кадром, start/stop-разметка btnPlay/btnShop/btnCloseShop/btnReset/btnResetNo по `YG.counts`, пауза/резюме — заморозка frame()). Итог **136/136 PASS, exit 0** (`node tools/check.mjs` — OK). Ревью F1–F7 закрыты (заморозка frame(), loader-ретраи, src по протоколу, guard btnPlay, доки).
**Monte-Carlo:** не требовался — физика вываживания не тронута; SDK не влияет на геймплей.
**Заметки (осознанные решения и ловушки):**
- Стаб при file:// без `?sdk=1` — осознанно: скрипт SDK не создаётся (чистая консоль), игра остаётся полностью играбельной вне платформы.
- Резюме (`YG.onResume`) не снимается, пока открыта реклама — снимает `endAd()` (guard `adShowing`; путь: visibilitychange/game_api_resume, а оверлей рекламы ещё открыт).
## Этап 25. Y2: Монетизация
**Что сделано в `index.html`:**
1. **Interstitial** через `ysdk.adv.showFullscreenAdv` — на закрытии карточки улова (`tryInterstitial()` в хендлере `btnCardOk`): логическая пауза (треб. 4.4), гейты `AD_FIRST_MS` (первый показ не раньше 2 мин сессии) / `AD_COOLDOWN_MS` (между показами 3 мин), `persist()` **ДО** показа (4.2 — улов не теряется при убийстве страницы платформой), `YG.onPause` — звук + процесс (4.7), watchdog 90 с (1.14) — если SDK не закрыл рекламу, паузу снимаем сами.
2. **Rewarded «монеты ×2»** — RV-кнопка `#btnRv` «📺 Реклама: ×2 за этот улов» на карточке улова через `ysdk.adv.showRewardedVideo`: `onRewarded` → +val (цена улова), раз на улов (`rvDone`), бонус не блокирует закрытие карточки (4.5.2); честная формулировка (4.5.1); простой текстовый button — НЕ имитация рекламного блока платформы (1.16).
3. **Стаб** (file:// без SDK): fullscreen → `false` (реклама не показана, мгновенный resume), RV — синхронная симуляция успешного просмотра (локальная разработка, all-в-одном-клике флоу).
**Тесты:** +9 проверок в `tests/test.js` (§Y2: RV-карточка (кнопка видна/включена, текст «×2», `rvVal` = цене улова), RV-клик (×2 монет, раз на улов, повторный клик no-op), interstitial-гейты 2 мин / кулдаун 3 мин по счётчику `fsAdv` (adShowing/paused=false), cleanup — возврат в MENU). Итог **145/145 PASS, exit 0** (`node tools/check.mjs` — OK). Кадр `card-rv` в `tests/shots.js`. Ревью F1–F5 закрыты (guard adShowing в резюме, watchdog, дельта-пин, текст кнопки).
**Monte-Carlo:** не требовался — физика вываживания не тронута; реклама не влияет на геймплей.
**Заметки (осознанные решения и ловушки):**
- Карточка улова БЕЗ `gameplayStop()` — осознанно (часть цикла улова, не выход из геймплея).
- `lastAdTs` — осознанно «попытка», не «показ»: при троттлинге платформы `wasShown=false` кулдаун всё равно сгорает.
- Реклама не персистится в сейв (как погода): `persist()` только перед показом и после бонуса.
## Этап 26. Y3: Полировка
**Что сделано в `index.html`:**
1. **`-webkit-touch-callout:none`** — в универсальный селектор (треб. 1.6.1.8: лонгтап не вызывает системный touch-callout на iOS; user-select и contextmenu были закрыты ранее).
2. **`.rvbtn{min-height:44px}`** — гарантия тап-таргета RV-кнопки ≥44px независимо от line-height/шрифта платформы (по замечанию UI-ревью).
3. **Дедуп watchdog** — тело watchdog вынесено в метод `YG._startAdWatchdog()`, вызов из `showFullscreen`/`showRewarded` после успешного `adv.*`; `endAd()` без изменений.
**Тесты:** +1 проверка в `tests/test.js` (§Y3: computed-style `.rvbtn` min-height 44px). Итог **146/146 PASS, exit 0** (`node tools/check.mjs` — OK).
**Monte-Carlo:** не требовался — изменения только в CSS и декомпозиции YG.
**Заметки (осознанные решения и ловушки):**
- Финальное gate-ревью Y1–Y3: PASS по таблице требований 1.1–8.x (частично 1.20/1.23 — emoji-шрифт на старых Win ОС, осознанно).
- Доки README/AGENTS.md синхронны с реальным числом проверок (146).
## Этап 27. Y4: Упаковка
**Что сделано:**
1. **`tools/pack.mjs`** (чистый Node, без зависимостей) — сборка `dist/fishing-yandex.zip` для Яндекс Игр (треб. 1.22): ТОЛЬКО `index.html` в корне архива, без docs/tests/screenshots/прочего. Сборщик: `zip -j` (основной, deflate; junk-путь — файл сразу в корне), при отсутствии — `python3 -m zipfile -c` (fallback; наличие обеих команд проверяется заранее, если обеих нет — понятная ошибка и exit 1). `dist/` создаётся автоматически, старый архив удаляется перед сборкой; `dist/` добавлен в `.gitignore`.
2. **Самопроверка в том же скрипте** — после сборки архив распаковывается в каталог tmp (`fs.mkdtemp`, встроенный zip-ридер: EOCD + центральная директория + `zlib.inflateRawSync`, `unzip` не нужен) и проверяются 5 пунктов, каждый с PASS/FAIL: (a) ровно один файл `index.html` в корне; (b) размер архива < 100 МБ (размер выводится); (c) имена без пробелов/кириллицы (regex); (d) sha256 распакованного файла == sha256 исходного `index.html` (byte-идентичность: игра в архиве = игра в репо); (e) синтаксис JS, вынутый из распакованного файла (тот же паттерн извлечения `<script>`-блоков, что в `tools/check.mjs`), через `node --check`. Любой FAIL — exit 1 и сообщение; в конце — путь к zip + размер.
3. **README** — раздел «Публикация в Яндекс Играх»: команда сборки, SDK-стаб локально, отладка с `?sdk=1` (sdk.js рядом или локальный прокси Яндекса), настройки консоли разработчика.
**Результат прогона:** все 5 пунктов PASS, exit 0. `dist/fishing-yandex.zip` — 40.6 KB (zip/deflate); python3-фолбэк тоже deflate (проверено на Python 3.12) — 40.7 KB, все 5 пунктов PASS. sha256 исходного `index.html` `824309ff20f3…` совпадает с распакованным.
**Что делать в консоли Яндекса (публикация):** загрузить `dist/fishing-yandex.zip`; включить монетизацию; опция облачных сейвов — выключена (игра сама хранит сейв в `localStorage`); платформы — без Android TV; `debug-mode=16` — для проверки лоадера.
**Тесты:** `node tools/check.mjs` — OK; `cd tests && npm test` — **146/146 PASS, exit 0** (код игры не тронут, число проверок не изменилось).
**Smoke упакованной копии (независимая верификация Y4):** повторный `node tools/pack.mjs` — 5 PASS, exit 0 (идемпотентно); архив распакован отдельно (`python3 -m zipfile -e`) — ровно один `index.html` в корне, sha256 распакованного совпадает с репо (`824309ff20f3…`), список записей вторым независимым ридером (`unzip -l`) — тот же один файл. Новый персистентный тест `tests/pack-smoke.js` гоняет игру с КОПИИ из архива (не из репо): MENU → YG-стаб (active=false, readySent=true) → #btnPlay (IDLE, меню скрыто) → #btnShop/#btnCloseShop → ноль JS-ошибок — **PACK-SMOKE: 6/6, exit 0** (3 прогона подряд). Подводка окружения: headless Chromium в этом чекауте не видит `/tmp` по `file://` (отдельный tmpfs, ERR_FILE_NOT_FOUND) — smoke при этом авто-откатывается во временный каталог под `$HOME`.
**Monte-Carlo:** не требовался — геймплей не тронут.
**Заметки (осознанные решения и ловушки):**
- Самопроверка распаковывает встроенным zip-ридером, а не `unzip` — у скрипта ноль бинарных зависимостей помимо самого сборщика (`zip` → fallback `python3`).
- python3-фолбэк тоже deflate (проверено на Python 3.12) — встроенный ридер самопроверки понимает и stored, и deflate; по размеру разница несущественна (40.6 против 40.7 KB).
## Этап 28. Встроенный emoji-шрифт (субсет Noto Color Emoji)
**Проблема:** все emoji игры (UI-строки, таблицы данных, canvas `fillText` — 💥🌀⚡💪🐟💤) рисовались системным шрифтом платформы. На системах без emoji-шрифта (headless Linux) — квадраты/монохром (известный подводный камень для скриншотов), а вид emoji разнился между платформами (Segoe/Apple/Android). Публикация в Яндекс Игры — строго один `index.html` (треб. 1.22, `tools/pack.mjs`), поэтому внешний `.woff2` невозможен — шрифт встраивается в HTML.
**Что сделано:**
1. **`tools/emoji-font.py`** (Python; dev-зависимости fonttools+brotli в `tools/.venv`) — генератор встроенного шрифта: вытаскивает все emoji-кодопоинты из `index.html` (одна точка истины — сам файл игры; диапазонные правила по Unicode-категориям + явные одиночные: стрелки, U+FE0F), строит субсет через `fontTools.subset` (woff2, без hinting; legacy-таблица SVG отбрасывается — цвет только через COLRv1) и вписывает base64 `@font-face` в `index.html` между маркерами `<!-- EMOJI-FONT:BEGIN/END -->`. Режим `--check` — регрессия: падает с листом недостающих кодопоинтов, если в `index.html` появился emoji, которого нет в субсете (требует только пересечение с cmap исходника — см. заметки).
2. **index.html** — блок `@font-face font-family:'EmojiEmbedded'` (57 глифов, 78 КБ woff2 → +105 КБ base64; index.html 155→260 КБ). Шрифт стоит ПЕРВЫМ в font-stack `html,body` и во всех 10 `ctx.font` (константа `UI_FONT` в секции «ОТРИСОВКА») — единый Google-стиль emoji на всех платформах. Перед первым кадром — `document.fonts.load('16px "EmojiEmbedded"')` с timeout-фолбэком 1.5 с (canvas-emoji рисуются загруженным шрифтом; data: URI грузится за миллисекунды, фолбэк страхует от «зависания» первого кадра на file://).
3. **tests/test.js** — секция §Y7: @font-face зарегистрирован в `document.fonts` + `document.fonts.check('16px "EmojiEmbedded"','🪙')` (шрифт реально резолвит glyph, а не молча падает в системный фоллбек).
4. **`.gitignore`** — `tools/fonts-src/` (исходник Noto Color Emoji, 25 МБ) и `tools/.venv/`.
**Почему Noto Color Emoji COLRv1 (2022+):** в игре есть emoji из Emoji 15.0 (2022) — 🪙 U+1FA99, 🪱 U+1FAB1, 🪝 U+1FA9D; старые версии шрифта их не знают. COLRv1 поддерживается всеми актуальными браузерами (Chrome/Firefox/Safari с 2023–2024).
**Результат прогона:** `python3 tools/emoji-font.py` → 57 глифов, woff2 78 КБ; `python3 tools/emoji-font.py --check` — OK; `node tools/pack.mjs` — 5 PASS, zip 110.9 КБ (было ~40 КБ до шрифта; лимит требования 1.22 — 100 МБ).
**Тесты:** `node tools/check.mjs` — OK; `cd tests && npm test` — **164/164 PASS, exit 0** (+2 новых §Y7); `node tests/pack-smoke.js` — **6/6**. Рендер-пробы (headless, canvas `fillText`): 🪙/⚠️/⏸/⭐ шрифтом «EmojiEmbedded» — цветные пиксели (не фоллбек).
**Визуальная проверка:** все 14 кадров `node tests/shots.js` пересняты, `screenshots/` обновлены. Среда чекаута — системный emoji-шрифт Emoji One (не Noto): на кадрах виден именно встроенный Google-стиль (🎣🖱️ в меню, ⚡🐟💪 на канвасе вываживания, 🐠🪙🏅📺 в карточке, 🐋 у босса, 🌧 в HUD-пилюле, 🏆 в тосте ачивки) — доказательство, что рисуется встроенный шрифт, а не системный.
**Заметки (осознанные решения и ловушки):**
- ✓ (U+2713), №, ●, ○, ↩ — в Noto Color Emoji их нет (текстовые глифы) — рисуются системными шрифтами, как и раньше; `--check` это учитывает (требует только `wanted ∩ cmap(исходник)`).
- ⚡ ⭐ ☀ ⚠ ⏸ ⚖ ⛵ ⚒ стали цветными emoji на всех платформах (на части систем раньше — монохромные) — осознанная унификация вида.
- U+FE0F (variation selector, 18 вхождений в «⚠️») в субсете нет: NCE мапит U+26A0 прямо в цветной глиф, FE0F остаётся нулевым формат-знаком — «⚠️» рисуется цветным (рендер-проба).
- Перегенерация субсета обязательна при добавлении новых emoji в index.html: `python3 tools/emoji-font.py` (реэкз в `tools/.venv`-интерпретатор встроен в скрипт); `--check` — защита от «добавил emoji, забыл перегенерировать».
## Этап 29. Клик по монетам → лавка; хаб «Меню» (перенос сброса/звука)
**Задача:** 1) клик по счётчику монет в HUD открывает «Лавку рыбака» ровно на вкладке магазина снастей — вкладка переименована «Снасти» → «Лавка», эмодзи 🎣 → 🛒 (внутри секции «🎣 Удилища»/«🪱 Приманки» не тронуты); 2) новый диалог «Меню» (`#gamemenu`): туда перенесены 🗑️ сброс и 🔊 звук из HUD + кнопки Магазин/Достижения/Дневник/Инструкции; «В меню» из паузы открывает именно его; при старте новой игры (первая загрузка и после сброса) по-прежнему показывается диалог с инструкциями (`#menu`).
**Решения:**
- **state сохраняется**: старое «В меню» делало `state='MENU'` (сброс заброса/боя). Новый хаб — оверлей как лавка: `uiOpen=true` замораживает игру, «Продолжить» возвращает в ТОТ ЖЕ момент (тот же pattern, что «Продолжить» паузы). `state='MENU'` теперь только при загрузке/сбросе.
- **Z-порядок оверлеев**: `#gamemenu` стоит в DOM ПОЗЖЕ `#shop`/`#resetDlg` (тот же z-index:20) — рисовался бы поверх лавки/подтверждения сброса. Поэтому при открытии лавки/сброса из «Меню» гменю прячется, а закрытие возвращает его (флаг `shopFromMenu`: `closeShop()` → показать гменю, `uiOpen` остаётся true, БЕЗ `YG.gameplayStart()`; лавка из HUD — как раньше, в игру).
- **YG-счётчики**: вход в гменю — из паузы (`onPause` уже сделал `gameplayStop`), поэтому `onResume` при `uiOpen=true` start не зовёт; «Продолжить» → `gameplayStart()` (+1/−1 сбалансировано); `btnReset` из гменю БЕЗ `gameplayStop` (был бы дубль stop). Пин в тесте §Y8(4): stop +1, start +0.
- **Инструкции из хаба** — тот же `#menu` («Играть» → игра, state='IDLE'); `btnPlay` дополнительно прячет `#gamemenu` и сбрасывает `shopFromMenu`.
- **Открытие вкладки**: `openShop(tab?)` — аргумент форсит вкладку (`syncTabs()` вынесен из `setTab`); `#coins` → `openShop('tackle')`, HUD 🛒 — без аргумента (помнит последнюю вкладку).
- **Кнопки перенесены с сохранением id** (`#btnReset`/`#btnMute` теперь в `#gamemenu`) — обработчики и i18n (`reset_title_attr`) без изменений; `updateHUD()` печатает `🔊/🔇 + t('gm_sound')`, `setLang()` дёргает `updateHUD()`.
- Новые i18n-ключи: `gm_title/gm_shop/gm_ach/gm_journal/gm_howto/gm_sound/gm_reset` (+EN); все emoji набора уже были в субсете — перегенерация `tools/emoji-font.py` не потребовалась.
**Тесты:** флоу `#btnReset`/`#btnMute` предваряются `toGameMenu()` (⏸ → «В меню»); §Y6(5) переписан (гменю виден, state=IDLE сохранён), (6) заходит к `#btnPlay` через «Инструкции»; новая §Y8: HUD-состав (🛒+⏸), монеты → «Лавка», вкладки из гменю, × → назад в «Меню» (YG-пин), mute-тогл (семантика «каждый клик — тогл»: фикстура старого сейва в §«локации» ставит `muted:true` — абсолютное значение не пинуем), «Продолжить», инструкции. `node tests/shots.js` — новый кадр `gamemenu` (15/15), `screenshots/` обновлены.
**Orca-плейтест:** шаг 2b переведён на настоящий UI-путь сброса (Играть → ⏸ → «В меню» → 🗑️ «Очистить» → «Сбросить») — синтетический `.click()` по скрытой кнопке заменил реальный путь пользователя. Новая секция 9e — переходы по хабу «Меню»: монеты → «Лавка», Магазин/Достижения/Дневник (× шаг назад в хаб, `uiOpen` остаётся true), Инструкции → «Играть», 🔊-тогл (скаляры CLI приходят JSON-строками — читаем через объект `({m: save.muted})`), «Продолжить». Плюс два давних флейка живого браузера закрыты (доки: BROWSER-TESTING.md п. 11–13): 2c — форс дня (сейв профиля приходил с ночью, ночью без фонарика не работает даже `forceBite`); шаг 12 — босс 5–40 кг рвал стартовую удочку натяжением до босс-фаз (выдаём «Титан», `save.rod=3`) + ожидание персистентного `bossPhase` вместо транзитного SNAP-окна 0.6 с (CLI-roundtrip мог его проскочить → CARD в фазе 3). Итог: **76/76 PASS**.
**Результат прогона:** `node tools/check.mjs` — OK; `cd tests && npm test` — **177/177 PASS, exit 0** (+8 §Y8, пересчёт §Y6); `node tools/pack.mjs` — 5/5; `node tests/pack-smoke.js` — 6/6.
## Этап 30. M6 расширен: медные/кварцевые/изумрудные/алмазные рыбы + буст шансов особых
**Задача:** добавить 4 новых варианта рыб (медная, кварцевая, изумрудная, алмазная) и в целом увеличить шанс особой рыбы. Решение по балансу (с владельцем): агрессивный буст, топ лестницы остаётся у радужной, кварц встаёт между серебром и золотом; новых ачивок не добавляем (только расширяется check `full_variants`).
**Решения:**
- **Лестница `VARIANTS` — 8 вариантов**, порядок ролла = порядок массива: copper ×1.5 @0.12 → silver ×2 @0.08 → quartz ×2.5 @0.07 → gold ×3 @`SHINY_CHANCE` (0.03→**0.06**, живая) → emerald ×4 @0.01 → diamond ×4.5 @0.007 → rainbow ×5 @0.005 (топ: самая редкая и самый большой множитель), иначе normal. P(особой) ≈ **31%** (было ≈8.5%), E[mult] ≈ **1.36** (было ≈1.13). `rollVariant()` обобщён — ролл идёт по `VARIANTS` в порядке массива (без хардкода списка id).
- **Счётчики улова**: карта `VARIANT_KEY={copper:'cp',silver:'sv',quartz:'qz',gold:'sn',emerald:'em',diamond:'dm',rainbow:'rb'}` (sn = «shiny» = gold — семантика не тронута). `catchFish()` пишет запись `caught[id]` обобщённо (дефолты всех счётчиков + инкремент по ключу варианта) — все ключи всегда присутствуют. Миграция в `loadSave()` расширяется: дефолт 0 для всех `VARIANT_KEY`-ключей (старые сейвы читаются).
- **Дневник** — обобщённые мини-чипы: обычная (n−Σособых) + 7 особых в порядке лестницы, подписи через `trName` (i18n); новые цвета `.jv-cp/.jv-qz/.jv-em/.jv-dm`.
- **Карточка улова** — оформление `copper-card`/`quartz-card`/`emerald-card`/`diamond-card` (+бейдж «МЕДНАЯ»/«КВАРЦЕВАЯ»/«ИЗУМРУДНАЯ»/«АЛМАЗНАЯ»); `showCard()` обобщён: тогл класса по всем вариантам `VARIANTS` (normal — без класса). CSS: медь — бронза, кварц — молочно-сиреневый, изумруд — зелёный, алмаз — ледяной голубой (бейдж + панель + свечение эмодзи).
- **`full_variants` «Полная коллекция»** — check расширен на все 8 вариантов (все счётчики `VARIANT_KEY` >0 у каждого вида); награда прежняя (+5000 🪙 разово, coinPct 0.05). Ачивок по-прежнему 29 — сумма `achBonus().coinPct` не изменилась (пин 0.16).
- **Дождь (M8)** — уже обобщён в `variantChance()` (все особые ×2,5), текст тоста «шанс серебряной/золотой/радужной» → «шанс особых рыб».
- **Флейка-фикс тестов**: медь (12%) роллится ПЕРЕД gold → тест «золотая рыба» (`SHINY_CHANCE=1`, §9a) и orca-шаг 8 получили `forceVariant='gold'` (форс сильнее ролла); восстановление дефолта `SHINY_CHANCE` во всех тестах 0.03 → 0.06. Границы ролла §10c(5) переписаны под новый порядок (последовательности из 1–7 pinned-значений); в eval добавлен сброс погоды (дождь ×2.5 сдвинул бы границы).
**Тесты:** §10c(1) — пин 8 вариантов/множителей/шансов; (2) — +cp/qz/em/dm=0; (5) — новые границы ролла; (6) — миграция всех счётчиков; (7) — probe всех 8 вариантов (бейдж/класс); (8) — `full_variants` 8×8 (sum 0.16 прежний); M8(2) — ×2.5 для всех 7 особых (tolerance 1e-9); локации §lc10 — сетап с 8 счётчиками.
**Результат прогона:** `node tools/check.mjs` — OK; `cd tests && npm test` — **177/177 PASS, exit 0**.
## Этап 31. Снасть усиливает шанс особых вариантов (M6+)
**Задача:** необычные рыбы ещё чаще — тип наживки и её уровень апгрейда повышают шанс особых вариантов, причём более редкие получают прибавку больше.
**Решения:**
- **Формула в `variantChance()`** (единственная точка вероятностей, как дождь M8): `variantBoost()` = `lure().rare×0.15 + (уровень наживки−1)×0.05`; множитель шанса варианта — **×(1+B·r)**, r — номер варианта в лестнице `VARIANTS` (медь 1 … радуга 7) → чем реже вариант, тем сильнее буст. Дождь ×2,5 применяется к уже усиленному шансу.
- **Источник B — существующий rare-стат наживки** (`effLure().rare`: простой крючок 0 / червь 0.2 / воблер 0.8 / блёстка 2.0, +1.0 на 3–4 ур.) — и «тип», и «уровень» наживки работают через один статики; уровень апгрейда добавляет своё слагаемое (+0.05/ур.) — буст есть на любом уровне, не только на 3-м.
- **Диапазон:** простая наживка 1 ур. → B=0 (шансы базовые, P(особой)≈31%); червь 1 ур. → B=0.03; воблер 1 ур. → B=0.12; блёстка 1 ур. → B=0.3 (rainbow ×3.1); блёстка 4 ур. → B=0.6 (rainbow ×5.2, gold ×3.4, P(особой)≈60%). Насыщения нет: при топ-снасти + дожде все шансы <1.
- Пины шансов в тестах (§10c(1)/(5), M8(2)) переведены на нейтральную снасть: снапшот `save.lure`/`lureLv` в начале eval, рестор в конце (снасть mid-сьюта после M5-тестов непредсказуема).
**Тесты:** +1 (§10c(9) «буст снасти», 178/178): B=0 у простой наживки; червь B=0.03 → copper 0.1236/rainbow 0.00605; блёстка B=0.3 → copper 0.156/gold 0.132/rainbow 0.0155; 4 ур. (rare 3.0) B=0.6 → rainbow 0.026; отношение множителей «радуга/медь» растёт с B (редким больше); ролл r=0.12 с червя → copper (граница под бустом); §10c(1) — +пин boost0=0.
**Результат прогона:** `node tools/check.mjs` — OK; `cd tests && npm test` — **178/178 PASS, exit 0**.
## Этап 32. Дождь ×3 (M8)
**Задача:** особые рыбы в дождь ещё чаще — множитель `WEATHER.rain.variantMult` ×2,5 → **×3**.
**Заодно:** найдено упущение Этапа 30 — тост погоды берётся из `T` (`t('wx_rain')`, i18n), а не из `WEATHER.rain.toast` (мёртвое поле), и в `T.wx_rain` оставался старый текст «шанс серебряной/золотой/радужной ×2,5». Исправлены оба: RU «Дождь: шанс особых рыб ×3», EN «Rain: special fish chance ×3» (и `WEATHER.rain.toast` для консистентности).
**Тесты:** M8(1) — пин variantMult===3; M8(2) — шансы ×3 (copper 0.36 / silver 0.24 / quartz 0.21 / gold 0.18 / emerald 0.03 / diamond 0.021 / rainbow 0.015) + пин текста тоста («особых», «×3»); orca-шаг 8 — ratio ×3. Доки: README/AGENTS/BROWSER-TESTING.
**Результат прогона:** `node tools/check.mjs` — OK; `cd tests && npm test` — **178/178 PASS, exit 0**.
## Этап 33. Ещё пять озёрных видов (11 → 16)
**Задача:** «хочу добавить побольше разных рыб» — расширить состав рыбы, не трогая Реку.
**Решения:**
- **5 новых видов в конце `SPECIES`** (после `crayfish`): bleak «Плотва» (r0, 18 🪙/кг, 0.05–0.6 кг), roundel «Густера» (r0, 24, 0.1–0.9), eelpout «Вьюн» (r1, 55, 0.05–0.4, 🐍), tench «Линь» (r2, 80, 0.3–4.0), navaga «Навага» (r3, 250, 0.3–2.5). Только в конец: `SPECIES[0..7]` пинятся тестами (§573/§1524/§2446) и квестами (`QUEST_TPLS`, индексы 0..2).
- **Озёра, не Река:** `RIVER_IDS` не тронут → роллы реки прежние (pin lc3). Озёрные веса — в `LAKE_DAY_WEIGHTS`/`LAKE_NIGHT_WEIGHTS`; `NIGHT_WEIGHTS` дополнен всеми пятью id (иначе `SPECIES_NIGHT` дал бы `weight=undefined` и упал бы lc2).
- **Баланс Озёр сохранён подъёмом дорогой рыбы.** Прикидка `E[выручка/улов]` (середина диапазона веса × цена): было день 91.9 / ночь 134.1 🪙; одна только мелочь роняла до 73.5 / 113.1 (Река для сравнения 65.1). Компенсация — `gold` 1.2→1.6, `sturge` 3→3.5 (день) / 4→4.5 (ночь), `crayfish` 1.5→2 / 2→2.5: стало **84.7 / 122.2**, доля gold 0.56% / 0.68% (было 0.55% / 0.64%). Озёра остаются доходнее Реки.
- **`minDist`/`strength` новых видов** вписаны в существующую кривую (0.05→0.75 по редкости, 0.28→0.70 силы) — `fishForce()`/`updateReel()` НЕ тронуты, Monte-Carlo по PHYSICS-RESEARCH не требовался.
- **Emoji:** четыре вида взяли 🐟/🐠 из уже покрытого субсета, только 🐍 (вьюн) потребовал перегенерации — субсет 57→58 глифов, woff2 80 КБ (+107 КБ base64 в index.html).
- **Мелочи:** `LOCATIONS[1].desc/descEn` — перечисление озёрных рыб; комментарии «все 11» → «все 16» в `pickSpecies()` и у ачивки `species_8` (check = `SPECIES.every`, масштабируется сам; id не тронут). Сейв-миграция не нужна: `save.caught[id]` создаётся в `catchFish()`, в старых сейвах новых записей просто нет — дневник покажет «???».
**Тесты:** lc1 — `SPECIES.length` 11→16, пины параметров пяти новых видов, порядок (`slice(0,8)` не сдвинут, `slice(8)` — ровно 8 озёрных) и заполненность всех полей (включая `nameEn` — иначе EN-ветка `trName()`); lc2 — `LAKE_SPECIES(_NIGHT)` 11→16, `SPECIES_NIGHT.length` 16 + веса числа; lc3 — без изменений (Река = старые 8); lc4 — текстовка «все 16» (состав берётся из `SPECIES.map`, подстраивается сам); lc9 — строк дневника 11→16; lc10 — сетап «+3 новых» → «+8 озёрных». Итого **178/178 PASS, exit 0**.
**Флейка, которую стоит помнить:** lc4 вызывает `pickSpecies()` **без аргумента** → `dist = undefined`, `undefined < s.minDist` === false, штраф ×0.06 не применяется. На «каждый вид ≥1 за 3000 роллов» влияет **только вес** — новому озёрному виду нужен вес ≥ ~2 в обеих озёрных таблицах (у navaga 2.5 / 2).
## Этап 34. Доходность Озёр 100+ 🪙 (веса)
**Задача:** «для озёр доходность желательно 100+, предлагаю просто увеличить вес рыб озёрных».
**Решения:**
- **Масштаб весов доход не меняет.** `pickSpecies()` нормирует веса (шанс = `w / total`, где `total` — сумма тех же `w`), поэтому ожидаемая выручка за улов (середина диапазона веса × цена, взвешенная по вероятностям) инвариантна к умножению ВСЕХ весов на константу: «просто увеличить все веса» дало бы ровно те же 84.7 🪙. Поднят **сдвиг долей** в пользу дорогой рыбы — заявленный эффект достигнут тем же инструментом (правкой `LAKE_*_WEIGHTS`).
- **Убраны размыватели, поднята дорогая рыба (день):** `carass` 60→55 (выручка ≈9 🪙, был 21% всех роллов) и `bleak` 30→18 (≈6 🪙) — главные «копейки» Озёр; вверх `zander` 14→16, `catfish` 8→10, `pike` 12→14, `tench` 10→14, `navaga` 2.5→3.4, `sturge` 3.5→**5**, `gold` 1.6→**2.2**, `crayfish` 2→2.4; вниз `bream` 30→24, `perch` 40→36, `chuch` 22→18, `roundel` 18→12, `carp` 25→24.
- **Ночь Озёр была убыточнее ночи Реки** (122.2 против 125.9 🪙) — вопреки комментарию про «ночное смещение к хищникам». Исправлена тем же сдвигом: `perch` 70→60, `carass` 25→22, `bream`/`chuch` 10→9, `tench` 8→11, `zander` 18→20, `catfish` 16→18, `sturge` 4.5→**6**, `gold` 1.6→**2.2**, `crayfish` 2.5→2.6, `navaga` 2→2.4.
- **Итог:** Озёра **день 84.7 → 110.4** 🪙 (×1.70 к дневному руслу реки 65.1), **ночь 122.2 → 145.5** (×1.16 к ночной реке 125.9). Река не тронута (`RIVER_IDS`, `SPECIES.weight`, `NIGHT_WEIGHTS`).
- **Пол редких видов сохранён:** минимальный вес 2.2 (`gold`/`navaga`/`crayfish`) — за 3000 роллов ожидается ≥25 попаданий (у `gold` раньше было ~20). Тест lc4 («каждый вид ≥1») проходит с вероятностью провала ~e⁻²⁵; ниже ~2 опускать нельзя (флейка выше: штраф `minDist` в тесте не действует).
**Тесты:** конкретных значений озёрных весов никто не пинит (lc2 — только длина 16 и «вес — число», lc4 — факт выпадения всех видов), правка таблиц не потребовала правок тестов: **178/178 PASS, exit 0**; `check.mjs` OK; `pack.mjs` + `pack-smoke` 6/6 (115.5 КБ). Скриншоты перегенерированы не нужны: кадр рила и карточка форсируют `sturge` в `gen-screenshots.js`, остальные кадры от весов не зависят.
## Этап 35. Море + эксклюзивная ихтиофауна локаций (16 → 23, свои рыбы у каждой воды)
**Задача:** «добавить новую локацию — море» + «переделать так, чтобы у каждой локации были только свои рыбы» (без пересечений с Рекой; Озёра — до 10 видов).
**Решения:**
- **Эксклюзивные наборы вместо `map` по всему `SPECIES`.** До этапа 35 `LAKE_SPECIES` строился как `SPECIES.map(...)` — речные рыбы неявно жили и в Озёрах (доход Озёр держался в основном на карасе/окуне). Теперь у каждой воды свой `*_IDS`-список и таблица строится фильтром по нему: `RIVER_IDS` (8, без изменений), `LAKE_IDS` (10), `SEA_IDS` (5). Пересечения между водами — пустые (пин-тест lc2). Река не тронута вообще: `RIVER_*`, `SPECIES[0..7]`, `NIGHT_WEIGHTS` речных — как были.
- **Новые виды — только в КОНЕЦ `SPECIES`** (23 всего): озёрные `rudd` «Краснопёрка» r1 26🪙 0.1–1.2 кг и `cisco` «Сиг» r2 90🪙 0.5–3.5 кг; морские `herring` «Сельдь» r0 30🪙, `mackerel` «Скумбрия» r1 70🪙, `flounder` «Камбала» r2 130🪙, `tuna` «Тунец» r3 180🪙 2–12 кг, `swordfish` «Рыба-меч» r4 320🪙 5–20 кг. `NIGHT_WEIGHTS` дополнен всеми 7 id (иначе `SPECIES_NIGHT.weight=undefined`).
- **Море**: `LOCATIONS[2]` (131072 🪙 = ×2 Озёр; Озёра при этом 100000 → 65536), `SEA_DAY/NIGHT_WEIGHTS` (ночь смещена к хищникам), развилка в `pickSpecies()` на 3 таблицы, тост `welcome_sea` (RU/EN), миграция `location` — граница `LOCATIONS.length-1` (старые сейвы с `location>2` → Река), clamp в `drawWater()` — по длине массива.
- **Цены локаций (правка после этапа):** Озёра 100000 → **65536** 🪙, Море 300000 → **131072** 🪙 (ровно ×2 Озёр) — по запросу; пины тестов (`costs`, покупки/защиты) и доки синхронизированы.
- **Перебаланс Озёр после потери речной рыбы:** доли перераспределены на своих жильцов — день ≈121 🪙 (было 110.4 при речных, ×1.86 к реке 65.1), ночь ≈134 (было 145.5; ночь реки 125.9 — Озёра снова впереди после «убыточной ночи» этапа 34). Море — топ-локация: день ≈190, ночь ≈222 🪙. Пол редких: мин. вес 1.2 (`swordfish`) — за 3000 роллов ≥40 попаданий.
- **Босс (sturge) остался только в Реке** — озёрные/морские таблицы его id не содержат (раньше «есть в обеих локациях» — теперь эксклюзивность по всем правилам).
**Тесты:** обновлены пины блока 10f (SPECIES=23, LOCATIONS 3, costs `0,100000,300000`, озёра 10 без речных, дневник 23, ачивка «все 23»), добавлены lc2-пересечения, lc4b (роллы Моря: все 5 видов, пресноводных нет), lc8b (покупка Моря: списание, авто-переход, тост). **180/180 PASS, exit 0**; `check.mjs` OK; `emoji-font.py --check` OK (58 emoji покрыты); `orca-playtest` 76/76; `pack.mjs` + `pack-smoke` 6/6 (116.0 КБ). Скриншоты RU/EN перегенерированы (вода Озёр/новые виды в дневнике). Доки (README/AGENTS) синхронизированы в том же коммите.
## Этап 36. Локация «Океан» (23 → 32 вида, четвёртая вода)
**Задача:** новая локация «Океан» (id 3): 9 новых глубоководных видов, цена 196608 🪙 = ×1.5 Моря. Баланс НЕ настраивать — веса «с потолка», точная настройка отдельным этапом через `tools/balance.mjs`.
**Решения:**
- **Новые виды — только в КОНЕЦ `SPECIES`** (32 всего, индексы 23..31): `moray` «Гигантская мурена» r0 25🪙 1.5–6 кг, `grouper` «Горбуна» r0 25🪙 2–10 кг, `turtle` «Морская черепаха» r1 45🪙 5–14 кг, `angler` «Удильщик» r2 110🪙 1–4 кг, `octopus` «Гигантский осьминог» r2 120🪙 4–12 кг, `manta` «Скат-манта» r3 150🪙 6–15 кг, `shark` «Большая белая акула» r3 160🪙 5–14 кг, `squid` «Гигантский кальмар» r4 190🪙 4–12 кг, `whale` «Синий кит» r4 350🪙 20–50 кг. Иконки: 🦈 — дубль swordfish, 🐳 — у кита (китовой акулы в Noto нет); minDist/strength — кривые по редкости. `NIGHT_WEIGHTS` дополнен всеми 9 id (значения декоративные — у Океана свои таблицы, главное — ключи есть, иначе `SPECIES_NIGHT.weight=undefined`).
- **Океан**: `LOCATIONS[3]` (196608 🪙 = ×1.5 Моря), вода глубже/темнее Моря (`#1e6fb5/#0e4a86/#031a3c` днём, `#081430/#050d22/#020611` ночью; nightMix-логика не тронута), `OCEAN_DAY/NIGHT_WEIGHTS` (ночь смещена к крупным: whale 2→2.5, manta 3→3.5 — паттерн LAKE/SEA), развилка `pickSpecies()` на 4 таблицы, тост `welcome_ocean` (RU/EN), 4-ветка тостов в `shopClick()`.
- **Исправлен живой баг границы миграции (с этапа 5):** в `loadSave()` было жёсткое `save.location>1` — граница не обновлялась в этапе 35, и купленное Море (location=2) сбрасывалось на Реку при reload. Теперь `save.location>=LOCATIONS.length` — авто-масштабируется на будущие локации. Регрессий-тесты lc5(d)/(e).
- **Ачивки +2 (29→31):** `sea_first` «Широкая вода» ⚓ и `ocean_first` «Глубина» 🌊 — locations-based (`save.locations.indexOf(2/3)`), не caught-based: пин §10c(8) `achBonus().coinPct===0.16` (там `locations=[0]`) не сдвигается.
- **Крупный вес кита (wMax 50) — ничего не ломает:** `d0=45+7·w` → 395 (порядок как у босс-осётра wMax 40 → 325; отрисовка и escape-порог `1.45·d0` нормированы на `d0` — бой просто дольше); `snap_big`/тосты (`toFixed(1)`) и карточка улова (текст «50.0 кг · 350 🪙/кг» влезает в панель 370px) — ок; квесты `catch_weight` (пороги 2/3/5 кг) — ок. Замечание для баланса (НЕ чинили): на «Титане» (maxW 40) кит даёт ov до 1.25 — почти гарантированный обрыв по механике «рыба крупнее предела удочки» (snap_big), вываживаем он только в рывках-затишьях; цена 350🪙/кг × до 50 кг = до ~17500 🪙 за улов — и то, и другое под отдельный баланс-этап.
- **`tools/balance.mjs` расширен на 4 локации** (6→8 таблиц): `OCEAN_SPECIES(_NIGHT)` в вырезке данных, 2 строки в `TABLES`, сводка-матрица и отношения соседних вод обходят ВСЕ `LOCATIONS` (инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» теперь включает Океан/Море), проверки целостности параметризованы по числу локаций. Цифры Океана (информационно, пины не гонялись): **день 539.5 / ночь 671.3 🪙** (Океан/Море ×2.83/×3.02; вклад кита ~43% — он главный «джекпот» Океана).
- **Emoji:** 6 новых глифов (🐢🐙🦑🐳🌊⚓) — перегенерация субсета 58→64 глифа, woff2 89 КБ (+119 КБ base64 в index.html), `--check` OK.
**Тесты:** блок 10f — lc1 (SPECIES=32, пины 9 океанских, `seaOrder` ограничен `slice(18,23)`, новый `oceanOrder=slice(23)`, LOCATIONS 4, costs `0,65536,131072,196608`), lc2 (+`OCEAN_SPECIES(_NIGHT)`=9, `SPECIES_NIGHT.length===32`, пересечения океан∩река/озёра/море пусты), lc5 (+кейсы d/e — Море/Океан не сбрасываются на reload), lc9 (23→32 строки дневника), lc10 («+15 новых» → «+24 новых»); новые: **lc4c** (роллы Океана: 3000 днём + 3000 ночью — все 9 видов ≥1, чужих вод нет), **lc8c** (покупка Океана: списание 196608, авто-переход location=3, тост «Добро пожаловать в Океан!»), **§10g(8b)** (sea_first/ocean_first — паттерн lake_first 1:1, по паре шагов с чисткой `save.ach` — ачивки не отзываются). §10g(2) — total 29→31 + id `sea_first`/`ocean_first`. Итого **183/183 PASS, exit 0** (было 180: +3 ok); `check.mjs` OK; `emoji-font.py --check` OK (64 emoji). Доки (README/AGENTS) синхронизированы в том же коммите.
## Этап 37. Рыба-босс в каждой локации (Осётр/Рак/Рыба-меч/Кит)
**Задача:** «рыба-босс в каждой локации»: Река = sturge (как в M4), Озёра = crayfish, Море = swordfish, Океан = whale. Босс — существующий вид с runtime-флагом `boss:true` (НОВЫХ видов в `SPECIES` НЕ добавлять). Вес босса — единое правило 20–50 кг (касается и осётра: теперь 20–50 вместо 5–40). Ачивки: `boss_first` — нейтральный текст; +3 новые пер-босс ачивки БЕЗ coinPct (пин §10c(8) `achBonus().coinPct===0.16` не должен сломаться).
**Решения:**
- **`BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale'}`** (секция ДАННЫЕ, после таблиц Океана). Появление — в `startBite()`: условие `sp.id==='sturge'` заменено на `bossId=BOSS_BY_LOCATION[save.location|0]; bossId===sp.id` (шанс 30% или `forceBoss` — тот же, клон `Object.assign({},sp,{boss:true})` — тот же паттерн M4). Механика фаз/наказаний/карточки уже вид-независима — ничего в `updateReel()`/`SNAP`/`showCard` не тронуто.
- **Единый вес босса 20–50 кг** — в `hook()`: `let w=sp.boss?20+Math.random()*30:rollWeight(sp);` (до `d0`/`ov`/`st0`; в `reel.w`/`catchFish` уходит тот же `w`). Случайный ролл по `wMin..wMax` вида у босса теперь не действует (осётр-босс: 5–40 → 20–50).
- **`save.bosses={id:true}`** — пер-босс трекинг: дефолт в `save`/`freshSave()` + миграция в `loadSave()` (`if(!save.bosses||typeof save.bosses!=='object') save.bosses={}`), ставится в boss-ветке `catchFish()` (там же, где `bossBeaten`/`bossCount`, до `checkAchievements()`; persist() уже был).
- **Ачивки 31→34:** `boss_first` — desc «Победи рыбу-босса» / «Defeat a boss fish» (name/bonus/check не тронуты); новые `lake_boss` «Победа над Раком» 🦞 / `sea_boss` «Победа над Рыбой-мечом» 🦈 / `ocean_boss` «Победа над Китом» 🐳 — check по `s.bosses[id]`, **БЕЗ coinPct** (пин 0.16 жив). Нюанс: `bonus` дан как пустой объект `{}`, а не «нет поля» — `bonusText(a.bonus)` в вкладке «Достижения» падает на `undefined`, и чек полей a22d требует truthy `a.bonus`; `{}` в `achBonus()` даёт 0 (пин не сдвигается). Осётр-босс отдельной ачивки не получил — покрывается `boss_first`.
- **Emoji:** 🦞/🦈/🐳 — все уже в субсете (иконки соответствующих записей `SPECIES`), перегенерация не нужна (`--check` OK, 64 глифа).
- **Босс до 50 кг > maxW «Титана» 40 кг:** при w>40 (в т.ч. у кит-босса) в режиме «держи кнопку» возможен НАСТОЯЩИЙ обрыв (натяжение достигает прочности лески раньше, чем `d≤0`) — вываживается контролем натяжения (отпускать в рывок, как и любую рыбу крупнее предела удочки); аналог замечания Этапа 36 про кита — материал баланс-этапа (не чинили).
- **`tools/balance.mjs` не тронут:** босс — runtime-флаг, в данных отсутствует, в доходность не входит (строка «босс „Осётр“ (Река) клюёт вне обычного ролла» — как была; теперь боссы и в других водах, но они тоже вне ролла).
**Тесты:** новый блок **§9k2 «Этап 37»** (рядом с §9k, паттерн 1:1): цикл по 3 новым локациям — `save.location=N` + monkey-patch `pickSpecies` + `forceBoss` → старт босса (stateBoss/bossPhase=1/reel.boss/spId, вес 20–50 по фактическому `reel.w`) → `bossPhase=3` + улов (паттерн M6(4)) → монеты = w×price×5 (±10%), `save.bosses[X]=true`, bossCount=1, ачивка выдана, карточка «<имя>-БОСС» + бейдж + boss-card; в конце — миграция (старый сейв без `save.bosses` → `{}`, паттерн lc5: `delete save.bosses` + localStorage + `loadSave()`) и полный сброс (forceBoss/pickSpecies/location/bosses/ach/caught/coins). **a22d**: total 31→34 + id `lake_boss`/`sea_boss`/`ocean_boss`. M4-пины (§9k/§9l(3)/M6(4)) **не поправлялись**: награда читает фактический `w` (`window.__m4W`/`__m6W`), фазы нормированы на `d0` (20–50 кг → d0 185–395, отношения те же) — все 3 блока прошли без изменений. Итого **193/193 PASS, exit 0** (было 183: +10 ok), два прогона; `check.mjs` OK; `emoji-font.py --check` OK (64 emoji); `tools/balance.mjs` — цифры без изменений (Река 65.1/125.9 · Озёра 121.2/134.1 · Море 190.8/222.0 · Океан 539.5/671.3). Доки (README/AGENTS) синхронизированы в том же коммите.
## Этап 38. Лестница веса по локациям (дороже вода = тяжелее рыба = сложнее бой)
**Задача:** «на более дорогих локациях было сложнее ловить рыбу (например, её вес сильно больше будет)». Вес — главный рычаг сложности: `d0=45+7·w` (дольше бой) и `ov=w/rod().maxW → aggr` (сильнее рывки/натяжение/escape). До этапа лестница была «сломана»: E[вес] Река ≈1.3 ≈ Озёра ≈1.3 > Море ≈1.1, а максимумы — Река 40 кг (осётр) > Озёра 12 кг (судак): на «бесплатной» реке рыба была тяжелее, чем в Озёрах. Множители подобраны по запросу пользователя: **Озёра ×8, Море ×6 (рыба-меч ×4 — 20–80 кг, явно задано), Океан ×4 (синий кит ×2 — 40–100 кг, чтобы остался вываживаемым)**; Река не меняется (пины §9j/M4/M6).
**Решения:**
- **`SPECIES.wMin/wMax` — только 24 вида Озёр/Моря/Океана** (строки «Этап 5/33/35/36», река 0..7 не тронута). Ключевые: Озёра — судак 8–48, линь 2.4–32, сиг 4–28, навага 2.4–20; Море — скумбрия 1.8–15, камбала 3–30, тунец 12–72, рыба-меч 20–80; Океан — мурена 6–24, горбуна 8–40, черепаха 20–56, осьминог 16–48, манта 24–60, акула 20–56, кальмар 16–48, кит 40–100. Ролл-веса (`RIVER_*/LAKE_*/SEA_*/OCEAN_*`), цены, редкости, `minDist`/`strength` — не тронуты. Код боя не менялся: `rollWeight()`/`hook()`/`updateReel()` читают `wMin/wMax` напрямую; `st0` (0.65+0.35·min(1,w/wMax)) и силуэт (`reel.w/sp.wMax`) инвариантны к равномерному масштабированию диапазона.
- **Новые цифры (`tools/balance.mjs`, нейтральная снасть):** Озёра 970.0/1072.4 (было 121.2/134.1), Море 1028.9/1192.4 (было 190.8/222.0), Океан 1691.4/2053.9 (было 539.5/671.3); Река 65.1/125.9 — без изменений. Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» держится: Море/Озёра день **×1.06** (тонкий запас — при правках цен/весов следить, чтобы не ушло в <1), ночь ×1.11; Океан/Море ×1.64/×1.72. E[вес] улова: Река ≈1.3 → Озёра ≈10.7 → Море ≈6.5 → Океан ≈21 кг; максимумы — 40 (осётр) < 48 (судак) < 80 (меч) < 100 (кит) — лестница максимумов теперь монотонна.
- **Осознанные странности (не чинили, по решению пользователя):** (1) средний вес Моря (≈6.5 кг) легче среднего по Озёрам (≈10.7) — структура ролла Моря «82% сельдь/скумбрия + редкие гиганты», лестница дохода при этом нормальная; (2) кит 40–100 кг: на «Титане» (maxW 40) при w>40 в режиме «держи кнопку» — настоящий обрыв, вываживается контролем натяжения (тот же паттерн, что у кит-босса Этапа 37); (3) доходность выросла ×5–15 — **Этап 39** (отдельный коммит) урезает цены Озёр/Моря/Океана к целям Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5.
- **Боссы не затронуты:** вес фиксирован 20–50 кг в `hook()` (не читает `wMin/wMax`), `BOSS_BY_LOCATION` не меняется.
**Тесты:** пины **lc1** (10f) переписаны — 24 строки `name,rarity,price,wMin,wMax` (меняются только веса, цены не тронуты) + текст ассерта (кит «20–50 кг» → «40–100 кг», пометка про этап 38). lc2/lc4/lc4b/lc4c, §9j/M4/M6, §10 (физика) — **без изменений** (река и ролл-веса не тронуты). Итого **193/193 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — цифры выше, «здоровье» роллов без изменений (мин. 16.3 попаданий). Доки (AGENTS.md: строки «Локации», «Баланс Озёр», «Баланс-скрипт») синхронизированы в том же коммите.
## Этап 39. Подгонка цен Озёр/Моря/Океана (≈×3/×4/×5 Реки) через balance.mjs
**Задача:** после Этапа 38 (лестница весов) доходность новых вод выросла ×5–15 — «с помощью скрипта поменять цены так, чтобы доходность Озёр была примерно ×3 от Реки, Море ×4, Океан ×5».
**Решения:**
- **`tools/balance.mjs` + опция `--price id:цена`** — what-if переопределение цены вида (🪙/кг, можно несколько раз, НЕ сохраняется), паттерн `--set`: парсинг, валидация id (список известных в ошибке), `calcTable()` читает `prices[s.id] ?? s.price`, строка WHAT-IF печатает «id вес=…/цена=…», шапка и `--help` обновлены. Именно так набор цен собран и сверен ДО правки index.html (what-if → те же цифры после переноса — по циклу из AGENTS.md).
- **Выбор цен:** единый множитель на локацию к ЦЕЛИ ПО ДНЮ (Озёра 195.3 / Море 260.4 / Океан 325.5 = ×3/×4/×5 дневной Реки 65.1), округление до целых: Озёра k≈0.201 — zander 8, chuch 6, crayfish 40, bleak 4, roundel 5, eelpout 11, tench 16, navaga 50, rudd 5, cisco 18; Море k≈0.253 — herring 8, mackerel 18, flounder 33, tuna 46, swordfish 81; Океан k≈0.192 — moray 5, grouper 5, turtle 9, angler 21, octopus 23, manta 29, shark 31, squid 37, whale 67. Цены Реки не тронуты.
- **Почему цель по дню:** единый множитель масштабирует ОБЕ фазы одновременно, а соотношения ночь/день у новых вод ×1.11–1.21 против ×1.93 у Реки — «×3/×4/×5 в обеих фазах» ценами недостижимо (нужен сдвиг соотношения фаз = правка `*_WEIGHTS`, а не цен). День — опорная фаза (первая в сводке скрипта); ночные множители — как следствие: ×1.70/×2.42/×3.17 (зафиксировано в AGENTS.md: «ночной паритет» — править `*_WEIGHTS`, не цены).
- **Цифры (`tools/balance.mjs`):** Озёра 194.1/214.5 (×2.98/×1.70 Реки), Море 262.7/304.2 (×4.04/×2.42), Океан 329.1/398.9 (×5.06/×3.17); Река 65.1/125.9 без изменений. Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы»: Море/Озёра ×1.35/×1.42, Океан/Море ×1.25/×1.31 — держится с запасом (тонкий запас этапа 38, Море/Озёра день ×1.06, снят).
**Тесты:** пины **lc1** (10f) — 24 строки обновлены (колонка price) + текст ассерта (новые цены, пометка этапа 39). Тесты наград (M4/§9k2/M6/shiny) читают `w×price` ДИНАМИЧЕСКИ с живых объектов — прошли без изменений (босс-рак 200→40 🪙/кг — ассерт не пострадал). Итого **193/193 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — цифры выше, «здоровье» роллов без изменений. Доки: AGENTS.md (строки «Баланс Озёр»/«Баланс-скрипт» — финальные пины + `--price`), README (диапазон цен 4–1500 🪙/кг), шапка balance.mjs (пины).
## Этап 40. Ночные *_WEIGHTS «ночь доходнее» + меньше сельди/скумбрии в Море
**Задача:** «поправим ночные *_WEIGHTS и на море уменьшим вероятность сельди/скумбрии, при этом не подгоняй доходность под ×3/×4/×5, а просто напиши что получилось». После этапов 38/39 ночные множители новых вод отставали от дневных (×1.70/×2.42/×3.17 против ×2.98/×4.04/×5.06) — у Реки ночь в 1.93 раза доходнее дня, у новых вод только ×1.11–1.21; и средневесовое Море (≈6.5 кг) было легче Озёр (≈10.7) — за счёт 82% ролла из сельди/скумбрии.
**Решения (веса ролла, цены/веса видов/река не тронуты):**
- **`LAKE_NIGHT_WEIGHTS`:** zander 28→32, tench 22→24, navaga 5→10, cisco 13→18; chuch 4→3, bleak 4→3, eelpout 8→4, roundel 3→2.5, rudd 3→2.5 — ночь смещена к крупной/дорогой (судак V=224, линь 275, навага 560, сиг 288) с мелкой дешёвой (вьюн 19.8, плотва 10.4, густера 20).
- **`SEA_DAY_WEIGHTS`:** herring 34→32, mackerel 34→32, flounder 11→12, tuna 2.6→3, swordfish 1.2→1.5 — доля сельди/скумбрии 82.3→79.5%. Дневное Море ограничено инвариантом «Море < Океан» (дневной Океан 329.1 фиксирован ценами этапа 39) — сильнее срезать нельзя.
- **`SEA_NIGHT_WEIGHTS`:** herring 24→18, mackerel 30→20, flounder 11→15, tuna 2.6→4, swordfish 1.2→2.2 — доля 78.5→64.2%, ночь смещена к камбале/тунцу/мечу (V=544/1932/4050).
- **`OCEAN_NIGHT_WEIGHTS`:** whale 2.5→4, manta 3.5→5, shark 3→4.5, squid 3→4.5, octopus 6→8; moray 32→26, grouper 28→22 — ночь смещена к гигантам (V=4690/1218/1178/1184/736) с мелкой дешёвой (мурена 75, горбуна 120).
- **Здоровье роллов (lc4, 3000 роллов):** худший случай — Море день swordfish 55.9 ожидаемых попаданий (P(промаха)≈0), Озёра ночь roundel/rudd 72.1 — всё «ок» по скрипту.
**Цифры (`tools/balance.mjs`, нейтральная снасть — без подгонки под цели, просто факт):**
| локация | день | ночь | ночь/день | от Реки (день/ночь) |
|---|---|---|---|---|
| Река | 65.1 | 125.9 | ×1.93 | — |
| Озёра | 194.1 | 249.0 (было 214.5) | ×1.28 (было 1.11) | ×2.98 / ×1.98 (было ×1.70) |
| Море | 297.3 (было 262.7) | 476.7 (было 304.2) | ×1.60 (было 1.16) | ×4.57 / ×3.79 (было ×4.04/×2.42) |
| Океан | 329.1 | 552.7 (было 398.9) | ×1.68 (было 1.21) | ×5.06 / ×4.39 (было ×3.17) |
- Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» держится: Озёра/Река ×2.98/×1.98, Море/Озёра ×1.53/×1.91, **Океан/Море ×1.11/×1.16** — дневной запас тонкий (Море упирается в Океан): зафиксировано в AGENTS.md, дальнейшее срезание сельди/скумбрии ДНЁМ — только с подниманием дневного Океана или цен.
- Средний вес улова: Море день ≈7.0 кг (было 6.5), ночь ≈9.4; Озёра ночь ≈13.9 — разрыв «Море легче Озёр» сократился (ночь 9.4 против 13.9, было 6.5 против 10.7), полностью не закрыт: дневное Море ограничено инвариантом.
**Тесты:** lc2 (id/порядок/числовость — веса не пинятся), lc4/lc4b/lc4c («каждый вид ≥1» — все виды с запасом), §9j/M4/M6/§10 — без изменений (река и константы физики не тронуты). Итого **193/193 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — цифры выше. Доки: AGENTS.md (строки «Локации»/«Баланс Озёр»/«Баланс-скрипт» — пины и пометка про тонкий запас Океан/Море), комментарии секции ДАННЫЕ в index.html (этап 40 у LAKE/SEA/OCEAN таблиц).
## Этап 41. Размер рыбы при вываживании — от абсолютного веса (0.1 кг → минимум, 100 кг → 2× лодки)
**Задача:** «размер рыбы в воде (когда её вытягиваешь) должен соответствовать её весу в кг, а не минимальному/максимальному весу вида; диапазон больше: 0.1 кг — максимально маленькая как сейчас, 100 кг — в 2 раза больше лодки». Первая ревизия этапа была лог-шкалой (средние рыбы 1–10 кг подросли до 0.9–1.4× лодки — «все рыбы сильно большие»); по решению заменена на линейную. Вторая ревизия: минимум опустили ещё — 0.1 кг с длины 19.6 px до **8 px** («ещё меньше»). Третья ревизия: **100 кг → 256 px** (×2 к 128, т.е. 4× лодки) и **1 кг → 20 px**; предложенная квадратичная шкала 3 якоря не держит монотонно (парабола по w: пик ~420 px на 61 кг, затем схождение к 256 px на 100 кг — кит меньше «средней» рыбы; квадратичная по ln w: провал до ~4 px у 0.2 кг) — взята степенная `L = 3.95 + 16.05·w^0.598`, точная по всем трём якорям и монотонная. Четвёртая ревизия (финальная): **явная узловая таблица пользователя** (веса — степени двойки, длины 16..256 px, «погрешность 10%»): степенная формула не покрыла таблицу в середине (на 25.6 кг было бы +24% к цифрам таблицы; локальный показатель кривой гуляет 0.26..0.61) — заменена линейной интерполяцией по log₂(веса) между узлами: узлы бьются точно (0%), кривая монотонна, вне 0.1–100 кг — кламп в края таблицы. Пятая ревизия (ручная подгонка px пользователем): мелкие рыбы стали мельче (0.1 кг → 12 px, 3.2 → 48 px, 25.6 → 96 px), узлы 6.4 кг → 64 px (= лодка) и 100 кг → 256 px (= 4× лодки) не менялись.
**Решения:**
- **Новый хелпер `fishSizeFromW(w)` + таблица `FISH_SIZE_KNOTS`** (секция «ОТРИСОВКА», перед `drawHookedFish()`): узлы `[log2(w/0.1), длина px] = [0,12],[1,16],[2,20],[3,24],[4,36],[5,48],[6,64],[7,80],[8,96],[9,160],[log2(1000),256]`, интерполяция линейна по log₂(веса) (узлы — степени двойки), длина силуэта = 2.45·fs (тело 2·fs + хвост). Узлы: **0.1 кг → 12 px** · 0.2 → 16 · 0.4 → 20 · 0.8 → 24 · 1.6 → 36 · 3.2 → 48 · 6.4 → 64 (= лодка) · 12.8 → 80 · 25.6 → 96 · 51.2 → 160 · **100 кг → 256 px = 4× лодки 64 px** из `drawBoat()` (`bx−32..bx+32`). Веса вне 0.1–100 кг клампятся в края таблицы (нижний кламп затрагивает только золотую рыбку 0.05–0.1 кг).
- **`drawHookedFish()`:** `fs = fishSizeFromW(reel.w)` (было `8+(reel.w/sp.wMax)*10+sp.rarity`). Убраны **`+sp.rarity`** (редкая рыбка не обязана быть крупнее — размер = вес) и **босс-множитель ×1.5** (М4): босс весит 20–50 кг (90–158 px = 1.41–2.47× лодки по узловой шкале), с ×1.5 босс-рак 30 кг выглядел бы крупнее кита 100 кг — противоречие «крупнее вес → крупнее рыба»; драматизм босса теперь несёт аура + бейдж + фазы. Золотая аура босса сохранена (читает новый `fs`).
- **Не тронуто:** подплывающая рыба до подсечки (APPROACH/BITE, `12+rarity*2.5` — вес к моменту подплывания ещё не зароллен, `hook()` роллит его при подсечке), фоновые рыбы `rand(8,22)`, 🐟-маркер на дистанционной полосе, emoji-карточка улова. Баланс/роллы не задеты: `wMin/wMax` остаются только в `rollWeight()`/`hook()`/`st0`.
**Размеры (полная длина силуэта; лодка = 64 px):** 0.1 кг → 12 px (0.19×) · 0.2 → 16 px (0.25×) · 0.4 → 20 px (0.31×) · 0.8 → 24 px (0.38×) · 1.6 → 36 px (0.56×) · 3.2 → 48 px (0.75×) · 6.4 → 64 px (**1× = лодка**) · 12.8 → 80 px (1.25×) · 25.6 → 96 px (1.5×) · 51.2 → 160 px (2.5×) · 100 кг (кит max) → 256 px (**4×**). Диапазон 12..256 px ≈ ×21; 1 кг → 28 px (0.44×); между узлами — линейно по log₂(веса) (проверено: узлы 0% отклонения, монотонность 2001 точки). Тяжёлая рыба в конце вываживания (позиция `boatX+24`, поверх лодки) заметно перекрывает лодку — осознанный эффект «вывезена рядом с лодкой»; на мобильном 360 px кит ≈ 71% ширины экрана.
**Тесты:** пины на размер силуэта в `tests/test.js` отсутствуют (проверено: `drawFishShape`/`drawHookedFish`/`fishReelPos` не ассертятся; `reel.w` в тестах — для физики). Итого **198/198 PASS, exit 0** (включая новые 5 проверок §X «закрытие по тапу мимо» — отдельное изменение, коммитится вместе); `check.mjs` OK; `tools/balance.mjs` — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7). Скриншоты перегенерированы (`gen-screenshots.js`, 36 кадров × ru/en; кадр `reel` форсит `reel.w=12.5` — рыба ~79.5 px ≈ 1.24× лодки).
## Этап 42. SVG-портреты рыб в дневнике и карточке улова
**Задача:** подключить уже вшитый SVG-спрайт (32 symbol, предыдущий этап) к UI: дневник и карточка улова показывают портрет вида вместо эмодзи; варианты перекрашивают портрет.
**Решения:**
- **Портреты:** 32 flat-SVG в `assets/fish/fish-<id>.svg` (по одному на вид, viewBox 0 0 160 100, цвета `var(--f1..f4, hsl-fallback)` — переменные НЕ объявлены на `<svg>`/`<g>`, наследуются извне). Встраивание — `node tools/embed-fish.mjs` (идемпотентно: заменяет блок между маркерами `FISH-SVG:BEGIN/END` в index.html сразу после `<body>`; самопроверка + `check.mjs` внутри); приёмка — `tools/fish-preview.html` (file://).
- **Дневник и карточка через `<use>`:** пойманные виды в дневнике — `<svg viewBox="0 0 160 100"><use href="#fish-<id>">` в `.jrow .je` (не пойманные — по-прежнему ❓-эмодзи); карточка — `<use id="cardFishUse">` внутри `#cardEmoji` (span сохранён: на нём bob-анимация и variant-классы), `showCard` ставит `href='#fish-'+sp.id` (вместо `textContent=sp.emoji`). CSS: `.jrow .je svg` 44×28 (моб. 40×26), `#card .bigemoji svg` 120×75 (моб. 94×59, низкий экран 81×50.6 — соотношение 160:100).
- **Перекраска вариантов — CSS-переменные:** 5 новых правил `#card.<variant>-card .bigemoji{--f1:…;--f3:…}` (copper/gold/quartz/emerald/diamond — цвет тела/плавников; переменные наследуются сквозь `<use>` в спрайт). silver/rainbow закрываются существующими filter-правилами (grayscale/rainbowhue), boss — золотым filter — всё без изменений.
- **Не тронуто:** тосты улова/обрыва (`sp.emoji` + вес) и иконка квеста `catch_species` — эмодзи там уместны (перекрыто второй частью этапа — см. ниже); сам спрайт, `SPECIES` и логика улова.
**Тесты:** пины в `tests/test.js` (тот же коммит): **lc1** (10f) — `sprites===32` (число `<symbol>` в `#fishSprites`) + `spriteIdsOk` (для каждого вида из SPECIES есть id `fish-<id>`), текст «SVG-спрайт: 32 symbol, id на каждый вид»; **M6 карточка** — в пробе `showCard` (sp = SPECIES[0]) `href #cardFishUse` = `#fish-carass` для всех 8 вариантов. Итого **193/193 PASS, exit 0** (пины расширены на месте, новых ok() нет); `check.mjs` OK; `emoji-font.py --check` OK (64 emoji, новых глифов нет).
**Вторая часть этапа — полное удаление эмодзи рыб из игры:**
- **SPECIES/тосты:** поле `emoji` удалено из всех 32 записей `SPECIES`; два тоста (обрыв `snap`/`snap_big` и срыв `escaped`) — без `sp.emoji`, остаётся «… Имя вида W кг».
- **Иконка квеста `catch_species`:** `questIcon` теперь возвращает mini-SVG портрет `<svg class="qfish" viewBox="0 0 160 100"><use href="#fish-<id>">` (вид не найден — ❓). CSS `.jrow .je .qfish{width:24px;height:15px;display:inline-block;vertical-align:-2px}` — скоуп `.jrow .je` обязателен: иначе правило портрета `.jrow .je svg` (44×28, выше по специфичности) не даст иконке уместиться в строку квеста.
- **Остальные рыбные эмодзи (12 видов) заменены глифами, уже бывшими в субсете:** воблер `icon:'🐟'`→`'🎣'`; ачивки: first_catch `🐟`→`🏅`, weight_20 `🐋`→`💪` (лестница весов ⚖/💪/🚢 у 10/20/1000 кг), пер-босс 🦞/🦈/🐳 → 🗺️/⚓/🌊 (иконки локаций — те же, что у lake_first/sea_first/ocean_first). Рыбка-маркер на канвасе (в `drawMeters`: маркер на конце полоски дистанции + иконка-легенда под ней) теперь рисуется path-рыбкой — новый `drawFishMark(x,y,s)` (эллипс-тело + треугольный хвост + глазок) вместо `fillText('🐟')`; комментарии с 🐟 очищены (генератор шрифта сканирует весь файл, включая комментарии).
- **Шрифт перегенерирован:** `tools/emoji-font.py` — субсет **64 → 52 глифа** (ровно 12 рыбных; замены не добавили новых — все уже были в инвентаре), woff2 89 → 65 КБ.
- **Тесты:** `tests/test.js` (тот же коммит) — в пине **lc1** убран `s.emoji &&` из `fieldsOk` (+ комментарий «поле emoji убрано в этапе 42»); других пинов на `.emoji`/эмодзи в тостах/квестах не нашлось (grep). Итого **193/193 PASS, exit 0** (Y7 `document.fonts.check('16px "EmojiEmbedded"','🪙')` — монета в субсете на месте); `check.mjs` OK; `emoji-font.py --check` OK (52 emoji); финальный grep по 12 рыбным эмодзи в index.html — 0 вхождений.
## Этап 43. Визуальное различение локаций (лёгкий вариант: вода + волны + рыбы)
**Задача:** «локации визуально почти не отличаются — разве что Озёра другим цветом воды». Диагноз: сцену составляют небо (общее), градиент воды (единственная деталь по локации), общая волна и общие фоновые рыбы; при этом дневная вода Реки `#2f9bd8` и Моря `#2a8fd0` почти совпадала. По решению — «лёгкий» вариант без нового ландшафта (силуэты берега/корабли — в запасе, не сделаны).
**Решения:**
- **Цвета воды** (`LOCATIONS.*.water`, секция ДАННЫЕ): Река и Озёра **не тронуты** (базовая «родная вода» + уже различимый teal); Море — светлая бирюза (`#2a8fd0/#1663a8/#07294f` → `#41b1e8/#2287c9/#0b3d6e`, ночь `#0a1f42/#071634/#030c20` → `#112e5c/#0a2348/#04102a`); Океан — глубокий тёмно-синий (день `#1e6fb5/#0e4a86` → `#1a5fa8/#0c3d74`, ночь не тронут — и так самый тёмный). Формат 6 hex сохранён (пин lc1 test.js:1984 проверяет ФОРМАТ, значения не пины).
- **Волны по локации** (`LOCATIONS.*.waves` — `{amp,amp2,freq,freq2,speed,speed2}`; `drawWater()`): константы двух линий поверхности (`sin(x*0.05+time*2)*2.5` и `sin(x*0.04-time*1.4+1.7)*3`) заменены параметрами текущей локации (read тем же clamp-паттерном, что `water`). Характер: Река — мелкие гребни 2.2/2.5 px, быстро бегут в сторону (течение, speed 3.2); Озёра — длинная медленная гладь 1.2/1.6 px (speed 0.8); Море — умеренная волна 3.5/4.5 px; Океан — крупные зыби 6/8 px (медленные). Белые барашки/бурление не добавлялись (YAGNI).
- **Фоновые рыбы по локации** (новый `AMBIENT_FISH[4]` = `{n,sMin,sMax,hMin,hMax}` перед `initFish()`): Река 9 шт 8–22 px hue 130–220 (как было), Озёра 9 шт 10–26 hue 120–200 (зеленоватые), Море 8 шт 12–32 hue 160–230 (синеватые), Океан 6 шт 18–44 hue 180–250 (редкие крупные тёмно-синие). `initFish()` читает `save.location` (clamp по `AMBIENT_FISH.length`).
- **Провозировка:** `initFish()` добавлен в ОБЕ ветки смены локации в `shopClick()` (buylocation после авто-перехода, selectlocation) — раньше фоновые рыбы живились один раз на старте (3700) и не знали о смене воды; старт не тронут. `resize()` намеренно без `initFish()` (как раньше: рыбы пересобираются не по resize).
- **Скриншоты** (`tests/gen-screenshots.js`): +кадры `sea`/`ocean` (save.location=2/3, день, `initFish()` — как в реальных ветках shopClick) — 10 → **12 кадров**; кадр `lake` и сброс после него тоже зовут `initFish()`. README: строка «Локации» — «своя вода (цвет, характер волн, рыбы в толще)» + три моб. изображения (lake/sea/ocean), «12 кадров × 3 вьюпорта». Снимки перегенерированы: `screenshots-wiki/` и `screenshots-wiki-en/` по 36 PNG.
**Не тронуто:** небо (`SKY_PHASES`), облака, звёзды, лодка/поплавок, дорожка света от светила, погода — общие для всех вод (по выбранному варианту); роллы/баланс/физика — `waves` и `AMBIENT_FISH` чисто данные/визуал.
**Тесты:** новые пины не нужны (визуал не ассертится; формат `water` lc1 — без изменений). Итого **193/193 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7; `LOCATIONS` в вырезке скрипта, но это данные — цифры не сдвинулись). Визуальная приёмка по скриншотам desk-вьюпорта: Река (приглушённый циан, мелкие рыбы) / Озёра (teal, гладь) / Море (светлая бирюза, умеренная волна, средние рыбы) / Океан (глубокий тёмно-синий, крупные зыби, гигантские рыбы) — четыре разные воды.
## Этап 44. Береговые силуэты на горизонте (полный вариант различения локаций)
**Задача:** «давай сделаем полный вариант» — после лёгкого Этапа 43 (вода/волны/рыбы) добавить то, что делает место узнаваемым: горизонт. Первая ревизия внутри этапа: «на реке береговая полоса на всю ширину, на которой растут деревья, и убрать волны».
**Решения:**
- **`scenery` + `initScenery()`** (секция декораций, после `AMBIENT_FISH`/`initFish()`): массив силуэтов на горизонте по локации (индекс = `save.location`, clamp). Река — **береговая полоса на всю ширину** `{k:'bank',h}` (трава 12–17 px + тёмная влажная кромка у воды) + **хвойный лес в 2 ряда, посаженный НА КРОМКУ** (`{k:'pine',layer,by}`, `by=waterY−bankH` — база ствола; `by` хранится в объекте при генерации, `waterY` к этому моменту известен: `resize()` считает его до `initScenery()`, смена локации — в игре с готовым `waterY`). Озёра — холмы `{k:'hill',rx,ry}` (2–3, дуга эллипса над водой) + лиственные деревья `{k:'tree',h}` (ствол + крона из 3 кругов). Море — парусник `{k:'ship',x,v,s,dir}` (корпус/мачта/два светлых паруса) + 3 чайки `{k:'gull',y,v,ph}` (дуги крыла, взмах `sin(time*7+ph)`). Океан — **пусто**: открытый горизонт без берега (вода и небо — его отличительный знак).
- **`drawScenery()`** — между `drawSky()` и `drawWater()` в `draw()`: базу силуэтов (низ холмов, киль, нижние 3 px травы) перекрывает градиент воды — «садятся» ровно на линию воды. Ночь — `dusk(c)=hexLerp(c,'#0b1220',nm*0.8)` гасит силуэты к тёмно-синему; паруса темнеют (`0.9−0.45·nm`), чайки гаснут почти до нуля (alpha `<0.12` — пропуск). Порядок в массиве = порядок рисования: у Реки bank ПЕРВЫЙ (лес на нём), у Озёр холмы перед деревьями.
- **Дрейф** — `updateDecor(dt)`: парусник `x += dir·v` (2.5–5 px/s, обход за экраном по курсу), чайки `x += v` (14–26 px/s, вправо, обход).
- **Провозировка:** `initScenery()` — `resize()` (рядом с `initClouds/initSparkles/initRain`), ОБЕ ветки смены локации в `shopClick()` (рядом с `initFish()`), старт — через `resize()` (loadSave → resize). Кадры lake/sea/ocean в `gen-screenshots.js` тоже зовут `initScenery()` — без них кадр Моря показал бы лес с Реки (сцена живёт в памяти, а не пересобирается по `save.location`).
- **Ревизия — Река без волн:** `LOCATIONS[0].waves` → все нули, `drawWater()` рисует линию только при `amp>0`/`amp2>0` (обе линии). Река — спокойная вода: ровный край градиента + берег с лесом. Остальные воды — как в Этапе 43.
**Не тронуто:** небо/облака/звёзды/светила — общие; лодка/поплавок/дорожка света; роллы/баланс/физика; `resize()` по-прежнему без `initFish()` (рыбы не пересобираются по resize — поведение Этапа 43 сохранено).
**Тесты:** новые пины не нужны (визуал не ассертится; canvas-пиксели тестами не проверяются). Итого **193/193 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — пины без изменений. Визуальная приёмка (headless 1280×720, 4 локации × день/ночь, + кроп-контроль горизонта): Река — сплошная травяная полоса на всю ширину, лес с видимыми стволами на её кромке, волновых линий нет (день и ночь: ночью полоса/лес — тёмные силуэты, фонарь лодки светится); Озёра — холмы за деревьями, гладь; Море — парусник (паруса читаются днём и ночью) + чайки; Океан — открытый горизонт. «Светлые точки в небе» в дневном кадре — артефакт даунскейла превью (пиксельная выборка неба: чистый градиент).
## Этап 45. Локация «Волшебное болото» (5-я вода, 90 000 🪙)
**Задача:** по плану `docs/PLAN-SWAMP.md` добавить пятую локацию — **«Волшебное болото»** за 90 000 🪙: светящиеся ночью разными цветами рыбы, прыгающие лягушки, кувшинки и лилии (жёлтые/белые/розовые). Свечение — только ночью (`nightMix() > 0.35`), 5 новых видов по одному на редкость r0–r4, босс + персональная ачивка, доходность — между Озёрами и Морем.
**Решения:**
- **Данные** (секция ДАННЫЕ): `LOCATIONS[4]` — `{id:4,name:'Волшебное болото',nameEn:'Magic Swamp',cost:90000}` (в массиве — ПОСЛЕДНИЙ, но по цене между Озёрами и Морем), вода `#4d6b38/#2e4a28/#122818` днём и `#0d1f12/#08160c/#030a06` ночью — мрачно-болотная тёмно-зелёная, `waves {amp:0.8,amp2:0.5,freq:0.025,freq2:0.02,speed:0.5,speed2:0.35}` — слабая рябь (тише Озёр). 5 новых видов — только **В КОНЕЦ `SPECIES`** (32→37, порядок = `SWAMP_IDS`): glowfin «Светлячок» r0 8🪙 0.3–3 кг, moonfish «Лунная рыбка» r1 13🪙 0.8–8, emberfin «Изумрудный светляк» r2 20🪙 1.5–16, violetlure «Фиалковый огонёк» r3 38🪙 2–24, spiritfin «Дух болота» r4 70🪙 0.5–6; у каждого поле `glow` (#5ff2ff/#b9c8ff/#57ffa8/#d17bff/#ffe07a — 5 цветов, по одному на редкость). `NIGHT_WEIGHTS` — ключи всем 5 id (значения декоративные: у Болота своя таблица, ключи нужны лишь чтобы не было `weight=undefined`). Таблицы Болота — копия паттерна LAKE/SEA/OCEAN, без пересечений: `SWAMP_IDS=['glowfin','moonfish','emberfin','violetlure','spiritfin']`, `SWAMP_DAY_WEIGHTS={glowfin:3,moonfish:4,emberfin:28,violetlure:13,spiritfin:4.5}`, `SWAMP_NIGHT_WEIGHTS={glowfin:2.5,moonfish:2.5,emberfin:20,violetlure:30,spiritfin:4}` (ночью доля редких/дорогих выше — паттерн «ночь доходнее»). `BOSS_BY_LOCATION[4]='spiritfin'` (босс — Дух болота, вес 20–50 кг, награда ×5; новых видов под босса нет). Миграция сейва не потребовалась: граница `save.location>=LOCATIONS.length` авто-масштабируется с этапа 36, `save.bosses` уже миграционно готов.
- **`pickSpecies()` — табличный доступ:** вложенный тернарник заменён на `SPECIES_BY_LOCATION(_NIGHT)[idx]` по `save.location` с guard'ом «вне 0..4 → Река» (порядок таблиц = `LOCATIONS`); роллы локаций 0..3 идентичны прежним — пины §9j/M4/M6 затронуты не были.
- **Баланс** (подбор `node tools/balance.mjs --set`, финал перенесён в index.html и сверен без what-if): Болото **день 240.3 / ночь 328.9 🪙** (ночь/день ×1.37) — по цене и доходу между Озёрами (194.1/249.0) и Морем (297.3/476.7) в ОБЕ фазы; порядок вод по цене/доходу: **Река → Озёра → Болото → Море → Океан**. Пины остальных вод не изменились: Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7. Инвариант «новая (по цене) вода доходнее предыдущей в ОБЕ фазы»: Озёра/Река ×2.98/×1.98, Болото/Озёра ×1.24/×1.32, Море/Болото ×1.24/×1.45, Океан/Море ×1.11/×1.16 (тонкий дневной запас Океан/Море не задет). Минимальный вес вида 2.5 — тест «каждый вид ≥1» за 3000 роллов с запасом (день — min 171.4 попаданий, ночь — 127.1).
- **`tools/balance.mjs`:** +2 таблицы Болота (8→10), из вырезки данных добавлены `SWAMP_SPECIES(_NIGHT)`; сводка и пары отношений соседних вод сортируются **ПО ЦЕНЕ** `LOCATIONS` (Болото 90000 стоит в массиве последним, но по цене перед Морем/Океаном) — «каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы» теперь проверяется на парах Озёра/Река, Болото/Озёра, Море/Болото, Океан/Море. Проверки целостности: таблицы без пересечений, все 37 видов достигаемы, нечисловых весов нет.
- **Свечение — только ночь, `nightMix() > 0.35`** (тот же порог, что у фонаря лодки): `GLOW_RGBA` — кэш rgba-строк по hex (не собираются каждый кадр); `drawGlowAura(x,y,r,hex)` — радиальный градиент + `globalCompositeOperation='lighter'` в `save/restore`; общий хелпер `fishGlow(sp,x,y,r)` — аура подплывающей (APPROACH/BITE), уходящей и вываживаемой рыбы (вызов в `drawHookedFish()` внутри существующего save/restore — боссову золотую ауру не ломает). Фоновые стайки — в `drawAmbientFish()` при `save.location===4 && nightMix()>0.35`, цвет каждой рыбы `f.glow` из `palette`. Карточка улова — класс `glow-card` + CSS-переменная `--glow` (правило `#card.glow-card .bigemoji`) только для normal-варианта (чтобы не спорить с фильтрами вариантов); дневник — inline `filter:drop-shadow` на SVG (`class="glow-fish"`). Свечение — оформление вида, в UI не зависит от времени суток.
- **Декорации Болота:** ветка `i===4` в `initScenery()` — мшистый берег `{k:'bank'}`, камыш `{k:'reed'}`, кривые деревья `{k:'snag'}` и туи `{k:'pine'}` болотной гаммы; отдельные от `scenery` слои `pads[]` (8–14 кувшинок с вырезом-прорезью; у 45% — лилия одного из 3 цветов) и `frogs[]` (3–5 лягушек, стартуют на кувшинках). Оба слоя очищаются/пересобираются в `initScenery()`, а значит — в `resize()` и обеих ветках смены локации `shopClick()`. `updateDecor(dt)` — покачивание кувшинок (`sin`, 1.6 px) и прыжки лягушек: `sit` (3–7 с) → параболический `jump` (`x/y` по t плюс `sin(π·t)·jh`) на ближайшую ДРУГУЮ кувшинку (85%) или случайную точку воды; `drawSwampDecor()` — в `draw()` после `drawWater()`/`drawAmbientFish()` и до `drawBoat()` (поверх воды — `dusk()` берега декор не гасит; ночью притухает к #0b1a12). Фигуры — arc/ellipse/lineTo, без `shadowBlur`/градиентов на кадр.
- **`AMBIENT_FISH[4]`** — 7 рыб 12–30 px + `palette` из 4 цветов свечения (циан/зелёный/фиолет/золото); `initFish()` пишет цвет в `f.glow` по индексу рыбы (у локаций 0..3 — `null`).
- **Магазин / i18n / ачивки:** тост `welcome_swamp` (RU «Добро пожаловать в Волшебное болото! Здесь всё светится...» / EN «Welcome to the Magic Swamp! Everything glows here...»), в `shopClick` покупка — ветка `if/else if(loc.id===4)` вместо тернарника; название/описание локации и 5 видов — в `LOCATIONS`/`SPECIES` (`nameEn`/`descEn`). Ачивки 34→36: `swamp_first` «Тайна болота» (locations-based, `coinPct:0.02`) + `swamp_boss` «Победа над Духом болота» (пер-босс, `bonus:{}` без coinPct). Пин §10c(8) `achBonus().coinPct` **0.16→0.18** (в сумму вошла `swamp_first` +0.02); `species_8` теперь = все 37.
- **Портреты:** 5 SVG `assets/fish/fish-<id>.svg` (viewBox 0 0 160 100, `var(--f1..f4)`-заливки), id добавлены в `tools/embed-fish.mjs` (32→37), спрайт перевстроен — в index.html 37 `<symbol id="fish-...">`. Emoji-шрифт не тронут (новых emoji нет, `--check` OK).
**Не тронуто:** роллы локаций 0..3, `SPECIES[0..7]`, цены/веса существующих видов, физика боя/`updateReel()`, формат сейва (миграции не нужны), SDK/реклама/i18n-механика. Свечение и декор — чисто визуал.
**Тесты:** новые/обновлённые: **lc1** (SPECIES 32→37, LOCATIONS 4→5, costs `0,65536,131072,196608,90000`, `swampOrder=slice(32)`, `swampGlow`/`swampRar`, океанский `oceanOrder` ограничен `slice(23,32)`, SVG-спрайт 37 symbol), **lc2** (+`SWAMP_SPECIES(_NIGHT)`=5, порядок = `SWAMP_IDS`, пересечения с 4 водами пусты), **lc4d** (новый: роллы Болота 3000 днём + 3000 ночью — все 5 видов ≥1, чужих вод нет), **lc8w** (покупка Болота: списание 90000, авто-переход location=4, тост `welcome_swamp`), **lc9** (дневник 32→37), **lc10** (mid-лист swamp-видами), **lc11** (новый: у 5 видов glow — `#rrggbb`, 5 цветов различны, `SWAMP_DAY/NIGHT_WEIGHTS` покрывают все id, `NIGHT_WEIGHTS` — ключи всех 37), **a22d** (ACHIEVEMENTS 34→36 + `swamp_first`/`swamp_boss`), **a36loc** (+`swamp_first` для локаций `[0,4]`), пин §10c(8) 0.16→0.18, **§9k2** (+кейс Болота: босс `spiritfin`, `save.bosses.spiritfin`, ачивка `swamp_boss`). Итого **204/204 PASS, exit 0** (было 193); `check.mjs` OK; `tools/balance.mjs` — цифры выше, «здоровье» роллов без замечаний; `tools/emoji-font.py --check` OK.
**Скриншоты:** `tests/gen-screenshots.js` — +кадры `swamp` (день) и `swamp-night` (ночь со свечением) → 12→14 кадров; RU-набор перегенерирован (`screenshots-wiki/`, 42 PNG, включая `desk/mobl/mobp-swamp*.png`). EN-набор в этом этапе не переснимался (36 PNG без swamp; при необходимости — `node tests/gen-screenshots.js . screenshots-wiki-en en`). README: строка локаций + 2 изображения Болота (день/ночь), «14 кадров × 3 вьюпорта».
**Доки:** AGENTS.md (строки «Локации», «Баланс Озёр», «Баланс-скрипт», «Босс», «Награды», «Портреты рыб», «Скриншоты»), README.md (виды/локации/боссы/достижения/тесты/кадры), этот DEVLOG.
**Ревизия свечения и лягушек (21.09.2026, плейтест на телефоне):**
- **Плавный ramp вместо мгновенного включения:** хелпер `glowIntensity()` — 0 при `nightMix()<=0.35` (тот же старт, что у фонаря лодки), smoothstep `k²(3−2k)`, `k=(nm−0.35)/0.55`, до 1 при `nightMix()>=0.9`. Вечером свечение разгорается, к утру гаснет. `drawGlowAura(x,y,r,hex,I)` получил параметр интенсивности: `globalAlpha=I` (множитель на альфу всех стопов), ранний выход при `I<=0.02`. `fishGlow()` вместо жёсткого гейта `nightMix()>0.35` всегда передаёт `glowIntensity()`, `drawAmbientFish()` — `glowI=save.location===4?glowIntensity():0`.
- **Компактный ореол вместо размытого шара:** радиус уменьшен `size*2.6`→`size*1.5` (ambient), `bs/fs*2.6`→`bs/fs*1.5` (bite/hooked); профиль градиента крутой — стопы `0.55·I → 0.30·I (0.45) → 0` (было `0.42 → 0` с радиусом 2.6) — свет собран у тела, пятно не заезжает на рыбу. Силуэты светящихся видов ПОЛНОСТЬЮ непрозрачные (`drawFishShape(...,sp.glow?1:...)`, ambient-альфа `a+(1−a)·glowI`): тело/контур чёткие, аура позади. Боссовая золотая аура и CSS-свечение карточки/дневника не тронуты. Пиксель-приёмка (headless, Болото, фоновые): excess яркости ореола над фоном t=0.30 → 0 (I=0), t=0.55 → 7.4 (I=0.02), t=0.70 → 13.1 (I=0.50), t=0.85 → 18.6 (I=1.00); тело при I=1 насыщенное (sat≈93, «мыла»/белёсости 0%).
- **Лягушки крупнее:** `frogs[].s` 4.5–7 → 9–14 (≈×2), прыжковая высота `jh` 10–22 → 16–30, старт 14→20; вся отрисовка `drawSwampDecor()` уже масштабируется от `s` (лапы/тело/голова/глаз/передние лапы) — bbox при s=12 ≈28×21.5 px (при s=6 было 13.5×10.5).
- Скриншоты RU/EN перегенерированы (swamp-night изменился + лягушки в swamp).
## Этап 46. Аквариум (коллекция экземпляров, модель C — бесплатная копия)
**Задача:** по плану `docs/PLAN-AQUARIUM.md` добавить **Аквариум** — декоративную сцену коллекции ЭКЗЕМПЛЯРОВ (вид+вариант+вес), куда «высаживаются» пойманные рыбы. Это **не локация** для ловли: в `LOCATIONS`/`SPECIES_BY_LOCATION`/`BOSS_BY_LOCATION` не входит, роллы/боссы/баланс не трогаются (пины `tools/balance.mjs` живут).
**Решения:**
- **Данные** (секция ДАННЫЕ рядом со скинами/локациями): `save.aqFish[]` (`{id,v,w}` — вид, вариант, вес) и `save.aqSlots` (старт `AQ_BASE_SLOTS=6`), константы `AQ_BASE_SLOTS=6`, `AQ_MAX_SLOTS=12`, `AQ_SLOT_COSTS=[1500,4000,9000,20000,45000,100000]` (лестница за расширение 6→12; в масштабе цен локаций 65k–196k). Дефолты — в `save`/`freshSave()`.
- **Модель C — бесплатная КОПИЯ** (главное экономическое решение): `catchFish()` **не меняется** — монеты/квесты/ачивки/реклама начисляются на улове как раньше. Кнопка `#btnKeep` «🐠 В аквариум» на карточке улова лишь кладёт копию экземпляра: `showCard()` пишет `cardSpec={sp,w,variant}`, `syncKeepBtn()` держит кнопку `disabled` + подпись/`title` «Нет места» при `save.aqFish.length>=save.aqSlots`; клик — `push({id,v,w})`, `persist()`, `checkAchievements()`, тост `aq_kept`, закрытие карточки, `state='IDLE'`, `tryInterstitial()` (единообразно с `#btnCardOk`). Плюс: максимум пользы при нулевом риске для экономики и пинов; минус (осознанно): концептуальные «дубликаты» — рыба и продана на улове, и стоит в банке.
- **Сцена** (новая секция `АКВАРИУМ (Этап 46)` после «ОТРИСОВКА», перед Y1): `aqOpen`, `aqFishRt[]` (рантайм-раскладка `x,y,dir,ph,spd,vy,tail` — НЕ персистится, индекс = `save.aqFish`), `aqSel` (сентинел `-1`), `aqTank`/`aqPlants`/`aqBubbles`. `initAquarium()` — геометрия банки и сетка стартовых позиций; `updateAquarium(dt)` — плавание, разворот у стенок, вертикальный дрейф, анимация хвоста, пузыри; `drawAquarium()` — вода через `hexLerp` к `nightMix()` (общий цикл суток), косые лучи, волнистый грунт, растения, пузыри, рыбы (`drawFishShape` + подкраска варианта `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` + блик, свечение болотных `drawGlowAura`+`glowIntensity()`, размер `aqFishSize(sp,w)` от `w/wMax` и редкости), рамка выбранной, стекло. Точки жизненного цикла: `frame()` (`if(aqOpen) updateAquarium(dt)`), `draw()` (`if(aqOpen){ drawAquarium(); ctx.restore(); return; }` — банка ВМЕСТО озера), `onDown()` (ранний `if(aqOpen){ aqTap(x,y); return; }`), `resize()` (`if(aqOpen) initAquarium()`), `syncDom()` (под `aqOpen` `#btnPause`/`#btnRecall` не показываются).
- **Вход/выход и DOM:** `#btnGmAquarium` в хабе «Меню» `#gamemenu` → `openAquarium()`/`closeAquarium()` (аналог `shopFromMenu` — флаг `aqFromMenu`, «Назад» возвращает в гменю; `YG.gameplayStop()`; `uiOpen=true`). Бар `#aqBar` — «Назад» `#btnAqBack`, счётчик `#aqCount` «N/M», «+ Слот (N 🪙)» `#btnAqSlot` (цена `AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS]`, при максимуме `disabled` + `aq_max`); поповер `#aqPop` — `#aqPopName`/`#aqPopSub` (вес · редкость, БЕЗ цены). Тап по сцене `aqTap(x,y)` ищет ближайшую рыбу в радиусе → `aqSel` → `showAqPop()`.
- **«Выпустить» вместо «продать» (отступление от §7 плана):** по решению владельца продажа из банки (начисление монет по формуле улова) заменена на **выпуск** — `#btnAqRelease` → `aqReleaseSel()` удаляет экземпляр ИНДЕКСАМИ (`save.aqFish.splice` + `aqFishRt.splice`, без `initAquarium` — позиции остальных сохраняются) **без монет**, тост `aq_released`. Причина: продажа давала дыру ×2 дохода (рыба и продана на улове, и снова продана из банки). Экономика не затронута.
- **Миграция `loadSave()`:** `aqFish` фильтруется по `SPECIES` (`Number.isFinite(w)&&w>0`), неизвестный вариант → `'normal'`; `aqSlots` — `Math.trunc` (нецелое 6.5 давало `AQ_SLOT_COSTS[0.5]=undefined` → NaN) + клампы `AQ_BASE_SLOTS..AQ_MAX_SLOTS`; хвост списка обрезается по числу слотов. Старые сейвы без полей читаются по дефолтам.
- **Ачивки 36→38:** `aq_first` «Первый жилец» (`check: s=>(s.aqFish||[]).length>0`) и `aq_full` «Аквариум полон» (`>= (s.aqSlots||AQ_BASE_SLOTS)`), обе `bonus:{}` — пин §10c(8) `achBonus().coinPct=0.18` **не сдвинут**; проверки терпимы к старому сейву (ложных выдач нет).
- **i18n:** `gm_aquarium`, `aq_title`, `aq_keep`, `aq_back`, `aq_slots`, `aq_buy_slot`, `aq_max`, `aq_release`, `aq_released`, `aq_no_room`, `aq_kept` (RU/EN); `setLang()` перерисовывает `renderAqBar()` открытой сцены; имена/описания ачивок — в `ACHIEVEMENTS` (`nameEn`/`descEn`).
- **Emoji:** 🐠 (U+1F420) добавлен во встроенный субсет — перегенерация `tools/emoji-font.py` (`--check` OK: 53 emoji).
- **Подводные камни:** `body.aq-open` прячет `#hud` (CSS + `syncDom`); `#aqBar{pointer-events:none}` + `.aqpill{pointer-events:auto}` — иначе невидимая полоса бара перехватывала бы тапы по сцене; все проверки выбранного индекса — через `aqSel>=0` (сентинел `-1`).
**Не тронуто:** `catchFish()`/экономика (модель C), роллы/боссы/баланс (`tools/balance.mjs` — цифры без изменений), `SPECIES`/`LOCATIONS`, физика боя, формат сейва вне новых полей, SDK/реклама (кнопка `#btnRv` на карточке живёт рядом с `#btnKeep`; карточка БЕЗ `gameplayStop()` — как раньше).
**Тесты:** новый блок **§AQ «Этап 46»** (после §a36): (1) данные `freshSave()`/кноб слотов, (2) миграция старого сейва без `aqFish`/`aqSlots`, (3) миграция битых значений (чужой id/вес/вариант, `aqSlots=99`, обрезка хвоста), (4) keep — `cardSpec` + клик `#btnKeep` → 1 копия с верными `id/v/w`, карточка закрыта, монеты выросли только на цену улова (модель C), выдан `aq_first`, (5) лимит → `#btnKeep` `disabled` + «Нет места», (6) сцена `#btnGmAquarium`→`aqOpen`/`#aqBar`/скрытие `#gamemenu`/`#btnPause` и `#btnAqBack`→возврат, (7) слот — списание `AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS]` и `aqSlots+1`, (8) выпуск — `aqTap`→`aqSel`/поповер без цены, `#btnAqRelease` → экземпляр удалён, монеты/`earned` НЕ изменились, позиции остальных не «телепортируются». **a22d** обновлён: `ACHIEVEMENTS.length===38` + `aq_first`/`aq_full`. Итого **218/218 PASS, exit 0** (было 204: +14 ok); `check.mjs` OK; `tools/emoji-font.py --check` OK; `tools/balance.mjs` — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7).
**Скриншоты:** `tests/gen-screenshots.js` — +кадр `aquarium` (Этап 46) → 14→15 кадров; в кадре засеваются `save.aqFish` = карась (normal, 0.6 кг), щука (silver, 7.4 кг), золотая рыбка (rainbow, 0.8 кг), светлячок (normal, 2.4 кг) и вызывается `openAquarium()`. Оба набора перегенерированы: `screenshots-wiki/` и `screenshots-wiki-en/` — по 45 PNG (включая `desk/mobl/mobp-aquarium.png`). README: фича «Аквариум 🐠» + изображение `mobp-aquarium.png`, «15 кадров × 3 вьюпорта», 38 достижений, 218 тестов.
**Доки:** AGENTS.md (новый пункт «Аквариум (Этап 46)», глобалы, «Награды» 36→38, «Скриншоты» 14→15), README.md (фича, счётчики), `docs/PLAN-AQUARIUM.md` (статус «реализован» + заметка об отступлении §7), этот DEVLOG.
### Ревизия Этапа 46 (по просьбе владельца): жильцы банки — те же портреты, что на карточке улова
**Задача:** в аквариуме рыбы рисовались процедурным силуэтом `drawFishShape` (эллипс + хвост) и лишь отдалённо напоминали вид; на карточке улова и в дневнике давно стоят подробные SVG-портреты (`#fish-<id>`, этап 42). Владелец попросил, чтобы жильцы выглядели так же, как в карточке улова.
**Решение — DOM-слой портретов поверх canvas-сцены** (вода/грунт/растения/пузыри/стекло остаются canvas-рисунком):
- Новый слой `#aqFishLayer` (пустой `<div>` в `body`, сразу после `<canvas>`): `position:fixed; inset:0; z-index:5; pointer-events:none; display:none`, включается только под сценой (`body.aq-open #aqFishLayer{display:block}`). Клики не перехватывает — тапы по рыбам по-прежнему ловит canvas (`aqTap`).
- `aqBuildFishEl(i)` создаёт на каждого жильца `<div class="aqfish"><svg viewBox="0 0 160 100"><use href="#fish-<id>"/></svg></div>` — ровно тот же спрайт, что у дневника/карточки (`<use>` в общий `FISH-SVG`). Размер — прежний `aqFishSize(sp,w)`: длина `2·size`, высота по соотношению 160:100.
- `aqPlaceFish(el,rt)` каждый кадр из `updateAquarium(dt)` ставит `transform: translate(x,y) translate(-50%,-50%) rotate(...) scale(dir,1)` — движение/разворот/«качание хвоста» как было, но уже для портрета (крен ±4° вместо анимации хвоста).
- `initAquarium()` пересобирает слой (очистка + `clip-path: inset(...round 18px)` по банке, чтобы рыбы не вылезали за стекло), `aqReleaseSel()` удаляет DOM-элемент вместе с записью (индексами, без «телепорта» остальных).
- **Вариант — тем же оформлением, что на карточке:** перекраска `--f1`/`--f3` (наследуется сквозь `<use>`; copper/gold/quartz/emerald/diamond — цвета 1:1 из CSS карточки) + классы `.var<id>` в `#aqFishLayer` (silver — `grayscale+brightness`, rainbow — `aqrainbowhue`, остальные — цветной `drop-shadow`); normal — без оформления. Побочно это заменило прежний hoo-хака `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` + подкрашивающий ореол.
- **На canvas осталось:** вода/лучи/грунт/растения/пузыри/стекло/рамка выбранной рыбы + **аура свечения болотных** (`drawGlowAura`+`glowIntensity()` рисуется ДО портрета, поэтому уходит за силуэт, как и задумано).
- Итог: 8 вариантов × 37 видов в банке выглядят идентично карточке улова; геймплей/баланс/сейв/экономика не тронуты (портреты — чистая визуализация `save.aqFish`).
**Тесты:** в блоке §AQ добавлен подблок «портреты» (+4 проверки): на каждый `aqFish` — `.aqfish` с `use href="#fish-<id>"` (те же спрайты, что `#cardFishUse`), класс варианта и `--f1` ровно у перекрашиваемых, `transform` следует за `aqFishRt` индекс-в-индекс, слой виден только под сценой и `pointer-events:none`. Итого **222/222 PASS, exit 0**; `check.mjs` OK; `tools/balance.mjs` — пины без изменений.
**Скриншоты:** перегенерированы только кадры `aquarium` (оба набора, 6 PNG) — в банке теперь SVG-портреты. Остальные кадры не трогали: набор в целом не побитово-детерминирован (анимации/время), а canvas-сцены ловли ревизия не касается, так что лишний шум в диффе не нужен.
**Доки:** AGENTS.md (пункт «Аквариум» + глобалы: `AQ_VARIANT_RECOLOR`/`AQ_VARIANT_CLASS`/`aqApplyVariant`/`aqFishEls`/`aqBuildFishEl`/`aqPlaceFish` вместо `AQ_VARIANT_FX`/`AQ_VARIANT_HUE`), этот DEVLOG.
## Этап 47. Ревизия SVG-портретов рыб (детализация до уровня болотных)
**Задача:** поднять детализацию/объём портретов до уровня болотных рыб (эталон стиля — `fish-glowfin.svg`, Этап 45) с акцентом на биологическое правдоподобие и узнаваемые видовые отличия.
**Решения:**
- **Объём:** полная визуальная ревизия ВСЕХ 32 портретов, кроме 5 болотных (сделаны ранее и послужили эталоном): 8 речных (carass, perch, bream, carp, pike, catfish, sturge, gold), 10 озёрных (zander, chuch, crayfish, bleak, roundel, eelpout, tench, navaga, rudd, cisco), 5 морских (herring, mackerel, flounder, tuna, swordfish), 9 океанских (moray, grouper, turtle, angler, octopus, manta, shark, squid, whale).
- **Единый стиль:** послойные заливки без градиентов/`defs`; `--f1` тело и `--f3` плавники — перекрашиваются вариантами, `--f2` брюхо/светлое и `--f4` тёмный узор/кромка — НЕ перекрашиваются; спинная кромка f4-серпом; жаберная пластина + дуга; глаз 5 кругов (ободок→склера→радужка фикс-hex→зрачок→блик); составные узоры (полосы/пятна/чешуя/жучки/финлеты) — одним compound-path; ≤38 элементов на портрет; фикс-hex-акценты разрешены.
- **Разведение «двойников»:** карась↔карп (усы), окунь↔судак (горб/полосы/красное vs косая полоса/клыки), лещ↔густера (длинная анальная «юбка» vs короткий анальный + крупный глаз + киль), плотва↔краснопёрка (красный vs жёлтый глаз, рот конечный vs вверх), сом↔вьюн (голова-¼/длинные усы vs лента/10 усиков), мурена↔вьюн, осьминог↔кальмар (мешок + 8 равных рук vs мантия-ракета + 2 ловчих + крылышки), кит↔акула (флоскулы/фонтан/ус vs треуг. D/гетероцерк/5 щелей), сельдь↔скумбрия↔тунец↔меч, манта↔камбала↔рак, черепаха (вид сверху, не рыба).
- **«Не рыбы»:** crayfish (рак, вид сверху, клешни/ноги/веер), turtle (черепаха, вид сверху, щитки/4 ласты/клюв), octopus/squid/whale — иная анатомия (присоски/ловчие щупальца/флоскулы/фонтан); у них допустимые отклонения от «рыбьего» протокола.
- **Двойные боссы:** `sturge` (босс Реки) и `whale` (босс Океана) проверены под золотой аурой `#card.boss-card` — контраст держат f4-детали.
- **Дефекты, найденные и исправленные на приёмке** (точечно, композиция не менялась): у octopus 2 «щели» с фоном (заплатки), у swordfish светлый стык голова↔меч, у tuna прозрачная «дыра» в выемке хвоста-полумесяца, у grouper щель пасть↔нижняя челюсть, у angler просвет между римом и эской.
**Не тронуто:** `viewBox` (0 0 160 100) и id/имена файлов; данные/логика/баланс — правки чисто визуальные. **Подводный камень:** `node tools/embed-fish.mjs` — единственный способ обновить спрайт в `index.html` (блок FISH-SVG руками не править).
**Интеграция / превью:** `node tools/embed-fish.mjs` — блок FISH-SVG теперь 202478 байт / 1512 строк, 37 `<symbol>`; `tools/fish-preview.html` — блок «Варианты» (8 инлайн-копий carass синхронизированы с источником) + секция «Читаемость в UI» (44×28 / 40×26 / 240×150).
**Тесты:** не правились — пины lc1/M6 проверяют число `<symbol>`=37 и id, а не внутренности SVG. Итого **222/222 PASS, exit 0**; `node tools/check.mjs` OK; `tools/balance.mjs` — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7).
**Скриншоты:** перегенерированы `node tests/gen-screenshots.js` — кадры дневника/карточки/аквариума и локаций содержат новые портреты.
**Доки / файлы:** 32 × `assets/fish/fish-*.svg`, `index.html` (только блок FISH-SVG), `tools/fish-preview.html`, этот DEVLOG.
## Этап 48. Размер рыбы в аквариуме — от абсолютного веса (ревизия Этапа 46)
**Задача:** владелец заметил, что размер жильцов банки считался от `w/wMax` + `rarity`, хотя в игре уже есть узловая таблица абсолютного веса `FISH_SIZE_KNOTS`/`fishSizeFromW()` (Этап 41, используется `drawHookedFish()`). Нужно переиспользовать её, чтобы «крупнее вес → крупнее рыба» работало в банке так же, как в воде.
**Решение — `aqFishSize(w) = clamp(fishSizeFromW(w)·0.7·k, 7·k, min(W,H)·0.16·k)`, где `k=2` только для десктопа:**
- `aqFishSize` (`index.html`, секция АКВАРИУМ) больше не считает `rel=w/wMax` и не прибавляет `rarity` — только читает общую кривую воды. Формула: `s = fishSizeFromW(w)×AQ_FISH_SCALE×k`, кламп `[AQ_FISH_SIZE_MIN×k, min(W,H)×AQ_FISH_SIZE_REL×k]`.
- Новые константы: `AQ_FISH_SCALE=0.7` (масштаб к шкале воды), `AQ_FISH_SIZE_MIN=7` (мелкая рыба читаема/тапается), `AQ_FISH_SIZE_REL=0.16` (верхний кламп = 16% меньшей стороны экрана).
- **Ревизия (по просьбе владельца): десктоп ×2.** Мобильный вид одобрен владельцем как есть, поэтому множитель `k=AQ_DESKTOP_SCALE=2` включается только при `min(W,H)>=AQ_DESKTOP_MIN=600` (иначе `k=1`). Так десктоп 1280×720 и mobile-landscape 800×450 (min=450 < 600) остаются различными: банка на большом экране пустует сильнее, и жильцы там вдвое крупнее. Оба клампа (и `MIN`, и `REL`) масштабируются на `k` вместе с телом — иначе на десктопе `MIN`-рыба «отставала» бы от верхней границы, а `REL`-кламп резал бы только что увеличенных гигантов.
- Сигнатура `aqFishSize(sp,w)` → `aqFishSize(w)`: `sp` больше не нужен; обновлены 3 вызова — `aqBuildFishEl` (`f.w`), `drawAquarium` (`f.w`, `sp` остался для `sp.glow`), `aqTap` (`f.w`). Имя `aqFishSize` сохранено (глобал в тестах/AGENTS).
- Таблицу/кривую НЕ дублируем: `fishSizeFromW` объявлен в секции «ОТРИСОВКА» раньше секции аквариума — доступен как есть.
**Размеры (DOM-портрет = `2·size × 1.25·size`).** На телефоне 390×844 мобильный кламп (cap 125 px) режет только 100-кг кит; на десктопе 1280×720 cap = 460 px — в диапазоне 0.1–100 кг кламп не срабатывает вовсе:
| Вес | Моб. DOM | Десктоп DOM |
|---|---|---|
| 0.1–0.5 кг | 14×9 (MIN) | 28×18 (MIN×2) |
| 1 кг | 16×10 | 32×20 |
| 3 кг | 27×17 | 54×33 |
| 4.8 кг | 33×20 | 66×41 |
| 8 кг | 40×25 | 79×49 |
| 16 кг | 49×30 | 97×61 |
| 40 кг | 78×49 | 157×98 |
| 72 кг | 119×75 | 239×149 |
| 100 кг | **125×78 (clamp)** | 293×183 |
Мобильный/ландшафт-800 не тронуты ревизией. Ранее (w/wMax+rarity) 100 кг кит давал 73 px длины, теперь на десктопе 292 px, на мобильном 125 px; мелочь 0.1–0.7 кг на мобильном сглаживается `MIN=7` до 14 px, на десктопе `MIN×2` — до 28 px.
**Не тронуто:** `FISH_SIZE_KNOTS`/`fishSizeFromW`/`drawHookedFish` (вода), `wMin/wMax`, формат/миграция `save.aqFish`, баланс/роллы/физика, `drawFishShape` (в банке по-прежнему не используется).
**Тесты:** не правились — размеры в §AQ никто не пинит (проверяются `.aqfish`, `use href`, варианты, `transform`; `aqTap` жмёт точно центр). Итого **222/222 PASS, exit 0**; `node tools/check.mjs` OK; `tools/balance.mjs` — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7).
**Скриншоты:** перегенерированы только кадры `aquarium` (оба набора, 6 PNG) — набор в целом не побитово-детерминирован (анимации/время), остальные кадры не тронуты (паттерн ревизии Этапа 47). На кадре `save.aqFish` = карась 0.6 кг, щука 7.4 кг, золотая 0.8 кг, светлячок 2.4 кг.
**Доки:** AGENTS.md (пункт «Аквариум» — формула `aqFishSize(w)` + `AQ_FISH_SCALE`/`AQ_FISH_SIZE_MIN`/`AQ_FISH_SIZE_REL`), README.md (фича «Аквариум» — размер по весу), этот DEVLOG.
**Файлы:** `index.html`, `AGENTS.md`, `README.md`, `docs/DEVLOG.md`, `screenshots-wiki*/{desk,mobl,mobp}-aquarium.png`.