Files
fishing-game/docs/DEVLOG.md
T

69 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 — в бэклоге (отдельная задача).