Files
fishing-game/docs/DEVLOG.md
T
andrey 48c54491dd Этап 44: береговые силуэты на горизонте (полный вариант различения локаций) + ревизия Реки. scenery/initScenery() по локации: Река — береговая полоса на всю ширину {k:'bank',h} (трава 12–17 px + тёмная кромка у воды) + хвойный лес в 2 ряда, посаженный на её кромку ({k:'pine',layer,by} — by=waterY−bankH, база ствола); Озёра — холмы {k:'hill',rx,ry} (2–3 дуги за деревьями) + лиственные деревья {k:'tree'}; Море — парусник {k:'ship',dir,v,s} (медленно дрейфует по курсу, паруса светлые) + 3 чайки {k:'gull',y,v,ph} (летят, машут крыльями); Океан — ПУСТО (открытый горизонт без берега). drawScenery() — между drawSky() и drawWater() (вода перекрывает базу силуэтов); ночь — dusk()=hexLerp(·,'#0b1220',nm*0.8) гасит силуэты, чайки почти гаснут; дрейф — updateDecor(dt). Река без волн (ревизия): LOCATIONS[0].waves — все нули, drawWater() рисует линию только при amp>0/amp2>0 — спокойная вода, ровный край. Провозировка initScenery(): resize(), обе ветки смены локации shopClick (рядом с initFish), кадры lake/sea/ocean в gen-screenshots.js (без него кадр Моря показал бы лес с Реки). Скриншоты перегенерированы (72 PNG: screenshots-wiki/ + screenshots-wiki-en/). Чисто визуал: роллы/баланс/физика не тронуты. Тесты: 193/193 PASS, exit 0; check.mjs OK; balance.mjs — пины без изменений. Приёмка: headless 1280×720, 4 локации × день/ночь + кроп-контроль горизонта (пиксельная выборка неба — чистый градиент, «точки» в превью — артефакт даунскейла). Доки: DEVLOG (Этап 44), AGENTS.md (строка «Локации» — waves/сценарий silhouette + вызовы initScenery), README (строка локаций — «свой берег»).
2026-09-20 23:26:40 +03:00

171 KiB
Raw Blame History

Процесс разработки

Как игру «Рыбалка» спроектировали, собрали, проверили и опубликовали. Документ описывает этапы, инструменты и повторяемые команды — чтобы любой (включая вас через полгода) мог воспроизвести и продолжить процесс.

ТЗ → макет-прототип → среда проверки → автотесты → визуал → баги → публ. → поддержка
 1       2                 3              4          5       6       7        8

Этап 1. Требования и дизайн (ТЗ)

Исходное ТЗ: браузерная игра про рыбалку, играбельная на телефоне.

Ключевые решения:

Решение Зачем
Один самодостаточный index.html (HTML+CSS+JS) Нет сборки, зависимостей и бэкенда. Файл можно открыть где угодно: телефон, десктоп, статический хостинг
Вся графика на Canvas, без внешних ассетов Игра работает офлайн, грузится мгновенно
Портретная ориентация, управление одним пальцем Все действия — тап/удержание по экрану, больших тач-мишеней (кнопки ≥ 44 px)
HTML-оверлеи для меню/магазина/карточек, Canvas — только мир игры Кнопки и текст резкие и доступные, игровой мир — 60 fps
WebAudio для звуков (без файлов), navigator.vibrate для тактильного отклика Снова ноль внешних ресурсов; на телефоне — вибрация на клёв/рывки/обрыв
Прогресс в localStorage Мобильная игра без аккаунтов и сервера

Механика (единый цикл):

бросок (удержание = мощность) → ожидание → клёв («!») → подсечка (тап в окно)
→ вываживание (тяни/отпускай) → {улов / обрыв / срыв} → монеты → магазин

Этап 2. Реализация

Структура index.html (все секции помечены комментариями в файле):

  1. Данные — SPECIES (8 рыб: сила, редкость, цена, мин. дистанция), RODS, LURES. Баланс игры крутится здесь.
  2. Сохранение — save + localStorage (монеты, снасти, коллекция, статистика).
  3. Холст — масштабирование под devicePixelRatio, пересчёт layout'а при resize.
  4. Звук — генеративный WebAudio: 12 эффектов из осцилляторов и шума.
  5. Состояния — машина из 9 состояний: MENU → IDLE → CHARGE → CAST → WAIT → APPROACH → BITE → REEL → SNAP/CARD → (IDLE).
  6. Физика вываживания — updateReel(): напряжение шнура, дистанция, фазы «тянет/отдыхает». Сложность крутится здесь.
  7. Отрисовка — небо, вода, рыбы-силуэты, лодка, поплавок, частицы, риплы, метры.
  8. Ввод — Pointer Events (pointerdown/up), работают и палец, и мышь (важно для автотестов!).
  9. UI — магазин, дневник, карточка улова.

Этап 3. Среда проверки

Игра проверена в контейнере Alpine Linux 3.24. Минимальный набор инструментов:

Инструмент Версия (в нашем прогоне) Зачем Установка
Node.js v24.18.1 Статический анализ JS, запуск автотестов apk add nodejs npm
Chromium 151.0.7922 Headless-браузер для настоящего прогона игры apk add chromium
puppeteer-core 25.9.0 Управление Chromium через CDP (клики, скриншоты, чтение состояний) npm i puppeteer-core (в tests/)
Noto Color Emoji — Чтобы в headless-скриншотах эмодзи рендерились как на телефоне apk add font-noto-emoji
git 2.54.0 Публикация apk add git
curl — Проверка API Gitea, smoke-запросы уже есть в Alpine

В браузере на телефоне для игры не нужен ни один инструмент из таблицы — только сам браузер.

Этап 4. Статическая проверка

Первый рубеж — компиляция кода настоящим движком (V8):

# вытащить JS из HTML и проверить синтаксис
node --check game.js

Ловит опечатки, несбалансированные скобки, висячие конструкции — до запуска в браузере.

Этап 5. Автотесты (плейтест через CDP)

tests/test.js — сценарный автотест: headless Chromium загружает игру, а «игрок» управляет мышью (в браузере мышь порождает те же Pointer Events, что и палец). Тест читает и подталкивает внутренние состояния игры (state, biteTimer, reel.d) через page.evaluate, чтобы покрыть все ветки без ожидания случайностей.

Что покрыто (54 проверки):

  • меню → старт → зарядка → бросок → ожидание
  • клёв (ускорен через biteTimer) → подсечка → REEL
  • все три исхода вываживания: улов → карточка, обрыв шнура (SNAP), срыв рыбы
  • магазин: открытие, покупка удочки и приманки (с проверкой баланса и экипировки)
  • дневник: рендер, пойманная рыба в коллекции
  • сохранение после перезагрузки страницы (coins/снасти/статистика)
  • shiny: ×3 к цене, счётчик sn, бейдж; near-miss: 10% цены за сошедшую рыбу; миграция старого сейва
  • M2 (9 проверок): квест выполняется уловом (+reward), перегенерация квестов на новый день, стрик (день+1, награда дня), «щит» стрика (прощение пропуска и контр-кейс), достижения (бонус 1% к монетам), вкладки лавки 🎯/🏅
  • M3 (6 проверок): фазы суток по dayTime, ночная таблица рыб (тот же ролл → другая рыба), ночь без фонарика (клёва нет, тост разовый), ночь с фонариком (клёв работает), «Фонарик» в лавке, миграция сейва (dayTime/lastSaveMs)
  • M4 (5 проверок): старт босс-боя (stateBoss/bossPhase), 3 фазы боя (обрывы фаз без штрафа, дистанция растёт по фазам), награда ×5 + bossBeaten/bossCount, достижение boss_first, босс-карточка
  • ноль ошибок в консоли и pageerror

Запуск:

cd tests
npm install
node test.js        # при необходимости: CHROMIUM=/путь/к/хромью

Этап 6. Визуальная проверка

tests/shots.js — та же техника, но с фиксацией скриншотов каждого состояния (screenshots/shot-*.png). Скриншоты просматривает разработчик: ловит проблемы, которые не видны по коду и логам (сбившиеся метки, отсутствующие глифы, нечитаемые метры).

Этап 7. Найденные баги (ради чего всё это)

Автотест + скриншоты поймали то, что ручное «посмотрел глазами» пропустило:

# Баг Как нашли Лечение
1 Критический: после поимки рыбы игра вешалась навсегда. catchFish() ставил reel=null, но кейс REEL в updateGame() на следующей строке читал reel.holding → исключение убивало цикл requestAnimationFrame Автотест: второй бросок не происходил; pageerror показал точную строку if(state==='REEL'&&reel){...}
2 Точка крепления лески висела в углу экрана в меню/карточке Визуальный осмотр скриншотов Условие отрисовки по состояниям
3 Рыбка-маркер не стояла на краю полосы «дистанция» Визуальный осмотр Полоса = оставшаяся дистанция, маркер на её конце
4 Глиф ✕ (U+2715) отсутствовал в некоторых шрифтах Скриншот магазина Заменён на × (U+00D7)

Вывод: для игр с таймерами и случайностями автотест, который управляет временем и состоянием, покрывает за минуту то, что вручную ищут часами.

Этап 8. Публикация в Gitea

Использовано: Gitea API (создание репозитория) + обычный git push.

# 1. Создать репозиторий (токен: Настройки → Приложения → Токены, права: Repository)
curl -X POST -H "Authorization: token <ТОКЕН>" -H "Content-Type: application/json" \
  -d '{"name":"fishing-game","private":false,"auto_init":false}' \
  https://git.shstk.ru/api/v1/user/repos

# 2. Наполнить ветку и запушить
git init -b main
git add -A
git commit -m "..."
git remote add origin https://git.shstk.ru/andrey/fishing-game.git
git push -u origin main   # git попросит логин/пароль (или токен в URL — см. ниже)

Игра доступна анонимно по сырой ссылке: https://git.shstk.ru/andrey/fishing-game/raw/branch/main/index.html

🔐 Гигиена секретов (проверено на себе)

  1. Токен — одноразовый. Для публикации достаточно кратковременного токена с правами на репозиторий; после push его отзываем (в нашем случае — отозван, старый токен теперь отдаёт 401).
  2. Токен не попадает в историю и конфиг. Если передавать токен в URL (https://user:TOKEN@host/...), после push удалите его из конфига: git remote set-url origin https://git.shstk.ru/andrey/fishing-game.git
  3. В репозиторий и в документы — только плейсхолдеры (<ТОКЕН>), реальные секреты нигде не фиксируются.

Этап 9. Поддержка и развитие

Крутить баланс: константы SPECIES / RODS / LURES в начале index.html; физика вываживания — в updateReel().

Цикл изменений:

# 1. поправить index.html
# 2. прогнать автотест
cd tests && node test.js            # ждём 54/54
# 3. (опционально) свежие скриншоты
node shots.js
# 4. закоммитить и запушить
git add -A && git commit -m "что изменилось" && git push

Идеи развития (по нарастающей сложности):

  • продажи улова / рыбный прайс по редкости
  • мировая таблица лидеров (нужен простой бэкенд или Gitea-Actions с webhook'ом)
  • офлайн-режим PWA (service worker в тот же файл)

Шпаргалка: минимум инструментов по этапам

Этап Инструменты
Игра (конечный пользователь) Любой браузер на телефоне/десктопе
Написание/изменение кода Текстовый редактор (или LLM-сессия)
Статическая проверка Node.js
Автотесты Node.js + npm + Chromium + puppeteer-core
Визуальная проверка Скриншоты из автотеста + Noto Color Emoji в headless
Публикация git + curl + Gitea (токен с минимальными правами, отзываемый)

Этап 10. M1: shiny-рыба, near-miss и починка тест-запуска

Короткий лог по M1:

  1. Починка запуска автотестов — tests/test.js хардкодил executablePath: '/usr/bin/chromium' и URL file:///fishing/index.html; в этом чекауте их нет. Теперь, как в shots.js: process.env.CHROMIUM || '/usr/bin/chromium-browser', а путь к игре резолвится динамически — 'file://' + path.resolve(__dirname, '..', 'index.html').
  2. Shiny-рыба — каждый улов роллит шанс SHINY_CHANCE (3%): золотая версия вида продаётся за SHINY_MULT (×3) к цене с тем же разбросом ±5%, на карточке — бейдж «✨ ШИНИ!» и золотое оформление (рамка/свечение), в дневнике у вида появляется метка «✨ N» (счётчик sn). Формат сейва: caught[id] = {n, best, sn}; миграция в loadSave() дописывает sn = 0 старым записям — числовые и {n,best}-форматы читаются без потерь.
  3. Near-miss при сходе — если рыба сбежала в вываживании (ветка d ≥ d0×1.4 в updateReel()), игрок получает утешение: 10% её цены (floor(вес × цена/кг × 0.1), если > 0) и тост «Сбежала! …». Обрыв (SNAP) выплаты не даёт.
  4. Тесты — добавлены 4 проверки: shiny (монеты ×3 в диапазоне, sn = 1, бейдж в DOM), миграция старого сейва (число и {n,best} без sn), near-miss (монеты ≈10% цены и тост «Сбежала»). Итог: 34/34 PASS, exit 0 (число в README и этом документе обновлено).

Этап 11. Инструмент браузерного плейтеста (Orca CLI)

Что. tests/orca-playtest.js — второй слой плейтестов: тот же полный геймплей, но в настоящей вкладке браузера Orca app (не в headless Chromium). Управление — командами Orca CLI (tab create / eval / screenshot / console / tab close), только встроенные модули Node. Проверяет 31 ok(): состояния игры, DOM карточки/лавки/дневника, бейдж «ШИНИ», тост near-miss, чистоту консоли; скриншоты orca-*.png — в каталог запуска. Полное описание, таблица шагов и камешки: docs/BROWSER-TESTING.md.

Почему. Headless puppeteer-тест — главный рубеж, но он живёт в своей среде. Вкладка браузера Orca троттлит requestAnimationFrame (фоновая вкладка), имеет свой CLI-roundtrip и свою консоль: плейтест «вживую» ловит именно эти отличия и гарантирует, что игра играбельна в том браузере, которым пользуются люди.

Найденные камешки (ради чего писали этот слой):

# Камень Как нашли Лечение в скрипте
1 rAF троттлинг фоновой вкладки: игровое время идёт медленнее реального (кадр ≤ 0.05 c игрового) Фиксированные sleep не доходили до нужного состояния Все ожидания — polling с таймаутом (шаг 300 мс, таймауты 10–30 c); фиксированные sleep — только «удержание кнопки»
2 Окно подсечки (0.35–0.8 c) короче CLI-roundtrip (~0.3–0.5 c) — тап опаздывает Подсечка случайно промахивалась biteTimer = 0.05 → ждём BITE → biteT = 5 (расширяем окно) → СНАЧАЛА скриншот, потом тап
3 Цитирование: JS с кавычками, переданный одной строкой через shell (PTY), ломается Повреждённые выражения в eval Только child_process.execFile с массивом аргументов, без shell
4 Transient-зависания CLI (единичный hang eval на ~1 мин, проходит повтором) Прогон вешался на одном вызове Таймаут execFile 20 c + ретраи ×3; tab create ретраев не имеет (дубль вкладки)
5 Тосты живут 1.6 c — отдельный вызов для чтения текста опаздывает Пустой тост в orca-escape Текст тоста читаем в ТОМ ЖЕ eval, что и ожидание WAIT

Как использовать. Запущенный Orca app + orca-ide в PATH (или ORCA_CLI_COMMAND); из каталога репо: node tests/orca-playtest.js. Если Orca не запущен — пропустить и не блокировать (главный рубеж — tests/test.js). В AGENTS.md — пункт 3 раздела «Проверка».

Этап 12. M2: награды — квесты дня, стрик входов, достижения

Что добавлено в index.html (секция «M2. Награды» после сейва):

  1. Квесты дня — save.quests = {date, list:[3]}. Три квеста генерируются по дню (seed — FNV-1a от YYYY-MM-DD, шаблоны без повторов): «поймай N вида», «заработай N 🪙», «улов ≥ N кг», «N забросов», «N рыб редкости R», «поймай шини». Прогресс растёт в questHit() (вызывается из doCast()/catchFish()); выполненный квест выдаёт награду один раз (save.questsDoneTotal — счётчик для достижения). Новый день (первый заход после смены даты) — перегенерация.
  2. Стрик входов со «щитом» — save.streak = {last, days, shieldWeek}. При загрузке applyStreak(): вчерашний визит → день+1; пропуск в середине недели → день+1, если shieldWeek ещё не равен текущей ISO-неделе (ISO — isoWeek(), «YYYY-Www», неделя с понедельника); иначе стрик сбрасывается в день 1. Награда дня 1–7: 25/50/75/100/125/150/200 🪙. Первый визит (визитов не было) запускает стрик с дня 1 без награды — иначе миграция старых сейвов (без поля streak) давала бы «воздушные» монеты.
  3. Достижения — 16 штук в ACHIEVEMENTS (первый улов, 10/100 забросов, заработано 1k/10k, 3/8 видов, редкая/легендарная рыба, первый шини, 10/20 кг, 10/50 обрывов — для этого save.snaps++ в ветке SNAP, стрик 7, выполнено 5 квестов). checkAchievements() — по событиям (бросок/улов/обрыв/покупка/пополнение кошелька); выданные дают постоянные бонусы через achBonus(): +1–2% к монетам, +0.02–0.06 с к окну подсечки, +5–10 к нагрузке шнура.
  4. UI — в лавке две вкладки: 🎯 «Награды» (полоса стрика с точками дня 1–7 + квесты дня с прогрессом и наградой) и 🏅 «Достижения» (выданные — яркие с ✓, остальные приглушённые с бонусом).
  5. Сейв — новые поля только через миграцию/дефолты в loadSave(); старые сейвы читаются (проверяется тестом миграции — он не тронут).

Тесты: +9 проверок в tests/test.js (итого 43/43 PASS, exit 0), +2 шага (🎯/🏅) в tests/orca-playtest.js (итого 35/35 PASS, exit 0). Точные проверки монет (M1 shiny, M1 near-miss) получили «гвард»: на время проверки квесты/стрик/счётчики фиксируются в eval, чтобы случайная награда не исказила баланс.

Найденные камешки:

# Камень Как нашли Лечение
1 Ветка else if(shopTab==='journal') терялась при вставке новых вкладок — журнал дублировался, новые вкладки недостижимы Проваленный прогон: вкладки 🎯/🏅 рендерили «Снасти» Явные else if по каждой вкладке + дубликат удалён
2 h='…' без let в новых ветках renderShop() → ReferenceError в strict mode pageerror: h is not defined let h в каждой ветке
3 Стрик давал «воздушные» +25 при миграции старых сейвов (без поля streak) — ломал точную проверку монет M1-теста Провал coins===77 после reload Первый визит (last=null) — стрик стартует без награды
4 page.click('#btnRecall') утаскивает мышь на кнопку → следующий mouse.down() в puppeteer-тесте падал на кнопку, а не на canvas Тест вешался на WAIT (state=IDLE) page.mouse.move(195, 600) после клика по кнопке
5 На первопланарной вкладке Okno BITE (0.35–0.8 c реального времени) закрывалось до применения отдельного eval biteT = 5 Плейтест упал на REEL (state=WAIT) Расширение окна — в ТОМ ЖЕ eval, что детекция BITE (state === "BITE" ? (biteT = 5, "yes") : …)

Этап 13. Спринт 2: ночная рыбалка + босс-рыба (M3+M4)

M3 — день/ночь, что добавлено в index.html (секция «M3: ЦИКЛ ДНЯ/НОЧИ»):

  1. Цикл суток — DAY_LENGTH = 300 (секунд на полные сутки), глобалы dayTime (0..1) и dayPhase (утро 0–0.25 → день 0.25–0.5 → вечер 0.5–0.75 → ночь 0.75–1). Время идёт в реальном времени: в frame() каждый кадр setDayTime(dayTime + dt/DAY_LENGTH). Пока игрока не было, сутки догоняются при загрузке (dayTime + (Date.now() - lastSaveMs), mod 1).
  2. Визуал фаз — SKY_PHASES: свой градиент неба на каждую фазу (утро — оранжевый, день — голубой, вечер — закатный, ночь — тёмно-синий); солнце/луна ходят по дуге (положение из dayTime, ночью — полумесяц), облака ночью приглушены, на воду ложится тёмный оверлей (прозрачность 0–0.3 по фазе).
  3. Ночной клёв — SPECIES_NIGHT (клон SPECIES со смещёнными весами спавна: сом/щука/окунь активнее, дневные виды реже). pickSpecies() берёт ночную таблицу вечером и ночью. Ночью без фонарика клёв не стартует: biteTimer тикает вхолостую, разово показывается тост «Ночью нужен фонарик» (флаг nightNoLureToast, сброс — новым забросом).
  4. Фонарик — новая приманка LURES id=4 (500 🪙, флаг nightOnly, в лавке — пометка «ночь»): на клёв не влияет, но разрешает ночную рыбалку (проверка save.lures.includes(4)).
  5. Сейв — persist() пишет save.dayTime/save.lastSaveMs; миграция в loadSave(): старый сейв стартует с утра (dayTime≈0), lastSaveMs проставляется — старые сейвы читаются (есть тест).

M4 — босс-рыба (Осётр-босс):

  1. Появление — при клёве осётра шанс 30% (проверка в startBite() — где рождается клёв; воркер M4 отклонился от «в pickSpecies()», отклонение согласовано): рыба помечается sp.boss = true клоном объекта — таблица SPECIES не мутируется. Для тестов — глобал forceBoss. Босс отличается: окно подсечки короче (0.25–0.5 c, ветка в biteWindow()), «злость» выше (сила 0.95), силуэт рыбы ×1.5.
  2. Бой в 3 фазы — глобалы stateBoss/bossPhase; босс-ветка в updateReel(): у босса в фазах 1–2 d ≤ 0 — «босс рвёт леску» (SNAP без штрафа: save.snaps не растёт) и без ввода игрока начинается следующая фаза глубже (d = 0.6·d0, затем 1.0·d0); в фазе 3 — обычный улов. Настоящий обрыв (по натяжению) и near-miss завершают босс-бой (stateBoss=false).
  3. Награда — ×5 к цене улова (множитель в catchFish()); поля save.bossBeaten/save.bossCount (дефолты для старых сейвов в loadSave()); тост «🏆 БОСС ПОБЕЖДЁН! +N 🪙»; всплеск и частицы крупнее.
  4. Достижения — 17-е достижение boss_first «Босс-рыбак» (+3% к монетам).
  5. UI — босс-карточка: имя «Осётр-БОСС», бейдж «🏆 БОСС!», золотое оформление (boss-card); во время боя над метрами — строка «⚠️ БОСС-РЫБА · ФАЗА N/3».

Тесты: +11 проверок в tests/test.js (M3 — 6: фазы суток, ночная таблица, ночь без/с фонариком, «Фонарик» в лавке, миграция сейва; M4 — 5: старт боя, 3 фазы, награда ×5, достижение, карточка). Итог 54/54 PASS, exit 0. В tests/orca-playtest.js +3 шага (ночь dayTime=0.85 + скриншот, «Фонарик» в лавке, босс-бой до карточки со сбросом forceBoss в finally) — итого 46/46 PASS, exit 0. В tests/shots.js добавлены shot-night.png (ночь, dayTime=0.85) и shot-boss.png (босс-карточка, полный цикл через forceBoss), плюс недостающие shot-wait.png/shot-reset.png — всего 11 кадров, скопированы в screenshots/.

Найденные камешки:

# Камень Как нашли Лечение
1 Тост «Ночью нужен фонарик» показывался бы на каждом кадре: ночью ветка «клёв не стартует» срабатывает на каждом тике biteTimer ≤ 0 Ревью при написании M3 Флаг nightNoLureToast — тост разовый; сброс новым забросом
2 Босс-флаг «залипал» бы в общей таблице: пометить объект осётра в SPECIES как босса — все следующие осётры тоже боссы Ревью при написании M4 В startBite() босс-осётр клонируется (Object.assign), таблица не трогается
3 Настоящий обрыв (по натяжению) не завершал бы босс-бой: stateBoss оставался бы true, и логика фаз ездила бы по уже кончившемуся бою Тест M4 (проверки после обрывов фаз) stateBoss=false; bossPhase=0 в ветках натяжения и near-miss
4 В состоянии SNAP отпускание пальца не сбрасывает reel.holding (onUp слушает только CHARGE/REEL) — после авто-перехода в следующую фазу босса игра «втягивает» сама, как будто палец ещё на экране Тест M4: дистанция фазы 2 не сходилась с 0.6·d0 В тестах holding=false в том же eval, что детекция SNAP; для игрока некритично (окно 0.6 c), в будущем — чистить holding при выходе из SNAP
5 console-ошибок нет, но квестные тосты затирали проверочный тост фонарика в тесте Прогон M3-тестов На время проверки квесты очищаются (save.quests={list:[]}), как в M2

Этап 14. Спринт 3: параллельные worktree — баги, визуал, тест-инфра, порядок, баланс

Новый процесс. Впервые задачи гонялись не последовательно в одном чекауте, а параллельно: 5 потоков (T1–T5), каждый — свой воркер (opencode через Orca orchestration) в собственном worktree и своей ветке, мерж в main — по очереди после приёмки. Чтобы мержи были бесконфликтными, зоны правок заранее разведены: index.html одновременно правили максимум два потока (логика vs CSS/рендер), tests/test.js закреплён за одним, README.md/AGENTS.md — разделены.

Что сделано по потокам:

  1. T4 «Порядок» (sprint3-techdebt): корневой .gitignore (мусор плейтестов больше не светится в git status), tools/check.mjs — автоматизация проверки синтаксиса JS из index.html (с номером строки при ошибке), npm test в tests/package.json теперь реально запускает плейтест (раньше был заглушкой — см. AGENTS.md), актуализация «Проверка».
  2. T1 «Баг-хант» (sprint3-bugs): аудит логики с puppeteer-пробниками; починено 4 бага — (1) квест «Сделай N забросов» падал TypeError'ом (questHit('casts') без condFn) и молча съедал persist()/достижения после заброса; (2) фазовый обрыв босса не сбрасывал reel.tension — «обрыв без штрафа» превращался в настоящий; (3)–(4) HUD монет не обновлялся после квестовой награды на забросе и near-miss. +4 регресс-теста (секция 9l) → 58.
  3. T3 «Тест-инфра» (sprint3-testing): харденинг orca-playtest.js — pageerror-хуки (47-я проверка), ретраи CLI с бэкоффом, диагностика кнопок, закрытие вкладки в finally; shots.js — ожидание отрисовки через waitForFunction+двойной rAF вместо sleep'ов, выбор кадров через argv (node shots.js --list, подмножество); актуализация BROWSER-TESTING.md/README.
  4. T2 «Визуал» (sprint3-visual): плавные переходы неба день/ночь со звёздами, интерполированная вода с дорожкой бликов, анимация хвостов и след поплавка, ночной фонарь на лодке, полировка CSS панелей/кнопок/карточек/тостов, адаптив узких экранов. 11 скриншотов перегенерированы. Механика не тронута (54/54 без правки тестов — критерий). Один затык: воркер завис на 25 минут в ожидании умершего tool-call — оживлен пинком в терминал, доработал без рестарта.
  5. T5 «Играбельность/сложность» (sprint3-playability): 4 живые сессии во встроенном браузере Orca (бот с реакцией 140 мс, 620 забросов): день с нуля, ночь без/с фонариком, босс forceBoss, контрольный замер после баланса. Вердикты: клёв, вываживание, экономика и босс — too_easy, прогрессия — ok (клёв p50 4.8 c и 0 пустых забросов; 0 сходов на 329 уловов; 192–979 мон/мин; босс 18/18 без шанса проиграть). Баланс-коммит только числами: тайминги клёва rand(2.5,7)→(4,10) и аналоги, веса щуки 12→9, сома 7→5, осётра 3.5→2.5 (цена 80→65), ночные веса притушены; константы, зафиксированные тестами (карась 100/40, цены удочки/приманки), не тронуты. После правки: живой ретест p50 клёва 8.1 c, 58/58 ×6 прогонов.

Интеграция: мержи в main по очереди (techdebt FF → testing → bugs → visual → playability), автослияние index.html прошло чисто на обоих пересечениях, после каждого мержа — полный прогон (58/58) + node tools/check.mjs.

Найденные камешки:

# Камень Как нашли Лечение
1 worker-start при 4 одновременных запусках: 3 из 4 промптов не принялись (agent_prompt_stalled) — TUI opencode не успели Гейт PO по receipt'ам (state=failed) Ретрай --retry-of с --terminal на уже прогретой TUI + --worktree path:… (без него — terminal_worktree_mismatch)
2 Worktree создаётся от дефолтной базы репо (origin/main), а она отстала от локального main Вопрос T4 координатору + сверка git log всех 5 worktree Воркеру — git merge --ff-only 5c95309; пушить main почаще или держать origin актуальным
3 commit.gpgsign=true ронял коммиты воркеров (pinentry некому вводить в headless) ask от T3 Коммиты воркеров — --no-gpg-sign (локальные ветки, без пуша)
4 Воркер T2 завис на 25 мин: tool-call умер, агент вечно «ждал результатов» Чекпоинт: замороженный счётчик токенов в TUI Пинок terminal send с инструкцией прервать (esc) и продолжить — рестарт не понадобился
5 Живая игра слишком легка: клёв мгновенный, сходов нет, босс без риска — головой по числам это не видно, только плейтестом T5: замеры в живом браузере Баланс числами (см. выше); системно — вываживание и ×5 босса требуют правки логики, оставлено как рекомендации

Этап 15. Спринт 4: симуляторская физика

План спринта. Вываживание из «держалки с таймерами» превратить в перетягивание: у рыбы появляется выносливость, у катушки — тормоз, силы сторон честные, а отпускание в рывке — осознанная стратегия, а не промах. Исследование и план с инвариантами: docs/PHYSICS-RESEARCH.md (коммит fafa0ad). Поток: T2 — P0-физика (70ff964), T3 — независимое ревью с Monte-Carlo («к мержу после фиксов», 2 MAJOR), T2b — ребаланс по ревью (60f3755), T4 — тесты + живой плейтест + замеры ботами (cc6238a, вердикт «Релиз готов»), T5 — релиз.

Что добавлено (механики по пунктам):

  1. Выносливость рыбы (stamina) — reel.st: стартовое st0 = 0.65+0.35·min(1,w/wMax) (крупная выносливее), у босса 1 (свежий). Убывает весь бой в updateReel(): быстрее в рывке, при удержании и при «сдаче в рывок» (pull без holding). Свежая рыба тянет дольше (рывок (0.8+2.2·st)·(0.6+0.7·ag)·rand(0.8,1.25) с), уставшая — короче и слабее: при st ≤ 0.12 флаг tired — сила рывков ×0.45 (fishForce), втягивание быстрее до ×1.6. UI: тонкая фиолетовая полоска под дистанцией (затухает к нулю), при усталости — 💤 и статус «💤 Рыба устала — втягивай!»; силуэт уставшей рыбы вялый (амплитуда ×0.35).
  2. Тормоз катушки (drag) — новая черта удилищ в RODS (drag 34/47/63/87): в рывок сила рыбы fishForce() перетягивается против тормоза — если рывок сильнее (over = F−drag > 0), леска скользит (дистанция растёт over·0.22) и нагрузка растёт быстрее; ниже тормоза — нормальное втягивание. UI: отметка drag на жёлтой полосе нагрузки («выше — леска скользит»).
  3. Честный баланс сил (T2b) — сила рыбы fishForce(r,ag,pull): в рывке (16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov), в затишье 5+7·ag (уставшая ×0.45); натяжение 10+max(0,over)·1.1+22·ag (в затишье ×0.35); втягивание (11+3·reel)·(1+0.6·(1−st)); свободный бег в рывке F·0.32; старт боя d0 = 45+7·w; near-miss-порог 1.45·d0 (утешение 10% цены).
  4. Escape: отпускать в рывок безопасно — фикс MAJOR-1: escape-бар больше не растёт при pull && !holding (стоимость отпускания — только дистанция, «сдался рыбе»), а растёт только при зависании в затишье без удержания (0.2+ag·0.4/с, гашение 0.5/с при holding) — пассивная игра (не держать кнопку) = гарантированный сход. Подсказка «⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡» теперь соответствует механике (MAJOR-2).
  5. Пустые клёвы — поплавок молчит: по таймеру в WAIT ролл biteChance() (приманка/дистанция/ночь), промах — новый таймер rand(2.5,5)/bite. Тест-форс — глобал forceBite (let, паттерн forceBoss), сброс — в btnResetYes.

Тесты (числа):

  • tests/test.js: +7 проверок (61→68): ст-инициализация, ст-убывание, ослабление fishForce (F2<0.6·F1), tired-флаг, drag-скольжение (d растёт, tension>drag), drag-втягивание (d падает, tension<drag), пустой клёв. T4: 68/68 PASS ×3 подряд, exit 0.
  • tests/orca-playtest.js: харденинг против гонки T2b (атомарный hold, см. камешки) — 47/47 live PASS в браузере Orca, без retry'ев.
  • tests/shots.js: 11/11 кадров, свежие в screenshots/.
  • Monte-Carlo приёмка по точным формулам (N=400/сценарий, dt=1/60, боты доки §4.4): «умный» (держать в calm/отпускать в pull) — карась 98%/13.0 с, окунь 85%, лещ 92%, сом/Карбон 66%/20.4 с, босс/Титан 59.5%/42.8 с (босс ≠ 100% даже на топ-удочке); «всегда держать» — мелочь 100%, сом/Стекло 39% + SNAP 61%, босс/Титан 62% + SNAP 38%; «пассив» — 100% сходов за 3.5–6.7 с. Stamina-дуга видна: st при улове 0.2–0.5 против старта 0.7–1.0.

Найденные камешки:

# Камень Как нашли Лечение
1 MAJOR-1 (T3): skill-curve инвертирован — «правильная» стратегия (держать в calm / отпускать в pull) теряла рыбу: свежий рывок 2.5–3.9 с длиннее escape-окна 1.7–2.3 с (ag≥0.6) → «умный» босс 98–100% сходов; «всегда держать» = 100% уловов впритык и босса на Титане/Карбоне (peakT p90 25–80%); бои 1.3–7.2 с против целей 8–50 с; stamina-дуга не видна Monte-Carlo (N=400) точных формул из index.html с тремя ботами доки §4.4 Т2b-ребаланс: escape не растёт при pull&&!holding, drain медленнее ×~4 (дуга на бой), d0=45+7·w, натяжение 10+over·1.1+22·ag, втягивание (11+3·reel)·(1+0.6(1−st)), free-run F·0.32; после: босс/Титан 59.5%, бои 10–45 с
2 MAJOR-2 (T3): подсказка «⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡» противоречила механике — отпускание в рывок гарантировало «Сбежала!» Т3-ревью: сопоставление UI-текста с формулами Лечено вместе с MAJOR-1: отпускание стало безопасным и выигрышным, текст сохранён
3 МИНОР (T3): AGENTS.md не обновлён — новые тест-глобалы forceBite/fishForce не в списке защищённых имён, счётчик проверок устарел Т3-чеклист ограничений Обновление AGENTS.md в коммите T2b (имена в списке, 68 проверок, секция «Физика боя»)
4 МИНОР (T3): инвариант доки «втягивание в calm при st=1 = ровно 30·reel px/с» неверен — формула даёт 30 только для rod 0, rod 1–3 медленнее на 17–41% Сверка доки §4.2 с кодом Исправлена дока (код — источник истины); учтено в T2b-числах
5 НИТ (T3): полоска stamina перекрывала верх эмодзи ⛵ на HUD Скриншот shot-reel.png T2b: полоска начинается после эмодзи (dx+18)
6 НИТ (T3): forceBite/forceBoss не сбрасывались в btnResetYes Т3-ревью (паттерн предшествовал коммиту) T2b: сброс добавлен
7 НИТ (T3): тесты §10 завязаны на абсолютные константы физики (запасы 18–80%) Т3-ревью покрытия После T2b пороги не упёрлись (запасы ×2.4–×3.1 проверены прогоном); в AGENTS.md — правило гонять Monte-Carlo при ребалансе
8 НИТ (T3): stale reel после SNAP/escape/near-miss (не null до следующего hook()) Т3-аудит утечек состояния Пререксизинг с main, все потребители под guard'ами state!=='REEL' — оставлено как есть
9 Гонка в orca-playtest.js: CLI-roundtrip (reset → pointerdown ≈ 1.5–2.5 c игрового времени) длиннее роста escape-бара (1.67 с) — бот «пассивен в затишье» между eval'ами, босс-шаг падал сходом; headless-стресс той же последовательности: 14/60 боёв кончались сходом T4: прогон 1 упал на босс-шаге (timeout: SNAP фазы 1, state=WAIT); диагностика стрессом Т2b-атомарный hold в тесте: сброс+удержание в ОДНОМ eval, гашение escape-бара в check-eval и при SNAP (паттерн forceBite); после: 0/60 в стрессе (даже с гэпом 1.2 с), 47/47 live. Код игры не тронут — хрупок был тест
10 Босс на Титане после подсечки не создаёт угрозы: 20/20 побед, ни одного опасного состояния нагрузки/дистанции (too_easy на адекватной снасти) T4: живой плейтест + бот (pump, 20 боёв) и трассировки Не блокер — передано в следующий спринт (усилить рывки/нагрузку на эндгир-удочке или осознанно оставить «наградой за прогресс»)
11 0 настоящих обрывов лески в 259 обычных боёв — ось «прочность шнура» не ощущается игроками-«держателями» T4: трассировки (макс. нагрузка 80/100) Не блокер — в следующий спринт (MINOR)
12 43–47% раннего дохода — утешительные выплаты за сходы (+10% цены): фармить сходы выгоднее осторожной игры (1786 монет за сходы против 953 за уловы) T4: экономические замеры ботом (~340 забросов) Не блокер — в следующий спринт (ограничить утешение, напр. только для рыб дороже N)
13 Стартовый клёв p90 13.4 с и ночной простой ~25% суток без фонарика — на границе комфорта первых минут T4: бот 100 забросов стартовой снастью Не блокер — в следующий спринт (NIT)

Итог. Вердикт T4: «Релиз готов» — все рубежи зелёные (68/68 ×3, 47/47 live, 11/11 скриншотов), крашей и pageerror нет, сейв/миграции не трогались (новых персистентных полей нет: st/tired — поля боя, drag — константа RODS, forceBite — тест-форс). Балансные находки (камни 10–13) — не блокеры, backlog следующего спринта.

Этап 16. M5: Апгрейды снастей (уровни 1..4, фонарик, гейтинг лавки)

Что сделано в index.html:

  1. Уровни снастей 1..4 — новые поля сейва save.rodLv/save.lureLv (объект «уровень по id»; купленная снасть — уровень 1). Эффективные статы — effRod()/effLure() (база × множители уровней): удилище Lv2 — леска ×1.15, Lv3 — тормоз ×1.15 и катушка ×1.10, Lv4 — леска ещё ×1.15 и крупные +0.10; приманка Lv2 — клёв ×1.15, Lv3 — редкие +1.0, Lv4 — крупные +0.15. Стоимость переходов 1→2/2→3/3→4: ROD_UPG_COST=[400,1200,3000], LURE_UPG_COST=[300,900,2200]; подписи «что даёт следующий уровень» — ROD_UPG_NEXT/LURE_UPG_NEXT. UI лавки: пипсы ●/○ и «N ур.» у купленной снасти, кнопка ⬆ со стоимостью (на 4 ур. исчезает), у закрытых строк — 🔒.
  2. Фонарик больше не приманка — LURES id=4 удалён из данных (LURES.length===4, поле nightOnly убрано): фонарик — апгрейд 1 любого удилища (2+ уровень): hasLight() — rodLvOf(save.rod)>=2, проверка в ветке WAIT (ночью без фонарика клёв не стартует, таймер тикает вхолостую, тост разовый — флаг nightNoLightToast, сброс новым забросом).
  3. Гейтинг открытия — снасть №i открывается, когда предыдущая снасть того же типа на 4 ур. (lv(i−1)===4): в UI строка locked + 🔒 + «Откроется: улучши «…» до 4 ур.», в shopClick() buy-ветке — двойная защита (при закрытом гейтинге покупка не проходит). Покупка разблокированной снасти стартует с уровня 1.
  4. Миграция старых сейвов (loadSave()): нет rodLv/lureLv → купленным присваивается уровень 1; приманка id=4 вычёркивается из save.lures, save.lure===4 → 0. Старые сейвы читаются (проверяется тестом §10b(1)).

Тесты: +14 проверок в tests/test.js (§10b: миграция сейва; данные — LURES.length===4, без nightOnly; точные эффективные статы удочки и приманки на всех 4 уровнях; hasLight(); гейтинг лавки (locked/🔒/нет buy → снятие после 4 ур.); двойная защита buy; покупка апгрейда (1000→600 🪙, фонарик включился); макс. уровень (кнопки upgrade нет); не хватает монет; ночь без/с фонариком; покупка разблокированной снасти — уровень 1). Итог 82/82 PASS, exit 0 (node tools/check.mjs — OK).

БЛОК БАЛАНСА (M-1: «принято без ребаланса»). Ревью подняло вопрос: не тривиализирует ли M5 босса на максимальной снасти (камень #10 этапа 15: «босс на Титане too_easy: 20/20 побед, ни одного опасного состояния»)? Monte-Carlo по точным формулам updateReel() (скрипт /tmp/opencode/boss_mc.mjs, N=400/сценарий, dt=1/60, боты §4.4 доки) для босс / Титан lv4 + Блёстка lv4 (line 342.25, drag 100.05, reel 2.53, size 0.95):

Бот Исход
«умный» (держать в calm / отпускать в pull) 43.5% улов / SNAP 0 / сход 56.5%
«держать» (всегда держать) 77.3% улов / SNAP 22.8%
пассив 100% сход

Модель валидирована на эталоне этапа 15 (босс/Титан lv1): smart 55.8% vs доки 59.5%, hold 67.3/32.8 vs 62/38 — в пределах MC-шума (N=400, σ≈2.5–3 п.п.). Числа lv4-кейса: size-апгрейд (0.95) утяжеляет босса — E[w]≈25.7 кг (против ~20 кг на lv1), d0≈225 px — и именно это режет «умный» улов вдвое (59.5→43.5%), а «держать» остаётся доходным, но рвёт леску в ~23% боёв. Вывод: 100%-стратегии нет, hold рвётся ~23% — тривиализации нет; M-1 принят без ребаланса. M5 расширяет разрыв камня #10 (босс с прогрессией снастей стал ещё доступнее, но остался честным боем) — принято осознанно: снасти — награда за прогресс, а риск босса держит физика (stamina/drag/near-miss), не гейтинг.

Этап 17. M6: Варианты рыб (серебряная/золотая/радужная) и «Улучшить вариант»

Что сделано в index.html:

  1. Таблица VARIANTS — 4 варианта улова: normal ×1, silver ×2 (5%), gold ×3 (живая SHINY_CHANCE, по умолчанию 3%), rainbow ×5 (0.5%); E[mult]≈1.13. Ролл — rollVariant(): порядок gold → silver → rainbow, иначе normal; тест-форс — forceVariant (let, паттерн forceBoss, сброс в btnResetYes). Константа SHINY_MULT из M1 удалена: ×3 теперь — множитель gold-варианта.
  2. catchFish() — ценность улова умножается на variant.mult (босс ×5 не складывается с вариантом: босс всегда normal — variant = boss ? VARIANTS[0] : rollVariant()). Семантика «shiny» = gold-вариант (квест catch_shiny, счётчик sn); частицы улова у варианта ярче и многочисленнее.
  3. Счётчики и миграция — save.caught[id] теперь {n, best, sn, sv, rb} (gold/silver/rainbow); миграция M6 в loadSave(): старым записям без sv/rb дописываются 0 — старые сейвы читаются (тест §10c(6)).
  4. Карточка улова — бейдж варианта #cardVariant (имя заглавными, скрыт у normal) и оформление карточки silver-card/gold-card/rainbow-card; кнопка «Улучшить вариант» #btnCardUp во второй кнопке карточки: цепочка VARIANT_CHAIN normal → silver → gold → rainbow, у rainbow и у босса кнопки нет. Формулы — в едином хелпере cardUpNext(): vNext = round(vCur·nextMult/curMult), cost = round(0.6·(vNext−vCur)) (cost = 0.6×Δ ценности). Клик: итог улова становится value_final−Σcost (coins += (vNext−vCur)−cost, earned += (vNext−vCur)), счётчики вида переводятся со старого варианта на новый, карточка/бейдж/HUD обновляются; защита в глубину — if(!cost) return; (бесплатно нельзя) и if(!rec) return; (нет записи вида).
  5. Дневник — у каждого вида 4 мини-счётчика вариантов: обычная (n−sv−sn−rb) / серебряная / золотая / радужная; не пойманный вариант — «—».
  6. Достижение full_variants «Полная коллекция» (18-е) — поймать все виды во всех вариантах: check по SPECIES.every (масштабируется до 11 видов этапа 5), награда +5000 🪙 разово и +5% к монетам перманентно (coinPct 0.05 в ACHIEVEMENTS, выплата в checkAchievements()).
  7. variantChance() — единственная точка вероятностей всех вариантов (gold — живая SHINY_CHANCE); сюда встанет дождь M8.

Тесты: +10 проверок в tests/test.js (§10c: данные VARIANTS и живой gold-шанс; forceVariant=silver — ×2 + счётчик sv + бейдж/класс; forceVariant=rainbow — ×5 + rb, кнопки «Улучшить» нет; босс+forceVariant — только ×5 босса, босс всегда normal; границы ролла rollVariant() с закреплённым Math.random; миграция сейва (sv/rb → 0, n/best/sn сохранены); бейдж/класс карточки по варианту (showCard); улучшение normal→silver (vNext = vCur×2, дельта монет, счётчик sv, кнопка стала «ЗОЛОТАЯ»); цепочка до rainbow (итог value_final−Σcost, rb=1, кнопка исчезла); full_variants (+5000 ровно один раз, achBonus().coinPct = 0.13, повтор — без выплаты)). Итог 92/92 PASS, exit 0 (node tools/check.mjs — OK). В tests/orca-playtest.js шаг 8 переведён на M6-селекторы: бейдж #cardVariant «ЗОЛОТАЯ», класс карточки gold-card (ранее #cardShiny/shiny-card/«ШИНИ»).

Monte-Carlo: не требовался — физика вываживания не тронута (формулы/константы боя без изменений); варианты влияют только на ценность улова, счётчики и UI.

Заметки (осознанные решения и ловушки):

  • Улучшение улова до gold инкрементирует счётчик sn → ачивка first_shiny («Золотая жила») достижима покупкой варианта, а не только удачным роллом. Осознанно: рыба буквально становится золотой.
  • Ловушка этапа 5 (при 11 видах): check ачивки species_8 = SPECIES.every — потребует все 11 видов, а desc говорит про 8. Разберёмся в этапе 5.

Этап 18. M8: Погода — дождь и туман

Что сделано в index.html:

  1. Таблица WEATHER — два события: rain 🌧️ (variantMult:2.5 — варианты silver/gold/rainbow ×2,5) и fog 🌫️ (biteMult:1.5 — клёв чаще); у каждого иконка и тосты старта/конца («☀️ Погода прояснила»). Тест-форс — forceWeather (let, значения 'rain'/'fog'/null, паттерн forceBoss — «активна всегда», сброс в btnResetYes).
  2. Таймеры — в реальном времени (updateWeather() в frame(), как день/ночь): между событиями случайный gap WEATHER_GAP_MIN..WEATHER_GAP_MAX (120–240 с), событие длится WEATHER_DURATION (120 с), rain/fog 50/50; под форсом таймеры не гоняются. Погода session-only — в сейв не персистится: на чистом старте (загрузка/сброс) — weather=null и новый случайный gap.
  3. weatherActive() — единственная точка чтения погоды (форс приоритетнее, иначе естественное состояние): эффекты только в variantChance() (rain: silver/gold/rainbow ×2,5; normal не трогаем, босс всегда normal) и biteChance() (fog: клёв ×1,5).
  4. HUD-пилюля #weather рядом с монетами: иконка + остаток секунд («🌧️ 97с»); под форсом — полная длительность; без погоды — скрыта (сравнение целым text — смена иконки rain↔fog не мигает).
  5. Canvas-визуал (drawWeather() — слой в конце draw-цикла): дождь — 40 косых полосок (частицы rain, initRain(), обновление в updateDecor()); туман — полупрозрачный слой + 2 полосы глубже с дрейфом; rect тумана расширен на 8px по периметру — слой рисуется внутри shake-транса, при обрыве фона по краям не просвечивает.
  6. Скриншот — кадр weather-rain в tests/shots.js (паттерн форса night-кадра: forceWeather='rain', ждём инициализацию частиц, сброс), screenshots/shot-weather-rain.png для README.

Тесты: +8 проверок в tests/test.js (§10d: данные — множители WEATHER и константы таймингов; дождь — variantChance ×2,5 (silver 0.125, gold 0.075, rainbow 0.0125); туман — biteChance ровно ×1,5 к базовому; weatherActive() — форс/естественная/null; естественный старт (gap 0,5 с → событие, weatherT≈120, тост); естественный конец (новый gap 120–240 с); таймеры — заморозка под форсом / тик без; HUD-пилюля rain↔fog↔hidden). Итог 100/100 PASS, exit 0 (node tools/check.mjs — OK). Ожидание естественных переходов — polling условия (паттерн waitState), а не фиксированные sleep: dt игры клампится (dt=min(0.05,…)) и при fps<20 игровое и реальное время расходятся.

Monte-Carlo: не требовался — физика вываживания не тронута; погода влияет только на вероятности (варианты/клёв), формулы и константы боя без изменений.

Заметки (осознанные решения и ловушки):

  • Пре-эксзистинг-флейка §9h (~4,5%/прогон): вид рыбы не был закреплён — ролл вида с rarity≥2 выдавал ачивку rare_fish (coinPct 0,01→0,02 и +монеты) и ломал точную проверку achBonus()/монет. Захарденено пином pickSpecies = () => SPECIES[0] (карась, rarity 0) по паттерну §10c(4).
  • Туман: итоговая вероятность клёва может превысить 1 (p = p_базы×1,5) → насыщение 100% клёва. Осознанно — туман «клевое» событие; Math.min(1,p) не добавляли.

Этап 19. M-skins: Скины лодки и поплавка

Что сделано в index.html:

  1. Таблицы BOAT_SKINS/BOBBER_SKINS — лодка: «Обычная» (0), «Красная лодка» (800 🪙), «Лодка-домик» (2000 🪙); поплавок: «Обычный» (0), «Красный» (500 🪙), «Неоновый» (1500 🪙). Чистая косметика — coin-sink, без эффектов на геймплей (монеты, физика, погода не затронуты).
  2. Сейв и миграция — save.boatSkins:[0]/save.boatSkin:0, save.bobberSkins:[0]/save.bobberSkin:0 (купленные id + экипированный). Миграция в loadSave(): нет массивов/полей → дефолты [0]/0; битые значения (скин вне 0..2, экипированный не в купленных) → 0; бесплатный скин 0 гарантирован — при битом сейве (напр. boatSkins:[1]) без 0 в купленных дописывается push(0), иначе лавка показывала бы «Купить бесплатно» скин 0, хотя лодка уже рендерится как скин 0.
  3. Вкладка «Скины» 🎨 (#tabSkins, setTab('skins')) — группы «Лодка»/«Поплавок», строка skinRow(list,i,owned,equipped,group) (group передаётся явно: 'boat'/'bobber' — без identity-чека таблиц). shopClick(): ветка buyskin (защиты: уже куплено / не хватает монет → тост; списание, push, авто-экипировка, persist) и select (защита: не куплено — выбрать нельзя; монеты не тратятся).
  4. Отрисовка — drawBoat(): корпус по save.boatSkin с clamp 0..2 (скин 1 — красный градиент, скин 2 — синий + каюта с ярко-жёлтым окном); drawBobber(): по save.bobberSkin (скин 2 — неон #aaff00 + shadowBlur=10 в ctx.save()/restore(), чтобы свечение не перетекало на другие элементы).

Тесты: +7 проверок в tests/test.js (§10e: данные таблиц (id/cost); миграция старого сейва → массивы [0], экипированные 0/0; битый сейв (скин вне 0..2, экипированный не в купленных) → 0; покупка «Красная лодка» за 1000 монет (coins=200, boatSkins=[0,1], авто-экип, «Выбрано ✓» disabled); не хватает монет (тост, без изменений); выбор после покупки (монеты не тратятся); вкладка/группы/6 названий + смоук отрисовки скинов 2 (~1 с, без pageerror)). Итог 107/107 PASS, exit 0 (node tools/check.mjs — OK).

Monte-Carlo: не требовался — физика вываживания не тронута; скины влияют только на отрисовку.

Заметки (осознанные решения и ловушки):

  • Brute-force проверка «coin-tolerance» ассертов §10e (ищутся ли посторонние источники монет, ломающие точные проверки) — пинить нечего: монеты каждого шага задаются явно.
  • Известный suite-level риск: проход границы даты 00:00 за время прогона (~0.2%) может сдвинуть точные ассерты; guard в tests/test.js — в бэклоге (отдельная задача).

Этап 20. Локации: Озёра

Что сделано в index.html:

  1. 3 новых вида в SPECIES (всего 11) — zander «Судак» (r2, 40 🪙/кг, 1.0–6.0 кг), chuch «Хариус» (r1, 30 🪙/кг, 0.2–2.0 кг), crayfish «Гигантский рак» (r3, 200 🪙/кг, 0.2–1.0 кг) — жильцы Озёр, в реку не заходят (роллы реки не меняются).
  2. 4 таблицы ролла + pickSpecies() — RIVER_SPECIES(_NIGHT) (фильтр SPECIES/SPECIES_NIGHT по RIVER_IDS — ровно старые 8, порядок и веса как прежде) и LAKE_SPECIES(_NIGHT) (все 11, свои веса LAKE_DAY_WEIGHTS/LAKE_NIGHT_WEIGHTS: днём «рыбное» озеро, ночью смещение к хищникам). pickSpecies() выбирает таблицу по save.location × dayPhase (вечер/ночь) — логика самого ролла не тронута.
  3. LOCATIONS + drawWater() по локации — таблица: Река (0, бесплатно) / Озёра (25000 🪙); у каждой water — 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot]: градиент воды берёт базовые цвета текущей локации (clamp 0..1), nightMix-логика прежняя.
  4. Сейв и миграция — save.locations:[0]/save.location:0; миграция в loadSave() (гарантия бесплатной Реки, битые значения → 0, паттерн скинов) — старые сейвы читаются (тест §10f(5)).
  5. Вкладка «Локации» 🗺️ (#tabLocations, строка locationRow) — shopClick(): ветка buylocation (защиты: уже куплено / не хватает монет; списание, push, АВТО-ПЕРЕХОД save.location, тост «Добро пожаловать на Озёра!») и selectlocation (выбрать только купленное, монеты не тратятся).
  6. Ачивка species_8 — desc теперь «Поймай все виды» (честно: check = SPECIES.every — все 11); id не тронут — совместимость старых сейвов.

Тесты: +10 проверок в tests/test.js (§10f: данные/таблицы/роллы реки 3000×2/роллы Озёр 3000×2/миграция/покупка/защиты/переключение/дневник 11/масштаб ачивок). Итог 117/117 PASS, exit 0 (node tools/check.mjs — OK).

Monte-Carlo: не требовался — физика вываживания не тронута (формулы/константы боя без изменений).

Заметки (осознанные решения и ловушки):

  • Разбор «ловушки этапа 17»: при 11 видах check ачивки species_8 (SPECIES.every) честно требует все 11 видов — desc обновлён («Поймай все виды»), id не тронут ради старых сейвов.
  • Экономика: 25000 🪙 ≈ 250–400 средних озерных уловов — честный end-game coin-sink. Озёра — цель разнообразия/коллекции (гейт на species_8 и full_variants 11×4); рак (40–200 🪙 за улов, шанс ~0.7–1%) — low-frequency бонус, а не чит на монеты.

Этап 21. Убрано «Улучшить вариант» — рыбу нельзя апгрейдить

Что сделано в index.html:

  1. Кнопка «Улучшить вариант» полностью удалена — #btnCardUp из разметки карточки, CSS (#btnCardUp и ставший мёртвым .big:disabled), функции cardUpNext()/renderCardUpBtn(), клик-обработчик, глобал cardInfo, константа VARIANT_CHAIN. Карточка улова теперь — бейдж/оформление по варианту + кнопка «Отлично!».
  2. Что осталось без изменений — таблица VARIANTS, роллы (rollVariant()/variantChance(), включая дождь M8), тест-форс forceVariant, счётчики sv/sn/rb и их миграция, дневник, ачивка full_variants. Формат сейва не менялся — миграция не нужна, старые сейвы читаются.

Тесты: −2 проверки в tests/test.js (§10c: удалены «улучшение normal→silver» и «цепочка до rainbow»; из данных-теста убран чек цепочки; тест silver теперь проверяет, что элемента улучшения в DOM нет; rainbow/босс-тесты переименованы без упоминаний #btnCardUp). Итог 115/115 PASS, exit 0 (node tools/check.mjs — OK). Скриншоты перегенерированы (кадр card без кнопки), свежие скопированы в screenshots/.

Monte-Carlo: не требовался — физика вываживания не тронута; удаление касается только UI карточки.

Заметки (осознанные решения и ловушки):

  • Глобалы cardInfo и VARIANT_CHAIN убраны из игры — тесты и AGENTS.md обновлены в том же коммите.
  • sv/sn/rb в сейве остались: дневник и full_variants работают как прежде, старые сейвы без миграции.

Этап 22. Расширение достижений + тексты «shiny» → «золотая рыба»

Что сделано в index.html:

  1. Тексты — «shiny» из видимых мест убран (M6: «shiny» = gold-вариант): квест catch_shiny → «Поймай золотую рыбу», desc ачивки first_shiny «Золотая жила» → «Поймай золотую рыбу». Идентификаторы first_shiny/catch_shiny и глобал SHINY_CHANCE не тронуты (тесты, старые сейвы); README/BROWSER-TESTING/лейблы orca-playtest приведены к «золотая рыба» (скрипт-файл orca-shiny.png имя не менял).
  2. 11 новых достижений (18 → 29) — по итогам Этапов 1–5:
    • варианты (M6): first_silver «Серебро» (🥈, +1%), first_rainbow «Радужный улов» (🌈, +2%);
    • погода (M8): rain_variant «Дождливый везунчик» — особая (≠normal) рыба в дождь (🌧️, +1%), fog_catch «Туманный улов» — любой улов в тумане (🌫️, +1%);
    • скины (M-skins): skin_first «Красуйся» — куплен любой скин (🎨, +1%), skins_all «Стилист» — все 6 (🎭, +2%);
    • локации (Этап 5): lake_first «Новые берега» — открыты Озёра (🗺️, +2%);
    • снасти (M5): gear_max «Максимальная снасть» — снасть 4 ур. (⚒️, +1%), titan «Титан» — куплена удочка «Титан» (💪, +2%);
    • новое: weight_1000 «Тонна рыбы» — суммарно 1000+ кг (🚢, +2%), night_first «Ночной клёв» — первый ночной улов (🌙, +1%). Все бонусы — coinPct (монеты); суммарная инфляция при всех ачивках ~+15% к монетам.
  3. Новые поля сейва — save.kgTotal (сумма кг уловов), save.nightCatch (bool), save.wxRainV/save.wxFog (счётчики уловов в дождь с особым вариантом / в тумане). Дефолты в save/freshSave(); миграция не нужна — все чтения через ||0/флаг, старые сейвы читаются (тест §10g(10)).
  4. Крючок в catchFish() — до checkAchievements(): kgTotal += w; dayPhase==='night' → nightCatch=true; weatherActive()==='rain' && variant!=='normal' → wxRainV++ (босс всегда normal — не заходит), weatherActive()==='fog' → wxFog++.

Тесты: +11 проверок в tests/test.js (§10g: тексты (desc/квест, «shiny» в desc нет); данные (11 id, ACHIEVEMENTS.length===29, уникальность, поля); геймплей — ночной улов в дождь с серебряным: крючки счётчиков (nightCatch, wxRainV, kgTotal ровно на w) и выдачи first_silver+rain_variant+night_first (туманного нет); first_rainbow по rb=1; fog_catch по wxFog=1; граница weight_1000 (999.9/1000); скины (1 купленный → skin_first, все 6 → skins_all); lake_first ([0]→[0,1]); снасти (макс 3 ур. — нет, 4 ур./«Титан» — да); старый сейв без новых полей — без ложных выдач и ошибок, дефолты freshSave). Пин §10c(8) full_variants обновлён: сумма achBonus().coinPct 0.13 → 0.16 (туда попали first_silver+first_rainbow). Итог 128/128 PASS, exit 0 (node tools/check.mjs — OK).

Monte-Carlo: не требовался — физика вываживания не тронута; изменения — только тексты, достижения и счётчики сейва.

Заметки (осознанные решения и ловушки):

  • Геймплей-тест ночного улова требует фонарика: save.rodLv[save.rod]=2 (ветка WAIT: ночью без hasLight() клёв не стартует — таймер тикает вхолостую).
  • «Все рыбы во всех вариантах» — отдельная ачивка не добавлялась: это существующая full_variants «Полная коллекция» (11×4).
  • Порядок записей в ACHIEVEMENTS — тематический (снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации у магазинных); тесты не зависят от порядка (только ACHIEVEMENTS.length и id).

Этап 23. Ушедшие рыбы без наград + рыба в тосте обрыва

Что сделано в index.html:

  1. Сходы без выплат — из обеих веток схода в updateReel() убрано «утешение» 10% цены (floor(вес × цена/кг × 0.1) + save.coins+=nm; persist(); updateHUD()): escape-бар (reel.escape>=1) и near-miss (reel.d>=reel.d0*1.45). Общий хвост (сброс stateBoss/bossPhase, тост → WAIT → biteTimer → sfx('escape')) вынесен в fishEscaped(sp); тост один: «Сбежала! <имя> <вес> кг». Устаревший комментарий near-miss-ветки удалён. Формат сейва не менялся — миграция не нужна.
  2. Рыба в тосте обрыва — тост SNAP теперь с указанием рыбы: «💥 Линия лопнула! <имя> <вес> кг» / «💥 Рыба крупнее предела удочки! <имя> <вес> кг» (разбор ov>1.15 сохранён). Босс-обрыв фазы («⚠️ Босс рвёт леску! Фаза N/3») не тронут — это не настоящий обрыв, а рыба и так известна.

Тесты: переписаны на месте §9c (tests/test.js: пин save.quests/стрика перед прогоном — монеты не изменились; тост «Сбежала!» с весом, без «🪙») и (2b) (§9l: сход монет не даёт, HUD в согласии с сейвом); +1 проверка в тесте «обрыв: SNAP -> IDLE» — тост начинается с «💥» и содержит «кг» (тост читается в следующем eval сразу после waitState('SNAP'): тост живёт 1.6 с, SNAP — 1.0 с, окна хватает). Комментарий шага 9 в tests/orca-playtest.js: «сход с утешением» → «сход без выплат». Итог 129/129 PASS, exit 0 (node tools/check.mjs — OK). Счётчики проверок в README/AGENTS.md: 128 → 129.

Monte-Carlo: не требовался — физика вываживания не тронута; убраны только выплаты.

Заметки (осознанные решения и ловушки):

  • Закрывает камень #12 из Этапа T4: утешительные выплаты за сходы давали 43–47% раннего дохода (фармить сходы было выгоднее осторожной игры).
  • fishEscaped(sp) — единственная точка исхода «рыба ушла»: обе ветки сходятся в неё, новые тексты/звук — править в одном месте.

Этап 24. Y1: SDK Яндекс Игр

Что сделано в index.html:

  1. Обёртка YG — секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ», единственная точка контакта с платформой: стаб при file:// без ?sdk=1 (скрипт /sdk.js не создаётся — чистая консоль; no-op'ы + счётчики вызовов YG.counts для тестов) и http(s) — динамическая загрузка /sdk.js (один ретраи, warn, 3-сек slow-warn), апгрейд стаб → реальный SDK в YG.init() (однократно, catch-up ready(), если SDK доинициализировался позже).
  2. LoadingAPI.ready() — первым кадром frame() + catch-up в init().
  3. GameplayAPI.start/stop — btnPlay/openShop/closeShop/btnReset/btnResetNo; карточка улова — без stop (часть цикла улова, осознанно).
  4. Пауза платформы — game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend(); при YG.paused в frame() заморожено всё игровое время).

Тесты: +7 проверок в tests/test.js (§Y1: стаб в file:// — SDK не создан и счётчики на месте, ready() ровно один раз первым кадром, start/stop-разметка btnPlay/btnShop/btnCloseShop/btnReset/btnResetNo по YG.counts, пауза/резюме — заморозка frame()). Итог 136/136 PASS, exit 0 (node tools/check.mjs — OK). Ревью F1–F7 закрыты (заморозка frame(), loader-ретраи, src по протоколу, guard btnPlay, доки).

Monte-Carlo: не требовался — физика вываживания не тронута; SDK не влияет на геймплей.

Заметки (осознанные решения и ловушки):

  • Стаб при file:// без ?sdk=1 — осознанно: скрипт SDK не создаётся (чистая консоль), игра остаётся полностью играбельной вне платформы.
  • Резюме (YG.onResume) не снимается, пока открыта реклама — снимает endAd() (guard adShowing; путь: visibilitychange/game_api_resume, а оверлей рекламы ещё открыт).

Этап 25. Y2: Монетизация

Что сделано в index.html:

  1. Interstitial через ysdk.adv.showFullscreenAdv — на закрытии карточки улова (tryInterstitial() в хендлере btnCardOk): логическая пауза (треб. 4.4), гейты AD_FIRST_MS (первый показ не раньше 2 мин сессии) / AD_COOLDOWN_MS (между показами 3 мин), persist() ДО показа (4.2 — улов не теряется при убийстве страницы платформой), YG.onPause — звук + процесс (4.7), watchdog 90 с (1.14) — если SDK не закрыл рекламу, паузу снимаем сами.
  2. Rewarded «монеты ×2» — RV-кнопка #btnRv «📺 Реклама: ×2 за этот улов» на карточке улова через ysdk.adv.showRewardedVideo: onRewarded → +val (цена улова), раз на улов (rvDone), бонус не блокирует закрытие карточки (4.5.2); честная формулировка (4.5.1); простой текстовый button — НЕ имитация рекламного блока платформы (1.16).
  3. Стаб (file:// без SDK): fullscreen → false (реклама не показана, мгновенный resume), RV — синхронная симуляция успешного просмотра (локальная разработка, all-в-одном-клике флоу).

Тесты: +9 проверок в tests/test.js (§Y2: RV-карточка (кнопка видна/включена, текст «×2», rvVal = цене улова), RV-клик (×2 монет, раз на улов, повторный клик no-op), interstitial-гейты 2 мин / кулдаун 3 мин по счётчику fsAdv (adShowing/paused=false), cleanup — возврат в MENU). Итог 145/145 PASS, exit 0 (node tools/check.mjs — OK). Кадр card-rv в tests/shots.js. Ревью F1–F5 закрыты (guard adShowing в резюме, watchdog, дельта-пин, текст кнопки).

Monte-Carlo: не требовался — физика вываживания не тронута; реклама не влияет на геймплей.

Заметки (осознанные решения и ловушки):

  • Карточка улова БЕЗ gameplayStop() — осознанно (часть цикла улова, не выход из геймплея).
  • lastAdTs — осознанно «попытка», не «показ»: при троттлинге платформы wasShown=false кулдаун всё равно сгорает.
  • Реклама не персистится в сейв (как погода): persist() только перед показом и после бонуса.

Этап 26. Y3: Полировка

Что сделано в index.html:

  1. -webkit-touch-callout:none — в универсальный селектор (треб. 1.6.1.8: лонгтап не вызывает системный touch-callout на iOS; user-select и contextmenu были закрыты ранее).
  2. .rvbtn{min-height:44px} — гарантия тап-таргета RV-кнопки ≥44px независимо от line-height/шрифта платформы (по замечанию UI-ревью).
  3. Дедуп watchdog — тело watchdog вынесено в метод YG._startAdWatchdog(), вызов из showFullscreen/showRewarded после успешного adv.*; endAd() без изменений.

Тесты: +1 проверка в tests/test.js (§Y3: computed-style .rvbtn min-height 44px). Итог 146/146 PASS, exit 0 (node tools/check.mjs — OK).

Monte-Carlo: не требовался — изменения только в CSS и декомпозиции YG.

Заметки (осознанные решения и ловушки):

  • Финальное gate-ревью Y1–Y3: PASS по таблице требований 1.1–8.x (частично 1.20/1.23 — emoji-шрифт на старых Win ОС, осознанно).
  • Доки README/AGENTS.md синхронны с реальным числом проверок (146).

Этап 27. Y4: Упаковка

Что сделано:

  1. tools/pack.mjs (чистый Node, без зависимостей) — сборка dist/fishing-yandex.zip для Яндекс Игр (треб. 1.22): ТОЛЬКО index.html в корне архива, без docs/tests/screenshots/прочего. Сборщик: zip -j (основной, deflate; junk-путь — файл сразу в корне), при отсутствии — python3 -m zipfile -c (fallback; наличие обеих команд проверяется заранее, если обеих нет — понятная ошибка и exit 1). dist/ создаётся автоматически, старый архив удаляется перед сборкой; dist/ добавлен в .gitignore.
  2. Самопроверка в том же скрипте — после сборки архив распаковывается в каталог tmp (fs.mkdtemp, встроенный zip-ридер: EOCD + центральная директория + zlib.inflateRawSync, unzip не нужен) и проверяются 5 пунктов, каждый с PASS/FAIL: (a) ровно один файл index.html в корне; (b) размер архива < 100 МБ (размер выводится); (c) имена без пробелов/кириллицы (regex); (d) sha256 распакованного файла == sha256 исходного index.html (byte-идентичность: игра в архиве = игра в репо); (e) синтаксис JS, вынутый из распакованного файла (тот же паттерн извлечения <script>-блоков, что в tools/check.mjs), через node --check. Любой FAIL — exit 1 и сообщение; в конце — путь к zip + размер.
  3. README — раздел «Публикация в Яндекс Играх»: команда сборки, SDK-стаб локально, отладка с ?sdk=1 (sdk.js рядом или локальный прокси Яндекса), настройки консоли разработчика.

Результат прогона: все 5 пунктов PASS, exit 0. dist/fishing-yandex.zip — 40.6 KB (zip/deflate); python3-фолбэк тоже deflate (проверено на Python 3.12) — 40.7 KB, все 5 пунктов PASS. sha256 исходного index.html 824309ff20f3… совпадает с распакованным.

Что делать в консоли Яндекса (публикация): загрузить dist/fishing-yandex.zip; включить монетизацию; опция облачных сейвов — выключена (игра сама хранит сейв в localStorage); платформы — без Android TV; debug-mode=16 — для проверки лоадера.

Тесты: node tools/check.mjs — OK; cd tests && npm test — 146/146 PASS, exit 0 (код игры не тронут, число проверок не изменилось).

Smoke упакованной копии (независимая верификация Y4): повторный node tools/pack.mjs — 5 PASS, exit 0 (идемпотентно); архив распакован отдельно (python3 -m zipfile -e) — ровно один index.html в корне, sha256 распакованного совпадает с репо (824309ff20f3…), список записей вторым независимым ридером (unzip -l) — тот же один файл. Новый персистентный тест tests/pack-smoke.js гоняет игру с КОПИИ из архива (не из репо): MENU → YG-стаб (active=false, readySent=true) → #btnPlay (IDLE, меню скрыто) → #btnShop/#btnCloseShop → ноль JS-ошибок — PACK-SMOKE: 6/6, exit 0 (3 прогона подряд). Подводка окружения: headless Chromium в этом чекауте не видит /tmp по file:// (отдельный tmpfs, ERR_FILE_NOT_FOUND) — smoke при этом авто-откатывается во временный каталог под $HOME.

Monte-Carlo: не требовался — геймплей не тронут.

Заметки (осознанные решения и ловушки):

  • Самопроверка распаковывает встроенным zip-ридером, а не unzip — у скрипта ноль бинарных зависимостей помимо самого сборщика (zip → fallback python3).
  • python3-фолбэк тоже deflate (проверено на Python 3.12) — встроенный ридер самопроверки понимает и stored, и deflate; по размеру разница несущественна (40.6 против 40.7 KB).

Этап 28. Встроенный emoji-шрифт (субсет Noto Color Emoji)

Проблема: все emoji игры (UI-строки, таблицы данных, canvas fillText — 💥🌀⚡💪🐟💤) рисовались системным шрифтом платформы. На системах без emoji-шрифта (headless Linux) — квадраты/монохром (известный подводный камень для скриншотов), а вид emoji разнился между платформами (Segoe/Apple/Android). Публикация в Яндекс Игры — строго один index.html (треб. 1.22, tools/pack.mjs), поэтому внешний .woff2 невозможен — шрифт встраивается в HTML.

Что сделано:

  1. tools/emoji-font.py (Python; dev-зависимости fonttools+brotli в tools/.venv) — генератор встроенного шрифта: вытаскивает все emoji-кодопоинты из index.html (одна точка истины — сам файл игры; диапазонные правила по Unicode-категориям + явные одиночные: стрелки, U+FE0F), строит субсет через fontTools.subset (woff2, без hinting; legacy-таблица SVG отбрасывается — цвет только через COLRv1) и вписывает base64 @font-face в index.html между маркерами <!-- EMOJI-FONT:BEGIN/END -->. Режим --check — регрессия: падает с листом недостающих кодопоинтов, если в index.html появился emoji, которого нет в субсете (требует только пересечение с cmap исходника — см. заметки).
  2. index.html — блок @font-face font-family:'EmojiEmbedded' (57 глифов, 78 КБ woff2 → +105 КБ base64; index.html 155→260 КБ). Шрифт стоит ПЕРВЫМ в font-stack html,body и во всех 10 ctx.font (константа UI_FONT в секции «ОТРИСОВКА») — единый Google-стиль emoji на всех платформах. Перед первым кадром — document.fonts.load('16px "EmojiEmbedded"') с timeout-фолбэком 1.5 с (canvas-emoji рисуются загруженным шрифтом; data: URI грузится за миллисекунды, фолбэк страхует от «зависания» первого кадра на file://).
  3. tests/test.js — секция §Y7: @font-face зарегистрирован в document.fonts + document.fonts.check('16px "EmojiEmbedded"','🪙') (шрифт реально резолвит glyph, а не молча падает в системный фоллбек).
  4. .gitignore — tools/fonts-src/ (исходник Noto Color Emoji, 25 МБ) и tools/.venv/.

Почему Noto Color Emoji COLRv1 (2022+): в игре есть emoji из Emoji 15.0 (2022) — 🪙 U+1FA99, 🪱 U+1FAB1, 🪝 U+1FA9D; старые версии шрифта их не знают. COLRv1 поддерживается всеми актуальными браузерами (Chrome/Firefox/Safari с 2023–2024).

Результат прогона: python3 tools/emoji-font.py → 57 глифов, woff2 78 КБ; python3 tools/emoji-font.py --check — OK; node tools/pack.mjs — 5 PASS, zip 110.9 КБ (было ~40 КБ до шрифта; лимит требования 1.22 — 100 МБ).

Тесты: node tools/check.mjs — OK; cd tests && npm test — 164/164 PASS, exit 0 (+2 новых §Y7); node tests/pack-smoke.js — 6/6. Рендер-пробы (headless, canvas fillText): 🪙/⚠️/⏸/⭐ шрифтом «EmojiEmbedded» — цветные пиксели (не фоллбек).

Визуальная проверка: все 14 кадров node tests/shots.js пересняты, screenshots/ обновлены. Среда чекаута — системный emoji-шрифт Emoji One (не Noto): на кадрах виден именно встроенный Google-стиль (🎣🖱️ в меню, ⚡🐟💪 на канвасе вываживания, 🐠🪙🏅📺 в карточке, 🐋 у босса, 🌧 в HUD-пилюле, 🏆 в тосте ачивки) — доказательство, что рисуется встроенный шрифт, а не системный.

Заметки (осознанные решения и ловушки):

  • ✓ (U+2713), №, ●, ○, ↩ — в Noto Color Emoji их нет (текстовые глифы) — рисуются системными шрифтами, как и раньше; --check это учитывает (требует только wanted ∩ cmap(исходник)).
  • ⚡ ⭐ ☀ ⚠ ⏸ ⚖ ⛵ ⚒ стали цветными emoji на всех платформах (на части систем раньше — монохромные) — осознанная унификация вида.
  • U+FE0F (variation selector, 18 вхождений в «⚠️») в субсете нет: NCE мапит U+26A0 прямо в цветной глиф, FE0F остаётся нулевым формат-знаком — «⚠️» рисуется цветным (рендер-проба).
  • Перегенерация субсета обязательна при добавлении новых emoji в index.html: python3 tools/emoji-font.py (реэкз в tools/.venv-интерпретатор встроен в скрипт); --check — защита от «добавил emoji, забыл перегенерировать».

Этап 29. Клик по монетам → лавка; хаб «Меню» (перенос сброса/звука)

Задача: 1) клик по счётчику монет в HUD открывает «Лавку рыбака» ровно на вкладке магазина снастей — вкладка переименована «Снасти» → «Лавка», эмодзи 🎣 → 🛒 (внутри секции «🎣 Удилища»/«🪱 Приманки» не тронуты); 2) новый диалог «Меню» (#gamemenu): туда перенесены 🗑️ сброс и 🔊 звук из HUD + кнопки Магазин/Достижения/Дневник/Инструкции; «В меню» из паузы открывает именно его; при старте новой игры (первая загрузка и после сброса) по-прежнему показывается диалог с инструкциями (#menu).

Решения:

  • state сохраняется: старое «В меню» делало state='MENU' (сброс заброса/боя). Новый хаб — оверлей как лавка: uiOpen=true замораживает игру, «Продолжить» возвращает в ТОТ ЖЕ момент (тот же pattern, что «Продолжить» паузы). state='MENU' теперь только при загрузке/сбросе.
  • Z-порядок оверлеев: #gamemenu стоит в DOM ПОЗЖЕ #shop/#resetDlg (тот же z-index:20) — рисовался бы поверх лавки/подтверждения сброса. Поэтому при открытии лавки/сброса из «Меню» гменю прячется, а закрытие возвращает его (флаг shopFromMenu: closeShop() → показать гменю, uiOpen остаётся true, БЕЗ YG.gameplayStart(); лавка из HUD — как раньше, в игру).
  • YG-счётчики: вход в гменю — из паузы (onPause уже сделал gameplayStop), поэтому onResume при uiOpen=true start не зовёт; «Продолжить» → gameplayStart() (+1/−1 сбалансировано); btnReset из гменю БЕЗ gameplayStop (был бы дубль stop). Пин в тесте §Y8(4): stop +1, start +0.
  • Инструкции из хаба — тот же #menu («Играть» → игра, state='IDLE'); btnPlay дополнительно прячет #gamemenu и сбрасывает shopFromMenu.
  • Открытие вкладки: openShop(tab?) — аргумент форсит вкладку (syncTabs() вынесен из setTab); #coins → openShop('tackle'), HUD 🛒 — без аргумента (помнит последнюю вкладку).
  • Кнопки перенесены с сохранением id (#btnReset/#btnMute теперь в #gamemenu) — обработчики и i18n (reset_title_attr) без изменений; updateHUD() печатает 🔊/🔇 + t('gm_sound'), setLang() дёргает updateHUD().
  • Новые i18n-ключи: gm_title/gm_shop/gm_ach/gm_journal/gm_howto/gm_sound/gm_reset (+EN); все emoji набора уже были в субсете — перегенерация tools/emoji-font.py не потребовалась.

Тесты: флоу #btnReset/#btnMute предваряются toGameMenu() (⏸ → «В меню»); §Y6(5) переписан (гменю виден, state=IDLE сохранён), (6) заходит к #btnPlay через «Инструкции»; новая §Y8: HUD-состав (🛒+⏸), монеты → «Лавка», вкладки из гменю, × → назад в «Меню» (YG-пин), mute-тогл (семантика «каждый клик — тогл»: фикстура старого сейва в §«локации» ставит muted:true — абсолютное значение не пинуем), «Продолжить», инструкции. node tests/shots.js — новый кадр gamemenu (15/15), screenshots/ обновлены.

Orca-плейтест: шаг 2b переведён на настоящий UI-путь сброса (Играть → ⏸ → «В меню» → 🗑️ «Очистить» → «Сбросить») — синтетический .click() по скрытой кнопке заменил реальный путь пользователя. Новая секция 9e — переходы по хабу «Меню»: монеты → «Лавка», Магазин/Достижения/Дневник (× шаг назад в хаб, uiOpen остаётся true), Инструкции → «Играть», 🔊-тогл (скаляры CLI приходят JSON-строками — читаем через объект ({m: save.muted})), «Продолжить». Плюс два давних флейка живого браузера закрыты (доки: BROWSER-TESTING.md п. 11–13): 2c — форс дня (сейв профиля приходил с ночью, ночью без фонарика не работает даже forceBite); шаг 12 — босс 5–40 кг рвал стартовую удочку натяжением до босс-фаз (выдаём «Титан», save.rod=3) + ожидание персистентного bossPhase вместо транзитного SNAP-окна 0.6 с (CLI-roundtrip мог его проскочить → CARD в фазе 3). Итог: 76/76 PASS.

Результат прогона: node tools/check.mjs — OK; cd tests && npm test — 177/177 PASS, exit 0 (+8 §Y8, пересчёт §Y6); node tools/pack.mjs — 5/5; node tests/pack-smoke.js — 6/6.

Этап 30. M6 расширен: медные/кварцевые/изумрудные/алмазные рыбы + буст шансов особых

Задача: добавить 4 новых варианта рыб (медная, кварцевая, изумрудная, алмазная) и в целом увеличить шанс особой рыбы. Решение по балансу (с владельцем): агрессивный буст, топ лестницы остаётся у радужной, кварц встаёт между серебром и золотом; новых ачивок не добавляем (только расширяется check full_variants).

Решения:

  • Лестница VARIANTS — 8 вариантов, порядок ролла = порядок массива: copper ×1.5 @0.12 → silver ×2 @0.08 → quartz ×2.5 @0.07 → gold ×3 @SHINY_CHANCE (0.03→0.06, живая) → emerald ×4 @0.01 → diamond ×4.5 @0.007 → rainbow ×5 @0.005 (топ: самая редкая и самый большой множитель), иначе normal. P(особой) ≈ 31% (было ≈8.5%), E[mult] ≈ 1.36 (было ≈1.13). rollVariant() обобщён — ролл идёт по VARIANTS в порядке массива (без хардкода списка id).
  • Счётчики улова: карта VARIANT_KEY={copper:'cp',silver:'sv',quartz:'qz',gold:'sn',emerald:'em',diamond:'dm',rainbow:'rb'} (sn = «shiny» = gold — семантика не тронута). catchFish() пишет запись caught[id] обобщённо (дефолты всех счётчиков + инкремент по ключу варианта) — все ключи всегда присутствуют. Миграция в loadSave() расширяется: дефолт 0 для всех VARIANT_KEY-ключей (старые сейвы читаются).
  • Дневник — обобщённые мини-чипы: обычная (n−Σособых) + 7 особых в порядке лестницы, подписи через trName (i18n); новые цвета .jv-cp/.jv-qz/.jv-em/.jv-dm.
  • Карточка улова — оформление copper-card/quartz-card/emerald-card/diamond-card (+бейдж «МЕДНАЯ»/«КВАРЦЕВАЯ»/«ИЗУМРУДНАЯ»/«АЛМАЗНАЯ»); showCard() обобщён: тогл класса по всем вариантам VARIANTS (normal — без класса). CSS: медь — бронза, кварц — молочно-сиреневый, изумруд — зелёный, алмаз — ледяной голубой (бейдж + панель + свечение эмодзи).
  • full_variants «Полная коллекция» — check расширен на все 8 вариантов (все счётчики VARIANT_KEY >0 у каждого вида); награда прежняя (+5000 🪙 разово, coinPct 0.05). Ачивок по-прежнему 29 — сумма achBonus().coinPct не изменилась (пин 0.16).
  • Дождь (M8) — уже обобщён в variantChance() (все особые ×2,5), текст тоста «шанс серебряной/золотой/радужной» → «шанс особых рыб».
  • Флейка-фикс тестов: медь (12%) роллится ПЕРЕД gold → тест «золотая рыба» (SHINY_CHANCE=1, §9a) и orca-шаг 8 получили forceVariant='gold' (форс сильнее ролла); восстановление дефолта SHINY_CHANCE во всех тестах 0.03 → 0.06. Границы ролла §10c(5) переписаны под новый порядок (последовательности из 1–7 pinned-значений); в eval добавлен сброс погоды (дождь ×2.5 сдвинул бы границы).

Тесты: §10c(1) — пин 8 вариантов/множителей/шансов; (2) — +cp/qz/em/dm=0; (5) — новые границы ролла; (6) — миграция всех счётчиков; (7) — probe всех 8 вариантов (бейдж/класс); (8) — full_variants 8×8 (sum 0.16 прежний); M8(2) — ×2.5 для всех 7 особых (tolerance 1e-9); локации §lc10 — сетап с 8 счётчиками.

Результат прогона: node tools/check.mjs — OK; cd tests && npm test — 177/177 PASS, exit 0.

Этап 31. Снасть усиливает шанс особых вариантов (M6+)

Задача: необычные рыбы ещё чаще — тип наживки и её уровень апгрейда повышают шанс особых вариантов, причём более редкие получают прибавку больше.

Решения:

  • Формула в variantChance() (единственная точка вероятностей, как дождь M8): variantBoost() = lure().rare×0.15 + (уровень наживки−1)×0.05; множитель шанса варианта — ×(1+B·r), r — номер варианта в лестнице VARIANTS (медь 1 … радуга 7) → чем реже вариант, тем сильнее буст. Дождь ×2,5 применяется к уже усиленному шансу.
  • Источник B — существующий rare-стат наживки (effLure().rare: простой крючок 0 / червь 0.2 / воблер 0.8 / блёстка 2.0, +1.0 на 3–4 ур.) — и «тип», и «уровень» наживки работают через один статики; уровень апгрейда добавляет своё слагаемое (+0.05/ур.) — буст есть на любом уровне, не только на 3-м.
  • Диапазон: простая наживка 1 ур. → B=0 (шансы базовые, P(особой)≈31%); червь 1 ур. → B=0.03; воблер 1 ур. → B=0.12; блёстка 1 ур. → B=0.3 (rainbow ×3.1); блёстка 4 ур. → B=0.6 (rainbow ×5.2, gold ×3.4, P(особой)≈60%). Насыщения нет: при топ-снасти + дожде все шансы <1.
  • Пины шансов в тестах (§10c(1)/(5), M8(2)) переведены на нейтральную снасть: снапшот save.lure/lureLv в начале eval, рестор в конце (снасть mid-сьюта после M5-тестов непредсказуема).

Тесты: +1 (§10c(9) «буст снасти», 178/178): B=0 у простой наживки; червь B=0.03 → copper 0.1236/rainbow 0.00605; блёстка B=0.3 → copper 0.156/gold 0.132/rainbow 0.0155; 4 ур. (rare 3.0) B=0.6 → rainbow 0.026; отношение множителей «радуга/медь» растёт с B (редким больше); ролл r=0.12 с червя → copper (граница под бустом); §10c(1) — +пин boost0=0.

Результат прогона: node tools/check.mjs — OK; cd tests && npm test — 178/178 PASS, exit 0.

Этап 32. Дождь ×3 (M8)

Задача: особые рыбы в дождь ещё чаще — множитель WEATHER.rain.variantMult ×2,5 → ×3.

Заодно: найдено упущение Этапа 30 — тост погоды берётся из T (t('wx_rain'), i18n), а не из WEATHER.rain.toast (мёртвое поле), и в T.wx_rain оставался старый текст «шанс серебряной/золотой/радужной ×2,5». Исправлены оба: RU «Дождь: шанс особых рыб ×3», EN «Rain: special fish chance ×3» (и WEATHER.rain.toast для консистентности).

Тесты: M8(1) — пин variantMult===3; M8(2) — шансы ×3 (copper 0.36 / silver 0.24 / quartz 0.21 / gold 0.18 / emerald 0.03 / diamond 0.021 / rainbow 0.015) + пин текста тоста («особых», «×3»); orca-шаг 8 — ratio ×3. Доки: README/AGENTS/BROWSER-TESTING.

Результат прогона: node tools/check.mjs — OK; cd tests && npm test — 178/178 PASS, exit 0.

Этап 33. Ещё пять озёрных видов (11 → 16)

Задача: «хочу добавить побольше разных рыб» — расширить состав рыбы, не трогая Реку.

Решения:

  • 5 новых видов в конце SPECIES (после crayfish): bleak «Плотва» (r0, 18 🪙/кг, 0.05–0.6 кг), roundel «Густера» (r0, 24, 0.1–0.9), eelpout «Вьюн» (r1, 55, 0.05–0.4, 🐍), tench «Линь» (r2, 80, 0.3–4.0), navaga «Навага» (r3, 250, 0.3–2.5). Только в конец: SPECIES[0..7] пинятся тестами (§573/§1524/§2446) и квестами (QUEST_TPLS, индексы 0..2).
  • Озёра, не Река: RIVER_IDS не тронут → роллы реки прежние (pin lc3). Озёрные веса — в LAKE_DAY_WEIGHTS/LAKE_NIGHT_WEIGHTS; NIGHT_WEIGHTS дополнен всеми пятью id (иначе SPECIES_NIGHT дал бы weight=undefined и упал бы lc2).
  • Баланс Озёр сохранён подъёмом дорогой рыбы. Прикидка E[выручка/улов] (середина диапазона веса × цена): было день 91.9 / ночь 134.1 🪙; одна только мелочь роняла до 73.5 / 113.1 (Река для сравнения 65.1). Компенсация — gold 1.2→1.6, sturge 3→3.5 (день) / 4→4.5 (ночь), crayfish 1.5→2 / 2→2.5: стало 84.7 / 122.2, доля gold 0.56% / 0.68% (было 0.55% / 0.64%). Озёра остаются доходнее Реки.
  • minDist/strength новых видов вписаны в существующую кривую (0.05→0.75 по редкости, 0.28→0.70 силы) — fishForce()/updateReel() НЕ тронуты, Monte-Carlo по PHYSICS-RESEARCH не требовался.
  • Emoji: четыре вида взяли 🐟/🐠 из уже покрытого субсета, только 🐍 (вьюн) потребовал перегенерации — субсет 57→58 глифов, woff2 80 КБ (+107 КБ base64 в index.html).
  • Мелочи: LOCATIONS[1].desc/descEn — перечисление озёрных рыб; комментарии «все 11» → «все 16» в pickSpecies() и у ачивки species_8 (check = SPECIES.every, масштабируется сам; id не тронут). Сейв-миграция не нужна: save.caught[id] создаётся в catchFish(), в старых сейвах новых записей просто нет — дневник покажет «???».

Тесты: lc1 — SPECIES.length 11→16, пины параметров пяти новых видов, порядок (slice(0,8) не сдвинут, slice(8) — ровно 8 озёрных) и заполненность всех полей (включая nameEn — иначе EN-ветка trName()); lc2 — LAKE_SPECIES(_NIGHT) 11→16, SPECIES_NIGHT.length 16 + веса числа; lc3 — без изменений (Река = старые 8); lc4 — текстовка «все 16» (состав берётся из SPECIES.map, подстраивается сам); lc9 — строк дневника 11→16; lc10 — сетап «+3 новых» → «+8 озёрных». Итого 178/178 PASS, exit 0.

Флейка, которую стоит помнить: lc4 вызывает pickSpecies() без аргумента → dist = undefined, undefined < s.minDist === false, штраф ×0.06 не применяется. На «каждый вид ≥1 за 3000 роллов» влияет только вес — новому озёрному виду нужен вес ≥ ~2 в обеих озёрных таблицах (у navaga 2.5 / 2).

Этап 34. Доходность Озёр 100+ 🪙 (веса)

Задача: «для озёр доходность желательно 100+, предлагаю просто увеличить вес рыб озёрных».

Решения:

  • Масштаб весов доход не меняет. pickSpecies() нормирует веса (шанс = w / total, где total — сумма тех же w), поэтому ожидаемая выручка за улов (середина диапазона веса × цена, взвешенная по вероятностям) инвариантна к умножению ВСЕХ весов на константу: «просто увеличить все веса» дало бы ровно те же 84.7 🪙. Поднят сдвиг долей в пользу дорогой рыбы — заявленный эффект достигнут тем же инструментом (правкой LAKE_*_WEIGHTS).
  • Убраны размыватели, поднята дорогая рыба (день): carass 60→55 (выручка ≈9 🪙, был 21% всех роллов) и bleak 30→18 (≈6 🪙) — главные «копейки» Озёр; вверх zander 14→16, catfish 8→10, pike 12→14, tench 10→14, navaga 2.5→3.4, sturge 3.5→5, gold 1.6→2.2, crayfish 2→2.4; вниз bream 30→24, perch 40→36, chuch 22→18, roundel 18→12, carp 25→24.
  • Ночь Озёр была убыточнее ночи Реки (122.2 против 125.9 🪙) — вопреки комментарию про «ночное смещение к хищникам». Исправлена тем же сдвигом: perch 70→60, carass 25→22, bream/chuch 10→9, tench 8→11, zander 18→20, catfish 16→18, sturge 4.5→6, gold 1.6→2.2, crayfish 2.5→2.6, navaga 2→2.4.
  • Итог: Озёра день 84.7 → 110.4 🪙 (×1.70 к дневному руслу реки 65.1), ночь 122.2 → 145.5 (×1.16 к ночной реке 125.9). Река не тронута (RIVER_IDS, SPECIES.weight, NIGHT_WEIGHTS).
  • Пол редких видов сохранён: минимальный вес 2.2 (gold/navaga/crayfish) — за 3000 роллов ожидается ≥25 попаданий (у gold раньше было ~20). Тест lc4 («каждый вид ≥1») проходит с вероятностью провала ~e⁻²⁵; ниже ~2 опускать нельзя (флейка выше: штраф minDist в тесте не действует).

Тесты: конкретных значений озёрных весов никто не пинит (lc2 — только длина 16 и «вес — число», lc4 — факт выпадения всех видов), правка таблиц не потребовала правок тестов: 178/178 PASS, exit 0; check.mjs OK; pack.mjs + pack-smoke 6/6 (115.5 КБ). Скриншоты перегенерированы не нужны: кадр рила и карточка форсируют sturge в gen-screenshots.js, остальные кадры от весов не зависят.

Этап 35. Море + эксклюзивная ихтиофауна локаций (16 → 23, свои рыбы у каждой воды)

Задача: «добавить новую локацию — море» + «переделать так, чтобы у каждой локации были только свои рыбы» (без пересечений с Рекой; Озёра — до 10 видов).

Решения:

  • Эксклюзивные наборы вместо map по всему SPECIES. До этапа 35 LAKE_SPECIES строился как SPECIES.map(...) — речные рыбы неявно жили и в Озёрах (доход Озёр держался в основном на карасе/окуне). Теперь у каждой воды свой *_IDS-список и таблица строится фильтром по нему: RIVER_IDS (8, без изменений), LAKE_IDS (10), SEA_IDS (5). Пересечения между водами — пустые (пин-тест lc2). Река не тронута вообще: RIVER_*, SPECIES[0..7], NIGHT_WEIGHTS речных — как были.
  • Новые виды — только в КОНЕЦ SPECIES (23 всего): озёрные rudd «Краснопёрка» r1 26🪙 0.1–1.2 кг и cisco «Сиг» r2 90🪙 0.5–3.5 кг; морские herring «Сельдь» r0 30🪙, mackerel «Скумбрия» r1 70🪙, flounder «Камбала» r2 130🪙, tuna «Тунец» r3 180🪙 2–12 кг, swordfish «Рыба-меч» r4 320🪙 5–20 кг. NIGHT_WEIGHTS дополнен всеми 7 id (иначе SPECIES_NIGHT.weight=undefined).
  • Море: LOCATIONS[2] (131072 🪙 = ×2 Озёр; Озёра при этом 100000 → 65536), SEA_DAY/NIGHT_WEIGHTS (ночь смещена к хищникам), развилка в pickSpecies() на 3 таблицы, тост welcome_sea (RU/EN), миграция location — граница LOCATIONS.length-1 (старые сейвы с location>2 → Река), clamp в drawWater() — по длине массива.
  • Цены локаций (правка после этапа): Озёра 100000 → 65536 🪙, Море 300000 → 131072 🪙 (ровно ×2 Озёр) — по запросу; пины тестов (costs, покупки/защиты) и доки синхронизированы.
  • Перебаланс Озёр после потери речной рыбы: доли перераспределены на своих жильцов — день ≈121 🪙 (было 110.4 при речных, ×1.86 к реке 65.1), ночь ≈134 (было 145.5; ночь реки 125.9 — Озёра снова впереди после «убыточной ночи» этапа 34). Море — топ-локация: день ≈190, ночь ≈222 🪙. Пол редких: мин. вес 1.2 (swordfish) — за 3000 роллов ≥40 попаданий.
  • Босс (sturge) остался только в Реке — озёрные/морские таблицы его id не содержат (раньше «есть в обеих локациях» — теперь эксклюзивность по всем правилам).

Тесты: обновлены пины блока 10f (SPECIES=23, LOCATIONS 3, costs 0,100000,300000, озёра 10 без речных, дневник 23, ачивка «все 23»), добавлены lc2-пересечения, lc4b (роллы Моря: все 5 видов, пресноводных нет), lc8b (покупка Моря: списание, авто-переход, тост). 180/180 PASS, exit 0; check.mjs OK; emoji-font.py --check OK (58 emoji покрыты); orca-playtest 76/76; pack.mjs + pack-smoke 6/6 (116.0 КБ). Скриншоты RU/EN перегенерированы (вода Озёр/новые виды в дневнике). Доки (README/AGENTS/KODA) синхронизированы в том же коммите.

Этап 36. Локация «Океан» (23 → 32 вида, четвёртая вода)

Задача: новая локация «Океан» (id 3): 9 новых глубоководных видов, цена 196608 🪙 = ×1.5 Моря. Баланс НЕ настраивать — веса «с потолка», точная настройка отдельным этапом через tools/balance.mjs.

Решения:

  • Новые виды — только в КОНЕЦ SPECIES (32 всего, индексы 23..31): moray «Гигантская мурена» r0 25🪙 1.5–6 кг, grouper «Горбуна» r0 25🪙 2–10 кг, turtle «Морская черепаха» r1 45🪙 5–14 кг, angler «Удильщик» r2 110🪙 1–4 кг, octopus «Гигантский осьминог» r2 120🪙 4–12 кг, manta «Скат-манта» r3 150🪙 6–15 кг, shark «Большая белая акула» r3 160🪙 5–14 кг, squid «Гигантский кальмар» r4 190🪙 4–12 кг, whale «Синий кит» r4 350🪙 20–50 кг. Иконки: 🦈 — дубль swordfish, 🐳 — у кита (китовой акулы в Noto нет); minDist/strength — кривые по редкости. NIGHT_WEIGHTS дополнен всеми 9 id (значения декоративные — у Океана свои таблицы, главное — ключи есть, иначе SPECIES_NIGHT.weight=undefined).
  • Океан: LOCATIONS[3] (196608 🪙 = ×1.5 Моря), вода глубже/темнее Моря (#1e6fb5/#0e4a86/#031a3c днём, #081430/#050d22/#020611 ночью; nightMix-логика не тронута), OCEAN_DAY/NIGHT_WEIGHTS (ночь смещена к крупным: whale 2→2.5, manta 3→3.5 — паттерн LAKE/SEA), развилка pickSpecies() на 4 таблицы, тост welcome_ocean (RU/EN), 4-ветка тостов в shopClick().
  • Исправлен живой баг границы миграции (с этапа 5): в loadSave() было жёсткое save.location>1 — граница не обновлялась в этапе 35, и купленное Море (location=2) сбрасывалось на Реку при reload. Теперь save.location>=LOCATIONS.length — авто-масштабируется на будущие локации. Регрессий-тесты lc5(d)/(e).
  • Ачивки +2 (29→31): sea_first «Широкая вода» ⚓ и ocean_first «Глубина» 🌊 — locations-based (save.locations.indexOf(2/3)), не caught-based: пин §10c(8) achBonus().coinPct===0.16 (там locations=[0]) не сдвигается.
  • Крупный вес кита (wMax 50) — ничего не ломает: d0=45+7·w → 395 (порядок как у босс-осётра wMax 40 → 325; отрисовка и escape-порог 1.45·d0 нормированы на d0 — бой просто дольше); snap_big/тосты (toFixed(1)) и карточка улова (текст «50.0 кг · 350 🪙/кг» влезает в панель 370px) — ок; квесты catch_weight (пороги 2/3/5 кг) — ок. Замечание для баланса (НЕ чинили): на «Титане» (maxW 40) кит даёт ov до 1.25 — почти гарантированный обрыв по механике «рыба крупнее предела удочки» (snap_big), вываживаем он только в рывках-затишьях; цена 350🪙/кг × до 50 кг = до ~17500 🪙 за улов — и то, и другое под отдельный баланс-этап.
  • tools/balance.mjs расширен на 4 локации (6→8 таблиц): OCEAN_SPECIES(_NIGHT) в вырезке данных, 2 строки в TABLES, сводка-матрица и отношения соседних вод обходят ВСЕ LOCATIONS (инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» теперь включает Океан/Море), проверки целостности параметризованы по числу локаций. Цифры Океана (информационно, пины не гонялись): день 539.5 / ночь 671.3 🪙 (Океан/Море ×2.83/×3.02; вклад кита ~43% — он главный «джекпот» Океана).
  • Emoji: 6 новых глифов (🐢🐙🦑🐳🌊⚓) — перегенерация субсета 58→64 глифа, woff2 89 КБ (+119 КБ base64 в index.html), --check OK.

Тесты: блок 10f — lc1 (SPECIES=32, пины 9 океанских, seaOrder ограничен slice(18,23), новый oceanOrder=slice(23), LOCATIONS 4, costs 0,65536,131072,196608), lc2 (+OCEAN_SPECIES(_NIGHT)=9, SPECIES_NIGHT.length===32, пересечения океан∩река/озёра/море пусты), lc5 (+кейсы d/e — Море/Океан не сбрасываются на reload), lc9 (23→32 строки дневника), lc10 («+15 новых» → «+24 новых»); новые: lc4c (роллы Океана: 3000 днём + 3000 ночью — все 9 видов ≥1, чужих вод нет), lc8c (покупка Океана: списание 196608, авто-переход location=3, тост «Добро пожаловать в Океан!»), §10g(8b) (sea_first/ocean_first — паттерн lake_first 1:1, по паре шагов с чисткой save.ach — ачивки не отзываются). §10g(2) — total 29→31 + id sea_first/ocean_first. Итого 183/183 PASS, exit 0 (было 180: +3 ok); check.mjs OK; emoji-font.py --check OK (64 emoji). Доки (README/AGENTS/KODA) синхронизированы в том же коммите.

Этап 37. Рыба-босс в каждой локации (Осётр/Рак/Рыба-меч/Кит)

Задача: «рыба-босс в каждой локации»: Река = sturge (как в M4), Озёра = crayfish, Море = swordfish, Океан = whale. Босс — существующий вид с runtime-флагом boss:true (НОВЫХ видов в SPECIES НЕ добавлять). Вес босса — единое правило 20–50 кг (касается и осётра: теперь 20–50 вместо 5–40). Ачивки: boss_first — нейтральный текст; +3 новые пер-босс ачивки БЕЗ coinPct (пин §10c(8) achBonus().coinPct===0.16 не должен сломаться).

Решения:

  • BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale'} (секция ДАННЫЕ, после таблиц Океана). Появление — в startBite(): условие sp.id==='sturge' заменено на bossId=BOSS_BY_LOCATION[save.location|0]; bossId===sp.id (шанс 30% или forceBoss — тот же, клон Object.assign({},sp,{boss:true}) — тот же паттерн M4). Механика фаз/наказаний/карточки уже вид-независима — ничего в updateReel()/SNAP/showCard не тронуто.
  • Единый вес босса 20–50 кг — в hook(): let w=sp.boss?20+Math.random()*30:rollWeight(sp); (до d0/ov/st0; в reel.w/catchFish уходит тот же w). Случайный ролл по wMin..wMax вида у босса теперь не действует (осётр-босс: 5–40 → 20–50).
  • save.bosses={id:true} — пер-босс трекинг: дефолт в save/freshSave() + миграция в loadSave() (if(!save.bosses||typeof save.bosses!=='object') save.bosses={}), ставится в boss-ветке catchFish() (там же, где bossBeaten/bossCount, до checkAchievements(); persist() уже был).
  • Ачивки 31→34: boss_first — desc «Победи рыбу-босса» / «Defeat a boss fish» (name/bonus/check не тронуты); новые lake_boss «Победа над Раком» 🦞 / sea_boss «Победа над Рыбой-мечом» 🦈 / ocean_boss «Победа над Китом» 🐳 — check по s.bosses[id], БЕЗ coinPct (пин 0.16 жив). Нюанс: bonus дан как пустой объект {}, а не «нет поля» — bonusText(a.bonus) в вкладке «Достижения» падает на undefined, и чек полей a22d требует truthy a.bonus; {} в achBonus() даёт 0 (пин не сдвигается). Осётр-босс отдельной ачивки не получил — покрывается boss_first.
  • Emoji: 🦞/🦈/🐳 — все уже в субсете (иконки соответствующих записей SPECIES), перегенерация не нужна (--check OK, 64 глифа).
  • Босс до 50 кг > maxW «Титана» 40 кг: при w>40 (в т.ч. у кит-босса) в режиме «держи кнопку» возможен НАСТОЯЩИЙ обрыв (натяжение достигает прочности лески раньше, чем d≤0) — вываживается контролем натяжения (отпускать в рывок, как и любую рыбу крупнее предела удочки); аналог замечания Этапа 36 про кита — материал баланс-этапа (не чинили).
  • tools/balance.mjs не тронут: босс — runtime-флаг, в данных отсутствует, в доходность не входит (строка «босс „Осётр“ (Река) клюёт вне обычного ролла» — как была; теперь боссы и в других водах, но они тоже вне ролла).

Тесты: новый блок §9k2 «Этап 37» (рядом с §9k, паттерн 1:1): цикл по 3 новым локациям — save.location=N + monkey-patch pickSpecies + forceBoss → старт босса (stateBoss/bossPhase=1/reel.boss/spId, вес 20–50 по фактическому reel.w) → bossPhase=3 + улов (паттерн M6(4)) → монеты = w×price×5 (±10%), save.bosses[X]=true, bossCount=1, ачивка выдана, карточка «<имя>-БОСС» + бейдж + boss-card; в конце — миграция (старый сейв без save.bosses → {}, паттерн lc5: delete save.bosses + localStorage + loadSave()) и полный сброс (forceBoss/pickSpecies/location/bosses/ach/caught/coins). a22d: total 31→34 + id lake_boss/sea_boss/ocean_boss. M4-пины (§9k/§9l(3)/M6(4)) не поправлялись: награда читает фактический w (window.__m4W/__m6W), фазы нормированы на d0 (20–50 кг → d0 185–395, отношения те же) — все 3 блока прошли без изменений. Итого 193/193 PASS, exit 0 (было 183: +10 ok), два прогона; check.mjs OK; emoji-font.py --check OK (64 emoji); tools/balance.mjs — цифры без изменений (Река 65.1/125.9 · Озёра 121.2/134.1 · Море 190.8/222.0 · Океан 539.5/671.3). Доки (README/AGENTS/KODA) синхронизированы в том же коммите.

Этап 38. Лестница веса по локациям (дороже вода = тяжелее рыба = сложнее бой)

Задача: «на более дорогих локациях было сложнее ловить рыбу (например, её вес сильно больше будет)». Вес — главный рычаг сложности: d0=45+7·w (дольше бой) и ov=w/rod().maxW → aggr (сильнее рывки/натяжение/escape). До этапа лестница была «сломана»: E[вес] Река ≈1.3 ≈ Озёра ≈1.3 > Море ≈1.1, а максимумы — Река 40 кг (осётр) > Озёра 12 кг (судак): на «бесплатной» реке рыба была тяжелее, чем в Озёрах. Множители подобраны по запросу пользователя: Озёра ×8, Море ×6 (рыба-меч ×4 — 20–80 кг, явно задано), Океан ×4 (синий кит ×2 — 40–100 кг, чтобы остался вываживаемым); Река не меняется (пины §9j/M4/M6).

Решения:

  • SPECIES.wMin/wMax — только 24 вида Озёр/Моря/Океана (строки «Этап 5/33/35/36», река 0..7 не тронута). Ключевые: Озёра — судак 8–48, линь 2.4–32, сиг 4–28, навага 2.4–20; Море — скумбрия 1.8–15, камбала 3–30, тунец 12–72, рыба-меч 20–80; Океан — мурена 6–24, горбуна 8–40, черепаха 20–56, осьминог 16–48, манта 24–60, акула 20–56, кальмар 16–48, кит 40–100. Ролл-веса (RIVER_*/LAKE_*/SEA_*/OCEAN_*), цены, редкости, minDist/strength — не тронуты. Код боя не менялся: rollWeight()/hook()/updateReel() читают wMin/wMax напрямую; st0 (0.65+0.35·min(1,w/wMax)) и силуэт (reel.w/sp.wMax) инвариантны к равномерному масштабированию диапазона.
  • Новые цифры (tools/balance.mjs, нейтральная снасть): Озёра 970.0/1072.4 (было 121.2/134.1), Море 1028.9/1192.4 (было 190.8/222.0), Океан 1691.4/2053.9 (было 539.5/671.3); Река 65.1/125.9 — без изменений. Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» держится: Море/Озёра день ×1.06 (тонкий запас — при правках цен/весов следить, чтобы не ушло в <1), ночь ×1.11; Океан/Море ×1.64/×1.72. E[вес] улова: Река ≈1.3 → Озёра ≈10.7 → Море ≈6.5 → Океан ≈21 кг; максимумы — 40 (осётр) < 48 (судак) < 80 (меч) < 100 (кит) — лестница максимумов теперь монотонна.
  • Осознанные странности (не чинили, по решению пользователя): (1) средний вес Моря (≈6.5 кг) легче среднего по Озёрам (≈10.7) — структура ролла Моря «82% сельдь/скумбрия + редкие гиганты», лестница дохода при этом нормальная; (2) кит 40–100 кг: на «Титане» (maxW 40) при w>40 в режиме «держи кнопку» — настоящий обрыв, вываживается контролем натяжения (тот же паттерн, что у кит-босса Этапа 37); (3) доходность выросла ×5–15 — Этап 39 (отдельный коммит) урезает цены Озёр/Моря/Океана к целям Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5.
  • Боссы не затронуты: вес фиксирован 20–50 кг в hook() (не читает wMin/wMax), BOSS_BY_LOCATION не меняется.

Тесты: пины lc1 (10f) переписаны — 24 строки name,rarity,price,wMin,wMax (меняются только веса, цены не тронуты) + текст ассерта (кит «20–50 кг» → «40–100 кг», пометка про этап 38). lc2/lc4/lc4b/lc4c, §9j/M4/M6, §10 (физика) — без изменений (река и ролл-веса не тронуты). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — цифры выше, «здоровье» роллов без изменений (мин. 16.3 попаданий). Доки (AGENTS.md: строки «Локации», «Баланс Озёр», «Баланс-скрипт») синхронизированы в том же коммите.

Этап 39. Подгонка цен Озёр/Моря/Океана (≈×3/×4/×5 Реки) через balance.mjs

Задача: после Этапа 38 (лестница весов) доходность новых вод выросла ×5–15 — «с помощью скрипта поменять цены так, чтобы доходность Озёр была примерно ×3 от Реки, Море ×4, Океан ×5».

Решения:

  • tools/balance.mjs + опция --price id:цена — what-if переопределение цены вида (🪙/кг, можно несколько раз, НЕ сохраняется), паттерн --set: парсинг, валидация id (список известных в ошибке), calcTable() читает prices[s.id] ?? s.price, строка WHAT-IF печатает «id вес=…/цена=…», шапка и --help обновлены. Именно так набор цен собран и сверен ДО правки index.html (what-if → те же цифры после переноса — по циклу из AGENTS.md).
  • Выбор цен: единый множитель на локацию к ЦЕЛИ ПО ДНЮ (Озёра 195.3 / Море 260.4 / Океан 325.5 = ×3/×4/×5 дневной Реки 65.1), округление до целых: Озёра k≈0.201 — zander 8, chuch 6, crayfish 40, bleak 4, roundel 5, eelpout 11, tench 16, navaga 50, rudd 5, cisco 18; Море k≈0.253 — herring 8, mackerel 18, flounder 33, tuna 46, swordfish 81; Океан k≈0.192 — moray 5, grouper 5, turtle 9, angler 21, octopus 23, manta 29, shark 31, squid 37, whale 67. Цены Реки не тронуты.
  • Почему цель по дню: единый множитель масштабирует ОБЕ фазы одновременно, а соотношения ночь/день у новых вод ×1.11–1.21 против ×1.93 у Реки — «×3/×4/×5 в обеих фазах» ценами недостижимо (нужен сдвиг соотношения фаз = правка *_WEIGHTS, а не цен). День — опорная фаза (первая в сводке скрипта); ночные множители — как следствие: ×1.70/×2.42/×3.17 (зафиксировано в AGENTS.md: «ночной паритет» — править *_WEIGHTS, не цены).
  • Цифры (tools/balance.mjs): Озёра 194.1/214.5 (×2.98/×1.70 Реки), Море 262.7/304.2 (×4.04/×2.42), Океан 329.1/398.9 (×5.06/×3.17); Река 65.1/125.9 без изменений. Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы»: Море/Озёра ×1.35/×1.42, Океан/Море ×1.25/×1.31 — держится с запасом (тонкий запас этапа 38, Море/Озёра день ×1.06, снят).

Тесты: пины lc1 (10f) — 24 строки обновлены (колонка price) + текст ассерта (новые цены, пометка этапа 39). Тесты наград (M4/§9k2/M6/shiny) читают w×price ДИНАМИЧЕСКИ с живых объектов — прошли без изменений (босс-рак 200→40 🪙/кг — ассерт не пострадал). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — цифры выше, «здоровье» роллов без изменений. Доки: AGENTS.md (строки «Баланс Озёр»/«Баланс-скрипт» — финальные пины + --price), README (диапазон цен 4–1500 🪙/кг), KODA.md (0.05–100 кг, 4–1500 🪙/кг, пометки этапов 38/39), шапка balance.mjs (пины).

Этап 40. Ночные *_WEIGHTS «ночь доходнее» + меньше сельди/скумбрии в Море

Задача: «поправим ночные *_WEIGHTS и на море уменьшим вероятность сельди/скумбрии, при этом не подгоняй доходность под ×3/×4/×5, а просто напиши что получилось». После этапов 38/39 ночные множители новых вод отставали от дневных (×1.70/×2.42/×3.17 против ×2.98/×4.04/×5.06) — у Реки ночь в 1.93 раза доходнее дня, у новых вод только ×1.11–1.21; и средневесовое Море (≈6.5 кг) было легче Озёр (≈10.7) — за счёт 82% ролла из сельди/скумбрии.

Решения (веса ролла, цены/веса видов/река не тронуты):

  • LAKE_NIGHT_WEIGHTS: zander 28→32, tench 22→24, navaga 5→10, cisco 13→18; chuch 4→3, bleak 4→3, eelpout 8→4, roundel 3→2.5, rudd 3→2.5 — ночь смещена к крупной/дорогой (судак V=224, линь 275, навага 560, сиг 288) с мелкой дешёвой (вьюн 19.8, плотва 10.4, густера 20).
  • SEA_DAY_WEIGHTS: herring 34→32, mackerel 34→32, flounder 11→12, tuna 2.6→3, swordfish 1.2→1.5 — доля сельди/скумбрии 82.3→79.5%. Дневное Море ограничено инвариантом «Море < Океан» (дневной Океан 329.1 фиксирован ценами этапа 39) — сильнее срезать нельзя.
  • SEA_NIGHT_WEIGHTS: herring 24→18, mackerel 30→20, flounder 11→15, tuna 2.6→4, swordfish 1.2→2.2 — доля 78.5→64.2%, ночь смещена к камбале/тунцу/мечу (V=544/1932/4050).
  • OCEAN_NIGHT_WEIGHTS: whale 2.5→4, manta 3.5→5, shark 3→4.5, squid 3→4.5, octopus 6→8; moray 32→26, grouper 28→22 — ночь смещена к гигантам (V=4690/1218/1178/1184/736) с мелкой дешёвой (мурена 75, горбуна 120).
  • Здоровье роллов (lc4, 3000 роллов): худший случай — Море день swordfish 55.9 ожидаемых попаданий (P(промаха)≈0), Озёра ночь roundel/rudd 72.1 — всё «ок» по скрипту.

Цифры (tools/balance.mjs, нейтральная снасть — без подгонки под цели, просто факт):

локация день ночь ночь/день от Реки (день/ночь)
Река 65.1 125.9 ×1.93 —
Озёра 194.1 249.0 (было 214.5) ×1.28 (было 1.11) ×2.98 / ×1.98 (было ×1.70)
Море 297.3 (было 262.7) 476.7 (было 304.2) ×1.60 (было 1.16) ×4.57 / ×3.79 (было ×4.04/×2.42)
Океан 329.1 552.7 (было 398.9) ×1.68 (было 1.21) ×5.06 / ×4.39 (было ×3.17)
  • Инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы» держится: Озёра/Река ×2.98/×1.98, Море/Озёра ×1.53/×1.91, Океан/Море ×1.11/×1.16 — дневной запас тонкий (Море упирается в Океан): зафиксировано в AGENTS.md, дальнейшее срезание сельди/скумбрии ДНЁМ — только с подниманием дневного Океана или цен.
  • Средний вес улова: Море день ≈7.0 кг (было 6.5), ночь ≈9.4; Озёра ночь ≈13.9 — разрыв «Море легче Озёр» сократился (ночь 9.4 против 13.9, было 6.5 против 10.7), полностью не закрыт: дневное Море ограничено инвариантом.

Тесты: lc2 (id/порядок/числовость — веса не пинятся), lc4/lc4b/lc4c («каждый вид ≥1» — все виды с запасом), §9j/M4/M6/§10 — без изменений (река и константы физики не тронуты). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — цифры выше. Доки: AGENTS.md (строки «Локации»/«Баланс Озёр»/«Баланс-скрипт» — пины и пометка про тонкий запас Океан/Море), комментарии секции ДАННЫЕ в index.html (этап 40 у LAKE/SEA/OCEAN таблиц).

Этап 41. Размер рыбы при вываживании — от абсолютного веса (0.1 кг → минимум, 100 кг → 2× лодки)

Задача: «размер рыбы в воде (когда её вытягиваешь) должен соответствовать её весу в кг, а не минимальному/максимальному весу вида; диапазон больше: 0.1 кг — максимально маленькая как сейчас, 100 кг — в 2 раза больше лодки». Первая ревизия этапа была лог-шкалой (средние рыбы 1–10 кг подросли до 0.9–1.4× лодки — «все рыбы сильно большие»); по решению заменена на линейную. Вторая ревизия: минимум опустили ещё — 0.1 кг с длины 19.6 px до 8 px («ещё меньше»). Третья ревизия: 100 кг → 256 px (×2 к 128, т.е. 4× лодки) и 1 кг → 20 px; предложенная квадратичная шкала 3 якоря не держит монотонно (парабола по w: пик ~420 px на 61 кг, затем схождение к 256 px на 100 кг — кит меньше «средней» рыбы; квадратичная по ln w: провал до ~4 px у 0.2 кг) — взята степенная L = 3.95 + 16.05·w^0.598, точная по всем трём якорям и монотонная. Четвёртая ревизия (финальная): явная узловая таблица пользователя (веса — степени двойки, длины 16..256 px, «погрешность 10%»): степенная формула не покрыла таблицу в середине (на 25.6 кг было бы +24% к цифрам таблицы; локальный показатель кривой гуляет 0.26..0.61) — заменена линейной интерполяцией по log₂(веса) между узлами: узлы бьются точно (0%), кривая монотонна, вне 0.1–100 кг — кламп в края таблицы.

Решения:

  • Новый хелпер fishSizeFromW(w) + таблица FISH_SIZE_KNOTS (секция «ОТРИСОВКА», перед drawHookedFish()): узлы [log2(w/0.1), длина px] = [0,16],[1,20],[2,25],[3,35],[4,48],[5,64],[6,80],[7,100],[8,120],[9,170],[log2(1000),256], интерполяция линейна по log₂(веса) (узлы — степени двойки), длина силуэта = 2.45·fs (тело 2·fs + хвост). Узлы: 0.1 кг → 16 px · 0.2 → 20 · 0.4 → 25 · 0.8 → 35 · 1.6 → 48 · 3.2 → 64 (= лодка) · 6.4 → 80 · 12.8 → 100 · 25.6 → 120 · 51.2 → 170 · 100 кг → 256 px = 4× лодки 64 px из drawBoat() (bx−32..bx+32). Веса вне 0.1–100 кг клампятся в края таблицы (нижний кламп затрагивает только золотую рыбку 0.05–0.1 кг).
  • drawHookedFish(): fs = fishSizeFromW(reel.w) (было 8+(reel.w/sp.wMax)*10+sp.rarity). Убраны +sp.rarity (редкая рыбка не обязана быть крупнее — размер = вес) и босс-множитель ×1.5 (М4): босс весит 20–50 кг (113–168 px = 1.77–2.6× лодки по узловой шкале), с ×1.5 босс-рак 30 кг выглядел бы крупнее кита 100 кг — противоречие «крупнее вес → крупнее рыба»; драматизм босса теперь несёт аура + бейдж + фазы. Золотая аура босса сохранена (читает новый fs).
  • Не тронуто: подплывающая рыба до подсечки (APPROACH/BITE, 12+rarity*2.5 — вес к моменту подплывания ещё не зароллен, hook() роллит его при подсечке), фоновые рыбы rand(8,22), 🐟-маркер на дистанционной полосе, emoji-карточка улова. Баланс/роллы не задеты: wMin/wMax остаются только в rollWeight()/hook()/st0.

Размеры (полная длина силуэта; лодка = 64 px): 0.1 кг → 16 px (0.25×) · 0.2 → 20 px (0.31×) · 0.4 → 25 px (0.39×) · 0.8 → 35 px (0.55×) · 1.6 → 48 px (0.75×) · 3.2 → 64 px (1× = лодка) · 6.4 → 80 px (1.25×) · 12.8 → 100 px (1.56×) · 25.6 → 120 px (1.88×) · 51.2 → 170 px (2.66×) · 100 кг (кит max) → 256 px (4×). Диапазон 16..256 px = ×16; между узлами — линейно по log₂(веса) (проверено: узлы 0% отклонения, монотонность 1000 точек). Тяжёлая рыба в конце вываживания (позиция boatX+24, поверх лодки) заметно перекрывает лодку — осознанный эффект «вывезена рядом с лодкой»; на мобильном 360 px кит ≈ 71% ширины экрана.

Тесты: пины на размер силуэта в tests/test.js отсутствуют (проверено: drawFishShape/drawHookedFish/fishReelPos не ассертятся; reel.w в тестах — для физики). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7). Скриншоты перегенерированы (gen-screenshots.js, 30 кадров; кадр reel форсит reel.w=12.5 — рыба ~99 px ≈ 1.55× лодки).

Этап 42. SVG-портреты рыб в дневнике и карточке улова

Задача: подключить уже вшитый SVG-спрайт (32 symbol, предыдущий этап) к UI: дневник и карточка улова показывают портрет вида вместо эмодзи; варианты перекрашивают портрет.

Решения:

  • Портреты: 32 flat-SVG в assets/fish/fish-<id>.svg (по одному на вид, viewBox 0 0 160 100, цвета var(--f1..f4, hsl-fallback) — переменные НЕ объявлены на <svg>/<g>, наследуются извне). Встраивание — node tools/embed-fish.mjs (идемпотентно: заменяет блок между маркерами FISH-SVG:BEGIN/END в index.html сразу после <body>; самопроверка + check.mjs внутри); приёмка — tools/fish-preview.html (file://).
  • Дневник и карточка через <use>: пойманные виды в дневнике — <svg viewBox="0 0 160 100"><use href="#fish-<id>"> в .jrow .je (не пойманные — по-прежнему ❓-эмодзи); карточка — <use id="cardFishUse"> внутри #cardEmoji (span сохранён: на нём bob-анимация и variant-классы), showCard ставит href='#fish-'+sp.id (вместо textContent=sp.emoji). CSS: .jrow .je svg 44×28 (моб. 40×26), #card .bigemoji svg 120×75 (моб. 94×59, низкий экран 81×50.6 — соотношение 160:100).
  • Перекраска вариантов — CSS-переменные: 5 новых правил #card.<variant>-card .bigemoji{--f1:…;--f3:…} (copper/gold/quartz/emerald/diamond — цвет тела/плавников; переменные наследуются сквозь <use> в спрайт). silver/rainbow закрываются существующими filter-правилами (grayscale/rainbowhue), boss — золотым filter — всё без изменений.
  • Не тронуто: тосты улова/обрыва (sp.emoji + вес) и иконка квеста catch_species — эмодзи там уместны (перекрыто второй частью этапа — см. ниже); сам спрайт, SPECIES и логика улова.

Тесты: пины в tests/test.js (тот же коммит): lc1 (10f) — sprites===32 (число <symbol> в #fishSprites) + spriteIdsOk (для каждого вида из SPECIES есть id fish-<id>), текст «SVG-спрайт: 32 symbol, id на каждый вид»; M6 карточка — в пробе showCard (sp = SPECIES[0]) href #cardFishUse = #fish-carass для всех 8 вариантов. Итого 193/193 PASS, exit 0 (пины расширены на месте, новых ok() нет); check.mjs OK; emoji-font.py --check OK (64 emoji, новых глифов нет).

Вторая часть этапа — полное удаление эмодзи рыб из игры:

  • SPECIES/тосты: поле emoji удалено из всех 32 записей SPECIES; два тоста (обрыв snap/snap_big и срыв escaped) — без sp.emoji, остаётся «… Имя вида W кг».
  • Иконка квеста catch_species: questIcon теперь возвращает mini-SVG портрет <svg class="qfish" viewBox="0 0 160 100"><use href="#fish-<id>"> (вид не найден — ❓). CSS .jrow .je .qfish{width:24px;height:15px;display:inline-block;vertical-align:-2px} — скоуп .jrow .je обязателен: иначе правило портрета .jrow .je svg (44×28, выше по специфичности) не даст иконке уместиться в строку квеста.
  • Остальные рыбные эмодзи (12 видов) заменены глифами, уже бывшими в субсете: воблер icon:'🐟'→'🎣'; ачивки: first_catch 🐟→🏅, weight_20 🐋→💪 (лестница весов ⚖/💪/🚢 у 10/20/1000 кг), пер-босс 🦞/🦈/🐳 → 🗺️/⚓/🌊 (иконки локаций — те же, что у lake_first/sea_first/ocean_first). Рыбка-маркер на канвасе (в drawMeters: маркер на конце полоски дистанции + иконка-легенда под ней) теперь рисуется path-рыбкой — новый drawFishMark(x,y,s) (эллипс-тело + треугольный хвост + глазок) вместо fillText('🐟'); комментарии с 🐟 очищены (генератор шрифта сканирует весь файл, включая комментарии).
  • Шрифт перегенерирован: tools/emoji-font.py — субсет 64 → 52 глифа (ровно 12 рыбных; замены не добавили новых — все уже были в инвентаре), woff2 89 → 65 КБ.
  • Тесты: tests/test.js (тот же коммит) — в пине lc1 убран s.emoji && из fieldsOk (+ комментарий «поле emoji убрано в этапе 42»); других пинов на .emoji/эмодзи в тостах/квестах не нашлось (grep). Итого 193/193 PASS, exit 0 (Y7 document.fonts.check('16px "EmojiEmbedded"','🪙') — монета в субсете на месте); check.mjs OK; emoji-font.py --check OK (52 emoji); финальный grep по 12 рыбным эмодзи в index.html — 0 вхождений.

Этап 43. Визуальное различение локаций (лёгкий вариант: вода + волны + рыбы)

Задача: «локации визуально почти не отличаются — разве что Озёра другим цветом воды». Диагноз: сцену составляют небо (общее), градиент воды (единственная деталь по локации), общая волна и общие фоновые рыбы; при этом дневная вода Реки #2f9bd8 и Моря #2a8fd0 почти совпадала. По решению — «лёгкий» вариант без нового ландшафта (силуэты берега/корабли — в запасе, не сделаны).

Решения:

  • Цвета воды (LOCATIONS.*.water, секция ДАННЫЕ): Река и Озёра не тронуты (базовая «родная вода» + уже различимый teal); Море — светлая бирюза (#2a8fd0/#1663a8/#07294f → #41b1e8/#2287c9/#0b3d6e, ночь #0a1f42/#071634/#030c20 → #112e5c/#0a2348/#04102a); Океан — глубокий тёмно-синий (день #1e6fb5/#0e4a86 → #1a5fa8/#0c3d74, ночь не тронут — и так самый тёмный). Формат 6 hex сохранён (пин lc1 test.js:1984 проверяет ФОРМАТ, значения не пины).
  • Волны по локации (LOCATIONS.*.waves — {amp,amp2,freq,freq2,speed,speed2}; drawWater()): константы двух линий поверхности (sin(x*0.05+time*2)*2.5 и sin(x*0.04-time*1.4+1.7)*3) заменены параметрами текущей локации (read тем же clamp-паттерном, что water). Характер: Река — мелкие гребни 2.2/2.5 px, быстро бегут в сторону (течение, speed 3.2); Озёра — длинная медленная гладь 1.2/1.6 px (speed 0.8); Море — умеренная волна 3.5/4.5 px; Океан — крупные зыби 6/8 px (медленные). Белые барашки/бурление не добавлялись (YAGNI).
  • Фоновые рыбы по локации (новый AMBIENT_FISH[4] = {n,sMin,sMax,hMin,hMax} перед initFish()): Река 9 шт 8–22 px hue 130–220 (как было), Озёра 9 шт 10–26 hue 120–200 (зеленоватые), Море 8 шт 12–32 hue 160–230 (синеватые), Океан 6 шт 18–44 hue 180–250 (редкие крупные тёмно-синие). initFish() читает save.location (clamp по AMBIENT_FISH.length).
  • Провозировка: initFish() добавлен в ОБЕ ветки смены локации в shopClick() (buylocation после авто-перехода, selectlocation) — раньше фоновые рыбы живились один раз на старте (3700) и не знали о смене воды; старт не тронут. resize() намеренно без initFish() (как раньше: рыбы пересобираются не по resize).
  • Скриншоты (tests/gen-screenshots.js): +кадры sea/ocean (save.location=2/3, день, initFish() — как в реальных ветках shopClick) — 10 → 12 кадров; кадр lake и сброс после него тоже зовут initFish(). README: строка «Локации» — «своя вода (цвет, характер волн, рыбы в толще)» + три моб. изображения (lake/sea/ocean), «12 кадров × 3 вьюпорта». Снимки перегенерированы: screenshots-wiki/ и screenshots-wiki-en/ по 36 PNG.

Не тронуто: небо (SKY_PHASES), облака, звёзды, лодка/поплавок, дорожка света от светила, погода — общие для всех вод (по выбранному варианту); роллы/баланс/физика — waves и AMBIENT_FISH чисто данные/визуал.

Тесты: новые пины не нужны (визуал не ассертится; формат water lc1 — без изменений). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — пины без изменений (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7; LOCATIONS в вырезке скрипта, но это данные — цифры не сдвинулись). Визуальная приёмка по скриншотам desk-вьюпорта: Река (приглушённый циан, мелкие рыбы) / Озёра (teal, гладь) / Море (светлая бирюза, умеренная волна, средние рыбы) / Океан (глубокий тёмно-синий, крупные зыби, гигантские рыбы) — четыре разные воды.

Этап 44. Береговые силуэты на горизонте (полный вариант различения локаций)

Задача: «давай сделаем полный вариант» — после лёгкого Этапа 43 (вода/волны/рыбы) добавить то, что делает место узнаваемым: горизонт. Первая ревизия внутри этапа: «на реке береговая полоса на всю ширину, на которой растут деревья, и убрать волны».

Решения:

  • scenery + initScenery() (секция декораций, после AMBIENT_FISH/initFish()): массив силуэтов на горизонте по локации (индекс = save.location, clamp). Река — береговая полоса на всю ширину {k:'bank',h} (трава 12–17 px + тёмная влажная кромка у воды) + хвойный лес в 2 ряда, посаженный НА КРОМКУ ({k:'pine',layer,by}, by=waterY−bankH — база ствола; by хранится в объекте при генерации, waterY к этому моменту известен: resize() считает его до initScenery(), смена локации — в игре с готовым waterY). Озёра — холмы {k:'hill',rx,ry} (2–3, дуга эллипса над водой) + лиственные деревья {k:'tree',h} (ствол + крона из 3 кругов). Море — парусник {k:'ship',x,v,s,dir} (корпус/мачта/два светлых паруса) + 3 чайки {k:'gull',y,v,ph} (дуги крыла, взмах sin(time*7+ph)). Океан — пусто: открытый горизонт без берега (вода и небо — его отличительный знак).
  • drawScenery() — между drawSky() и drawWater() в draw(): базу силуэтов (низ холмов, киль, нижние 3 px травы) перекрывает градиент воды — «садятся» ровно на линию воды. Ночь — dusk(c)=hexLerp(c,'#0b1220',nm*0.8) гасит силуэты к тёмно-синему; паруса темнеют (0.9−0.45·nm), чайки гаснут почти до нуля (alpha <0.12 — пропуск). Порядок в массиве = порядок рисования: у Реки bank ПЕРВЫЙ (лес на нём), у Озёр холмы перед деревьями.
  • Дрейф — updateDecor(dt): парусник x += dir·v (2.5–5 px/s, обход за экраном по курсу), чайки x += v (14–26 px/s, вправо, обход).
  • Провозировка: initScenery() — resize() (рядом с initClouds/initSparkles/initRain), ОБЕ ветки смены локации в shopClick() (рядом с initFish()), старт — через resize() (loadSave → resize). Кадры lake/sea/ocean в gen-screenshots.js тоже зовут initScenery() — без них кадр Моря показал бы лес с Реки (сцена живёт в памяти, а не пересобирается по save.location).
  • Ревизия — Река без волн: LOCATIONS[0].waves → все нули, drawWater() рисует линию только при amp>0/amp2>0 (обе линии). Река — спокойная вода: ровный край градиента + берег с лесом. Остальные воды — как в Этапе 43.

Не тронуто: небо/облака/звёзды/светила — общие; лодка/поплавок/дорожка света; роллы/баланс/физика; resize() по-прежнему без initFish() (рыбы не пересобираются по resize — поведение Этапа 43 сохранено).

Тесты: новые пины не нужны (визуал не ассертится; canvas-пиксели тестами не проверяются). Итого 193/193 PASS, exit 0; check.mjs OK; tools/balance.mjs — пины без изменений. Визуальная приёмка (headless 1280×720, 4 локации × день/ночь, + кроп-контроль горизонта): Река — сплошная травяная полоса на всю ширину, лес с видимыми стволами на её кромке, волновых линий нет (день и ночь: ночью полоса/лес — тёмные силуэты, фонарь лодки светится); Озёра — холмы за деревьями, гладь; Море — парусник (паруса читаются днём и ночью) + чайки; Океан — открытый горизонт. «Светлые точки в небе» в дневном кадре — артефакт даунскейла превью (пиксельная выборка неба: чистый градиент).