Files
andrey 3061247ce5 Паттерны и кампания: 8 новых фигур, 3 showcase-уровня, факты в README
- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30)
- CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103)
- README: раздел «Интересные факты» и список ресурсов
- tests: счётчики и проверки новых фигур/уровней синхронизированы
2026-10-13 15:06:52 +03:00

378 lines
106 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
Игра — один самодостаточный файл `index.html` (HTML+CSS+один `<script>`): без сборки, бандлера, фреймворка, TypeScript, зависимостей и бэкенда. Весь код в одном `<script>`, секции помечены рамками-комментариями. UI-тексты, комментарии и доки — на русском; при правках сохраняйте этот стиль. Этот файл — роль «конфига проекта» (линтеров/CI нет): здесь правила разработки, тестирования и список стабильных глобалов, на которые завязаны автотесты.
## О проекте
Web-ремейк старого прототипа `old/life` (C++/Qt4): **Conway's Game of Life с территориальной борьбой двух игроков**. Каждый игрок своим цветом расставляет живые клетки, затем поле живёт по правилам Конвея — клетки выживают/рождаются и наследуют цвет большинства живых соседей. Цель — оставить на доске больше своих живых клеток, чем у соперника.
Формат партии — **пошаговый hot-seat за одним экраном**: игроки ходят по очереди на одном устройстве, передавая его друг другу. Настраиваемый размер поля (**40/60/80/100/120/150/200/250/300**, плюс прямоугольные **60×100/100×60**), дефолт — **60×60**. Клетки всегда квадратные, поле может быть прямоугольным. Каждый игрок может ставить клетки только в своей **зоне рисования** (P1 — левая половина, P2 — правая) и ограничен **бюджетом клеток** (дефолт 100). Опция **«Замкнутая карта» (torus)** включает заворот границ; по умолчанию границы **жёсткие**. Партия заканчивается при **вымирании** (когда у игрока не осталось ни одной клетки его цвета — бюджет его уже не спасает), **ручным завершением** (кнопка «Завершить партию», с подтверждением) либо **тупиком по бюджету** (поле стабильно, а отстающему нечем ходить — см. «Геймплей и FSM»); победитель определяется по числу живых клеток своего цвета.
`old/` — референс старого C++/Qt4 прототипа, **не часть продукта**; его не трогать и не собирать (он даже в `.gitignore`).
## Архитектура и стек
- **Один файл `index.html`**: HTML + CSS + **один** `<script>`. Никаких отдельных `.js`/`.css`/ассетов — всё инлайн.
- **Vanilla JS (ES6+)**, без сборки/бандлера/транспиляции/TS/фреймворков; работает напрямую с **`file://`** (без http-сервера) и в любом современном браузере.
- **Canvas 2D** для отрисовки доски + **DOM-оверлеи/HUD** для меню, диалогов и тулбара.
- **Адаптивная раскладка управления:** в ландшафте доска занимает всю высоту и прижата влево, а управление (HUD/тулбар/действия) собирается в боковую панель справа (`body.side`, ширина `--side-w`); в портрете/узком ландшафте — прежняя центрированная раскладка (HUD сверху, панели снизу). См. «Рендер, зоны и HUD».
- **Производительный рендер** больших полей (до 300×300): клетки — через offscreen-канву `fieldW×fieldH` + `ImageData`, масштабируемую одним `drawImage`; пересборка только по dirty-флагу (см. «Рендер, зоны и HUD»).
- **Внешних ассетов нет** (без картинок/шрифтов/сети); всё рисуется кодом/эмодзи.
- **Персистентность** — `localStorage`, единственный ключ **`life-save-v1`** (настройки + резюм текущей партии, включая `reason` финала — `'extinct'`/`'manual'`/`'stalemate'`). Смена формата — только с миграцией в `loadSave()`.
- **Секции внутри `<script>`** помечены рамками-комментариями (`// ==== ДАННЫЕ ====`, `// ==== МОДЕЛЬ ПОЛЯ ====`, `// ==== FSM ====`, `// ==== ОТРИСОВКА ====`, `// ==== СОБЫТИЯ ====`, `// ==== ЦИКЛ ====` и т.п.) — навигация по одному длинному скрипту.
- **Никакого `Math.random` в игровой логике** — симуляция детерминирована (см. «Подводные камни»).
## Структура проекта
```
life/
├── index.html # вся игра: HTML + CSS + <script>
├── tests/
│ ├── package.json # зависимость puppeteer-core
│ ├── package-lock.json # лок-файл (коммитим для воспроизводимости)
│ ├── parity-fixture.json # компактная фикстура паритета (clean/proto) для суиты
│ └── test.js # полная headless-суита (Conway/FSM/скрытие/резюм/паритет/…)
├── tools/
│ ├── check.mjs # проверка синтаксиса JS из index.html
│ ├── gen-campaign-levels.mjs # генератор 96 уровней кампании (seeded PRNG, без Math.random)
│ └── old-ref/ # harness паритета с прототипом (C++ + генераторы; см. README там)
├── AGENTS.md # этот файл: правила разработки/тестирования + глобалы
├── README.md # краткая документация для игрока
├── .gitignore # old/, node_modules/, .slim/, dist/ и пр.
├── .opencode/ # агентский конфиг (в .gitignore, не часть продукта)
└── .slim/deepwork/ # локальное состояние процесса (git-ignored), в т.ч. план
```
- `old/` — референс прототипа, **не часть продукта**, не собирается и не трогается (в `.gitignore`).
- `tools/old-ref/` — независимый harness паритета: `old_ref.cpp` (порт `Life::nextStep()` + «чистое» правило + torus-эталон), `reference.json`/`reference-clean.json`/`reference-torus.json`, генераторы `gen-clean.js`/`gen-torus.js`/`gen-fixture.js`, дифтест `diff-our.js` и свой `README.md`. Бинарник `old_ref` не коммитится (собирается `g++`, см. `.gitignore`).
- `.opencode/` — агентский конфиг (в `.gitignore`, не часть продукта).
- `.slim/deepwork/` — локальное состояние процесса (git-ignored), в т.ч. план; ре-включено для поиска через `.ignore` (`!.slim/deepwork/`).
- `.slim/deepwork/life-web-remake.md` — зафиксированные решения и план фаз; читать перед началом работы.
## Правила игры
- **Автомат** — классический Conway **B3/S23**: живая клетка выживает при **2–3** живых соседях, мёртвая рождается **ровно при 3**.
- **Окрестность — Мура (8 соседей)**.
- **Границы**: по умолчанию **жёсткие** (клетки за краем поля не существуют и не считаются соседями — как в прототипе `old`); опция **«Замкнутая карта» (`wrap`/torus)** включает заворот, и соседи берутся с противоположной стороны. Дефолт `wrap=false`.
- **Цвет (владение) клетки**: цвет новорождённой = **цвет большинства из 3 живых соседей**, породивших её. При двух игроках расклад 2-1 всегда однозначен.
- **Tie-break для ≥3 игроков**: если большинство неоднозначно — выигрывает **наименьший id** цвета (при двух игроках не наблюдаем; пересмотреть при расширении).
- **Не воспроизводить баг старого прототипа**: в `old/life/life.cpp:19-50` массив `parent[]` не обнулялся на каждую клетку, из-за чего цвета «протекали» между итерациями. Мы реализуем чистое правило «большинство из 3 живых соседей»; поведение старого кода в этом месте — баг, а не спецификация.
## Геймплей и FSM
Поток партии (hot-seat):
1. **P1** расставляет свои клетки (`PLACE_P1`).
2. Передача устройства сопернику со скрытым его вводом (`HANDOFF_P1_P2`).
3. **P2** расставляет вслепую, не видя расстановку P1 (`PLACE_P2`).
4. Симуляция (`RUNNING`) → **авто-пауза при стабилизации поля** (см. ниже) → **каждый игрок по отдельности** дорисовывает свои клетки (`EDIT_P1`/`EDIT_P2`, в любом порядке, оба видят поле) → «Старт симуляции» → … → вымирание (когда цвет исчез — у игрока 0 клеток), тупик по бюджету (поле стабильно, отстающему нечем ходить) или ручное завершение (`GAME_OVER`).
Полный список состояний (`state`) и переходы:
| Состояние | Смысл | Переходы |
|---|---|---|
| `MENU` | стартовый сплэш: краткое описание игры, тема оформления, возврат к отложенной партии; вход в «Кампанию» или на экран «Игра на двоих» | `showTwoSetup()`/`#btnPlay` → `TWO_SETUP`; `resumeFromMenu()` → отложенная партия; `showLevelSelect()`/`#btnCampaign` → `LEVEL_SELECT` |
| `TWO_SETUP` | экран «Игра на двоих» (**не** резюмируется; `modeForState`→`menu`): своя справка + настройки поля (размер/torus/бюджет) | `#btnStartTwo`/`requestNewGame()` → `PLACE_P1` (подтверждение `'new'` при `suspended`); `toMenu()`/`#btnTwoBack` → `MENU` |
| `LEVEL_SELECT` | экран выбора уровня кампании (не резюмируется; `modeForState`→`menu`) | `requestCampaignLevel(i)` (подтверждение `'campaign'`) → `PLACE_P1` (кампания); `toMenu()`/`#btnLevelsBack` → `MENU` |
| `PLACE_P1` | P1 расставляет свои клетки (в кампании — игрок, `commitTurn()` сразу → `RUNNING`) | `commitTurn()` → `HANDOFF_P1_P2`; в кампании → `RUNNING` |
| `HANDOFF_P1_P2` | передача устройства P1→P2 (или назад к P1) | `handoffNext()` → `PLACE_P2`; `handoffBack()` → `PLACE_P1` |
| `PLACE_P2` | P2 расставляет вслепую | `commitTurn()` → `HANDOFF_P2_START` |
| `HANDOFF_P2_START` | расстановка P2 завершена: «Начать симуляцию» или вернуться к P1 (состояние реально существует в коде и раньше не попадало в таблицу) | `handoffNext()` → `RUNNING`; `handoffBack()` → `PLACE_P1` |
| `RUNNING` | симуляция идёт | `pauseGame()` → `PAUSED`; `checkEnd()` после каждого шага (вымирание) → `GAME_OVER`; авто-`PAUSED`+`fieldStable` при стабилизации поля (tick) и `checkStalemate()` (тупик по бюджету) → `GAME_OVER`/`stalemate`; `requestEndGame()` (подтверждение) → `GAME_OVER`/`manual`; `toMenu()` → `MENU` (партия откладывается) |
| `PAUSED` | пауза симуляции (без модального диалога); дорисовка доступна **только** при `fieldStable` | `resumeGame()` → `RUNNING`; `startEdit(1)`/`startEdit(2)` → `EDIT_P1`/`EDIT_P2` (только если `fieldStable`); `requestEndGame()` → `GAME_OVER`; `toMenu()` → `MENU` |
| `EDIT_P1` | P1 дорисовывает на паузе (видит всё) | `commitTurn()` → `PAUSED` |
| `EDIT_P2` | P2 дорисовывает (видит всё) | `commitTurn()` → `PAUSED` |
| `GAME_OVER` | итог партии, победитель | `newGame()` → `PLACE_P1`; `toMenu()` → `MENU` |
**Стабильность поля — гейт дорисовки:** дорисовка (`EDIT_*`) разрешается **только когда поле перестало меняться**. Во время `RUNNING` `tick()` после каждого шага считает `hashField()` и ищет его среди последних `STABLE_WINDOW` (1024) поколений (`recentHashes`): совпадение = поле вошло в **фиксированную точку** (период 1) или **короткий цикл** (например, блинкер, период 2). Тогда симуляция **сама** встаёт в `PAUSED` и выставляет `fieldStable = true` — кнопки дорисовки `#btnEditP1`/`#btnEditP2` становятся видны. **В кампании** (игрок один) после авто-паузы сразу вызывается `startEdit(1)` — фаза `EDIT_P1` открывается автоматически, без клика «Дорисовать» (если авто-пауза завершила партию поражением, состояние уже `GAME_OVER` и дорисовка не зовётся). Если поле не застывает (глайдеры/рост/хаос — хеш не повторяется в окне), `fieldStable` остаётся `false`: ручная пауза дорисовку **не** открывает, партию завершают вручную (либо она сама завершается вымиранием одной из сторон, см. ниже). `fieldStable` сбрасывается при любом изменении поля/запуске симуляции (`startRunning` засевает окно текущим хешем; `resumeGame`/`newGame`/`setFieldDim`/`setFieldASCII`/`setWrap`/`forceState`), а при выходе из `EDIT_*` (`startPaused`) сохраняется — второй игрок может дорисовать. Коллизия 32-битного хеша даёт ложное срабатывание с вероятностью ~2⁻³² (пренебрежимо).
**Причина финала (`reason`):** `'extinct'` — вымирание (вход в `GAME_OVER` из `checkEnd()`), `'manual'` — ручное завершение (`requestEndGame()` → подтверждение → `finish('manual')`), `'stalemate'` — тупик: симуляция сама встала на паузу (поле стабильно), а отстающему игроку нечем ходить (см. ниже), `'win'`/`'defeat'` — исход уровня **кампании** (см. «Кампания»). `reason` выставляется при входе в `GAME_OVER`, входит в форму сейва и сохраняется через `save`/`persist()`.
**Кампания.** Отдельный режим (кнопка «Кампания» в меню → состояние `LEVEL_SELECT`) с заранее расставленными стабильными фигурами **врага** (цвет 2, `CAMPAIGN_ENEMY`); игрок играет цветом 1 (`CAMPAIGN_PLAYER`), одна сторона. Враг статичен — поле живёт обычной детерминированной симуляцией. Уровень описывает `CAMPAIGN_LEVELS` (`{id,name,w,h,wrap,budget,zone:{x,y,w,h},cells:[[x,y],…]}`): `cells` — компактные координаты клеток врага (не ASCII-сетка), `buildCampaignField` строит поле прямо из них с проверкой границ. Набор — **103 уровня**: **7 заданы вручную** («Разминка», «Каравай», «Рой» — много фигур, «Издалека» — враг вне зоны игрока, включая ружьё Госпера, и showcase «Флотилия»/«Погоня»/«Кролики» с новыми фигурами), остальные **96 сгенерированы** детерминированным скриптом `tools/gen-campaign-levels.mjs` (seeded PRNG, без `Math.random`) и **вшиты в файл статически** (в рантайме ничего не генерируется; id 5..100). Размер поля у сгенерированных растёт от 60 до 300; зоны разнообразны, а часть уровней — с врагом вне зоны. У **сгенерированных** уровней зоны «всё поле» НЕТ: используются 7 типов (половина / центр / левый-верхний угол / правый-нижний угол / вертикальная полоса / горизонтальная полоса / произвольный прямоугольник), а полная зона оставлена только ручным «Разминке» и «Рою». Порядок массива = порядок в игре; `id` — метаданные (уникальны, 1..103). Ручной «Издалека» (id4) **вставлен на 10-е место** (индекс 9), сгенерированные id5..id10 сдвинуты вверх (индексы 3..8), а сразу за «Издалека» — ручные showcase id 101..103 (индексы 10..12). Размеры монотонны только у **сгенерированных** уровней (id 5..100, от 60 до 300); у ручных вставок размер свой. Решаемость уровней намеренно **не гарантируется**: среди них есть заведомо почти непроходимые (например с ружьём Госпера) — это задумка, а не дефект. Глобалы режима: `campaign` (bool), `levelIndex` (int≥0 | -1), `zoneRect` (прямоугольник зоны игрока | `null`), `campaignProgress = {unlocked}` (прогресс), `pendingLevel` (уровень в диалоге подтверждения). Особенности:
- **Зона рисования** — прямоугольник `zoneRect` (может покрывать всё поле), а не половина; предикат `inZoneAt(x,y,player)` в кампании смотрит `zoneRect`, иначе делегирует в `inZone(x,player)`. Игровые места (`paintTool`, `applyIntent`, ввод, ghost-превью, `clearZone`) ходят через `inZoneAt`; сами `inZone`/`zoneSplit` не меняются (контракт тестов).
- **Врага нельзя трогать:** в `paintTool` клетки `field[i] === CAMPAIGN_ENEMY` пропускаются во всех ветках (краска/штамп/ластик) без траты и без возврата бюджета. `applyBrush` не знает о кампании (геометрический примитив).
- **Скрытия нет:** `visibleMask()` при `campaign` возвращает `[1,2]` (враг виден всегда).
- **Своё поле/бюджет:** `startLevel(idx)` (`campaign=true`, `levelIndex=idx`, `zoneRect=campaignZoneFor(idx)`, `wrap` уровня, `buildCampaignField`, `budget.total=lv.budget`, `used=0`, `speedMult=1`, транзиентное сброшено, `beginDraw('PLACE_P1',1)`). `clearZone`/`enterPlace` не зовутся — иначе враг сотрётся из снимка начала хода.
- **Победа** — 0 клеток цвета 2: `checkEnd()` в кампании при `countByPlayer(field).p2===0` → `finish('win')` (приоритет над поражением); иначе вымирание **не** финал (0 клеток игрока — не проигрыш, пока есть бюджет).
- **Поражение** — бюджет исчерпан, а враг жив: `checkStalemate()` при `campaign && state==='PAUSED' && fieldStable && remaining(1)<=0 && p2>0` → `finish('defeat')`. Фиксируется только в ветке авто-паузы `tick()` (как `stalemate`), без собственных счётчиков шагов.
- **Победитель** в кампании — по `reason` (`computeWinner`: win→`'1'`, defeat→`'2'`). При `finish('win')` `campaignProgress.unlocked = max(unlocked, levelIndex+2)` (с клампом по длине).
- **Переходы:** `commitTurn()` в кампании: и `PLACE_P1`, и `EDIT_P1` → `startRunning()` («Готов» всегда сразу запускает симуляцию, отдельной кнопки «Старт» в цикле дорисовки нет); `startEdit(2)` — no-op; `newGame()`/`showLevelSelect()` сбрасывают `campaign`/`levelIndex`/`zoneRect`; `requestNewGame()` при `campaign && levelIndex>=0` → `startLevel(levelIndex)` («Заново» на экране финала, без подтверждения); `requestRestartLevel()` («Заново» в панели действий, доступна в любой фазе уровня) открывает подтверждение (`confirmAction='restart'`, на время диалога симуляция ставится на паузу, «Отмена» её возвращает); `confirmProceed()` ветки `'restart'` → `startLevel(levelIndex)`, `'campaign'` → `suspended=null; startLevel(pendingLevel)`; `cancelConfirm()` → `pendingLevel=null`.
- **Настройки пользователя vs активная игра (D1):** `startLevel()` применяет размер/wrap/бюджет УРОВНЯ к «активной игре» (`fieldW/fieldH/wrap/budget.total`), НЕ трогая `menuFieldW/menuFieldH/menuWrap/menuBudget`. Выход из кампании (`toMenu`/`showLevelSelect`) зовёт `applyMenuSettings()` — возвращает активные значения к пользовательским. Поэтому обычная партия после кампании стартует с прежних настроек (60×60 / 100 по умолчанию), а `#optSize`/`#optWrap`/`#optBudget` в `syncDom` всегда отражают `menu*`. `setFieldDim`/`setFieldSize` в кампании — **no-op** (иначе потерялся бы враг, D4); `setWrap`/`setBudgetTotal` обновляют и `menu*`.
- **Ручное завершение (D3):** `endGame()`/`finish('manual')` в кампании даёт `reason='manual'` и текст «Уровень прерван» (не «Поражение»); `computeWinner` для manual → `'draw'`. Кнопки `#btnEnd`/`#btnEndPause` в кампании скрыты.
- **`applyIntent` (D5):** возвращает `paintTool(...) > 0` — клик по клетке врага или по своей клетке (нет изменений) даёт `false`.
- **UI:** `#btnCampaign` (меню), `#levels` + `#levelList` (103 кнопки уровней, замок 🔒 для `i>=unlocked`; список — `max-height`~460px с `overflow-y:auto`, прокручивается, заголовок и «В меню» остаются видимы), `#aboutCampaign` (сворачиваемая справка «Как играть» на экране уровней), `#btnLevelsBack`, `#btnNextLevel` (только победа + есть следующий), `#btnGameoverLevels`; `#turnLabel` = «Уровень N/T» (+ «· поле стабильно»), `#scores` скрыт, `#budgetLabel` виден и в `RUNNING`/`PAUSED` (бюджет игрока 1), `#btnEditP2`/`#btnEnd`/`#btnEndPause` скрыты; кнопка **`#btnRestart`** («Заново») видна в любой фазе уровня (пока `campaign && levelIndex>=0`) и перезапускает уровень **через подтверждение** (`requestRestartLevel` → `confirmAction='restart'`, тогда как `#btnRevenge` на экране финала перезапускает сразу); диалог `#confirm` обслуживает `confirmAction` `'campaign'`/`'restart'`/`'new'`/`'end'`. `syncLevelList()` обновляет подписи/доступность только при `state==='LEVEL_SELECT'` (иначе 103 кнопки синхронятся вхолостую из `syncDom`).
- **Сейв:** аддитивные поля `campaign`, `levelIndex`, `progress:{unlocked}` (в `suspended` — тоже `campaign`/`levelIndex`), `SAVE_VERSION` остаётся 2; вайтлист `reason` расширен `win`/`defeat`. `LEVEL_SELECT` не резюмируется: при reload → `MENU`.
**Вымирание — после каждого такта:** `checkEnd()` вызывается из `tick()` после каждого шага симуляции и объявляет `extinct`, как только `isExtinct(field)` — т.е. у одного из игроков **0 клеток его цвета** (у обоих 0 → ничья в `computeWinner`). Бюджет финал **не** отодвигает: пока у игрока есть хоть одна клетка, он жив; как только цвет исчез — партия окончена, дорисовать (`EDIT_*`) его уже нельзя. Устойчивость поля (`fieldStable`) на финал **не** влияет — вымирание может завершить партию и на «живом» поле. Если оба цвета живы, партия сама не завершится вымиранием — её заканчивают вручную либо она завершается **тупиком по бюджету** (см. ниже).
**Тупик по бюджету — авто-финал на стабильной паузе:** сразу после того, как симуляция **сама** встала на паузу (обнаружена устойчивость), `tick()` вызывает `checkStalemate()`. Если поле стабильно и **отстающий** игрок (у кого меньше живых клеток) исчерпал бюджет (`remaining <= 0`), он не может ни поставить, ни стереть ничего (ластик снимает только правку текущего хода, а на паузе правок нет) — исход зафиксирован, партия завершается `finish('stalemate')`. При **равенстве** клеток тупик фиксируется только если бюджет исчерпан у **обоих** (иначе один из них ещё может разорвать ничью) → `winner='draw'`. Если у отстающего есть бюджет — игра продолжается (он может дорисовать). Проверка живёт **только** в ветке авто-паузы `tick()` (ручная пауза/`forceState('PAUSED')` её не запускают); расход остатка уже в фазе `EDIT_*` финал не триггерит — для срабатывания нужно снова запустить симуляцию, поле стабильно, пауза вернётся за пару тиков.
**Меню не теряет партию:** `toMenu()` (кнопки «В меню»/⚙️) вызывает `suspendGame()` — снимок текущей резюмируемой партии (размер/wrap/поле/бюджет) кладётся в глобал `suspended`, после чего состояние становится `MENU`. В меню при наличии `suspended` видна кнопка «Продолжить партию» (`resumeFromMenu()` восстанавливает партию целиком). «Игра на двоих»/«Реванш» при наличии `suspended` сначала открывают диалог подтверждения (`confirmAction='new'`; `requestNewGame()`), чтобы случайно не потерять партию; `newGame()` очищает `suspended`.
**Никакое действие не теряет партию одним кликом:** диалог подтверждения `#confirm` (`confirmAction` = `'new'` | `'end'` | `'campaign'` | `'restart'`) показывается при новой партии поверх незавершённой, при «Завершить партию» (`requestEndGame()`; `confirmAction='end'`), при выборе уровня кампании (`'campaign'`) и при перезапуске уровня «Заново» (`requestRestartLevel()`; `'restart'`). На время диалога завершения/перезапуска симуляция ставится на паузу (`pauseGame()`), а «Отмена» возвращает `RUNNING` (`cancelConfirm()`/`confirmResume`). Прямой `endGame()` (без диалога) оставлен только для тестов/`checkEnd`-пути.
**Скрытие (важнейшее правило):**
- чужое скрыто **ТОЛЬКО** в `PLACE_*` (первичная расстановка вслепую);
- в `EDIT_*` **показываем всё** (дорисовка идёт открыто, оба видят поле);
- в `HANDOFF_P1_P2` (первичная передача) маска пуста + оверлей «Передайте устройство», счёт **скрыт**; в дорисовке (`EDIT_*`) передачи нет — оба видят поле.
**Скрытие — ТОЛЬКО визуальное**: `field` всегда хранит целое поле целиком; `step` всегда считается по полному полю; **рендер не мутирует `field`**. Маска/видимость — это лишь параметр отрисовки, модель данных от неё не зависит.
## Инструменты и настройки
- **Инструменты:** карандаш **1×1**, кисть **3×3**, ластик **1×1**, ластик **4×4** (таблица `TOOLS`).
- **Правая кнопка мыши = ластик:** ПКМ (PointerEvent `button === 2`) на время удержания подменяет активный инструмент ластиком через глобал `paintOverride` (в `paintTool` и ghost-превью вместо `tool` берётся `paintOverride || tool`); `tool`/тулбар/сейв при этом не меняются. Если активен ластик, ПКМ сохраняет его размер (1×1/4×4), иначе берётся базовый 1×1 (`toolForButton`). Сбрасывается в `null` на `pointerup`/`pointercancel`. Контекстное меню подавлено глобальным `contextmenu → preventDefault()`.
- **Готовые паттерны (штампы):** таблица `PATTERNS` (`{ id, name, group, cells }`, `cells` — ASCII-строки, `'1'` = живая, `'.'` = пусто; `group` — категория для группировки дропдауна): **Стойкие** — блок, улей, ванна, лодка, кораблик, каравай, пожиратель, пруд, авианосец, длинная лодка, каноэ; **Осцилляторы** — мигалка, жаба, маяк, часы, пульсар, пентадекатлон; **Корабли** — глайдер, корабль LWSS, корабль MWSS, корабль HWSS, Loafer; **Долгожители** (метуселахи) — R-пентамино, жёлудь, Diehard (полностью гибнет на 130-м поколении), громовая птица, кролики; **Ружья** — ружьё Госпера, ружьё Симкина (36-клеточный вариант; LifeWiki даёт 29-клеточный). НЕ входят в `TOOLS`: выбираются дропдауном `#optPattern` в тулбаре (опции строит `buildPatternOptions()` из `PATTERNS` по порядку `PATTERN_GROUPS` — `<optgroup>` на категорию, пустая опция «нет паттерна» — прямым ребёнком `select`), активный живёт в глобале `stamp` (**транзиентно**, как `speedMult` — в сейв не пишется, между сессиями не переживает). **Нативный popup `<select>` подавлен** (`mousedown` → `preventDefault`), вместо него показывается свой список **`#patternMenu`** (`buildPatternMenu()`, `.patitem`/`.patgroup`, позиция в `positionPatternMenu()` рядом с селектом, но **никогда не поверх него**): на длинном списке (30 фигур) Chromium ставит нативный popup **поверх курсора**, и обычный клик выбирает случайную фигуру — дропдаун «открывается на нажатии и закрывается на отпускании». Свой список открывается/закрывается по клику на `#optPattern` (тумблер), выбор фигуры пишет `value` в `#optPattern` и шлёт `change` (единый путь установки `stamp`), закрывается по выбору/Escape/клику вне/resize/scroll; вне фаз рисования `syncDom` его закрывает. Сам `<select>` остаётся контрактом (value/change/disabled/active/`optgroup`/`page.select`). Разбор ASCII → offsets делает `patternCells(p)` (лениво кэширует `p._cells`/`_w`/`_h`), поиск по id — `findPattern(id)`. Ставится **веткой `stamp` в `paintTool`**: якорь — **левый верхний угол bbox** в кликнутой клетке; зона/бюджет/перезапись чужого — как у краски (вне `inZone` не ставится, своя не тратит, при нехватке `min(new, remaining)` в порядке клеток), плюс кламп по доске. **Одно нажатие = одна постановка** (в `onPointerDown` штамп не включает `painting`, drag-повтор не заливает бюджет); ПКМ штамп не ставит, а стирает. Ghost-превью в `draw()`: клетки в своей зоне — цветом игрока, вылет за зону — `COLORS.stampOut` (клетки за краем доски не рисуются). `selectTool()` сбрасывает `stamp` («последнее действие побеждает»); пока штамп активен, ни одна кнопка инструмента не подсвечена, а `#optPattern` — подсвечен (`.active`). На дефолтном 60×60 зона = 30 столбцов, поэтому ружьё (36×9) обрезается — целиком оно влезает при поле ≥ 80. Порядок групп/записей в дропдауне задаёт `PATTERN_GROUPS` (внутренняя константа, не на `window`).
- **Поворот штампа (колесо мыши и кнопка «↻»):** глобал `stampRot` (**0..3**, транзиентный, как `stamp`) — поворот активного паттерна на 90° по часовой за шаг. Источники: (1) обработчик `wheel` на `canvas` (`{ passive:false }`, `preventDefault`) — действует **только в фазах рисования и при выбранном `stamp`**, иначе инертен; `deltaY` накапливается в `wheelAccum` до порога `WHEEL_ROT_STEP` (~100px; `deltaMode` нормализуется к пикселям) — один поворот на «щелчок» колеса, а тачпад с мелкими частыми дельтами поворачивает контролируемо; (2) кнопка `#btnRotate` в тулбаре (для сенсорных экранов без колеса) — `stampRot = (stampRot+1)%4` за click, видна по `syncDom` только в фазах рисования при `stamp`, подпись `#rotDeg` показывает угол. `stampCells()` отдаёт offsets с учётом `stampRot` (базовый разбор кэширует `patternCells`; повёрнутый — на объекте, `p._rot1..3`) через `rotate90(cells)` (клетка `(x,y)` в bbox `w×h` → `(h-1-y,x)`, якорь остаётся левым верхним углом). `paintTool` и ghost-превью используют `stampCells()`, поэтому поворот влияет и на постановку, и на превью. `stampRot`/`wheelAccum` сбрасываются при смене паттерна (`#optPattern`) и выборе инструмента (`selectTool`). В сейв не пишется.
- **Режим «постановка по отпусканию» (тап, сенсорные экраны):** тумблер `#btnTapPlace` в тулбаре + глобал `tapPlace` (**транзиентный**, как `stamp`/`speedMult` — в сейв не пишется, но `newGame` его **не** сбрасывает: это предпочтение устройства в рамках сессии; между сессиями не переживает). При `tapPlace=true` ввод работает иначе: `onPointerDown` только фиксирует удержание (`pressing=true`, `paintOverride` под ПКМ) и показывает ghost-превью, `onPointerMove` ведёт превью (линия не рисуется, `painting` не включается), а постановка/стирание происходит **в `onPointerUp`** одним `paintTool` (в клетке под пальцем; `pointercancel` отменяет — постановки нет). Так палец не закрывает точку постановки, а drag не заливает бюджет. Кнопка подсвечена `.active` при `tapPlace`. См. `tests/test.js` 36e–36j.
- **Размер поля:** 40 / 60 / 80 / 100 / 120 / 150 / 200 / 250 / 300 (`FIELD_SIZES`, квадраты) плюс прямоугольные `FIELD_RECTS = [[60,100],[100,60]]`; полный список для меню — `FIELD_DIMS` = квадраты + прямоугольники. Дефолт **60×60** (`fieldW`/`fieldH`). Клетки квадратные (`board.cell` — сторона), сама доска может быть прямоугольной (`board.w`×`board.h`). Квадратный размер меняет `setFieldSize(n)` (валидация по `FIELD_SIZES`), произвольный W×H — `setFieldDim(w,h)`.
- **Зоны рисования:** `inZone(x, player)` — P1 ставит только в левой половине (`2*x < fieldW`), P2 — только в правой (`2*x >= fieldW`); формула целочисленна (корректна и для нечётной ширины). Зона производна от `fieldW`, в сейв не пишется. `zoneSplit()` возвращает границу (`Math.ceil(fieldW/2)`) и предикат. `inZoneAt(x,y,player)` — игровой предикат зоны: вне кампании делегирует `inZone`, в кампании проверяет прямоугольник `zoneRect` (см. «Кампания»).
- **Пользовательские настройки (D1):** `setFieldDim`/`setFieldSize` пишут и `menuFieldW/menuFieldH`, `setWrap` — `menuWrap`, `setBudgetTotal` — `menuBudget`; в кампании `setFieldDim` — no-op. `applyMenuSettings()` возвращает активную игру (`fieldW/fieldH/wrap/budget.total`) к `menu*` (вызывается `toMenu`/`showLevelSelect`).
- **Бюджет клеток:** накопительный **на всю партию**, дефолт **100** (`DEFAULT_BUDGET`), задаётся `setBudgetTotal(n)` (применяется в `newGame`). Глобал `budget = { total, used1, used2 }`, `remaining(player)` — остаток. Тратится **только на ручную постановку** клетки (перезапись чужого в своей зоне — тоже трата); своя клетка не тратит; при нехватке ставится `min(new, remaining)` в детерминированном порядке («жадно»). **Ластик возвращает** бюджет. **Симуляция бюджет не меняет** (`used` — инкрементальный счётчик, не выводится из `field`).
- **Правило «новой клетки»** (краска): ставится, если `field[i] !== player` (пусто или чужой) и `remaining > 0`; `used++`.
- **Правило ластика:** стирает **только правку текущего хода** (`field[i] === player` и `turnStartField[i] !== player`), восстанавливая `turnStartField[i]` и возвращая бюджет; клетки, существовавшие до хода, не трогаются.
- **`applyBrush`** — чисто **геометрический** примитив (кламп по полю, без зоны/бюджета). Игровое действие (фаза + зона + бюджет) — в **`paintTool(x,y)`** (единственная точка мутации `budget.used*`); его используют `applyIntent`, `onPointerDown/Move`, `paintLine`.
- **«Сброс»**: в фазах рисования — возвращает **и поле, и бюджет** к снимкам начала хода (`turnStartField`, `turnStartBudget`) (не трогает расстановку прошлых фаз, не перезапускает партию).
- **Скорость**: `STEPS_PER_SEC` настраивается, стартово **~8** (подбирается плейтестом). Во время симуляции (`RUNNING`) в панели действий есть ряд из четырёх маленьких кнопок **1×/2×/4×/8×** (`#speedRow`, кнопки `.speedbtn` с `data-speed`): клик выбирает множитель `speedMult` (активная кнопка подсвечена). Эффективная скорость — `STEPS_PER_SEC * speedMult`; множитель **транзиентный** (не в сейв, сбрасывается в `1` при `newGame`); выбор — `setSpeed(m)`.
- **Клавиатура**: **пробел** (`keydown`, `event.code==='Space'`) дублирует паузу — в `RUNNING` ставит на паузу (`pauseGame`), в `PAUSED` запускает (`resumeGame`). В прочих фазах и при открытом `confirmAction` инертен; не перехватывается при фокусе в `input/select/textarea/button/a` (там пробел делает своё дело) и при `repeat`/Ctrl/Cmd/Alt.
- **Персистентность (перф):** в `RUNNING` `turnStartField` обнуляется, а `turnStartBudget` — в ноль (`startRunning`); запись сейва в `RUNNING` троттлится по времени (`PERSIST_INTERVAL_MS`, ~3 с).
## Рендер, зоны и HUD
- **Производительный рендер клеток:** offscreen-канва `fieldCanvas` в координатах `fieldW×fieldH` (1 px = 1 клетка) + `fieldImage` (`ImageData`). `rebuildFieldImage()` делает один проход по полю и пишет RGBA цвета игрока (alpha 255) для **видимых маской** клеток, alpha 0 — для скрытых/мёртвых; `draw()` масштабирует слой одним `ctx.drawImage(fieldCanvas, board.x, board.y, board.w, board.h)` при `imageSmoothingEnabled=false` (nearest-neighbor — цвет клеток **точный**). Рендер **не мутирует `field`**.
- **Dirty-флаг `renderDirty`:** пересборка offscreen — только при изменении поля/маски/размера. Ставится в `paintTool`, `tick` (после `step`), `beginDraw`/переходах FSM (смена маски), `resetGame`, `setFieldSize`, `setFieldASCII`, `setWrap`, `applySave`, `forceState`; сбрасывается в `draw()` после `rebuildFieldImage()`. В статичных фазах `draw()` — практически только `drawImage`. `resize()` offscreen не пересобирает (он в координатах поля) — пересчитывает лишь геометрию доски. Сетка рисуется только при `board.cell >= 5` (на крупных полях не рисуется).
- **Подсветка зон рисования:** в `PLACE_*`/`EDIT_*` **своя** половина мягко тонируется цветом текущего игрока (10%, `rgba(P*_RGB,.10)`), а неактивная (чужая) — затемняется оверлеем (`COLORS.zoneDim`); оба оверлея **под** слоем клеток (точный цвет клеток не искажается) + тонкая вертикальная линия границы (`COLORS.zoneLine`, `zoneSplit().boundary`). В `RUNNING`/`PAUSED` рисуется мягкая (5%, `globalAlpha`) подсветка территорий под клетками: левая — цветом P1, правая — P2 (затемнения нет, половины равноправны); в `MENU`/финале не рисуется. Цвета затемнения/линии/ghost — из `COLORS`, зависят от темы (см. «Тема оформления»).
- **Акцент текущего игрока (UI-цвет):** в фазах рисования `syncDom()` вешает на `body` класс `turn-p1`/`turn-p2`; через CSS-переменную `--accent` им подсвечиваются: рамка боковой панели (`body.side … #ui`, `inset box-shadow` — не меняет габариты), метка фазы `#turnLabel` (`border-color`). Кнопки дорисовки `#btnEditP1`/`#btnEditP2` постоянно подкрашены цветом игрока (`.editp1`/`.editp2`). Вне фаз рисования `--accent` не задан — подсветки нет. Оверлей передачи (`#handoff.to-p2`) — цветом P2; панель финала (`#gameover.win-p1`/`win-p2`) — цветом победителя (ничья без класса).
- **HUD бюджета (`#budgetLabel`):** компактный текст «N / total» цветом текущего игрока (без префикса — чтобы плашка не переполняла HUD на узких экранах, M1); при нуле — класс `.zero` (красный). Виден **только в фазах рисования**, кроме кампании: там он виден ещё и в `RUNNING`/`PAUSED` (HUD уровня показывает остаток на протяжении всего уровня, всегда бюджет игрока 1). В `HANDOFF_*` (как счёт), `MENU`/`GAME_OVER` и в обычных `RUNNING`/`PAUSED` — скрыт (в симуляции/паузе обычной партии бюджет не расходуется). Синхронизация — в `syncDom()`.
- **UI меню:** стартовый сплэш `#menu` — короткое описание (одно предложение), селект темы `#optTheme` («Тёмная»/«Светлая») → `setTheme(value)`, `#btnContinue` («Продолжить партию», выделена акцентным `.big`, видна только в `MENU` при наличии `suspended`), `#btnCampaign` («Кампания» → `LEVEL_SELECT`) и `#btnPlay` («Игра на двоих» → `TWO_SETUP`) — оба режима в одинаковом нейтральном стиле `.act wide`; на сплэше нет свёрнутой справки-описания — оно краткое прямо в тексте. Настройки поля (размер/torus/бюджет) перенесены на экран «Игра на двоих» `#setup`: дропдаун размера `#optSize` — опции строит `buildSizeOptions()` из `FIELD_DIMS` (9 квадратов + `60×100`/`100×60`), `value` = `"WxH"`, подпись «W×H» → `setFieldDim(w,h)` по `change`; чекбокс `#optWrap` («Замкнутая карта (torus)») → `setWrap(checked)`; бюджет `#optBudget` — **комбобокс** (`<input type=number list=budgetOptions>` + `<datalist id="budgetOptions">` с пресетами 100/250/500): можно выбрать пресет или ввести любое число, `change`/`blur` → `setBudgetTotal(n)` (не-число/<1 игнорируется, значение нормализуется); `#btnStartTwo` («Начать» → `requestNewGame()`), `#btnTwoBack` («В меню» → `toMenu()`). У партии на двоих (`#aboutTwo`) и у кампании (`#aboutCampaign`) — свои сворачиваемые справки «Как играть» (`<details class="help">`). `syncDom()` отражает `fieldW×fieldH` (в `#optSize`), `wrap`, `theme` и `budget.total` в контролах, не перебивая ввод/фокус.
- **Тема оформления (`theme`):** `'dark'` (дефолт) | `'light'`. `applyTheme(name)` — **только визуал**: выставляет `data-theme` на `<html>` (CSS-переменные `:root[data-theme="light"]` переопределяют фон/панели/текст), копирует canvas-палитру из `CANVAS_THEME[theme]` в `COLORS` (`bg/grid/border/zoneDim/zoneLine/eraseGhost`) и правит `<meta name="theme-color">`. `setTheme(name)` = `applyTheme` + `syncDom()` + `persist()` (пользовательское действие). DOM-цвета — в CSS-переменных (`--bg/--fg/--line/--chip-bg/--panel-bg/--muted/...`), canvas-цвета — в `COLORS`/`CANVAS_THEME`; цвета игроков `--p1/--p2` одинаковы в обеих темах. Тема сохраняется в сейве (`theme`) и применяется в `applySave()` (нет поля → `'dark'`). Кнопки «В меню» (`.act.menu`) и «Завершить партию» (`.act.danger`) подкрашены статически (не тематические).
- **Адаптивная раскладка (`layoutSide`):** в ландшафте (`viewW > viewH`) и если сбоку реально влезает панель (≥ `SIDE_MIN = 210px` с учётом ширины доски, зависящей от пропорций поля), `computeBoard()` включает **боковую раскладку**: доска прижата влево и занимает **всю высоту** (`board.h = viewH - 2*pad`, `board.w = board.h/fieldH*fieldW`, `board.x = pad`, `board.y` по центру), а `body.side` собирает управление (`#ui`/`#uiInner`) в фиксированную зону справа шириной `--side-w` (выставляется в `computeBoard`), так что доска и панель **не пересекаются**. Иначе (портрет/узкий ландшафт) — прежняя центрированная раскладка: `#ui`/`#uiInner` — `display:contents`, HUD сверху, тулбар/действия снизу. Обёртки `#ui`/`#uiInner` — только для раскладки; `syncDom()` по-прежнему ходит к `#hud`/`#toolbar`/`#actions` по id.
- **Пауза — без модального оверлея:** модальный `#pause` удалён; управление паузой живёт инлайн в `#actions` (нижняя панель или боковая зона). Кнопки переключаются по `state` в `syncDom()`: в `RUNNING` видны «Пауза» (`#btnPause`) и «Завершить партию» (`#btnEnd`); в `PAUSED` — «Старт симуляции» (`#btnResume`), «Дорисовать красным» (`#btnEditP1`), «Дорисовать зелёным» (`#btnEditP2`), «Завершить партию» (`#btnEndPause`) и «В меню» (`#btnPauseMenu`). Кнопки дорисовки видны **только при `PAUSED && fieldStable`** (метка фазы получает суффикс «· поле стабильно»); на «живом» поле (ручная пауза, до стабилизации) они скрыты. Отдельные кнопки дорисовки дают независимый выбор игрока (`startEdit(1)`/`startEdit(2)`) без форс-цепочки P1→P2: `commitTurn()` из `EDIT_*` возвращает в `PAUSED`, а симуляцию запускает отдельная кнопка. Кнопки в нижней панели переносятся (`flex-wrap`), а в боковой зоне идут колонкой на всю ширину. Кнопки «Завершить партию» (`#btnEnd`/`#btnEndPause`) вызывают `requestEndGame()` — партия не завершается без подтверждения. Диалог подтверждения `#confirm` (`#btnConfirmOk`/`#btnConfirmCancel`) обслуживает оба случая: новую партию (`confirmAction='new'`) и завершение (`'end'`).
## Сохранение и резюм (localStorage `life-save-v1`)
Единственный ключ — **`SAVE_KEY = 'life-save-v1'`** (имя **историческое**, не менять), значение — JSON-объект `save` (шаблон `freshSave()`). **Форма сейва v2** (`v` — версия формата, текущая `SAVE_VERSION = 2`):
| Поле | Смысл |
|---|---|
| `v` | версия формата (текущая `SAVE_VERSION = 2`; для миграций) |
| `fieldW` / `fieldH` | размер **активной игры** (для резюма; в кампании — размер уровня) |
| `wrap` | «Замкнутая карта» активной игры |
| `menuFieldW` / `menuFieldH` | пользовательский размер поля из меню (настройка) |
| `menuWrap` | пользовательская «Замкнутая карта» (настройка) |
| `menuBudget` | пользовательский лимит бюджета (настройка) |
| `theme` | тема оформления `'dark'` \| `'light'` (настройка; дефолт `'dark'`) |
| `tool` | id активного инструмента (настройка; `pencil`/`brush`/`erase1`/`erase4`) |
| `stepsPerSec` | скорость симуляции (настройка) |
| `budget` | `{ total, used1, used2, start1, start2 }` — лимит и израсходовано по игрокам; `startN` — снимок начала хода |
| `state` / `turn` | состояние FSM и чей ход (резюм партии) |
| `reason` | причина финала: `'extinct'` \| `'manual'` \| `'stalemate'` \| `'win'` \| `'defeat'` \| `null` |
| `winner` | `'1'` \| `'2'` \| `'draw'` \| `null` |
| `field` | **поле целиком** в base64 (1 байт/клетка) — только при резюме, иначе `null` |
| `turnStartField` | снимок поля на начало хода для «Сброса» (base64 \| `null`) |
| `stable` | признак устойчивости поля на паузе (`fieldStable`; для гейта дорисовки после reload) — `true` \| `false` |
| `campaign` | идёт ли уровень кампании (аддитивно; нет поля → `false`) |
| `levelIndex` | индекс уровня кампании `0..N-1` или `null` (аддитивно; валидный int или `null`) |
| `progress` | прогресс кампании `{ unlocked }` — число открытых уровней; `normalizeProgress()` клампит в `[1, N]` (нет поля → `{unlocked:1}`) |
| `suspended` | отложенная в меню партия `{ state, turn, fieldW, fieldH, wrap, field, turnStartField, stable, budget:{total,used1,used2,start1,start2}, campaign, levelIndex }` (base64-поля) или `null` — для кнопки «Продолжить партию» |
**Разделение «настройки меню» / «активная игра» (D1):** `fieldW/fieldH/wrap/budget.total` описывают активную игру (её резюмит `field`), а `menuFieldW/menuFieldH/menuWrap/menuBudget` — пользовательские настройки. В обычном потоке они совпадают; кампания применяет к активной игре размер/wrap/бюджет **уровня**, не трогая `menu*` (`startLevel`). Выход из кампании (`toMenu`/`showLevelSelect`) и `#optSize`/`#optWrap`/`#optBudget` в `syncDom` работают с `menu*`, поэтому обычная партия после кампании стартует с прежними настройками пользователя. Старые сейвы без `menu*` читаются как «menu = активным».
Зона рисования в сейв **не пишется** (производна от `fieldW`; в кампании восстанавливается из `levelIndex` + конфига уровня). Поля `suspended`, `theme`, `stable`, `campaign`, `levelIndex`, `progress`, `menuFieldW/menuFieldH/menuWrap/menuBudget` — **аддитивные** расширения формата v2 (не ломают старые сейвы: нет поля → `null`/`'dark'`/`false`/`false`/`null`/`{unlocked:1}`; старый код просто игнорирует лишний ключ). Отложенная партия хранит свой размер/wrap/**budget.total** (D2) — в меню настройки могли измениться, но `resumeFromMenu()` восстанавливает снимок целиком (включая режим кампании и `zoneRect`).
- **Резюмируемые состояния** (`isResumable`): `PLACE_P1/PLACE_P2/EDIT_P1/EDIT_P2/RUNNING/PAUSED/HANDOFF_P1_P2`. Для них `field` пишется **целиком** (скрытие — только визуальное); в кампании дополнительно пишутся `campaign`/`levelIndex`, и при восстановлении `zoneRect` берётся из конфига уровня. `GAME_OVER`/`MENU`/`LEVEL_SELECT` — это настройки + чистый старт в `MENU` (выбор уровня не резюмируется); при этом в `MENU` отложенная партия/уровень живёт в `suspended`.
- **Когда пишем** (`persist()`): переходы FSM (`newGame/commitTurn/handoffNext/pauseGame/resumeGame/startEdit/finish/resetGame`), смена инструмента (`selectTool`), размера (`setFieldSize`), опции `wrap` (`setWrap`), темы (`setTheme`), бюджета (`setBudgetTotal`), выход в меню (`toMenu`); а во время `RUNNING` — **троттлинг по времени** (`PERSIST_INTERVAL_MS`, ~раз в 3 с в `tick()`), не каждый шаг. В `RUNNING` `turnStartField` обнуляется (`startRunning`), а `turnStartBudget` — в `{used1:0,used2:0}` (симметрично, чтобы в сейве не было иллюзии снимка хода).
- **Авто-пауза при уходе со вкладки** (`visibilitychange`) меняет `state` в памяти, но **не** вызывает `persist()` — чтобы скрытие вкладки на миг не перезаписывало `RUNNING` на `PAUSED` и партия корректно резюмилась.
- **Чтение** (`loadSave()`): нет данных / битый JSON / не объект / `v > SAVE_VERSION` → `null` (старт в `MENU`, без падений). Сейв без `v` трактуется как v1; поля нормализуются (в т.ч. `suspended` через `normalizeSuspended()` — битый/невалидный снимок даёт `null`, `stable` → `false`). `applySave(s)` при старте применяет настройки (`wrap`, `budget`, `rebuildNeighborTables()`), восстанавливает отложенную партию (`suspended`) и, если `s.state` резюмируемое и поле валидной длины, восстанавливает партию целиком (в `RUNNING` окно устойчивости засевается заново, в `PAUSED`/`EDIT_*` берётся `stable` из сейва).
- **Миграции**: при смене формата добавить шаг в `loadSave()` рядом с образцом-комментарием (`// M1: …`), подняв `SAVE_VERSION`; старые сейвы обязаны оставаться читаемыми. **M1 (v1→v2)**: `wrap = false`, свежий `budget = { total: DEFAULT_BUDGET, used1: 0, used2: 0, start1: 0, start2: 0 }`.
- **Валидация размера из меню** — квадраты по `FIELD_SIZES` (`setFieldSize` игнорирует чужие значения), прямоугольники — через `setFieldDim(w,h)` (принимает любой корректный `isValidSize`); при загрузке сейва принимается любой корректный размер (тестовые поля могут быть не из списка). `mode` — производный описатель фазы из `state` (`modeForState()`), используется в игровом цикле (`frame`).
## Соглашения о коммитах
- Сообщение коммита — краткое и по делу: тема одной строкой, при необходимости 2–4 пункта тела.
- Без лишних трейлеров и служебных подписей (`Co-authored-by` и т.п.) — только смысл изменения.
- Язык — русский, в стиле существующей истории коммитов (`git log --oneline`).
## Проверка
Линтеров/typecheck/CI нет — два рубежа:
1. **Синтаксис:** `node tools/check.mjs` — вынимает JS-код `<script>` из `index.html` и прогоняет `node --check` (при ошибке показывает номер строки `index.html`). Плюс `tools/check.mjs` печатает `check: OK — …`.
2. **Главный рубеж — headless-суита** (запускать после правок `index.html` или `tests/test.js`):
```bash
cd tests
npm install # puppeteer-core
npm test # = node test.js
```
Ждать **все PASS и exit 0** (финальный вывод `===== ИТОГ: N/N =====`).
3. **Паритет с прототипом** (harness — `tools/old-ref/`, не обязателен для каждой правки, но после изменений ядра полезен):
```bash
g++ -std=c++17 -O2 -o tools/old-ref/old_ref tools/old-ref/old_ref.cpp
node tools/old-ref/diff-our.js # exit 0 ⇔ наш step == clean (273/273)
```
Подробности и пересборка фикстуры — в `tools/old-ref/README.md`.
## Тестирование
**Инфраструктура.** `tests/test.js` — headless-суита на **`puppeteer-core`** со своим мини-фреймворком:
- `ok(name, cond, extra)` — записывает/печатает `PASS|FAIL`;
- `waitState(page, st, timeout)` — ждёт `state === st` (поллинг 40 мс);
- `waitCond(page, expr, timeout)` — ждёт произвольное выражение в странице;
- `sleep(ms)` — пауза.
Параметры запуска:
- Chromium из **`process.env.CHROMIUM || '/usr/bin/chromium-browser'`** (при падении на старте — поправьте путь под свой чекаут);
- **viewport 390×844 @2** (`defaultViewport`), запуск с `--no-sandbox --disable-gpu --hide-scrollbars --mute-audio`;
- URL игры резолвится от каталога репо (`file://…/index.html`);
- в конце — `console.log('===== ИТОГ: N/N =====')` и `process.exit(failed ? 1 : 0)`;
- суита подписывается на `page.on('pageerror')` и `page.on('console')` и **проверяет отсутствие `pageerror`/`console.error`** (молчаливые ошибки — это провал).
**Тест-форсы** (часть архитектуры, НЕ тестовый код): `forceState(name)`, `setFieldASCII(str,w)`, `stepN(n)`, `getScores()`, `hashField()` — выставляются на `window`, чтобы `page.evaluate` мог их дёргать. Тесты ходят к внутренним глобалам напрямую, поэтому **имена глобалов — стабильный публичный контракт** (см. раздел «Стабильные глобалы»).
**Что обязательно покрыть:**
- **Правила Conway:**
- блок 2×2 неподвижен;
- блинкер — период 2;
- глайдер за 4 шага сдвигается на (1,1);
- рождение при 3 соседях; смерть при <2 и >3; выживание при 2 и 3.
- **Наследование цвета:** `1,1,2 → 1`; `1,2,2 → 2`; tie-break `1,2,3 → 1` (наименьший id). При двух игроках `2-1` однозначно.
- **Границы:** `wrap=false` — соседи за краем не считаются (как раньше); `wrap=true` — заворот (сосед с противоположной стороны), рождение через шов. Паритет `step` с эталоном закреплён при `wrap=false` (273/273) и для torus (`reference-torus.json`, 273/273). Переключение глобала `setWrap(b)` влияет на `step`/`stepN` без явного параметра и **не мутирует `field`**.
- **Чистота `step`:** `step` не мутирует `src` (hash до/после совпадает) — в т.ч. при `wrap`; `stepN(n)` детерминирован (два прогона → одинаковый hash).
- **Инструменты:** карандаш = 1 клетка, кисть = 9, ластики 1×1 и 4×4, кламп у границ (инструмент не выходит за поле); ПКМ (`page.mouse.down/up({button:'right'})`) стирает клетку, не меняя активный инструмент (`paintOverride`).
- **Паттерны (штампы):** `patternCells` (число клеток/размер bbox для всех `PATTERNS`, включая новые фигуры); постановка через `paintTool` (все клетки, якорь левый-верхний, бюджет −N), свои клетки не тратят; обрезка по зоне и кламп по доске, «Сброс» возвращает поле+бюджет; корректность фигур на симуляции (глайдер (1,1) за 4 шага, блок неподвижен, мигалка период 2, пульсар период 3, ружьё растит население; **новые:** стойкие ванна/лодка/кораблик/каравай/пожиратель/пруд/авианосец/длинная лодка/каноэ неподвижны, маяк/часы период 2, пентадекатлон период 15, жёлудь/громовая птица/кролики растят население, Diehard полностью гибнет за 130 шагов, MWSS/HWSS период 4 со смещением 2, Loafer период 7 со смещением 1, ружьё Симкина растит население); дропдаун `#optPattern` (выбор → `stamp`, подсветка `.active`, `selectTool` сбрасывает, disabled вне рисования; **группировка** — 5 `optgroup` по `PATTERN_GROUPS`, пустая опция прямым ребёнком `select`, все id на месте); **свой список `#patternMenu`** (клик по `#optPattern` открывает/закрывает, 30 фигур + 5 заголовков групп, выбор фигуры задаёт `stamp`/`.active` и закрывает список, Escape/клик вне закрывают, `stamp` при этом не сбрасывается); одно нажатие = одна постановка (drag не повторяет), ПКМ стирает; транзиентность (`stamp` не переживает reload).
- **Поворот штампа:** `stampCells` при `stampRot` 0..3 (у симметричного bbox размер сохраняется, у несимметричного w/h меняются местами: мигалка 3×1→1×3), 4 поворота = исходная форма; `wheel` на `canvas` (вниз +1, вверх −1) действует только в фазах рисования при активном `stamp`, иначе инертен; кнопка `#btnRotate` (клик +90°, 4 клика → 0, подпись угла в `#rotDeg`, скрыта без штампа/вне рисования); постановка `paintTool` использует `stampCells` (вертикальная мигалка); `stampRot` сбрасывается при смене паттерна/инструмента.
- **Режим «постановка по отпусканию»:** тумблер `#btnTapPlace` (клик → `tapPlace`, подсветка `.active`); при `tapPlace=true` `mouse.down`/`mouse.move` (в т.ч. drag) не меняют поле и бюджет, `mouse.up` ставит ровно одну клетку под пальцем; со штампом фигура ставится целиком по отпусканию; `pointercancel` отменяет постановку; ПКМ стирает по отпусканию, не меняя инструмент; `tapPlace` транзиентен (не переживает reload).
- **Зоны:** `inZone` (P1 лево / P2 право), корректность для чётной и нечётной ширины (полное покрытие без пересечений); `applyIntent`/`paintTool` вне своей зоны/доски не действуют (поле не меняется); кисть обрезается по границе зоны; drag из своей половины в чужую не закрашивает чужую.
- **Бюджет:** трата на новую/чужую клетку (карандаш +1, кисть 3×3 на пустом +9), своя не тратит; перезапись чужого — трата +1; частичная нехватка — `min(new, remaining)`; исчерпание — новые не ставятся, ластик работает; ластик возвращает только правку текущего хода (и восстанавливает перезаписанный чужой цвет); «Сброс» восстанавливает бюджет к `turnStartBudget`; `setBudgetTotal` применяется в `newGame`; бюджет переживает reload.
- **Размеры:** `FIELD_SIZES` = 40…300 (9 значений) плюс прямоугольные `FIELD_RECTS` (60×100/100×60); дефолт 60; `setFieldSize` применяет значения из `FIELD_SIZES` и игнорирует чужие, `setFieldDim(w,h)` — произвольный W×H; 300×300 (90000 клеток) рендерится/резюмится без ошибок; прямоугольное поле (60×100) рендерится/резюмится, зона делится по ширине, клетки квадратные.
- **Кампания:** схема `CAMPAIGN_LEVELS` (103 уровня: 7 ручных + 96 сгенерированных, `cells` — непустой массив координат в границах без дублей, id уникальны и покрывают 1..103, зона внутри поля, свой бюджет; присутствуют все фигуры улей/мигалка/жаба/каравай/пульсар/ружьё, есть уровни с врагом вне зоны; «Издалека» на 10-м месте 60×60/зона {0,0,14,20}/бюджет 60; showcase id 101..103 на индексах 10..12; порядок вставки (id5..id10 на индексах 3..8, id4 на индексе 9); размеры сгенерированных (id 5..100) не убывают, последний == 300; у сгенерированных (id 5..100) нет зоны «всё поле», полная зона только у ручных «Разминка»/«Рой», среди сгенерированных присутствуют все 7 типов зон; детерминизм и `--check` генератора `tools/gen-campaign-levels.mjs`, сверка вывода с вшитым блоком id 5..100); `startLevel` (state/turn/режим/уровень/зона/бюджет/враг); видимость врага (`visibleMask`=[1,2] и пиксельно); `inZoneAt` (делегирование `inZone` вне кампании + прямоугольник `zoneRect`, регресс `zoneRect=null`); запрет трогать врага краской/штампом/ластиком (поле и бюджет неизменны); своя клетка ставится и тратит бюджет; победа (0 врага → `win`/`winner='1'`/`unlocked++`); вымирание игрока — не финал; поражение (`remaining(1)<=0` + враг жив на `PAUSED`/`fieldStable` → `defeat`/`winner='2'` через `checkStalemate` и через авто-паузу `tick`; негативные случаи); прогресс/разблокировка (закрытый уровень не выбирается); сейв/резюм уровня после reload; suspended кампании (+ reload); HUD («Уровень N/103», остаток бюджета, скрытый счёт); проводка UI (`#btnCampaign`/`#btnLevelsBack`/`#btnNextLevel`/`#btnRevenge`/`#btnGameoverLevels`/`#btnRestart`; список `#levelList` из `CAMPAIGN_LEVELS.length` кнопок прокручивается, `#btnLevelsBack` виден; `#btnRestart` видна в любой фазе уровня и перезапускает его через подтверждение `confirmAction='restart'`); авто-пауза кампании сразу открывает `EDIT_P1` (без клика «Дорисовать»); нормализация `reason` win/defeat и `progress`, reload в `LEVEL_SELECT`→`MENU`.
- **Скрытие:** в `PLACE_P1` рендер **не рисует** клетки цвета 2, но `field`/`hash` неизменны; смена маски **не мутирует `field`**.
- **Рендер (offscreen):** pixel-тесты скрытия проходят после перехода на `drawImage` (точный цвет клеток: 0 «зелёных» пикселей в `PLACE_P1`, >0 в `PLACE_P2`); `draw()` не мутирует `field`; dirty-флаг ставится на мутациях и сбрасывается в `draw()` (в т.ч. `stepN`); крупное поле (300×300) рисуется без `pageerror`; подсветка зон **не искажает** точный цвет живых клеток (пиксель центра клетки = цвет игрока, и в активной, и в затемнённой половине); 10%-тинт своей половины окрашивает пустую клетку в оттенок игрока (доминирует его канал); классы акцента `body.turn-p1`/`turn-p2` есть только в фазах рисования, `#handoff.to-p2` — в `HANDOFF_P1_P2`, `#gameover.win-p1`/`win-p2` — по победителю.
- **UI меню:** стартовый сплэш `#menu` (краткое описание + `#optTheme` + `#btnContinue`/`#btnCampaign`/`#btnPlay`) и экран `#setup` («Игра на двоих»): дропдаун размеров `#optSize` (квадраты 40…300 + прямоугольные 60×100/100×60, `value="WxH"`), чекбокс `#optWrap` → `wrap`, селект темы `#optTheme` (Тёмная/Светлая) → `theme`, комбобокс `#optBudget` (ввод + пресеты `#budgetOptions`) → `budget.total`; `#btnStartTwo`/`#btnTwoBack`; сворачиваемые справки `#aboutTwo`/`#aboutCampaign`; `syncDom()` отражает настройки.
- **FSM:** все переходы из таблицы (включая `HANDOFF_P1_P2` и независимый цикл `EDIT_*` → `PAUSED`: дорисовка любого игрока и отдельный «Старт симуляции»).
- **Стартовые экраны:** в `MENU` виден сплэш `#menu` (тема и режимы), `#setup` скрыт, настройки поля лежат в `#setup`; `#btnPlay` → `TWO_SETUP` (`#setup` виден, `#menu` скрыт), `#btnTwoBack` → `MENU`; сворачиваемые справки `#aboutTwo`/`#aboutCampaign` присутствуют; `TWO_SETUP` не резюмируется (reload → `MENU`); «Игра на двоих» при `suspended` открывает `#confirm` (`'new'`).
- **Стабильность/дорисовка:** блок 2×2 (фиксированная точка) и блинкер (период 2) в `RUNNING` → авто-`PAUSED` + `fieldStable=true`, кнопки дорисовки видны; в **кампании** авто-пауза сразу открывает `EDIT_P1` (без клика «Дорисовать»), а «Готов» из неё сразу запускает симуляцию (цикл «нарисовал → побежало»); глайдер (поле не застывает) держит `RUNNING`, `fieldStable=false`, ручная пауза дорисовку не открывает (`startEdit` — no-op); `resumeGame` сбрасывает `fieldStable`; `fieldStable` переживает reload в `PAUSED` и откладывается в `suspended`.
- **«Сброс»:** восстанавливает поле и бюджет к началу текущего хода.
- **Финал:** вымирание (`reason='extinct'`) → победа; ничья; ручное завершение (`reason='manual'`); вымирание объявляется после такта, как только у стороны **0 клеток** (бюджет его не отодвигает); реванш/сброс.
- **Тупик по бюджету (`reason='stalemate'`):** стабильное поле (авто-пауза) + отстающий без бюджета → авто-`GAME_OVER`/`stalemate` с победой лидера; ничья при равном счёте завершается, только если бюджет исчерпан у обоих (иначе игра продолжается); при бюджете у отстающего авто-финала нет; текст финала объясняет кончившийся бюджет; `reason='stalemate'` переживает нормализацию сейва.
- **Меню не теряет партию:** `toMenu()` откладывает партию в `suspended`, кнопка `#btnContinue` видна только при наличии `suspended`; `resumeFromMenu()` возвращает ту же партию (state/turn/поле/бюджет/размер); «Игра на двоих»/«Реванш» при `suspended` открывают `#confirm` (отмена сохраняет партию, подтверждение запускает новую); `suspended` переживает reload.
- **Подтверждение «Завершить партию»:** клик `#btnEnd`/`#btnEndPause` не завершает партию, а открывает `#confirm` (`confirmAction='end'`) и ставит симуляцию на паузу (поле за оверлеем не меняется); «Отмена» возвращает `RUNNING` (если шла), подтверждение → `GAME_OVER`/`manual`.
- **Резюм:** продолжение партии после reload (`localStorage`): `state`/`turn`/`field`/счёт восстанавливаются; сценарии в `PLACE_P2` и в `RUNNING`; партия остаётся играбельной; «Сброс» использует сохранённый снимок начала хода.
- **Сейв v2:** `freshSave()` даёт `v===2` и новые поля (wrap/бюджет); v1-сейв мигрируется (`wrap=false`, дефолтный бюджет) и **резюмится**; `v > SAVE_VERSION` → `null`.
- **Миграция/битый сейв:** мусор/массив/старый формат без `v` → `loadSave()` не падает, игра стартует в `MENU`; `v > SAVE_VERSION` → `null`; миграция v1→v2 даёт `wrap=false` и свежий бюджет.
- **Настройки:** размер из меню применяется к новой партии; `tool`, `STEPS_PER_SEC`, `wrap`, `theme` и `budget.total` переживают reload.
- **Тема:** дефолт `dark`; `setTheme('light')`/`#optTheme` меняет `data-theme`, `COLORS.bg`, `<meta name="theme-color">` и `save.theme`, переживает reload; смена темы в меню не теряет отложенную партию (`suspended`).
- **Проводка DOM / пауза инлайн:** реальные `page.click` по `#btnPlay/#btnStartTwo/#btnTwoBack/#btnReady/#btnHandoff/#btnHandoffBack/#btnPause/#speed1/#speed2/#speed4/#speed8/#btnResume/#btnEditP1/#btnEditP2/#btnEnd/#btnEndPause/#btnRevenge/#btnRestart/#btnSettings/#btnPauseMenu/#btnGameoverMenu/#btnContinue/#btnConfirmOk/#btnConfirmCancel` и кнопкам инструментов (размер — `page.select('#optSize', …)`); Pointer Events через `page.mouse`. Модальный `#pause` отсутствует в разметке, кнопки показываются по `state` (`RUNNING` — «Пауза»+«Завершить»+ряд скорости; `PAUSED` — «Старт симуляции»/«Дорисовать красным»/«Дорисовать зелёным»/«Завершить»/«В меню»); кнопки дорисовки видны только в `PAUSED` (и только при `fieldStable`). Обычная партия: `#btnPlay` → `TWO_SETUP`, `#btnStartTwo` → старт, `#btnTwoBack` → `MENU`. Кампания: `#btnCampaign` → `LEVEL_SELECT`, `#levelList` (заблокированные `button:disabled`), `#btnLevelsBack`, `#btnNextLevel`, `#btnGameoverLevels`, `#btnRevenge` («Заново», без подтверждения), `#btnRestart` («Заново» в панели действий, перезапуск через подтверждение), диалог `#confirm` (`confirmAction` `'campaign'`/`'restart'`).
- **Скорость (`#speedRow`):** ряд из 4 кнопок `1×/2×/4×/8×` виден только в `RUNNING`, скрыт в `PAUSED`; клик по кнопке задаёт `speedMult` (1/2/4/8), активная кнопка — класс `.active` (по умолчанию 1×).
- **Клавиатура (пробел):** `keydown` пробела (`event.code==='Space'`) в `RUNNING` ставит паузу, в `PAUSED` — старт симуляции (то же, что `#btnPause`/`#btnResume`); вне этих состояний, при `repeat`/модификаторах, при открытом `confirmAction` и при фокусе в `input/select/textarea/button/a` — инертен. Проверяется реальным `page.keyboard.press('Space')` с предварительным `blur()`.
- **Раскладка:** в ландшафте включена боковая панель (доска во всю высоту слева, управление справа, без пересечения); в портрете и узком ландшафте — центрированная раскладка.
- **`applyIntent`:** красит/стирает в фазе рисования; вне фаз и вне доски — не действует; возвращает `true` только при фактическом изменении (клик по врагу/своей клетке → `false`, D5).
- **Дефекты ревью (D1–D5):** настройки пользователя (`menu*`) переживают кампанию и стартуют обычную партию с прежними значениями (D1); `suspended` хранит `budget.total` и возвращает его (D2); ручное завершение уровня → «Уровень прерван» (D3); `setFieldSize`/`setFieldDim` в кампании — no-op (D4); победа кампании проверяется и честным tick-прогоном (враг гибнет под симуляцией).
- **Resize:** canvas пересчитывается под 2–3 viewport и DPR (кап 2), без `pageerror`.
## Подводные камни
- **Глобалы — контракт.** Переименование любого имени из раздела «Стабильные глобалы» (или имени состояния) ломает тесты **молча**. Держать имена, либо синхронно править `tests/test.js` в том же коммите.
- **Ввод — только Pointer Events** (`pointerdown`/`pointermove`/`pointerup`), НЕ touch/mouse-обработчики: так тест управляет игрой мышью через `page.mouse`. Не заменять на тач.
- **CSS**: `touch-action: none` на игровых поверхностях и `contextmenu` → `preventDefault()` (не всплывает меню на тач-устройствах / правом клике).
- **Рендер не мутирует `field`.** Отрисовка и логика разделены; скрытие реализуется параметром рендера, а не правкой модели.
- **Симуляция детерминирована**: никакого `Math.random` в игровой логике; фиксированный шаг через аккумулятор (`STEPS_PER_SEC`), клампить `dt` как в `../fishing-game` (чтобы переживать просадки fps).
- **Границы по умолчанию жёсткие** (как в прототипе); torus — только явная опция `wrap`. При `wrap=false` семантика соседей **не должна меняться** — иначе ломается паритет `step` с эталоном (273/273).
- **Правило рождения — большинство из 3**, а не баг прототипа (`old/life/life.cpp:19-50`, необнуляемый `parent[]`). Не восстанавливать.
- **`applyBrush` — геометрический** (без зоны и бюджета); зона и бюджет живут ТОЛЬКО в `paintTool`. Не тащить игровые правила в `applyBrush` (на нём держатся тесты инструментов).
- **`budget.used` — инкрементальный** счётчик: симуляция меняет `field`, но бюджет не трогает; `used` нельзя выводить из `field`. Мутируется только в `paintTool` (и сбрасывается/восстанавливается в `newGame`/`resetGame`/`applySave`).
- **Имена состояний — строго** из списка FSM (`MENU, TWO_SETUP, LEVEL_SELECT, PLACE_P1, HANDOFF_P1_P2, PLACE_P2, HANDOFF_P2_START, RUNNING, PAUSED, EDIT_P1, EDIT_P2, GAME_OVER`): тесты сравнивают `state` строками.
- **Сейв** — ключ localStorage **`life-save-v1`** (имя историческое); текущая версия формата **`SAVE_VERSION = 2`**; форма включает `wrap`, `budget`, `reason` финала, `stable` и отложенную партию `suspended`; при смене формата добавлять миграцию в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
- **Резюм**: после reload партия должна продолжаться с сохранённого состояния (не терять `field`/счётчики хода).
- **Дорисовка — только на стабильном поле.** `startEdit()` — no-op, если `!fieldStable` (на «живом» поле/ручной паузе кнопки скрыты). Признак `fieldStable` ставится ТОЛЬКО в `tick()` (обнаружение повтора хеша), сбрасывается при любом изменении поля/семантики (`startRunning`/`seedStability`, `newGame`, `setFieldDim`, `setFieldASCII`, `setWrap`, `resumeFromMenu`, `applySave`) и сохраняется при выходе из `EDIT_*`. Тест-форс `forceState('PAUSED')` выставляет `fieldStable = true` (тесты переходов не гоняют реальную симуляцию) — не полагаться на это в игровой логике.
- **`fieldStable` — производное, не в логике `step`.** Устойчивость определяется по `hashField()` (FNV-1a) в окне `STABLE_WINDOW`; коллизия 32-битного хеша даёт ложное срабатывание ~2⁻³². Не тащить детекцию в `step` (чистота/паритет ядра) и не делать `Math.random`.
## Где крутить
- `COLORS` — палитра игроков/фона/сетки (canvas); структурные цвета UI — CSS-переменные, палитры тем — `CANVAS_THEME`.
- `TOOLS` — состав и размеры инструментов (карандаш/кисть/ластики); `PATTERNS` — набор готовых паттернов-штампов (`{ id, name, group, cells }`, ASCII-формы `cells`, якорь — левый верхний угол; выбираются `#optPattern`, активный — транзиентный `stamp`); `PATTERN_GROUPS` — порядок групп дропдауна (Стойкие/Осцилляторы/Корабли/Долгожители/Ружья). `stampRot` — поворот штампа `0..3` (колесо мыши или кнопка «↻» `#btnRotate`), `rotate90`/`stampCells` — его реализация; `tapPlace` — режим «постановка по отпусканию» (кнопка «👆 тап» `#btnTapPlace`).
- `STEPS_PER_SEC` — скорость симуляции (стартово ~8); `speedMult` — её транзиентный множитель 1/2/4/8 (ряд кнопок `1×/2×/4×/8×` в `#speedRow`, не в сейв).
- `STABLE_WINDOW` — окно распознавания устойчивости поля (1024 поколения; гейт дорисовки, см. «Геймплей и FSM»).
- `DEFAULT_BUDGET` — лимит клеток на игрока за партию (стартово 100).
- `CAMPAIGN_LEVELS` — уровни кампании (`{id,name,w,h,wrap,budget,zone,cells}`, `cells` — массив координат клеток врага `[x,y]`): 7 ручных (id 1..4 и 101..103) + 96 сгенерированных (id 5..100); `CAMPAIGN_PLAYER`/`CAMPAIGN_ENEMY` — цвета сторон (1/2).
- `tools/gen-campaign-levels.mjs` — детерминированный генератор 96 уровней кампании (seeded PRNG, без `Math.random`): размер монотонно растёт 60→300, бюджет — с размером, зоны разнообразны (7 типов, **без «всё поле»**: половина/центр/углы/полосы/прямоугольник), часть уровней с врагом вне зоны. Печатает готовый блок `cells` для вставки в `CAMPAIGN_LEVELS` (`node tools/gen-campaign-levels.mjs`); `--check` — самопроверка (96 уровней, валидность, нет полной зоны, все 7 типов зон, монотонность). После правок генератора перенесите его вывод в `index.html`.
- Дефолтный размер поля — 60×60 (варианты 40…300, `FIELD_SIZES`; прямоугольные `FIELD_RECTS`, полный список — `FIELD_DIMS`).
- `menuFieldW`/`menuFieldH`/`menuWrap`/`menuBudget` — пользовательские настройки меню (D1), отдельно от активной игры (`fieldW/fieldH/wrap/budget.total`).
- `SAVE_KEY` — ключ localStorage (`life-save-v1`); текущий формат — `SAVE_VERSION = 2`.
## Стабильные глобалы
Полный список имён, на которые завязаны тесты (контракт):
```
state, turn, mode, tool, TOOLS, winner, field, next, fieldW, fieldH,
step, applyBrush, countByPlayer, isExtinct, liveNeighbors, majorityColor,
hashField, newGame, commitTurn, handoffNext, handoffBack, pauseGame, resumeGame, startEdit,
endGame, resetGame, checkEnd, save, SAVE_KEY, freshSave, loadSave, persist,
forceState, setFieldASCII, stepN, getScores,
ctx, canvas, viewW, viewH, dpr, visibleMask, applyIntent, reason, STEPS_PER_SEC, speedMult,
wrap, budget, inZone, zoneSplit, setWrap, setBudgetTotal, remaining, paintTool,
DEFAULT_BUDGET, renderDirty, fieldCanvas,
FIELD_SIZES, FIELD_DIMS, setFieldSize, setFieldDim, selectTool, syncDom, board, draw, fieldImage,
turnStartField, turnStartBudget, bytesToB64, layoutSide,
suspended, confirmAction, theme, applyTheme, setTheme, fieldStable,
PATTERNS, stamp, patternCells, findPattern, stampRot, stampCells, rotate90, wheelAccum,
tapPlace,
campaign, levelIndex, zoneRect, campaignProgress, pendingLevel,
CAMPAIGN_LEVELS, CAMPAIGN_PLAYER, CAMPAIGN_ENEMY,
startLevel, showLevelSelect, showTwoSetup, inZoneAt, campaignZoneFor, requestCampaignLevel, normalizeProgress,
menuFieldW, menuFieldH, menuWrap, menuBudget
```
Дополнительные имена в последних двух строках найдены прямым поиском по `tests/test.js`
(реально читаются тестами из контекста страницы): `FIELD_SIZES`/`setFieldSize` (размеры поля),
`selectTool` (инструменты), `syncDom` (проводка DOM), `board` (геометрия доски),
`draw`/`fieldImage` (offscreen-рендер), `turnStartField`/`turnStartBudget` (снимок хода),
`bytesToB64` (сейв), `layoutSide` (адаптивная боковая раскладка), `suspended`/`confirmAction`
(отложенная в меню партия и диалог подтверждения), `theme`/`applyTheme`/`setTheme` (тема оформления),
`fieldStable` (устойчивость поля — гейт дорисовки), `campaign`/`levelIndex`/`zoneRect`/`campaignProgress`/
`pendingLevel`/`CAMPAIGN_LEVELS`/`CAMPAIGN_PLAYER`/`CAMPAIGN_ENEMY`/`startLevel`/`showLevelSelect`/
`inZoneAt`/`campaignZoneFor`/`requestCampaignLevel`/`normalizeProgress` (кампания),
`menuFieldW`/`menuFieldH`/`menuWrap`/`menuBudget` (пользовательские настройки меню, D1).
`COLORS` и `SAVE_VERSION` в тестах встречаются только в комментариях и
именах кейсов — как глобалы из страницы они не читаются, но входят в контракт настроек/сейва.
`useWrap` — **опциональный** параметр `liveNeighbors(f,x,y,useWrap)`, `step(src,dst,useWrap)`,
`stepN(n,useWrap)`; по умолчанию берётся глобал `wrap` (существующие вызовы без параметра
сохраняют прежнее поведение). Параметр выбирает ветку обхода, но **таблицы индексов соседей
(`colL`/`colR`/`rowU`/`rowD`) строятся по глобалу** `wrap` и используются только в ветке
`wrap=true` (torus); при `useWrap=false` обход идёт brute-force циклом с проверкой границ.
Пересобираются таблицы только через `setWrap(b)`, поэтому смена режима — через `setWrap`
(явный `useWrap` держать согласованным с глобалом, иначе таблицы и ветка обхода разойдутся).
имена состояний:
```
MENU, TWO_SETUP, LEVEL_SELECT, PLACE_P1, HANDOFF_P1_P2, PLACE_P2, RUNNING, PAUSED,
EDIT_P1, EDIT_P2, GAME_OVER
```
Ожидаемая роль ключевых из них:
- `state` — текущее состояние FSM; `turn` — чей ход; `mode` — производный описатель фазы (`modeForState()`: menu/place/edit/handoff/run/pause/over; используется игровым циклом `frame`); `tool` — выбранный инструмент; `winner` — победитель/ничья;
- `field` — плоский `Uint8Array(fieldW*fieldH)` цветов (0 = мёртвая, 1 = P1, 2 = P2); `next` — буфер следующего поколения того же размера; `fieldW`/`fieldH` — размеры;
- `step` — один тик симуляции (читает `src` — текущее поле, пишет `dst` — буфер `next`, не мутирует `src`); `stepN(n)` — n тиков; `hashField()` — детерминированный хеш поля; `setFieldASCII(str,w)` — построить поле из ASCII-строк (согласует снимки `turnStartField`/`turnStartBudget`); `forceState(name)` — форс состояния;
- `applyBrush(...)` — ГЕОМЕТРИЧЕСКИЙ примитив штампа инструмента (кламп по полю, без зоны/бюджета); `paintTool(x,y)` — ИГРОВОЕ действие (фаза + зона + бюджет, единственная точка мутации `budget.used*`); `countByPlayer(...)` — счётчики живых по цветам; `isExtinct()` — вымирание; `liveNeighbors(...)` — число живых соседей; `majorityColor(...)` — цвет большинства живых с tie-break (наименьший id);
- `wrap` — глобал-опция «Замкнутая карта»; `setWrap(b)` — установить + пересобрать таблицы; `budget = { total, used1, used2 }` — лимит/расход; `remaining(player)` — остаток; `setBudgetTotal(n)` — задать лимит; `inZone(x,player)` — предикат зоны; `zoneSplit()` — граница зон + предикат; `DEFAULT_BUDGET` — дефолт лимита;
- `newGame()`, `commitTurn()`, `handoffNext()`, `handoffBack()`, `pauseGame()`, `resumeGame()`, `startEdit(player)`, `endGame()`, `resetGame()`, `checkEnd()` — переходы FSM; `requestEndGame()`/`confirmProceed()`/`cancelConfirm()` — подтверждение завершения партии (и общей опасной операции); `toMenu()`/`suspendGame()`/`resumeFromMenu()` и `requestNewGame()` — меню и отложенная партия (`suspended`);
- `suspended` — снимок прерванной партии (`{state,turn,fieldW,fieldH,wrap,field,turnStartField,stable,budget}`) или `null`; `confirmAction` — открытый диалог подтверждения (`null` | `'new'` | `'end'` | `'campaign'` | `'restart'`), `confirmResume` — вернуть ли симуляцию при «Отмене»;
- `visibleMask()` — массив видимых цветов по состоянию (`[1]`/`[2]`/`[]`/`[1,2]`; скрытие только визуальное); `applyIntent(px,py)` — применить активный инструмент в экранной точке (CSS-пиксели холста) для тестов/ввода (учитывает зону);
- `ctx`, `canvas`, `viewW`, `viewH`, `dpr` — холст и его геометрия (DPR-кап 2); `reason` — причина финала; `STEPS_PER_SEC` — скорость симуляции; `speedMult` — транзиентный множитель 1/2/4/8 (ряд кнопок `1×/2×/4×/8×`, в сейв не пишется);
- `renderDirty` — dirty-флаг offscreen-слоя клеток; `fieldCanvas` (`var`, доступна как глобал) — offscreen-канва `fieldW×fieldH` в координатах поля (см. «Рендер, зоны и HUD»);
- `fieldStable` — устойчивость поля на паузе (гейт дорисовки): `true`, только когда симуляция сама остановилась на фиксированной точке/коротком цикле (`recentHashes`/`STABLE_WINDOW`, см. «Геймплей и FSM»); `resetStability()`/`seedStability()` — сброс/засев окна хешей (внутренние, в контракт не входят); `checkStalemate()` — авто-финал тупика по бюджету на стабильной паузе (вызывается из `tick()`, внутренняя);
- `theme` (`'dark'`/`'light'`) — активная тема; `applyTheme(name)` — применить только визуал (data-theme/CSS-переменные/`COLORS`/meta); `setTheme(name)` — `applyTheme` + `syncDom` + `persist` (сохраняет тему в сейв);
- `PATTERNS` — таблица готовых паттернов-штампов (`{ id, name, group, cells }`); `PATTERN_GROUPS` — категории дропдауна (Стойкие/Осцилляторы/Корабли/Долгожители/Ружья), `buildPatternOptions()` строит `<optgroup>` по ним (внутренние, в контракт не входят); `patternCells(p)` — разбор ASCII → offsets (ленивый кэш `p._cells`/`_w`/`_h`); `findPattern(id)` — паттерн по id или `null`; `stamp` — активный паттерн (`PATTERNS[i]` | `null`), транзиентно (не в сейв, сбрасывается `selectTool`); `stampRot` — поворот активного штампа `0..3` (колесо мыши/кнопка «↻»), `stampCells()` — offsets с учётом поворота, `rotate90(cells)` — поворот offsets на 90° CW; ставится веткой `stamp` в `paintTool` (см. «Инструменты и настройки»); `tapPlace` — режим «постановка по отпусканию» (транзиентный), `pressing` — удержание в этом режиме (внутренний);
- `campaign`/`levelIndex`/`zoneRect`/`campaignProgress`/`pendingLevel` — режим «Кампания» (см. «Геймплей и FSM»); `CAMPAIGN_LEVELS` — конфиг уровней (`CAMPAIGN_PLAYER=1`/`CAMPAIGN_ENEMY=2`); `startLevel(idx)` — запуск уровня (враг/зона/бюджет/PLACE_P1), `showLevelSelect()` — экран выбора (`LEVEL_SELECT`), `requestCampaignLevel(idx)` — выбор уровня с подтверждением; `campaignZoneFor(idx)` — копия `zoneRect` уровня; `inZoneAt(x,y,player)` — предикат зоны с учётом кампании; `normalizeProgress(p)` — нормализация `progress`;
- `TWO_SETUP` / `showTwoSetup()` — экран «Игра на двоих» (`#setup`): своя справка и настройки поля перед обычной партией; вход — `#btnPlay` со сплэша `MENU`, старт — `#btnStartTwo` (`requestNewGame()`), возврат — `#btnTwoBack` (`toMenu()`); не резюмируется (reload → `MENU`);
- `save`, `SAVE_KEY`, `freshSave()`, `loadSave()`, `persist()` — персистентность (ключ `life-save-v1`; `loadSave()` с миграцией/нормализацией, `persist()` пишет настройки + резюм; форма сейва — см. раздел «Сохранение и резюм»); `getScores()` — счёт для HUD/финала.