Files
fishing-game/AGENTS.md
T
andrey b3f5b0724c Этап 64: выпуск рыбы из аквариума — только из диалога «Слоты» и с подтверждением; перерисовка наживок (червь на конце крючка, воблер-минноу, блёстка-вращалка). Выпуск: кнопка #btnAqRelease из поповера #aqPop убрана (поповер — чистое инфо), на строке жильца во вкладке «Слоты» #aqShop — кнопка «Выпустить» (data-act=askrelease, делегирование #aqShopBody), inline-подтверждение #aqShopConfirm (паттерн #aqPickConfirm, строка подсвечивается .replace, текст aq_release_q RU/EN), подтверждение — aqReleaseAt(i) (переименовано из aqReleaseSel: индекс вместо aqSel; aqSel при выпуске левее сдвигается, по индексу — снимается), по-прежнему без монет и без телепорта остальных; aqShopRelPending сбрасывается в openAqShop/closeAqShop/setAqTab и при сбросе прогресса. Наживки (чистый визуал, данные в записях LURES.hook, switch по id нет): Воблер — body (тельце-минноу: эллипс+хвост+брюшко+лопатка, bead — красный глаз, крючок выглядит тройником под вобом), Блёстка — blade (золотой лепесток с шириной |sin(t·4.5)|, кромка+блик, рисуется внутри gold-тени до restore), Червь — bait вдоль жала с торчащим кончиком и головным утолщением (lineWidth 1.6→2.2). Геометрия hx/hy/hookPos не тронута; баланс/сейв/ачивки/i18n/emoji не тронуты, миграция не нужна. Тесты: §10e2 — пин body/blade (#hex, у остальных нет); §AQ(8) переписан на новый флоу (поповер без кнопок → askrelease → подтверждение → «Отмена»/«Выпустить»), (8b) — aqReleaseAt(0). Проверки: check.mjs OK; emoji --check OK; test.js 261/261 PASS exit 0. Доки: AGENTS.md (тест-глобалы, пункты Э46/Э52/Э62/Э63), DEVLOG (Этап 64).
2026-11-06 15:54:55 +03:00

76 lines
75 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
Игра — один файл `index.html` (HTML+CSS+JS, ~2700 строк): без сборки, зависимостей и бэкенда. Весь код в одном `<script>`, секции помечены комментариями. UI, комментарии и доки — на русском; при правках сохраняйте этот стиль.
## Проверка (lint/typecheck отсутствуют)
1. Синтаксис: `node tools/check.mjs` — вынимает JS из `index.html` и прогоняет `node --check` (при ошибке показывает номер строки index.html).
2. Главный рубеж — плейтест (запускать после правок `index.html` или `test.js`):
```bash
cd tests
npm install # puppeteer-core
npm test # = node test.js
```
```
Ждать все PASS и exit 0.
```
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI, только по просьбе пользователя):
```bash
node tests/orca-playtest.js # из каталога репо; требует ЗАПУЩЕННЫЙ Orca app и orca-ide (или ORCA_CLI_COMMAND)
```
Проходит тот же геймплей «вживую» в браузере (не headless) и пишет `orca-*.png` в каталог запуска. Если Orca app не запущен — пропустить и НЕ блокировать (главный рубеж — пункт 2). Подробности и подводные камни: `docs/BROWSER-TESTING.md`.
4. Скриншоты: `node tests/gen-screenshots.js [каталог-сборки] [папка-вывода] [язык]` — 15 кадров × 3 вьюпорта (1280×720 / 360×640@2x / 800×[450@1.6x](mailto:450@1.6x); кадры sea/ocean — этап 43, swamp/swamp-night — этап 45, aquarium — этап 46), 24-битный PNG без альфа. Без аргументов — русские в `screenshots-wiki/`, с `en` — английские в `screenshots-wiki-en/`. README ссылается на `screenshots-wiki/mobp-*` (мобильный портрет). Emoji рисует встроенный шрифт (раздел «Встроенный emoji-шрифт») — системные emoji-шрифты для скриншотов больше не нужны.
5. Публикация в Яндекс Играх: `node tools/pack.mjs` (сборка dist/fishing-yandex.zip + самопроверка 5 пунктов) → `node tests/pack-smoke.js` (smoke играбельности КОПИИ из архива, 6 чек) → загрузить zip в консоль. Доки: раздел README «Публикация в Яндекс Играх», запись Этап 27 в DEVLOG.
6. Релизная (уменьшенная) сборка: `node tools/minify.mjs` (зависимости: `npm i --prefix tools` — terser+csso) → `dist/index.release.html` (−35% к размеру) + самопроверка 6 пунктов (размер, node --check, маркеры EMOJI-FONT/FISH-SVG, набор id, видимый текст DOM, base64 шрифта). Методы: terser с `mangle.toplevel:false` (глобалы из буллета «Подводные камни» — «Тесты гоняют игру через внутренние глобалы» — держатся ИМЯМИ, поэтому артефакт можно прогнать тем же tests/test.js), csso в каждом `<style>` кроме блока шрифта (держится побайтово), в FISH-SVG удаляются комменты и whitespace между тегами, прочие HTML-комменты — кроме маркеров, whitespace DOM — только между блок-тегами. ПЕРЕД публикацией артефакта: подставить его под `index.html` и прогнать `node tests/test.js` (полную суиту) и/или цепочку пункта 5.
## Подводные камни
- `tests/test.js` берёт браузер из `process.env.CHROMIUM || '/usr/bin/chromium-browser'`, а URL игры резолвит динамически от каталога репо (тот же паттерн, что в `gen-screenshots.js`). Если тест падает на старте — поправьте имя/путь chromium под свой чекаут (переменная `CHROMIUM`).
- Граница полуночи: если прогон стартует в пределах 5 минут от 00:00, test.js сам ждёт безопасное окно (дата не должна смениться посреди прогона — падают date-ассерты квестов/стрика). Это нормально, не баг.
- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `rollVariant()`, `variantChance()`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()`, `BOAT_SKINS`, `BOBBER_SKINS`, `LINE_SKINS`, `SKIN_GROUPS` (Этап 59), `RODS`, `LURES`, `rodLook()`, `hookRec()` (внешний вид снасти, Этап 63, тесты §10e2), `LOCATIONS`, `RIVER_SPECIES`, `RIVER_SPECIES_NIGHT`, `LAKE_SPECIES`, `LAKE_SPECIES_NIGHT`, `SEA_SPECIES`, `SEA_SPECIES_NIGHT`, `OCEAN_SPECIES`, `OCEAN_SPECIES_NIGHT`, `RIVER_IDS`, `LAKE_IDS`, `SEA_IDS`, `OCEAN_IDS`, `SWAMP_SPECIES`, `SWAMP_SPECIES_NIGHT`, `SWAMP_IDS`, `SWAMP_DAY_WEIGHTS`, `SWAMP_NIGHT_WEIGHTS`, `SAVE_KEY`, `loadSave`, `freshSave`, `ACHIEVEMENTS`, `checkAchievements`, `questText`, `genQuests`, `questScale`, `localMedianW` (квесты Этапа 53, тесты §9e2), `YG` (и `YG.counts`), `forceLang`, `setLang()`, `LANG`, `trName()`, `trRarity()` (i18n, тесты §Y5), `pauseOpen` (пауза, тесты §Y6), `uiOpen`, `dayTime` и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`; аквариум (этап 46): `aqOpen`, `aqFishRt`, `aqSel`, `aqTank`, `AQ_BASE_SLOTS`, `AQ_MAX_SLOTS`, `AQ_SLOT_COSTS`, `AQ_VARIANT_RECOLOR`, `AQ_VARIANT_CLASS`, `aqApplyVariant`, `aqFishEls`, `aqBuildFishEl`, `aqPlaceFish`, `initAquarium`, `updateAquarium`, `drawAquarium`, `aqFishSize`, `aqTap`, `showAqPop`, `hideAqPop`, `renderAqBar`, `aqReleaseAt` (выпуск по индексу, Э64), `openAquarium`, `closeAquarium`, `syncKeepBtn`, `cardSpec`, `save.aqFish`, `save.aqSlots`, `aqPickOpen`, `aqPickPending`, `openAqPick`, `closeAqPick`, `renderAqPick`, `aqPickPlace`, `aqPickConfirmReplace`, `aqPickBuySlot`, `aqPickFinish`, декора аквариума (Этап 59): `AQ_FLOORS`, `AQ_DECOS`, `save.aqDecor`/`save.aqFloorOwned`/`save.aqDecoOwned`, `drawAqDeco`; диалога улучшений (Этап 62): `aqShopOpen`, `aqShopTab`, `openAqShop`, `closeAqShop`, `setAqTab`, `renderAqShop`, `aqDecorRow`, `aqBuySlot`, `aqEquipFloor`, `aqEquipDeco`, `aqShopRelPending` (pending выпуска, Э64) (`aqCycleFloor`/`aqCycleDeco`/`aqDecorName`/`btnAqFloor`/`btnAqDeco`/`btnAqSlot` — УДАЛЕНЫ Этапом 62; `aqReleaseSel`/`btnAqRelease` — УДАЛЕНЫ Э64 (выпуск только из диалога); `aqValue` удалён, `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` — тоже, не использовать). Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
- Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики.
- Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
## Где крутить
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта (базы: удочки **0/150/500/30000**, наживки **0/100/2000/22000** — Этап 53). Апгрейды снастей (уровни 1..4): цена перехода — `upgCost(type,i,lv)`: id 0 — фикс `ROD_UPG_COST`/`LURE_UPG_COST` (**20/50/100** и **15/40/80**), id&gt;0 — база×`UPG_MULT` (**0.15/0.35/0.6**, округление до 10); подписи уровней `ROD_UPG_NEXT`/`LURE_UPG_NEXT`, уровни `save.rodLv`/`save.lureLv`, эффективные статы — `effRod()`/`effLure()` (глобалы `rod()`/`lure()` отдают эффективные), гейтинг открытия — в `itemRow()`/`shopClick()`. **Этап 53 (ребаланс прогрессии):** ранний темп — Стеклопластик/Червь и Озёра ≤5 мин, затем плавное замедление (Карбон 5–10 мин, Болото +15 мин, Титан ~1 ч, Море ~2 ч, Океан ~4 ч). Цены калиброваны `tools/progression.mjs`; менять их — только перепрогнав этот скрипт и плейтест.
- **Внешний вид снасти (Этап 63, чистый визуал):** бланк удочки и крючок меняются от ЭКИПИРОВАННОЙ снасти, а не от скина (четвёртый класс скинов сознательно НЕ вводили — иначе два источника истины на один пиксель + миграция сейва/ачивки/пины). Данные только в записях таблиц (паттерн Этапа 57 — в отрисовке нет `switch` по id): `RODS[i].look={c,w,band?,fx?}` — цвет/толщина бланка, `band` — серебристое кольцо у кончика (Карбон/Титан), `fx:'glint'` — холодное мерцание (Титан); `LURES[i].hook={c,fx?,bait?,bead?,body?,blade?}` — цвет дуги/жала, `fx:'gold'` — золотое мерцание (Блёстка); **Э64:** `bait` — розовый червь НА конце крючка (по жалу, с головным утолщением; стал крупнее), `body` — тельце-минноу воблера над крючком (хвост, светлое брюшко, лопатка; `bead` — красный глаз), `blade` — золотой лепесток-«вращалка» Блёстки (ширина ходит `sin(time·4.5)`, рисуется внутри gold-тени). Чтение — хелперы `rodLook()`/`hookRec()` (кламп `length-1` + fallback на запись 0, паттерн `lineSkinRec()`); `effRod`/`effLure` — `Object.assign`-копии, косметика проходит через `rod()`/`lure()`. Отрисовка: `drawBoat()` (stroke бланка из `look`, glint/band в save/restore — тень не перетекает на лодку/флажок; **длина бланка и `tip` не тронуты — `tip` якорит леску/заброс, менять нельзя без перепроверки всей геометрии**) и `drawRig()` (крючок из `hook`, gold — паттерн `lineShadow()`; насадка поверх крючка ветвлением по полям записи, без switch по id: `blade` — внутри gold-тени до restore, `body`/`bait` — после; `body`+`bead` рисуются даже с крючком под тельцем — «тройник под вобом»; крючок виден только в WAIT/APPROACH/BITE — в REEL он «в рыбе»; геометрия `hx/hy/hookPos` Э64 НЕ тронута — только начертание). На статы/баланс/сейв/ачивки не влияет — миграция не нужна. Тесты §10e2 (данные + проход rod/lure 0..3 в WAIT без pageerror, со снимком экипировки).
- Ночной и погодный декор (Этап 58, чисто визуал): `fireflies[]`/`bats[]` собираются в `initScenery()` (Река/Озёра/Болото; у Моря/Океана пусто) там же очищаются; ход — только в `updateDecor(dt)` (дрейф Лиссажу, горизонтальный полёт мышей, старт/жизнь `meteor`, спавн/затухание `rainRipple`, `lightningT`). Отрисовка: светлячки `drawFireflies()` в `draw()` после `drawSwampDecor()` (гейт `nightMix()>0.35`), кольца дождя `drawRainRipple()` рядом до `drawBoat()` (массив отдельный от игровых `ripples[]`), мыши + падающая звезда — ВНУТРИ `drawSky()` (гейты 0.5/0.6). DOM/геймплей не трогает.
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — апгрейд 1 любого удилища (`save.rodLv[save.rod]>=2`, проверка `hasLight()` в ветке WAIT, секция «M3: Цикл дня/ночи»); фонарь на носу лодки в `drawBoat()` горит под тем же гейтом `hasLight()` — без апгрейда ночная лодка тёмная (Этап 61, чистый визуал; ночные кадры `gen-screenshots.js` ставят `rodLv=2`, чтобы фонарь в скриптах оставался горел). Мировое время (`setDayTime(...)` + `updateWeather()` в `frame()`) идёт ТОЛЬКО в игре под `if(!uiOpen)` — под любым неигровым экраном (#menu/#gamemenu/лавка/аквариум/#resetDlg/#pause) день/ночь и погода заморожены; `time`/`updateDecor`/`updateAquarium` не морозятся (визуал). Под `YG.paused` (реклама/скрытая вкладка) заморожено как и раньше — весь блок кадра.
- Босс (M4, Этап 37 — босс в каждой локации, Этап 45 — +Болото, Этап 55/56 — босс Болота: Которыбка): тест-форс появления `forceBoss` (работает с боссом ТЕКУЩЕЙ локации), таблица `BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale',4:'meowfish'}` (секция ДАННЫЕ) — босс = существующий вид текущей локации (Река=Осётр как в M4, Озёра=Гигантский рак, Море=Рыба-меч, Океан=Синий кит, Болото=Которыбка (meowfish, экс-целакант этапа 55); Дух болота с Этапа 55 — обычная рыба) с runtime-флагом `boss:true` (в `startBite()`, НОВЫХ видов в `SPECIES` нет); вес ЛЮБОГО босса — единое правило 20–50 кг в `hook()` (`w=20+Math.random()*30` вместо ролла `wMin..wMax` вида; стоит до `d0`/`ov`/`st0` и уходит в `reel.w`/`catchFish` тем же `w`); фазы боя `stateBoss`/`bossPhase`, босс-ветки в `updateReel()` (обрыв фазы) и `SNAP` в `updateGame()` (авто-переход), награда ×5 в `catchFish()`; `save.bosses={id:true}` — пер-босс трекинг (дефолт в `save`/`freshSave()` + миграция в `loadSave()`, ставится в boss-ветке `catchFish()`); ачивки: `boss_first` (текст нейтральный «Победи рыбу-босса», бонус не тронут) + `lake_boss`/`sea_boss`/`ocean_boss`/`swamp_boss` (БЕЗ coinPct — пин §10c(8) `achBonus().coinPct=0.18` жив; `bonus:{}` пустой — `bonusText()` требует объект).
- Варианты рыб (M6): таблица `VARIANTS` — 8 вариантов, порядок ролла = порядок массива: copper ×1.5 @0.12 → silver ×2 @0.08 → quartz ×2.5 @0.07 → gold ×3 @`SHINY_CHANCE` (0.06, живая) → emerald ×4 @0.01 → diamond ×4.5 @0.007 → rainbow ×5 @0.005 (топ), иначе normal; P(особой)≈31% без буста снасти, E[mult]≈1.36. `variantChance()` — единственная точка вероятностей (дождь M8 уже здесь — ×3, см. строку «Погода (M8)»; буст снасти M6+ тоже здесь: `variantBoost()` = `lure().rare×0.15 + (уровень наживки−1)×0.05`, множитель ×(1+B·r), r — номер в лестнице 1..7 — редким больше; простая наживка 1 ур. → B=0; блёстка 4 ур. → B=0.6, rainbow ×5.2), счётчики улова — `VARIANT_KEY` (cp/sv/qz/sn/em/dm/rb; sn = «shiny» = gold), миграция счётчиков в `loadSave()` (дефолт 0), тест-форс `forceVariant`, босс всегда normal. Рыбу нельзя улучшать: «Улучшить вариант» (M6) убрано в этапе 21 — карточка улова только бейдж/оформление по варианту. Пины шансов в тестах (§10c(1)/(5), M8(2)) — при нейтральной снасти (снапшот/рестор `save.lure`/`lureLv` в eval).
- Погода (M8): таблица `WEATHER` (rain — `variantMult` ×3; fog — `biteMult` ×1,5), таймеры — `updateWeather()` в `frame()` под `if(!uiOpen)`: идут в реальном времени только в игре, под неигровыми экранами заморожены вместе с днём/ночью (gap `WEATHER_GAP_MIN..MAX` 120–240 с, длительность `WEATHER_DURATION` 120 с, под форсом не гоняются), `weatherActive()` — единственная точка чтения (форс приоритетнее), эффекты только в `variantChance()` (rain) и `biteChance()` (fog), тест-форс `forceWeather` ('rain'/'fog'/null, паттерн `forceBoss`); погода не персистится в сейв. Тост погоды — из `T` (`t('wx_'+weather)`; `WEATHER.*.toast` — не используется). Заметка: туман может дать p&gt;1 → насыщение 100% клёва (осознанно). **Этап 58 (визуал):** `drawWeather()` больше не заливает экран целиком — пелена прижата к ватерлинии (градиент от 0 на `waterY−H*0.22` до 0.28 на `waterY−8` и ниже), НАД водой непрозрачность ≤ именованной `FOG_TOP_ALPHA` (0.10 — бюджет читаемости поплавка/HUD); 2 полосы дрейфуют горизонтально у поверхности под `clip`. В дождь — редкая молния: `lightningT` (8–16 с) в `updateDecor` ставит `lightningFlash=1` (затухание `-dt*3`), `drawWeather` рисует белый экран альфой `lightningFlash*0.35` (≤0.35); поля рантайм, в сейв не идут.
- Скины (M-skins, рефактор Этапа 57 — скины управляются ТОЛЬКО таблицей): таблицы `BOAT_SKINS`/`BOBBER_SKINS` (секция ДАННЫЕ) — **цвета и спец-эффекты живут в записях**, в отрисовке нет `[skin]`-массивов. Формат: лодка `{id,name,nameEn,cost,cols:[верх градиента,низ градиента,кромка борта], fx?}` (fx: `'cabin'` каюта / `'dragon'` драконья голова на носу + щит / `'wake'` бурун под корпусом); поплавок `{id,name,nameEn,cost,bot,top,ring, fx?}` (fx: `'neon'` постоянное зелёное свечение / `'firefly'` свечение только при `nightMix()>0.35` / `'bell'` двойной диск + металлический блик). Цены (Этап 57): лодка **0 / 800 / 2000 / 6000 (Драккар/Longship) / 18000 (Катер/Speedboat)**; поплавок **0 / 500 / 1500 / 4000 (Светлячок/Firefly) / 12000 (Бубенчик/Bell)** — строго возрастающие. Сейв `save.boatSkin(s)`/`save.bobberSkin(s)` + миграция в `loadSave()` (включая гарантию бесплатного скина 0); **жёсткие границы — `>BOAT_SKINS.length-1`/`>BOBBER_SKINS.length-1`** (было `>2` — новые скины молча сбрасывались на 0 при reload!), отрисовка — `Math.min(BOAT_SKINS.length-1,…)` с fallback `BOAT_SKINS[0]`/`BOBBER_SKINS[0]`; вкладка «Скины» 🎨 (`#tabSkins`, строка `skinRow(list,i,owned,equipped,group)`, ветки buyskin/select в `shopClick()`: покупка со списанием и авто-экипировкой, выбор — только купленное); чистая косметика — без эффектов на геймплей. Свечение поплавка — `shadowBlur` в save/restore (не перетекает на воду). Ачивки `skin_first` (>1 скина лодки/поплавка) и `skins_all` (все id обеих таблиц — табличная проверка `BOAT_SKINS.every`/`BOBBER_SKINS.every`, подхватила новые скины без правки; `bonus.coinPct` не менялся — пин §10c(8) 0.18 жив).
**Этап 59 — ТРЕТИЙ класс: леска (`LINE_SKINS`).** Таблица рядом с лодкой/поплавком, тот же принцип «цвет/эффект только в записи»: `{id,name,nameEn,cost, c, w, fx?}` — `c` цвет основы (rgba-строка), `w` толщина на воде, `fx:'gold'` золотое мерцание (shadowBlur в save/restore, не перетекает). Скины: **0 Обычная / 3000 Флуоресцент / 12000 Золотая нить** (строго возрастающие, nameEn обязателен у всех). Сейв `save.lineSkins[]`/`save.lineSkin` + миграция в `loadSave()` (дефолты, гарантия индекса 0, клампы `LINE_SKINS.length-1`, купленные фильтруются от нечисловых/вне таблицы; у boatSkins/bobberSkins фильтра нет, только гарантия 0). Отрисовка — ЧЕТЫРЕ точки, все читают `lineSkinRec()` (`Math.min(length-1,…)` + fallback на скин 0): `drawLineTo()` (цвет+c.lineWidth из записи, `lineShadow()` для fx gold), поводок в `drawRig()` (тот же цвет, `globalAlpha=0.35` в save/restore), след поплавка в ветке CAST `drawLineAndBobber()` (цвет из записи, альфа — прежняя прогрессия `i/len*0.35`), подводный сегмент bobber→рыба в ветке REEL `drawLineAndBobber()` (цвет из записи, `globalAlpha=0.35`, `lineWidth=Math.min(1.2,ls.w)`, `lineShadow()` с парным restore). Удочка/катушка НЕ тронуты. Магазин: **таблица групп `SKIN_GROUPS={boat,bobber,line}`** (`list()`/`owned()`/`cur` — имя поля экипировки) — ветка `if(SKIN_GROUPS[type])` в `shopClick()` одна на все классы (прежний тернарник `type==='boat'?…:…` при третьем классе молча отдал бы поплавок — так и написан комментарий); `renderShop()` — секция `t('sec_line')` («Леска»/«Line») + `skinRow(...,'line')`. После покупки — `checkAchievements()` (Этап 59): ачивка `line_first` «Тонкая снасть» (`bonus:{}` — пин §10c(8) жив; `skin_first`/`skins_all` про лодку/поплавок НЕ расширены).
- Локации (этап 5, этап 35, этап 36 — эксклюзивная ихтиофауна, этап 45 — Болото): таблица `LOCATIONS` (Река 0 — бесплатно / «Озёра» **500** 🪙 / «Море» **100000** 🪙, этап 35 / «Океан» **300000** 🪙, этап 36 / «Волшебное болото» **10000** 🪙 — id 4, этап 45: в массиве `LOCATIONS` — ПОСЛЕДНИЙ; **Этап 53: цены пересмотрены** — Озёра 65536→500, Болото 90000→10000, Море 131072→100000, Океан 196608→300000; порядок по цене Река &lt; Озёра &lt; Болото &lt; Море &lt; Океан сохранён; `water` — 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot] для градиента `drawWater()`, nightMix-логика та же, у Болота — тёмно-моховая вода и слабая рябь `waves` тише Озёр), сейв `save.locations[]`/`save.location` + миграция в `loadSave()` (гарантия бесплатной Реки, граница `save.location>=LOCATIONS.length` — в этапе 36 ИСПРАВЛЕН баг этапа 5: жёсткое `>1` сбрасывало Море на reload, паттерн скинов), вкладка «Локации» 🗺️ (`#tabLocations`, строка `locationRow(loc,i,owned,selected)`, ветки buylocation/selectlocation в `shopClick()`: покупка — списание, push, АВТО-ПЕРЕХОД `save.location`, тост `welcome_lakes`/`welcome_sea`/`welcome_ocean`/`welcome_swamp` (у Болота — `if/else`-ветка, этап 45); выбор — только купленное). Таблицы рыб: у КАЖДОЙ воды свои `*_IDS`-списки, БЕЗ пересечений (этап 35): `RIVER_SPECIES(_NIGHT)` — ровно 8 базовых (`RIVER_IDS`; роллы реки не меняются: pin-тесты §9j(b)/M4/M6 завязаны на веса и порядок — новые виды в реку НЕ добавлять!), `LAKE_SPECIES(_NIGHT)` — только 10 озёрных (`LAKE_IDS`: zander/chuch/crayfish/bleak/roundel/eelpout/tench/navaga + rudd/cisco этап 35; веса `LAKE_DAY_WEIGHTS`/`LAKE_NIGHT_WEIGHTS`), `SEA_SPECIES(_NIGHT)` — только 5 морских (`SEA_IDS`: herring/mackerel/flounder/tuna/swordfish; веса `SEA_*_WEIGHTS`), `OCEAN_SPECIES(_NIGHT)` — только 9 океанских (этап 36, `OCEAN_IDS`: moray/grouper/turtle/angler/octopus/manta/shark/squid/whale; веса `OCEAN_*_WEIGHTS` — «с потолка», точный баланс отдельным этапом через tools/balance.mjs), `SWAMP_SPECIES(_NIGHT)` — только 6 болотных (этап 45, `SWAMP_IDS`: glowfin/moonfish/emberfin/violetlure/spiritfin + meowfish «Которыбка» (экс-целакант), Этап 56; веса `SWAMP_DAY_WEIGHTS`/`SWAMP_NIGHT_WEIGHTS`). Этап 38: лестница веса `wMin/wMax` — Озёра ×8, Море ×6 (рыба-меч ×4), Океан ×4 (синий кит ×2 — чтобы остался вываживаемым), Река НЕ меняется: дороже вода = тяжелее рыба = сложнее бой (дольше `d0=45+7·w`, сильнее `ov→aggr`). Этап 40: ночные `*_WEIGHTS` (Озёра/Море/Океан) подтянуты к «ночь доходнее» + доля сельди/скумбрии в Море снижена (день 82→79%, ночь 78→64%) — цифры см. строку «Баланс Озёр». `SPECIES`/`SPECIES_NIGHT` — полный словарь 38 видов; ночные веса реки — в `NIGHT_WEIGHTS`, ключ обязателен каждому id (иначе `weight=undefined`). **Новые виды добавлять только в КОНЕЦ `SPECIES`** — `SPECIES[0..7]` запинены тестами (§573/§1524/§2446 `SPECIES[0]`, §9j/M4/M6 по id) и квестами (`QUEST_TPLS` индексы 0..2); вставка в середину молча сломает их. `pickSpecies()` выбирает таблицу по `save.location` (0/1/2/3/4) и dayPhase (логика ролла та же) — ТАБЛИЧНЫЙ доступ `SPECIES_BY_LOCATION(_NIGHT)` по `save.location` с guard'ом (вне 0..4 → Река; роллы 0..3 идентичны прежним, этап 45); босс — вид `BOSS_BY_LOCATION[save.location]` текущей локации (Этап 37/45/56: стурдж/рак/рыба-меч/кит/которыбка; runtime-флаг, новых видов нет — см. строку «Босс (M4)»); ачивка species_8 — «Поймай все виды» (все 38, check = `SPECIES.every`, id не менять — старые сейвы). Ачивки локаций (этап 22/36/45): `lake_first`/`sea_first`/`ocean_first`/`swamp_first` — locations-based (проверка `save.locations`), не caught-based — пин §10c(8) achBonus не трогает, кроме `swamp_first` (её `coinPct:0.02` двигает пин 0.16→0.18, этап 45). Этап 43 (визуальное различение): значения `water` — Море светлая бирюза / Океан глубокий тёмно-синий, Река/Озёра не тронуты (тест пинит только ФОРМАТ 6 hex, значения можно двигать); `LOCATIONS.*.waves` — характер волн поверхности в `drawWater()` (amp/amp2·freq/freq2·speed/speed2; amp/amp2=0 — линия НЕ рисуется: Река — без волн, спокойная вода (ревизия Этапа 44); Озёра — медленная гладь, Море — умеренная волна, Океан — крупные зыби); `AMBIENT_FISH` (секция декораций, перед `initFish()`) — размер/оттенок/число фоновых рыб по локации (Река 9×8–22 как было, Озёра 9×10–26 зелёные, Море 8×12–32 синие, Океан 6×18–44 тёмно-синие, Болото 7×12–30 с `palette` из 4 цветов свечения — циан/зелёный/фиолет/золото; `initFish()` пишет цвет в `f.glow` по индексу рыбы, у локаций 0..3 он `null`); `initFish()` — вызовы: старт + ОБЕ ветки смены локации в `shopClick()` (buylocation/selectlocation) — новую точку смены локации без `initFish()` не добавлять. Чисто визуал — на роллы/баланс/физку не влияет. Этап 44 (береговые силуэты): `scenery`/`initScenery()` (секция декораций, после `initFish()`): Река — береговая полоса НА ВСЮ ШИРИНУ `{k:'bank',h}` (трава 12–17 px + тёмная кромка у воды) + лес в 2 ряда, посаженный НА КРОМКУ `{k:'pine',layer,by}` (`by` = верх кромки; без `by` — линия воды), Озёра — холмы `{k:'hill',rx,ry}` + деревья `{k:'tree',h}`, Море — парусник `{k:'ship',x,v,s,dir}` (дрейфует по курсу) + 3 чайки `{k:'gull',y,v,ph}` (летят, машут), Океан — ПУСТО (открытый горизонт); дрейф парусника/чаек — `updateDecor(dt)`; отрисовка `drawScenery()` — между `drawSky()` и `drawWater()` (вода перекрывает базу силуэтов); ночь — `dusk()` гасит силуэты к #0b1220 (nm×0.8), паруса/чайки темнеют. Вызовы `initScenery()`: `resize()` + ОБЕ ветки смены локации в `shopClick()` (рядом с `initFish` — новую точку без неё не добавлять) + кадры lake/sea/ocean в gen-screenshots.js. Этап 45 (свечение и декор Болота): у болотных видов поле `glow:'#hex'` (у 5 видов этапа 45 — по одному на редкость r0–r4; Этап 56: +которыбка `#ff9ecb`, второй r4 — всего 6 разных цветов); ночная аура — ПЛАВНЫЙ ramp `glowIntensity()` (ревизия: 0 при `nightMix()<=0.35` как старт фонаря, smoothstep до 1 при `>=0.9`), компактный ореол `drawGlowAura(x,y,r,hex,I)` (радиальный градиент с крутым профилем 0.55/0.30/0 от тела рыбы + `globalCompositeOperation='lighter'`, `save/restore`, ранний выход при `I<=0.02`; rgba-строки кэширует `GLOW_RGBA`), радиус ambient `size*1.5`, bite/hooked `bs/fs*1.5` — контур рыбы чёткий (силуэт светящегося вида непрозрачный, аура позади), общий хелпер `fishGlow(sp,x,y,r)` для подплывающей/зацепившейся/вываживаемой рыбы, фоновые — в `drawAmbientFish()` по цвету `f.glow` из `palette`; карточка улова — класс `glow-card` + CSS-переменная `--glow` (только normal-вариант — не спорить с фильтрами вариантов; CSS-правило `#card.glow-card .bigemoji`), дневник — inline `filter:drop-shadow` на SVG (`class="glow-fish"`). Декор: ветка `i===4` в `initScenery()` (bank + reed + snag + pine болотной гаммы) и отдельные от `scenery` слои `pads[]` (8–14 кувшинок, у 45% — лилия жёлтая/белая/розовая) и `frogs[]` (3–5 лягушек, размер `s`≈9–14 px — заметны на телефоне; вся отрисовка `drawSwampDecor()` масштабируется от `s`; прыжки `sit`→`jump` по параболе на соседнюю кувшинку, `jh` 16–30) — очищаются/пересобираются в `initScenery()`; покачивание кувшинок и прыжки — `updateDecor(dt)`; отрисовка `drawSwampDecor()` — в `draw()` после `drawWater()`/`drawAmbientFish()` и до `drawBoat()` (поверх воды, чтобы `dusk()` берега не гасил декор); `initScenery()` вместе с `pads`/`frogs` зовётся в `resize()` + ОБЕ ветки смены локации в `shopClick()`; кадры swamp/swamp-night в gen-screenshots.js.
- **Баланс Озёр (этап 34):** ожидаемая выручка за улов = Σ(w·V)/Σw, V — середина диапазона веса × цена. `pickSpecies()` нормирует веса, так что **умножение всех весов на константу доход НЕ меняет** — для «стало дороже» правят СДВИГ долей (меньше `carass`/`bleak` — самые дешёвые V≈9/6 🪙, больше `sturge`/`gold`/`catfish`/`tench`/`navaga`). Текущие ориентиры: Река день 65.1 / ночь 125.9 🪙 (не меняются с этапа 34); этап 38: лестница веса `wMin/wMax` (Озёра ×8, Море ×6 — меч ×4, Океан ×4 — кит ×2); этап 39: цены Озёр/Моря/Океана урезаны к целям (по дню) Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5; этап 40: ночные `*_WEIGHTS` подтянуты к «ночь доходнее» + доля сельди/скумбрии в Море снижена — актуальные пины: Озёра 194.1 / 249.0 (×2.98/×1.98 Реки), Море 297.3 / 476.7 (×4.57/×3.79), Океан 329.1 / 552.7 (×5.06/×4.39); этап 45/53/55: Болото **302.9 / 342.3** 🪙 (ночь/день ×1.13; **Этап 53 доходность Болота опускали до уровня Озёр и ниже, но Этап 55/56 добавил Которыбку (meowfish, r4, 50🪙/кг, 20–80 кг, ролл 4) и сознательно вернул Болото выше Озёр — решение владельца: «дорогая которыбка важнее инвариантов»; по 🪙/мин Болото лидирует среди «средних» вод**) — порядок вод по цене: **Река → Озёра → Болото → Море → Океан**; ночь/день: Река ×1.93, Озёра ×1.28, Болото ×1.13, Море ×1.60, Океан ×1.68; инвариант «новая (по цене) вода доходнее предыдущей в ОБЕ фазы» для Болота **не выполняется сознательно с Этапа 55** (Болото/Озёра per-catch ×1.56/×1.37, а днём Болото обгоняет и Море — ×1.02; Море/Болото ×0.98/×1.39) — остальные пары держатся (Озёра/Река ×2.98/×1.98, Океан/Море день **×1.11 — тонкий запас**) — проверять при любой правке `*_WEIGHTS`/`price`/`wMin/wMax`; считать руками не нужно — см. строки «Баланс-скрипт»/«Прогрессия». **Минимальный вес вида ≥ ~2.2**: ниже — тест lc4 (3000 роллов, «каждый вид ≥1») начинает плавать.
- **Баланс-скрипт (`tools/balance.mjs`):** `node tools/balance.mjs` — считает доходность рыб ЗА ВАС, ручные прикидки Σ(w·V)/Σw больше не нужны. Вывод: по каждой из 10 таблиц ролла (Река/Озёра/Болото/Море/Океан × день/вечер-ночь; Болото добавлено этапом 45) — виды (вес/доля %/V/вклад) и итог E[🪙 за улов], сводка-матрица с отношениями соседних вод в обеих фазах (Озёра/Река, Болото/Озёра, Море/Болото, Океан/Море — пары строятся ПО ЦЕНЕ воды, а не по порядку массива `LOCATIONS`: Болото 10000 стоит последним, но по цене между Озёрами и Морем — инвариант «каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы»; для Болота — сознательное исключение с Этапов 53/55), «здоровье» (⚠ если у самого редкого вида P(промаха за 3000 роллов) > 0.1% — ориентир lc4 «каждый вид ≥1», соответствует вес ≥ ~2.2 при Σw≈95) и проверки целостности (пересечения таблиц, нечисловые веса, недостижимые виды). Данные берёт ПРЯМО из index.html: вырезает секцию «ДАННЫЕ» по якорям `const SPECIES=[` … закрытие таблицы `VARIANTS` и исполняет в изоляции (переименование якоря → понятная ошибка скрипта, чинить в самом скрипте) — правки баланса видны сразу, ничего дублировать не нужно. Модель ролла та же, что `pickSpecies()`: по умолчанию нейтрально (lure rare=0, dist=1 — штраф `minDist` ×0.06 не действует, как в пинах и lc4); опции: `--dist 0..1` (штраф дальних видов), `--lure <rare>` (буст `1+rare·rarity` — снасть может менять сравнение локаций, не путать с пинами), `--set id:вес` (what-if, можно несколько раз: переопределяет вес вида во ВСЕХ его таблицах — день и ночь; НЕ сохраняется), `--price id:цена` (what-if, можно несколько раз: переопределяет цену вида 🪙/кг; НЕ сохраняется — так же настраивали цены этапа 39), `--help`. Варианты рыб — строкой в шапке: E[mult]≈1.36 нейтрально / ≈1.69 при дожде — одинаково для всех локаций, на СРАВНЕНИЕ не влияет (босс «Осётр» — вне ролла, в доходность не входит). **Пины при нейтральной снасти (сверять до и после правок): Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 302.9/342.3 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь 🪙; этапы 38–55: лестница весов + урезанные цены + ночные веса + Болото с которыбкой).**
- **Прогрессия (`tools/progression.mjs`, Этап 53):** `node tools/progression.mjs` — модель «за сколько минут игрок дойдёт до цели»: берёт цены/статы из index.html, моделирует улов (`pickSpecies`+`rollWeight`+варианты+обрыв по `maxW`) и цикл ловли (`biteChance`/таймеры/fishForce/updateReel ботом), прогоняет жадный маршрут покупок и печатает доход по водам (🪙/мин) + ETA целей. Опции: `--route`, `--start-coins`, `--per-day`, `--samples`, what-if `--rod/--lure/--loc/--rod-upg/--lure-upg/--upg-mult/--set/--set-day/--set-night`. **Калибровочные цели Этапа 53, пересняты на Этапе 55/56 после добавления болотной Которыбки** (модель «идеального» игрока): Стекло ~4.5 мин · Озёра ~10.5 · Карбон ~7 · Болото 22.7 (12.2 после Озёр) · Титан ~38 мин · Море 1 ч 29 · Океан 3 ч 14. Любую правку цен/весов сверять этим скриптом.
Цикл настройки баланса новой локации: (1) новые виды — только в КОНЕЦ `SPECIES` + ключ каждому id в `NIGHT_WEIGHTS`; (2) свои `*_IDS` + `*_DAY_WEIGHTS`/`*_NIGHT_WEIGHTS` без пересечений с другими водами (пин lc2 + проверка скрипта), развилка таблиц в `pickSpecies()`; (3) крутить доли прикидками без правки игры: `node tools/balance.mjs --set zander:30 --set bleak:5` (id — латиницей!) до нужных цифр — двигают СДВИГ долей, ×константа всех весов доход не меняет; (4) перенести финальные веса в index.html и перегнать скрипт БЕЗ `--set` — цифры обязаны совпасть с what-if; (5) цена/веса самих видов (`SPECIES`) — тоже видны скрипту сразу, но их правки могут задевать другие локации (общий словарь!) — пересчитать всё; (6) прогнать плейтест (lc2/lc4 и пины §9j/M4/M6 — река не должна меняться).
- Сложность вываживания: `updateReel()`.
- Физика боя (спринт 4): `fishForce()` (сила рыбы: рывок `(16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov)`, затишье `5+7·ag`, уставшая ×0.45) и `updateReel()` — выносливость `st` (старт `st0`, флаг `tired` при `st≤0.12`: рывки ×0.45, втягивание быстрее до ×1.6), тормоз катушки (черта `drag` в `RODS`: в рывке выше тормоза леска скользит `over·0.22`), натяжение (`10+over·1.1+22·ag`, ×0.35 в затишье), втягивание `(11+3·reel)·(1+0.6·(1−st))`, свободный бег `F·0.32`, escape-бар растёт только при затишье без удержания (отпускание в рывок безопасно), near-miss `1.45·d0`, `d0=45+7·w`. Пустые клёвы: ролл `biteChance()` в ветке WAIT (тест-форс `forceBite`). Тесты (§10) завязаны на эти константы — при ребалансе гонять Monte-Carlo точными формулами (docs/PHYSICS-RESEARCH.md, §4.7) и править тесты в том же коммите.
- Награды (M2): шаблоны квестов `QUEST_TPLS`, награды стрика `STREAK_REWARDS`, достижения `ACHIEVEMENTS` — секция «M2. Награды» после сейва. Этап 22: ачивок 29, этап 36: 31 (+`sea_first`/`ocean_first` — locations-based, пин §10c(8) не трогает), этап 37: 34 (+`lake_boss`/`sea_boss`/`ocean_boss` — пер-босс, БЕЗ coinPct, пин §10c(8) не трогает), этап 45: 36 (+`swamp_first` — locations-based, `coinPct:0.02`; `swamp_boss` — пер-босс, БЕЗ coinPct), этап 46: 38 (+`aq_first` «Первый жилец» / `aq_full` «Аквариум полон» — аквариум, `bonus:{}`, пин §10c(8) не трогают), **этап 59: 39** (+`line_first` «Тонкая снасть» — скин лески, `bonus:{}`, пин §10c(8) не трогает; тест §22(2) пинит `ACHIEVEMENTS.length===39`) (идут `ACHIEVEMENTS`-записями по темам: снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации/аквариум у магазинных); новые счётчики сейва `save.kgTotal`/`nightCatch`/`wxRainV`/`wxFog` (чтение через `||0` — без миграции) ставятся в `catchFish()` до `checkAchievements()` (rain-счётчик — только вариант ≠ normal, босс всегда normal); тест-пин §10c(8): сумма `achBonus().coinPct` (все 8 видов × 8 вариантов) = 0.18 (этап 45 добавил `swamp_first` +0.02; старые пины 0.16 обновлены; этап 46 не меняет — aq-ачивки с `bonus:{}`).
- **Этап 53 (масштабирование квестов):** `questScale()` = `clamp(round((save.earned/2000)^0.6),1,30)` — метрика `save.earned` (только доход с рыбалки; награды квестов его не разгоняют). `QUEST_TPLS.make(r)` масштабирует ВСЕ шаблоны: `earn_coins` goal×scale (reward = round(goal·0.25)); `casts` ×(1+0.15·scale); `catch_weight` порог от `localMedianW()` (медиана середин весов рыб ТЕКУЩЕЙ локации ×scale^0.35, а не фикс 2/3/5 кг); `catch_rarity` r = clamp(1+floor(1.5·log10(scale+1)),1,4); `catch_species` вид — из `SPECIES_BY_LOCATION[save.location]` с rarity≤2 (не только речные 0..2), `questText` RU/EN терпим к неречным id (fallback `trName`). `qReward(base)` = `round(base·scale^0.6)`. Первые 8 видов и порядок `SPECIES` не тронуты. Тесты §9e2.
- SDK Яндекс Игр (Y1): секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ» — обёртка YG (стаб при file:// без ?sdk=1: скрипт не создаётся, no-op'ы + счётчики counts), динамическая загрузка /sdk.js (http(s)) с retry/warn, LoadingAPI.ready() первым кадром, GameplayAPI.start/stop в btnPlay/openShop/closeShop/btnGmResume (в гменю-флоу stop даёт `YG.onPause()`, дублей нет — пин §Y8(4)), game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend, пауза всего игрового времени в frame()).
- Реклама (Y2): константы `AD_FIRST_MS`/`AD_COOLDOWN_MS` (секция Y1), точка interstitial — `tryInterstitial()` в хендлере `btnCardOk` (гейт: первый показ ≥2 мин сессии, кулдаун 3 мин; `lastAdTs` — осознанно «попытка», не показ — при троттлинге платформы wasShown=false кулдаун сгорает), RV-кнопка `#btnRv` «📺 Реклама: ×2 за этот улов» на карточке улова (`showCard` сбрасывает `rvVal`/`rvDone`), методы `YG.showFullscreen/showRewarded` (+watchdog 90 с), `endAd()`; карточка улова БЕЗ `gameplayStop()` — осознанно (часть цикла улова); реклама не персистится в сейв (только `persist()` перед показом и после бонуса).
- i18n (треб. 2.14/2.2): таблица `T={ru,en}` (единая точка строк UI), `t(key)`/`tt(key,o)`, `LANG` (дефолт `ru`), `forceLang` (тест-форс), `setLang()` (обновляет `<html lang>`/`<title>` + пере-рендер меню/статичного UI и открытых оверлеев — магазина/карточки), `resolveLang(ysdk)` — резолюция: `forceLang` → `ysdk.environment.i18n.lang` → `ru`; стаб (file:// без SDK) — всегда `ru` (`navigator.language` НЕ используется — ломает headless-тесты). `trName`/`trDesc`/`trRarity` — выбор по `nameEn`/`descEn`/`RARITY_NAMES_EN` в таблицах данных. Меню — `renderMenu()` (device-aware: тач/мышь, `IS_TOUCH`). EN-ветка `questText()` (RU-ветка с `pluralN` не меняется).
- Пауза (треб. 6.3/1.3): HUD-кнопка `#btnPause` (⏸) → `openPause()` (гарды MENU/uiOpen/adShowing/pauseOpen; `YG.onPause()` + оверлей `#pause`); `#btnPauseResume` → `closePause()`, `#btnPauseMenu` → хаб «Меню» `#gamemenu` (с `YG.onResume()`). Гард `pauseOpen` в `YG.onResume` (после `adShowing`) — blur/focus/visibilitychange во время открытой паузы не снимают паузу (снимут только `closePause()`/«Продолжить» гменю). `window blur` → `YG.onPause()`, `focus` → `YG.onResume()` (треб. 1.3, дополнение к visibilitychange). Видимость `#btnPause` и `#btnRecall` — в `syncDom()` (вызывается каждый кадр в `frame()` вне `YG.paused` — без этого `#btnPause` «залипает» видимым за оверлеями). Сам оверлей `#pause` — частный случай правила «мировое время стоит под `uiOpen`» (см. строку «Ночь (M3)»): под паузой `uiOpen=true`, поэтому день/ночь и погода не идут.
- Хаб «Меню» (Э29): оверлей `#gamemenu` — кнопки «Продолжить» (`#btnGmResume`: uiOpen=false + `YG.gameplayStart()`), Магазин/Достижения/Дневник (`fromMenuShop(tab)` → `openShop('tackle'|'ach'|'journal')`), «Инструкции» (`#btnGmHowto` → `#menu`; «Играть» вернёт в игру), звук `#btnMute` и сброс `#btnReset` (→ `#resetDlg`) — ПЕРЕНЕСЕНЫ из HUD (id сохранены; в `.hudbtns` остались только 🛒 и ⏸). «В меню» НЕ меняет `state` — сессия (заброс/вываживание) заморожена под оверлеем и продолжается с того же места; `state='MENU'` теперь только при загрузке/после сброса (там показывается `#menu` с инструкциями — «старт новой игры» прежний). Флаг `shopFromMenu`: лавка, открытая из «Меню», при × возвращает в `#gamemenu` (uiOpen остаётся true, БЕЗ `YG.gameplayStart()`); лавка из HUD/`#coins` — как раньше, в игру. Z-порядок: `#gamemenu` в DOM позже `#shop`/`#resetDlg` (тот же z-index) — при открытии лавки/сброса из «Меню» гменю прячется, иначе перекрыл бы их. **Этап 59:** «Аквариум» и «Инструкции» стоят ПАРОЙ в строке (`.mbtn.mbhalf` + `.mbtns.flexrow`) — бар сцены банки вырос на 2 пилюли, и на телефоне 9 кнопок столбиком не влезали в 844px (кнопка «Аквариум» уезжала за экран — клики в тестах падали).
- Монеты → лавка (Э29): клик по `#coins` (pointer-events:auto + cursor в CSS) → `openShop('tackle')` — «Лавка рыбака» на вкладке «Лавка» (`#tabTackle`, эмодзи 🛒, `T.tab_tackle` 'Лавка'/'Shop'; RU-секции «🎣 Удилища»/«🪱 Приманки» внутри не тронуты). `openShop(tab?)` — аргумент форсит вкладку (`syncTabs()` синк активных классов, вынесен из `setTab`); HUD 🛒 зовёт `openShop()` без аргумента — помнит последнюю вкладку.
- **Иконки лавки (Этап 60, чистый визуал):** у записей `LOCATIONS`/`BOAT_SKINS`/`BOBBER_SKINS`/`LINE_SKINS` поле `icon` (как у `RODS`/`LURES`); `skinRow`/`locationRow` рисуют его `<span class="itemicon">` ДО `.itemname` — ВНУТРЯ нельзя: тесты сверяют текст `.itemname` строго (`=== 'Красная лодка'`, §скины(4)/(7)); у строк снастей иконка по-прежнему инлайн в `.itemname` (тесты там через `includes`). Кнопка апгрейда снасти вместо generic `⬆` показывает `item.icon` (fallback `⬆`) — тест M3(e) проверяет `indexOf`, зелёный. Иконки: локации 🏞️ 🛶 ⛵ 🌊 🐸; лодки 🛶 🔴 🏠 🐉 🚤; поплавки ⚪ 🔴 💚 ✨ 🔔; лески 🪢 💚 🪡. Новые emoji — перегенерирован `tools/emoji-font.py` (субсет 53→65 глифов); для ⛵/⚪/🏞️ FE0F (текстовая презентация по умолчанию — как 🗺️/⚠️ в файле).
- Закрытие по тапу мимо диалога: `backdropClose(el,fn)` (секция «КНОПКИ», после Y2-хендлеров) — `pointerdown`, где `e.target ===` сам оверлей (тап на подложку, не .panel), закрывает диалог тем же действием, что и кнопка: `#shop`→`closeShop`, `#resetDlg`→«Отмена» (`btnResetNo.click()`), `#pause`→`closePause`, `#gamemenu`→«Продолжить» (`btnGmResume.click()`), `#aqPick`→`closeAqPick` (Этап 52), `#aqShop`→`closeAqShop` (Этап 62). `#menu` — экран старта, не диалог. Новый оверлей-диалог без `backdropClose` не добавлять. **Карточка улова `#card` — ОСОЗНАННО ВНЕ `backdropClose`** (ревизия): случайный тап мимо НЕ закрывает её и не дёргает интерстишел; закрывается только явными кнопками (`#btnCardOk`/`#btnKeep`, каждая со своим хендлером и гейтом рекламы). Тест §X(5) пинит именно это (тап мимо → карточка открыта, `state='CARD'`; ждёт 400 мс — pop-анимация `#card{animation:pop .3s}` сужает hit-area подложки).
- Встроенный emoji-шрифт (Э28): все emoji UI и canvas рисует вшитый шрифт — субсет Noto Color Emoji (COLRv1, OFL) как base64 `@font-face font-family:'EmojiEmbedded'` в index.html между маркерами `<!-- EMOJI-FONT:BEGIN/END -->` (внешний .woff2 невозможен: публикуется только один index.html). В font-stack `html,body` и во всех `ctx.font` (константа `UI_FONT`, секция «ОТРИСОВКА») стоит ПЕРВЫМ — единый Google-стиль emoji на всех платформах; headless-скриншоты не зависят от системных шрифтов. Первый кадр ждёт `document.fonts.load('16px "EmojiEmbedded"')` с timeout-фолбэком 1.5 с. Генератор `tools/emoji-font.py` (venv: `python3 -m venv tools/.venv && tools/.venv/bin/pip install fonttools brotli`; исходник `tools/fonts-src/NotoColorEmoji-Regular.ttf` из google/fonts, всё в gitignore): `python3 tools/emoji-font.py` — перегенерация по текущим emoji index.html (ОБЯЗАТЕЛЬНО при добавлении новых emoji), `--check` — падает, если субсет не покрывает index.html (✓ № ● ○ ↩ — в NCE их нет, это ок, рисуются системными шрифтами). Тест §Y7 (tests/test.js) гоняет `document.fonts.check('16px "EmojiEmbedded"','🪙')`; семейство переименовывать нельзя (тест + @font-face + UI_FONT).
- **Портреты рыб (SVG-спрайт)**: исходники `assets/fish/fish-<id>.svg` (38 файлов — болотных 6, посл. «Которыбка» (meowfish), этапы 55/56, 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 href="#fish-<id>">` (дневник `.jrow .je svg`, карточка `#cardEmoji svg`, `showCard` ставит href у `#cardFishUse`); перекраска вариантов — CSS-переменные `--f1`/`--f3` в `#card.<variant>-card .bigemoji` (наследуются сквозь `<use>`; silver/rainbow — существующие filter); правка портрета = правка `assets/fish/*.svg` + повторный запуск embed-скрипта. Эмодзи рыб УБРАНЫ (этап 42): поле emoji удалено из SPECIES, тосты без эмодзи, иконка квеста catch_species — mini-SVG `<svg class="qfish"><use href="#fish-<id>">`; субсет emoji-шрифта перегенерирован (рыбных глифов нет).
- **Favicon (Этап 54)**: логотип вшит в `index.html` как `data:`-URI PNG — внешняя иконка в архив Яндекс Игр не доехала бы (публикуется только `index.html`), а data:-URI работает и под `file://`, и в iframe площадки. Генератор `tools/favicon.mjs` (чистый Node, без зависимостей, идемпотентно, маркеры `<!-- FAVICON:BEGIN/END -->` сразу после `<title>`): декодирует `logo.png` (512×512 RGB, собственный PNG-декодер на zlib), area-average ресемплит в 64×64 (`rel="icon"`) и 180×180 (`rel="apple-touch-icon"`), кодирует PNG с адаптивным фильтром строк (эвристика libpng), кладёт base64 в `<link>`; самопроверка + `check.mjs` внутри. Обновить иконку = заменить `logo.png` + `node tools/favicon.mjs`. Маркеры снимаются минификатором (их нет в списке исключений EMOJI-FONT/FISH-SVG) — в релизе остаются только `<link>` с data:-URI; проверки `pack.mjs`/`minify.mjs` не затронуты.
- **Аквариум (Этап 46)**: декоративная сцена коллекции ЭКЗЕМПЛЯРОВ (вид+вариант+вес), **НЕ локация** — в `LOCATIONS`/`SPECIES_BY_LOCATION`/`BOSS_BY_LOCATION` не входит, роллы/боссы/баланс/пины не трогает (`tools/balance.mjs` не менялся). Данные: `save.aqFish[]` (`{id,v,w}`) + `save.aqSlots` (старт `AQ_BASE_SLOTS=6`, макс `AQ_MAX_SLOTS=12`), константы `AQ_BASE_SLOTS`/`AQ_MAX_SLOTS`/`AQ_SLOT_COSTS=[1500,4000,9000,20000,45000,100000]` в секции ДАННЫЕ рядом со скинами/локациями. **Модель C — бесплатная КОПИЯ**: `catchFish()` НЕ меняется (монеты/квесты/ачивки/реклама начисляются на улове как раньше), кнопка `#btnKeep` «🐠 В аквариум» на карточке улова (`cardSpec={sp,w,variant}` в `showCard()`) **Этап 52: открывает DOM-диалог выбора слота `#aqPick`**, а не кладёт рыбу сразу (см. отдельный пункт «Диалог слота аквариума (Этап 52)»). `syncKeepBtn()` теперь держит кнопку **всегда активной** (подпись/`title` = `aq_keep`), логика «Нет места»/`disabled` убрана: из диалога можно заменить занятый слот или докупить новый. Сцена: `aqOpen`, `aqFishRt[]` (раскладка `x,y,dir,ph,spd,vy,tail` — НЕ персистится, индекс = `save.aqFish`), `aqSel` (сентинел `-1` = ничего), `aqTank`/`aqPlants`/`aqBubbles`, `initAquarium()`/`updateAquarium(dt)`/`drawAquarium()` (вода `hexLerp` к `nightMix()` — общий цикл суток; размер `aqFishSize(w)` — от АБСОЛЮТНОГО веса по общей с вываживанием кривой `fishSizeFromW()` (Этап 41): `clamp(fishSizeFromW(w)·AQ_FISH_SCALE·k, AQ_FISH_SIZE_MIN·k, min(W,H)·AQ_FISH_SIZE_REL)`, `AQ_FISH_SCALE=2.1`/`AQ_FISH_SIZE_MIN=10`/`AQ_FISH_SIZE_REL=0.30`, масштаб экрана `k=clamp(min(W,H)/AQ_TANK_REF, 1, AQ_MAX_K)` — ПЛАВНЫЙ (`AQ_TANK_REF=360` → k=1, `AQ_MAX_K=2.4`; десктоп 1280×720 → k=2), верхний кап НЕ умножается на `k`; правка после Этапа 46 + ревизия размера (`w/wMax`+`rarity` не влияют), раньше был бинарный `AQ_DESKTOP_SCALE=2` при `min(W,H)>=600`; сетка стартовых позиций, разворот у стенок (per-fish `rise=size*0.65` держит крупную рыбу ниже бара/выше грунта), пузыри). **Ревизия: жильцы банки — те же SVG-портреты, что на карточке улова** (DOM-слой `#aqFishLayer` поверх canvas, спрайт `#fish-<id>` + вариант `.var<id>`/`--f1`/`--f3` 1:1 с карточкой; `aqBuildFishEl(i)`/`aqPlaceFish(el,rt)`/`aqFishEls[]`, transform обновляется в `updateAquarium`; слой включается только под `body.aq-open`, `pointer-events:none`, `clip-path` по банке). На canvas — вода/грунт/растения/пузыри/стекло, свечение болотных `drawGlowAura`+`glowIntensity()` (уходит ЗА портрет) и рамка выбора; `drawFishShape` в банке больше не используется (остался для рыб в сценах ловли). Точки жизненного цикла: `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` (Этап 62: `#btnAqBack`, `#aqCount` «N/M», пилюля `#btnAqShop` «🛒 Улучшения» → диалог `#aqShop`; прежние `#btnAqSlot`/`#btnAqFloor`/`#btnAqDeco` убраны), поповер `#aqPop` (`#aqPopName`/`#aqPopSub` — вес · редкость, БЕЗ цены; Э64: кнопок в поповере нет — чистое инфо), тап по рыбе `aqTap(x,y)` (ближайшая в радиусе → `aqSel` → `showAqPop()`). «Выпустить» — Э64: **только из диалога «Слоты»** (`#aqShop`, кнопка на строке жильца + подтверждение, см. пункт Этапа 62) → `aqReleaseAt(i)` — точечное удаление экземпляра ИНДЕКСАМИ (без `initAquarium`, позиции остальных сохраняются; `aqSel` при необходимости сдвигается/снимается) **БЕЗ монет** (освобождение слота; продажа из банки была в плане §7, но по решению владельца заменена на выпуск — экономика не затронута; прежняя кнопка `#btnAqRelease` в поповере убрана). Миграция `loadSave()`: `aqFish` фильтруется по `SPECIES` (`Number.isFinite(w)&&w>0`), вариант → `'normal'`, `aqSlots` — `Math.trunc` + клампы `AQ_BASE_SLOTS..AQ_MAX_SLOTS`, хвост обрезается по слотам. Ачивки 36→38: `aq_first` «Первый жилец» / `aq_full` «Аквариум полон» — обе `bonus:{}` (пин `achBonus().coinPct=0.18` не сдвинут).
**Этап 59 — декор сцены (чистая косметика, coin-sink):** таблицы `AQ_FLOORS` (`{id,name,nameEn,cost,c,c2}` — песок **0** / галька **2500** / тёмный грунт **8000**; `c` — цвет толщи, `c2` — кромки, ночное затемнение то же `nm*0.75`, что у старого песка) и `AQ_DECOS` (`{id,name,nameEn,cost}` — сундук **5000** / коряга **9000** / замок **20000**) в секции ДАННЫЕ рядом с `AQ_*`. Сейв: `save.aqDecor={floor:0,deco:-1}` (экипировка; `deco:-1` — без орнамента), `save.aqFloorOwned:[0]` / `save.aqDecoOwned:[]` (купленные — массивы индексов); миграция в `loadSave()` рядом с `aqFish`/`aqSlots` — дефолты, клампы `floor` 0..2 / `deco` −1..2 (невалид → дефолт), фильтр мусора в купленных, экипировка обязана быть куплена. Отрисовка: грунт в `drawAquarium()` берёт `AQ_FLOORS[save.aqDecor.floor]`; орнамент — `drawAqDeco(nm)` (после грунта/растений, ДО пузырей/стекла/блика, чтобы стекло было сверху; рыбы-жильцы — DOM-слой и так выше): простые фигуры (сундук у правого края: корпус+крышка+золотые полосы; коряга у левого: ствол+отростки; замок по центру у грунта мельче — `s=0.72*k`), масштаб — тот же плавный `k=clamp(min(W,H)/AQ_TANK_REF,1,AQ_MAX_K)`, ночь — `hexLerp(t,'#0b1220',nm*0.55)`; всё в save/restore. UI — **Этап 62: вместо пилюль в баре диалог `#aqShop`** (см. строку «Диалог улучшений аквариума (Этап 62)»); тосты покупки/выбора — `aq_floor_bought`/`aq_deco_bought`/`aq_floor`/`aq_deco`, отказ — `sfx('no')`+`not_enough`. `setLang()` пере-рендерит открытый диалог. Экономику улова/слотов не трогает. 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`, `aq_pick_title`, `aq_empty_slot`, `aq_replace_q`, `aq_replace`, `aq_replace_short` (RU/EN). Emoji: 🐠 добавлен во встроенный субсет (перегенерация `tools/emoji-font.py`, `--check` OK). Подводные камни: `body.aq-open` прячет `#hud` (CSS + `syncDom`), а `#aqBar{pointer-events:none}` + `.aqpill{pointer-events:auto}` — иначе невидимая полоса бара перехватывала бы тапы по сцене; сентинел `aqSel=-1` (все проверки индекса — `aqSel>=0`); «выпустить» без монет — не вводить продажу (сознательное решение, дыра ×2 дохода закрыта).
- **Диалог слота аквариума (Этап 52)**: кнопка `#btnKeep` на карточке улова не кладёт рыбу сразу, а открывает DOM-оверлей `#aqPick` поверх `#card` (тот же `.overlay`/z-index 20, в DOM позже — лёг сверху; карточку `uiOpen` не ставит и не морозит мир). Список `save.aqSlots` строк `.aqpickrow` (занятые — `<use href="#fish-<id>">` + имя/вариант/вес, вариант через `aqApplyVariant()` на строку, CSS-фильтры `#aqPickList .aqpickrow.var* .je svg`; пустые — `+`/`aq_empty_slot`). Логика: клик по пустой строке — `aqPickPlace(i)` (поселить копию `cardSpec` сразу); клик по занятой — `aqPickPending=i`, inline-подтверждение `#aqPickConfirm` (текст `aq_replace_q` с именем, кнопки «Отмена»/«Заменить»), `aqPickConfirmReplace()` меняет экземпляр в слоте (вытесненная рыба выпускается **бесплатно**, как выпуск через `aqReleaseAt` в диалоге «Слоты», экономика не затронута); докупка `#btnAqPickSlot` → `aqPickBuySlot()` — общая `aqBuySlot()` (Этап 62, она же в `#aqShop`; `AQ_SLOT_COSTS`, `disabled` при максе/во время подтверждения). Финиш `aqPickFinish()`: `persist()`/`checkAchievements()`/тост `aq_kept`/`cardSpec=null`/закрытие `#aqPick` И `#card`/`state='IDLE'`/`tryInterstitial()` (как прежний `#btnKeep`). Глобалы `aqPickOpen`/`aqPickPending`, функции `openAqPick`/`closeAqPick`/`renderAqPick`/`aqPickPortrait`/`aqPickPlace`/`aqPickConfirmReplace`/`aqPickBuySlot`/`aqPickFinish`; DOM: `#aqPick`, `#aqPickTitle`, `#aqPickList`, `.aqpickrow(.empty/.replace)`, `#aqPickConfirm`, `#aqPickConfirmText`, `#btnAqPickConfirmNo`, `#btnAqPickConfirmYes`, `#btnAqPickSlot`, `#btnAqPickCancel`. `backdropClose($('aqPick'),closeAqPick)` (тап мимо = отмена, возврат к карточке); `setLang()` пере-рендерит открытый диалог; сброс прогресса закрывает `#aqPick`. Тесты §AQ(4)/(5)/(5b) Этапа 52. `aq_no_room` оставлен в `T`, но не используется.
- **Диалог улучшений аквариума (Этап 62)**: DOM-оверлей `#aqShop` поверх сцены банки — вместо прежних пилюль `#btnAqFloor`/`#btnAqDeco`/`#btnAqSlot` в баре. Вход — пилюля `#btnAqShop` «🛒 Улучшения» в `#aqBar` (в баре остались «← Назад», счётчик `#aqCount`, пилюля входа — media-сжатие ≤520px перенесено на `#btnAqShop`); `openAqShop(tab?)`/`closeAqShop()`/`setAqTab()`/`renderAqShop()`, глобалы `aqShopOpen`/`aqShopTab` (паттерн `aqPickOpen`). **z-index 27** — выше `#aqBar`/`#aqPop` (26), иначе бар лёг бы поверх диалога (общий `.overlay` = 20, переопределён). Две вкладки (`.tabs`, кнопки `#tabAqSlots`/`#tabAqUpg`, пере-подпись из `T`): **«🫙 Слоты»** — все `AQ_MAX_SLOTS` строк `.aqpickrow` (стили `#aqPick`, CSS-фильтры вариантов расширены на `#aqShopBody`): жилец — `<use href="#fish-<id>">`+вариант (`AQ_VARIANT_RECOLOR` inline `--f1/--f3`)+имя/вес/редкость; пустой — `+`; покупаемый (`i===save.aqSlots`) — `data-act="buyaslot"` с ценой `AQ_SLOT_COSTS`; дальние — 🔒 (гейтинг как `itemRow`); **Э64: жилец — кнопка `Выпустить` (`data-act="askrelease"`)**. **«🪸 Апгрейды»** — секции `sec_aq_floor`/`sec_aq_deco`, строки `aqDecorRow()` по образцу `skinRow` (иконка — поле `icon` в `AQ_FLOORS`/`AQ_DECOS`, паттерн Э60; «Купить N 🪙 / Выбрать / Выбрано ✓»), первой «🚫 Без декора» (`selnone` → `aqEquipDeco(-1)` — снимает орнамент, заменяет прежний цикл к `-1`). Клик — делегирование `#aqShopBody` (`data-act`: `buyaslot`/`buyfloor`/`selfloor`/`buydeco`/`seldeco`/`selnone`/`askrelease`), покупки — общие `aqBuySlot()`/`aqEquipFloor(i)`/`aqEquipDeco(i)` (её же зовёт `aqPickBuySlot`); после `buyaslot` — ещё `renderAqBar()` (счётчик). **Э64 — выпуск только отсюда:** `askrelease(i)` ставит `aqShopRelPending=i` (строка подсвечивается `.replace`), под списком — inline-подтверждение `#aqShopConfirm` (`#aqShopConfirmText` = `aq_release_q` с именем; `#btnAqShopConfirmNo`/`#btnAqShopConfirmYes`, паттерн `#aqPickConfirm`); «Выпустить» → `aqReleaseAt(i)` (без монет), «Отмена» снимает pending; pending сбрасывается в `openAqShop`/`closeAqShop`/`setAqTab` и при сбросе прогресса; кнопка `#btnAqRelease` из поповера сцены убрана. `aqCycleFloor`/`aqCycleDeco`/`aqDecorName`/`aq_floor_short`/`aq_deco_short` — УДАЛЕНЫ. `closeAquarium()` закрывает диалог; `setLang()` пере-рендерит открытый; сброс прогресса — тоже; `backdropClose`. Новое в `T`: `aq_shop_btn`, `tab_aq_slots`, `tab_aq_upg`, `aq_shop_slots`, `aq_slot`, `aq_slot_locked`, `sec_aq_floor`, `sec_aq_deco` + Э64 `aq_release_q` (RU/EN). Новые emoji (🫙🪸🏖️🪨🟫💰🪵🏰🚫) — субсет перегенерирован (65→73 глифа; 🏖️ с FE0F). Тесты §Э62 (открытие/вкладки/покупки/«без декора»/backdrop/слот через диалог/бар из 3 элементов).
## Запуск и публикация
- Dev-сервер: `python3 -m http.server 8000`; игра так же работает с `file://`.
- Процесс разработки, публикация в Gitea, гигиена секретов: `docs/DEVLOG.md`.