Files
andrey ad079b08f3 Инструмент: релизная сборка tools/minify.mjs (index.html −35%). (1) Методы: JS — terser compress passes:2 + mangle.toplevel:false (глобалы из буллета «Подводные камни» держатся именами — артефакт прогоняется обычной тестовой суитой); CSS — csso в каждом <style> КРОМЕ блока EMOJI-FONT (base64 woff2 держится побайтово, csso мог бы перенормировать кавычки); FISH-SVG — вырезаны HTML-комменты (~50 КБ описаний анатомии) и whitespace между тегами (в SVG текст-ноды не рендерятся — безопасно); прочие HTML-комменты — кроме маркеров EMOJI-FONT/FISH-SVG BEGIN/END (нельзя: whitespace внутри отрицательного lookaхeda, иначе <!-- EMOJI-FONT:BEGIN съедался бэктреком); whitespace DOM — только между блок-тегами (div/section/… + canvas/svg), inline-текст и пробелы между <b>…</b> и словом не трогаются. (2) Самопроверка 6 пунктов при любом FAIL exit 1: (a) размер, (b) node --check вынутого JS (паттерн tools/check.mjs), (c) маркеры по одному, (d) набор id идентичен (104), (e) видимый текст DOM идентичен, (f) блок EMOJI-FONT побайтово. (3) Артефакт dist/index.release.html: 541029 → 349886 байт (−35,3%), gzip 183366 → 128565; разбивка релиза: JS 112 КБ, SVG 120 КБ, шрифт 93 КБ, CSS 18 КБ, DOM ~5 КБ. (4) Валидация: ПОЛНАЯ тестовая суита на самом артефакте (подставлен под index.html) — 222/222 PASS, exit 0, headless Chromium. (5) Зависимости tools/: terser 5.51.2 + csso 5.0.5 (package.json + package-lock.json, npm i --prefix tools; tools/node_modules не коммитится — .gitignore +node_modules/, заодно прикрыл tests/node_modules). (6) index.html, balance, тесты, скриншоты НЕ тронуты. Доки: README (секция «Релизная (уменьшенная) сборка» в «Публикация в Яндекс Играх»), AGENTS.md (пункт 6 в «Проверка»: команда + методы + обязательный прогон tests/test.js на артефакте перед публикацией).
2026-09-24 19:25:04 +00:00

66 lines
49 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`, `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`, `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`, `aqReleaseSel`, `openAquarium`, `closeAquarium`, `syncKeepBtn`, `cardSpec`, `save.aqFish`, `save.aqSlots` (`aqValue` удалён, `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` — тоже, не использовать). Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
- Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики.
- Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
## Где крутить
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта (базы: удочки 875/4500/15000, наживки 625/3000/11250). Апгрейды снастей (уровни 1..4): цена перехода — `upgCost(type,i,lv)`: id 0 — фикс `ROD_UPG_COST`/`LURE_UPG_COST` (70/210/500 и 60/160/400), id&gt;0 — база×`UPG_MULT` (0.2/0.6/1.2, округление до 10); подписи уровней `ROD_UPG_NEXT`/`LURE_UPG_NEXT`, уровни `save.rodLv`/`save.lureLv`, эффективные статы — `effRod()`/`effLure()` (глобалы `rod()`/`lure()` отдают эффективные), гейтинг открытия — в `itemRow()`/`shopClick()`.
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — апгрейд 1 любого удилища (`save.rodLv[save.rod]>=2`, проверка `hasLight()` в ветке WAIT, секция «M3: Цикл дня/ночи»).
- Босс (M4, Этап 37 — босс в каждой локации, Этап 45 — +Болото): тест-форс появления `forceBoss` (работает с боссом ТЕКУЩЕЙ локации), таблица `BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale',4:'spiritfin'}` (секция ДАННЫЕ) — босс = существующий вид текущей локации (Река=Осётр как в M4, Озёра=Гигантский рак, Море=Рыба-меч, Океан=Синий кит, Болото=Дух болота) с 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()` (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% клёва (осознанно).
- Скины (M-skins): таблицы `BOAT_SKINS`/`BOBBER_SKINS` (лодка: Обычная 0 / «Красная лодка» 800 / «Лодка-домик» 2000 🪙; поплавок: Обычный 0 / Красный 500 / Неоновый 1500 🪙), сейв `save.boatSkin(s)`/`save.bobberSkin(s)` + миграция в `loadSave()` (включая гарантию бесплатного скина 0), вкладка «Скины» 🎨 (`#tabSkins`, строка `skinRow(list,i,owned,equipped,group)`, ветки buyskin/select в `shopClick()`: покупка со списанием и авто-экипировкой, выбор — только купленное), отрисовка `drawBoat()`/`drawBobber()` с clamp 0..2 (скин 2: каюта у лодки, неоновое свечение `shadowBlur` в save/restore у поплавка); чистая косметика — без эффектов на геймплей.
- Локации (этап 5, этап 35, этап 36 — эксклюзивная ихтиофауна, этап 45 — Болото): таблица `LOCATIONS` (Река 0 — бесплатно / «Озёра» 65536 🪙 / «Море» 131072 🪙 = ×2 Озёр, этап 35 / «Океан» 196608 🪙 = ×1.5 Моря, этап 36 / «Волшебное болото» 90000 🪙 — id 4, этап 45: по ЦЕНЕ стоит между Озёрами и Морем, но в массиве `LOCATIONS` — ПОСЛЕДНИЙ; `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)` — только 5 болотных (этап 45, `SWAMP_IDS`: glowfin/moonfish/emberfin/violetlure/spiritfin; веса `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` — полный словарь 37 видов; ночные веса реки — в `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: стурдж/рак/рыба-меч/кит/дух болота; runtime-флаг, новых видов нет — см. строку «Босс (M4)»); ачивка species_8 — «Поймай все виды» (все 37, 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 (свечение и декор Болота): у 5 болотных видов поле `glow:'#hex'` (5 разных цветов, по одному на редкость r0–r4); ночная аура — ПЛАВНЫЙ 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: Болото 240.3 / 328.9 🪙 (ночь/день ×1.37; по ЦЕНЕ и доходу стоит между Озёрами и Морем в ОБЕ фазы) — порядок вод по цене/доходу: **Река → Озёра → Болото → Море → Океан**; ночь/день: Река ×1.93, Озёра ×1.28, Болото ×1.37, Море ×1.60, Океан ×1.68 (этап 40 поднял у новых вод с ×1.11–1.21, у Реки ×1.93 — полностью не сравнять: у новых вод ночь богаче днём по составу не настолько, как у Реки); инвариант «новая (по цене) вода доходнее предыдущей в ОБЕ фазы» держится (Озёра/Река ×2.98/×1.98, Болото/Озёра ×1.24/×1.32, Море/Болото ×1.24/×1.45, Океан/Море день **×1.11 — тонкий запас**: дневное Море упирается в дневной Океан 329.1, дальше срезать сельдь/скумбрию днём — только поднимая дневной Океан или цены) — проверять при любой правке `*_WEIGHTS`/`price`/`wMin/wMax`; считать руками не нужно — см. строку «Баланс-скрипт». **Минимальный вес вида ≥ ~2.2**: ниже — тест lc4 (3000 роллов, «каждый вид ≥1») начинает плавать.
- **Баланс-скрипт (`tools/balance.mjs`):** `node tools/balance.mjs` — считает доходность рыб ЗА ВАС, ручные прикидки Σ(w·V)/Σw больше не нужны. Вывод: по каждой из 10 таблиц ролла (Река/Озёра/Болото/Море/Океан × день/вечер-ночь; Болото добавлено этапом 45) — виды (вес/доля %/V/вклад) и итог E[🪙 за улов], сводка-матрица с отношениями соседних вод в обеих фазах (Озёра/Река, Болото/Озёра, Море/Болото, Океан/Море — пары строятся ПО ЦЕНЕ воды, а не по порядку массива `LOCATIONS`: Болото 90000 стоит последним, но по цене между Озёрами и Морем — инвариант «каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы»), «здоровье» (⚠ если у самого редкого вида 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 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь 🪙; этапы 38–45: лестница весов + урезанные цены + ночные веса + Болото).**
Цикл настройки баланса новой локации: (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) не трогают) (идут `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:{}`).
- 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` «залипает» видимым за оверлеями).
- Хаб «Меню» (Э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) — при открытии лавки/сброса из «Меню» гменю прячется, иначе перекрыл бы их.
- Монеты → лавка (Э29): клик по `#coins` (pointer-events:auto + cursor в CSS) → `openShop('tackle')` — «Лавка рыбака» на вкладке «Лавка» (`#tabTackle`, эмодзи 🛒, `T.tab_tackle` 'Лавка'/'Shop'; RU-секции «🎣 Удилища»/«🪱 Приманки» внутри не тронуты). `openShop(tab?)` — аргумент форсит вкладку (`syncTabs()` синк активных классов, вынесен из `setTab`); HUD 🛒 зовёт `openShop()` без аргумента — помнит последнюю вкладку.
- Закрытие по тапу мимо диалога: `backdropClose(el,fn)` (секция «КНОПКИ», после Y2-хендлеров) — `pointerdown`, где `e.target ===` сам оверлей (тап на подложку, не .panel), закрывает диалог тем же действием, что и кнопка: `#shop`→`closeShop`, `#resetDlg`→«Отмена» (`btnResetNo.click()`), `#pause`→`closePause`, `#gamemenu`→«Продолжить» (`btnGmResume.click()`), `#card`→«Отлично!» (`btnCardOk.click()`, вкл. интерстишел). `#menu` — экран старта, не диалог. Новый оверлей-диалог без `backdropClose` не добавлять. Подводный камень: у `#card` pop-анимация (`#card{animation:pop .3s}` — transform scale) — пока идёт (300 мс), hit-area подложки сужена и тап мимо уходит на канвас (в реальном флоу state='CARD' → onDown no-op, безвредно; тест §X ждёт 400 мс после showCard).
- Встроенный 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` (37 файлов — 5 болотных добавлено этапом 45, 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-шрифта перегенерирован (рыбных глифов нет).
- **Аквариум (Этап 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()`, `syncKeepBtn()` — `disabled` + подпись/`title` «Нет места» при `save.aqFish.length>=save.aqSlots`), затем `persist()`/`checkAchievements()`/тост `aq_kept`/закрытие карточки/`state='IDLE'`/`tryInterstitial()` (единообразно с `#btnCardOk`). Сцена: `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·k)`, `K=0.7`/`MIN=7`/`REL=0.16`, десктоп-множитель `k=AQ_DESKTOP_SCALE=2` при `min(W,H)>=AQ_DESKTOP_MIN=600` (иначе 1) — ревизия после Этапа 46, `w/wMax`+`rarity` больше не влияют; сетка стартовых позиций, разворот у стенок, пузыри). **Ревизия: жильцы банки — те же 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` (`#btnAqBack`, `#aqCount` «N/M», `#btnAqSlot` — цена `AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS]`, при максе `disabled`), поповер `#aqPop` (`#aqPopName`/`#aqPopSub` — вес · редкость, БЕЗ цены), тап по рыбе `aqTap(x,y)` (ближайшая в радиусе → `aqSel` → `showAqPop()`). «Выпустить» = `#btnAqRelease` → `aqReleaseSel()` — точечное удаление экземпляра ИНДЕКСАМИ (без `initAquarium`, позиции остальных сохраняются) **БЕЗ монет** (освобождение слота; продажа из банки была в плане §7, но по решению владельца заменена на выпуск — экономика не затронута). Миграция `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` не сдвинут). 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). 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 дохода закрыта).
## Запуск и публикация
- Dev-сервер: `python3 -m http.server 8000`; игра так же работает с `file://`.
- Процесс разработки, публикация в Gitea, гигиена секретов: `docs/DEVLOG.md`.