# Процесс разработки Как игру «Рыбалка» спроектировали, собрали, проверили и опубликовали. Документ описывает этапы, инструменты и повторяемые команды — чтобы любой (включая вас через полгода) мог воспроизвести и продолжить процесс. ``` ТЗ → макет-прототип → среда проверки → автотесты → визуал → баги → публ. → поддержка 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): ```bash # вытащить 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` Запуск: ```bash 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`. ```bash # 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()`. **Цикл изменений:** ```bash # 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=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, вынутый из распакованного файла (тот же паттерн извлечения `