944 lines
210 KiB
Markdown
944 lines
210 KiB
Markdown
# Процесс разработки
|
||
|
||
Как игру «Рыбалка» спроектировали, собрали, проверили и опубликовали. Документ описывает этапы, инструменты и повторяемые команды — чтобы любой (включая вас через полгода) мог воспроизвести и продолжить процесс.
|
||
|
||
```
|
||
ТЗ → макет-прототип → среда проверки → автотесты → визуал → баги → публ. → поддержка
|
||
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`.
|