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

75 KiB
Raw Permalink Blame History

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):
 cd tests
 npm install   # puppeteer-core
 npm test      # = node test.js
    Ждать все PASS и exit 0.
  1. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI, только по просьбе пользователя):
 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; кадры 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>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>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; порядок по цене Река < Озёра < Болото < Море < Океан сохранён; 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.