VanguardReMaster/docs/HANDOFF.md

510 lines
33 KiB
Markdown
Raw 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.

# Передача сессии
Состояние проекта на **21 октября 2026**. Документ нужен, чтобы новая сессия
продолжила работу без повторного разбора истории.
---
## 1. Проект
Фан-ремастер **Cardfight!! Vanguard** базовой эры (2011), формат **Classic**.
MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты, 124 принта.
Стек:
- сервер — **.NET 10**, планируются **Orleans**, PostgreSQL, Redis;
- клиент — **Unity 6** (ещё не начат);
- лаунчер — **WPF** (ещё не начат);
- контент — YAML + C#-скрипты карт;
- транспорт — WebSocket/TCP + MessagePack (решение зафиксировано, кода ещё нет).
Репозиторий:
- ветка `master`;
- remote `ssh://git@192.168.31.158:2222/mpakobecex/VanguardReMaster.git`;
- последний коммит на момент передачи — **`99532b6`**;
- рабочее дерево чистое.
Главные принципы:
- сервер авторитарный, клиент тонкий;
- скрытая информация (колода, рука противника, закрытый урон, seed) не уходит клиенту;
- `Vanguard.Rules` — чистая библиотека без сети, БД и Orleans: команда на вход, события на выход;
- каждый блок работы коммитится и пушится в `master`.
---
## 2. Текущее состояние
| Проверка | Результат |
|---|---|
| Тесты | **164/164** (131 правила/контракты + 33 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
| Карты | **94/94** (66 с текстом + 28 ванильных) |
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker |
| Клиент / лаунчер | не начаты |
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
боты доигрывают 1000 партий, инварианты держатся, реплей воспроизводится
бит-в-бит по хэшу событий.
---
## 3. Как проверить
`dotnet` установлен в `%LOCALAPPDATA%\dotnet`, поэтому в PowerShell:
```powershell
$env:PATH = "$env:LOCALAPPDATA\dotnet;$env:PATH"
dotnet test server\Vanguard.slnx
dotnet run --project server\src\Vanguard.Cli -- validate
dotnet run --project server\src\Vanguard.Cli -- translations check
dotnet run --project server\src\Vanguard.Cli -- stress 1000
```
Ожидаемо:
```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0
```
Сервер локально:
```powershell
dotnet run --project server\src\Vanguard.Server --urls http://localhost:5080
# GET http://localhost:5080/health
```
Тестовый стенд на `ssh home` (Linux, Docker):
```bash
ssh home
cd ~/VanguardReMaster
# .env: VANGUARD_PORT=18080, VANGUARD_WS_URL=ws://192.168.31.158:18080/game
docker compose up -d --build
curl http://127.0.0.1:18080/health
# остановить: docker compose down
```
`Storage:Provider=None` по умолчанию: локальный запуск без базы. Стенд
использует `Postgres`, `Redis` и восстанавливает матчи после рестарта.
Solution — **`server/Vanguard.slnx`**, не `.sln`.
---
## 4. Что сделано
### 4.1. Контент
- 94 карты, 124 принта в `content/sets/BT01.yaml`, `TD01.yaml`, `TD02.yaml`.
- Импортёр `tools/import_cards.py`.
- Валидатор контента: щиты, Sentinel, параллельные арты, лимиты колоды.
- Токенизация текстов, `content/keywords.yaml`.
- Русские переводы `content/translations/ru.yaml` — 94/94.
- Канон перевода `docs/translation-rules.md` v0.8.
### 4.2. Игровое ядро
`server/src/Vanguard.Rules/Engine/GameEngine.cs`.
Реализовано:
- фазы `Mulligan → Stand → Draw → Ride → Main → Battle → End`;
- стартовый Авангард, маллиган, добор;
- райд, призыв, перемещение в пределах колонки (занятый круг меняется местами);
- атака по Авангарду и переднему ряду Арьергарда;
- поддержка, перехват, идеальная защита (Sentinel);
- drive check и damage check с выбором целей;
- триггеры: Critical, Stand, Draw, Heal;
- победа/поражение, детерминированный RNG;
- стоимости: Counter Blast, Soul Blast, сброс, поворот себя, уничтожение себя;
- поиск в колоде без просмотра, поиск с призывом и с райдом;
- просмотр верхней карты;
- способности: `Triggered`, `Continuous`, `Activated`;
- модификаторы силы/крита с источником и сроком жизни (`StatModifier`);
- выдача и снятие способностей (`GrantedAbility`);
- запреты (`CardRestriction`): не вставать, не атаковать, не поддерживать,
не перехватывать, не быть выбранным;
- снятие Сдерживания эффектом;
- возврат карты с поля в колоду после боя и в конце хода;
- способности из руки, сброса и круга защиты;
- `AskUnitMultiChoice` для «до N» / «ровно N»;
- `LegalCommands()` — все легальные команды текущего момента;
- `GameInvariants.Check()` — проверка 50 карт, уникальных `instanceId`, урона ≤ 6.
Триггеры (`AbilityTrigger`):
`OnRideOver`, `OnPlace`, `OnBoost`, `OnBoosted`, `OnBoostedHit`, `OnDriveCheck`,
`OnGuard`, `OnRetireFromRC`, `OnOpponentRetire`, `OnLeaveGuardian`,
`OnPhaseStart`, `OnAttackDeclared`, `OnAttackHit`, `OnDamageTaken` (объявлен,
но пока не вызывается — карт с ним нет).
### 4.3. Карты
- **59** скриптов в `CardScripts.cs`, `CardScriptsBT01.cs`, `CardScriptsTD.cs`.
- **6** карт Sentinel/Heal обрабатываются данными и движком без скрипта.
- **Tejas** реализован флагом `snipeBackRow` в YAML.
- **28** ванильных карт скрипта не требуют.
- Итого **66/66** карт с текстом закрыты.
### 4.4. Реплей
`server/src/Vanguard.Rules/Replay/`:
- `CommandCodec` — команда в стабильную строку и обратно;
- `MatchRecord` — seed, колоды, команды, хэш событий;
- `MatchRecorder` — записывает только принятые команды;
- `ReplayRunner` — воспроизводит и проверяет хэш;
- `EventHasher` — FNV-1a по тексту событий.
Запись переживает JSON и воспроизводится бит-в-бит.
### 4.5. Диагностика
- `server/src/Vanguard.Rules/Diagnostics/GameInvariants.cs`;
- CLI-команда `stress [партий]` — случайный бот, лимит 5000 шагов на партию.
### 4.6. Тесты
`server/tests/Vanguard.Rules.Tests/GameEngineTests.cs` — **117** тестов:
- механики фаз, боя, защиты, перехвата, триггеров;
- все ключевые карты и стоимости;
- 50 детерминированных партий случайного бота с инвариантами;
- JSON-реплей полной партии с проверкой хэша.
`server/tests/Vanguard.Rules.Tests/ProtocolTests.cs` — **12** тестов:
- round-trip всех кадров клиента и сервера через MessagePack, включая
вложенные union'ы `MatchView` и `EventView`;
- отказ на пустом и слишком большом кадре;
- соответствие интентов и `GameCommand` в обе стороны;
- скрытие руки противника, колоды и закрытого урона;
- отсутствие seed/RNG в DTO;
- `LegalIntents` только у решающего игрока;
- скрытие поиска в колоде от противника и сохранение глобальных номеров
событий при фильтрации.
### 4.7. Сеть: протокол и контракты
- `docs/protocol.md` — версия 1: WebSocket + MessagePack, handshake по
одноразовому тикету, `intentId` и идемпотентность, `eventSeq` и resync,
heartbeat, реконнект 0/2/10/30 с, таймер 90 с + банк 5 мин, авто-поражение
через 5 минут отсутствия, таблица видимости зон.
- `Vanguard.Shared` — мультитаргет `netstandard2.1;net10.0`:
- `netstandard2.1` — DTO и MessagePack для Unity, без ссылки на правила;
- `net10.0` — дополнительно проекция состояния поверх `Vanguard.Rules`.
- DTO: кадры клиента и сервера, 18 интентов, 30 событий, `MatchView`,
`PlayerView`, `CardView`, `ClockView`.
- `ProtocolResolver` — `StandardResolver` + интернирование строк;
`MessagePackSecurity.UntrustedData`; лимит кадра 256 КиБ.
- `MatchProjection` — отдельный `MatchView` на каждого игрока;
`EventProjection` — фильтрация скрытых событий и диапазон по `eventSeq`;
`IntentMapping` — единственное место связи DTO с командами движка.
- Проекция вызывается для наблюдателя без побочных эффектов и не трогает
`LegalCommands()` чужого игрока (иначе утекала бы рука решающего).
### 4.8. Сервер
`server/src/Vanguard.Server` — ASP.NET Core 10, один процесс.
Матчи, очередь и тикеты сохраняются в PostgreSQL/Redis, если они настроены;
по умолчанию (`Storage:Provider=None`) сервер работает без внешних сервисов.
- **HTTP**: `POST /api/game/queue`, `GET /api/game/queue/{id}`,
`DELETE /api/game/queue/{id}`, `POST /api/game/tickets`, `GET /health`.
Контракт — `docs/server-api.md`.
- **Матчмейкинг**: две очереди (Classic Ranked / Casual), скрытый MMR,
окно ±100 с расширением на 50 каждые 5 с до 2000, выбор ближайшего MMR.
Ранкед меняет MMR по Эло (K = 32), casual — нет.
- **Тикеты**: одноразовые, TTL 60 с, привязаны к `(playerId, matchId, seat)`;
при настроенном Redis изымаются через `GETDEL` (одноразовость между
инстансами), иначе — из памяти.
- **`IMatchGrain`**: 1 матч = 1 экземпляр, все входы (интенты, подключения,
тики) проходят через один семафор; внутри нет ни ASP.NET Core, ни WebSocket —
только `IMatchConnection`. Orleans-хостинг подключается поверх контракта.
- **WebSocket** `/game`: handshake, MessagePack, `intentId`, idempotency,
`eventSeq`, `resync`, heartbeat (20 с простоя → ping, ещё 20 с без ответа
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
`ServerClock` каждые 5 с.
- **Результат**: сдача, таймер и отсутствие завершают партию через
`GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
результат пишется в профили.
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
только количества и открытые зоны. `seed` и RNG не покидают сервер.
- **PostgreSQL**: таблицы `matches` и `match_intents`. В заголовке — seed,
колоды, игроки, часы, банки, статус; в журнале — `intentId`, команда,
результат. При старте незавершённые матчи восстанавливаются повтором
принятых команд на детерминированном движке, включая ожидания выбора:
делегаты движка не сериализуются. Журнал реплея сверяется по `eventSeq`.
Банк и дедлайн пишутся каждые ~5 с; потеря при аварии — не больше тика.
- **Redis**: тикеты и записи очереди (JSON + sorted set по режимам, TTL);
очередь поднимается при старте. Ключи с префиксом `vanguard:`.
- **Конфигурация**: `Storage:Provider` = `None` / `Memory` / `Postgres`,
`Storage:ConnectionString`; `Redis:Configuration`. `Memory` — EF InMemory
для тестов, `Postgres` — постоянное хранилище.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
- **Тесты** (`Vanguard.Server.Tests`, 33):
- тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
команды после финала, банк времени, отсутствие, старт часов,
reconnect с `eventSeq`, захват сессии;
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
перезагрузка очереди из хранилища;
- персистентность: EF-хранилище, восстановление матча повтором команд,
`MatchRecoveryService` после «рестарта»;
- внешние хранилища (`ExternalStorageTests`, запускаются при
`VANGUARD_TEST_POSTGRES` / `VANGUARD_TEST_REDIS`): реальные PostgreSQL
и Redis, изъятие тикета, очередь;
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния.
Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
---
## 5. Структура и ключевые файлы
```
/server/src/Vanguard.Rules
/Engine/GameEngine.cs основной движок (~2500 строк)
/Engine/GameState.cs состояние, зоны, InTransit
/Engine/GameModels.cs CardInstance, Step, Phase, PendingChoice
/Engine/GameCommands.cs команды
/Engine/GameEvents.cs события
/Engine/Modifiers.cs StatModifier, GrantedAbility, CardRestriction
/Engine/Abilities/Abilities.cs AbilityDefinition, ActivatedAbility, триггеры
/Engine/Abilities/CardScripts.cs реестр Default + базовые карты
/Engine/Abilities/CardScriptsBT01.cs
/Engine/Abilities/CardScriptsTD.cs
/Cards/CardDefinition.cs статы, Sentinel, Restraint, snipeBackRow
/Decks/DeckValidator.cs правила колоды
/Replay/ запись и воспроизведение
/Diagnostics/GameInvariants.cs
/server/src/Vanguard.Content загрузка YAML и валидация
/server/src/Vanguard.Cli validate, stats, translations, stress, deck
/server/src/Vanguard.Shared
/Protocol/*.cs кадры, интенты, события, view, кодек
/Projection/*.cs проекция состояния, фильтр событий, маппинг
/server/src/Vanguard.Server
/Program.cs ASP.NET Core, DI, WebSocket endpoint
/Api/ очередь, тикеты, /health
/Matchmaking/ очереди, MMR, Redis-хранилище очереди
/Matches/ IMatchGrain, таймеры, тикер, регистр
/Realtime/ WebSocket-обвязка, чтение кадров
/Tickets/ одноразовые тикеты: память или Redis
/Storage/ EF-хранилище, восстановление матчей
/Profiles/ скрытый MMR и статистика
/Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища
/content/sets карты
/content/translations/ru.yaml переводы
/docs/translation-rules.md канон перевода
/docs/protocol.md протокол матча, версия 1
/docs/server-api.md HTTP-очередь, тикеты, /health
/docs/HANDOFF.md этот файл
/docker-compose.yml postgres, redis, server
/.env.example VANGUARD_PORT и VANGUARD_WS_URL
```
---
## 6. Принятые решения
Их не нужно пересматривать без причины.
- Формат называется **Classic**, не Standard.
- Имена карт английские; `nameRu` зарезервировано.
- Термины: Авангард, Арьергард, ранг, смена, поддержка, проверка,
фазы Подъёма/Добора/Смены/Основная/Боевая/Завершающая, завершающий шаг,
Сдерживание, «вы», буква ё.
- Способности ищутся по `nameId`, а не по принту.
- Лимит 4 копий — по `nameId`, только правило декбилдера.
- Дубль в коллекции — по `printingId`; порог 4, замена внутри редкости.
- Бустер: 5 карт = 4 C + 1 R+; веса R 70 / RR 22 / RRR 7 / SP 1.
- Gold за матч: 30/15/20 (победа/поражение/ничья); Gold = XP / 5.
- Таймер хода 90 с + банк 5 мин.
- Транспорт матча — **WebSocket** + MessagePack; TCP с префиксом длины —
резерв для Unity, если WebSocket не подойдёт.
- Тикет на подключение — одноразовый, TTL 60 с, передаётся в `ClientHello`,
не в URL; reconnect получает новый тикет.
- `playerId` в HTTP-API — dev-заглушка до JWT. Выставлять сервер в интернет
в таком виде нельзя.
- Состояние сервера: PostgreSQL — снапшот/журнал матчей, Redis — тикеты
и очередь; без конфигурации работает in-memory. `IMatchGrain` отделён
от транспорта, Orleans подключается поверх.
- Rate limit — 16 интентов в секунду на соединение.
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
- Непрерывные эффекты пересчитываются при каждом запросе.
- Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор.
- Эффекты не вкладываются: цепочка строго друг за другом.
- Каждый блок — коммит и push в `master`.
---
## 7. Что осталось
### 7.1. Ближайший блок — сервер
Сделано в этом блоке:
1. **[x] `docs/protocol.md`** — версия 1:
кадры, handshake по тикету, `intent` с `intentId` и `eventSeq`,
resync, реконнект 0 / 2 / 10 / 30 с, отсутствие 5 минут → авто-поражение,
таймер 90 с + банк 5 мин, heartbeat, коды ошибок, таблица видимости.
2. **[x] `Vanguard.Shared`**:
DTO интентов и событий, MessagePack-резолвер, проекция состояния
на каждого игрока, фильтрация скрытых событий, маппинг интентов.
3. **[x] `Vanguard.Server`** — сетевой матч работает:
ASP.NET Core + WebSocket, тикеты, `IMatchGrain` (in-process),
матчмейкинг, MMR, идемпотентность, таймеры, реконнект, `/health`,
Dockerfile и compose. Контракт HTTP — `docs/server-api.md`.
4. **[x] Проекция состояния** подключена к грану и проверена E2E-тестом:
своя рука и свой выбор полностью, у противника только количества
и открытые зоны, seed и RNG не уходят.
5. **[x] `Docker Compose`** — postgres, redis (AOF) и сервер; `/health`,
порт через `VANGUARD_PORT`.
6. **[x] Персистентность**:
- PostgreSQL — заголовок матча + журнал интентов, восстановление повтором
команд после рестарта, `MatchRecoveryService`;
- Redis — одноразовые тикеты (`GETDEL`) и очередь с перезагрузкой.
Осталось в серверном направлении:
7. **Orleans** — заменить in-process регистр на настоящий silo и граны.
`IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность
и проекция от хостинга не зависят.
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
Критерий готовности серверного блока выполнен: два клиента играют полный
матч по сети; kill клиента на любом шаге восстанавливается через новый
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это
закрывается пунктом 7.
### 7.2. Мета-API
- `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация).
- `register`, `login`, `refresh`, `logout`, `me`.
- Коллекция по принтам, без верхнего предела.
- Колоды: CRUD, `validate`, лимит 30, шаринг по коду.
- Магазин, бустеры, триал-деки, токены.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт ×4, SP не крафтится.
- Онбординг: триал-дека + 5 токенов.
- Матчмейкинг, результат матча, Gold/XP, история.
- Rate limiting.
### 7.3. Клиент
- Unity 6 LTS, URP, Addressables, Input System, Localization, DOTween.
- Сцены `Boot`, `Meta`, `Battle`.
- VContainer, `ApiClient`, `GameConnection`.
- Экраны: логин, меню, магазин, коллекция, декбилдер, очередь, настройки.
- Боевая сцена: поле, рука, зоны, анимации, таймер.
- Локализация RU/EN на лету.
### 7.4. Лаунчер
- WPF: обновления, запуск клиента, выбор сервера.
### 7.5. Мелкие технические долги
- `OnDamageTaken` не вызывается — добавить, когда появится карта с таким триггером.
- Глобальный лимит шагов внутри движка не enforced — только в стресс-тесте.
- `GameEngine.cs` большой; при росте разнести на partial-файлы.
- `MatchRecord` сейчас JSON; для сети позже MessagePack.
- В `LegalCommands()` не входят активируемые способности со стоимостью «сброс карты» —
для UI их нужно будет перечислить с вариантами сброса.
---
## 8. Порядок действий в новой сессии
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
2. Прогнать проверки из раздела 3.
3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo,
затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч,
PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`,
`Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`.
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
---
## 9. Подводные камни
- **Кодировка.** Не использовать PowerShell `Get-Content`/`Set-Content` для
UTF-8 файлов с русским текстом: однажды это уже испортило комментарии.
Правки — только через `edit`/`write`, либо Python с `encoding="utf-8"`.
- **PATH.** `dotnet` лежит в `%LOCALAPPDATA%\dotnet`; без добавления в PATH
команды не находятся.
- **Solution.** `server/Vanguard.slnx`, не `.sln`.
- **Тесты.** `StartedEngine()` принудительно ставит Conroe стартовым Авангардом,
чтобы Barcgal не срабатывал.
- **Shared.** Мультитаргет: файлы `Projection/` компилируются только для
`net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет.
- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его
для наблюдателя нельзя — в списке есть printingId из чужой руки.
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
в E2E специально притормаживается; живой UI в лимит не упирается.
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут
в памяти. Для рестарта нужен `Postgres` + `Storage:ConnectionString`;
схема создаётся `EnsureCreated`, миграций пока нет. `Memory` — EF InMemory.
- **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды,
игроки, часы и журнал интентов. При восстановлении команды проигрываются
заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой.
Делегаты `PendingChoice`/`Continuation` восстанавливаются самим реплеем.
- **Сервер: восстановление.** `MatchRecoveryService` поднимает только
незавершённые матчи за последние 7 дней. Завершённые остаются историей
в PostgreSQL и не восстанавливаются в память. Банк/дедлайн пишутся
раз в ~5 с, поэтому авария теряет не больше тика.
- **Сервер: Redis.** Тикеты изымаются через `GETDEL` (Redis 6.2+; в compose 7).
Очередь — JSON + sorted set по режимам, TTL в два раза больше очереди.
Префикс ключей — `Redis:Prefix` (`vanguard:`).
- **Сервер: Docker.** На домашнем сервере порт 8080 занят, поэтому стенд
поднимается с `VANGUARD_PORT=18080` и `VANGUARD_WS_URL` в `.env`.
`docker compose restart server` проверяет восстановление матчей.
- **Сервер: внешние тесты.** `VANGUARD_TEST_POSTGRES` и `VANGUARD_TEST_REDIS`
включают `ExternalStorageTests`; без них тесты проходят как no-op.
Postgres-тест делает `EnsureDeleted`, поэтому БД должна быть тестовой.
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
играет полный матч по WebSocket и проверяет, что победитель совпал.
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
поэтому UI должен показывать ещё и `BankMs`.
- **Сервер: часы.** Отсчёт стартует только когда подключились оба места.
До этого `ClockView.IsRunning = false`, но таймер отсутствия уже идёт.
- **Сервер: проекция.** `ServerSnapshot` и `ServerEvents` собираются
отдельно на каждое место; не переиспользовать один `MatchView` на двоих.
- **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`.
- **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы;
`State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов.
- **`OfferAbility`.** Возвращает `Skipped / Paused / Resolved`; вызывающий код
продолжает очередь только при `Skipped`. Ошибка здесь уже приводила к потере
шага защиты.
- **`InTransit`.** Найденная карта, ждущая выбора круга, лежит в
`State.InTransit`, иначе инвариант «50 карт» падает.
- **Задний ряд.** Обычные карты атакуют только передний ряд; Tejas — задний
в своей колонке через `snipeBackRow`.
- **Реестр карт.** Новая карта без записи в `CardScripts.Default` молча
останется без способностей; проверять покрытие скриптом.
- **Тестовые колоды.** `BuildDeck()` в тестах не содержит все карты; для
конкретной карты использовать `MakeCard()` и класть вручную.