diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 0d08c94..f9cc38c 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -170,18 +170,18 @@ ## 5. Сеть и матч `[8–12 дней]` -- [ ] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect. -- [ ] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер. +- [x] Спецификация протокола в `docs/protocol.md`: кадры, handshake, версия, ping/pong, reconnect. +- [x] `Vanguard.Shared`: DTO интентов и событий, MessagePack-резолвер. - [ ] `IGameTicketService`: одноразовый тикет на подключение (TTL 60 с). -- [ ] TCP-сервер: accept, handshake, auth по тикету, привязка к grain. +- [ ] Транспорт матча: WebSocket (ASP.NET Core); резерв — TCP с префиксом длины. Accept, handshake, auth по тикету, привязка к grain. - [ ] `IMatchGrain`: 1 матч = 1 grain, последовательная обработка. -- [ ] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне. -- [ ] `eventSeq` + `resync` по запросу. -- [ ] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». -- [ ] Политика отсутствия: 5 минут → авто-поражение. -- [ ] Таймер хода: 90 с + банк 5 мин. +- [x] Проекция состояния под клиента: своя рука, свой trigger, открытые зоны; **ничего** о колоде, руке противника и закрытом уроне. +- [~] `eventSeq` + `resync` по запросу. +- [~] Реконнект: 0 / 2 / 10 / 30 с, затем экран «не удалось». +- [~] Политика отсутствия: 5 минут → авто-поражение. +- [~] Таймер хода: 90 с + банк 5 мин. - [ ] Снапшот матча в PostgreSQL + восстановление grain после рестарта. -- [ ] Idempotency по `intentId`. +- [~] Idempotency по `intentId`. **Готово, когда:** два клиента играют полный матч по сети; kill процесса клиента на любом шаге восстанавливается. diff --git a/README.md b/README.md index 48d4096..0ff1b03 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ | Каталог карт (94 / 124 принта) | готово | | Контент-пайплайн и валидатор | готово | | Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон | -| Сервер, клиент, лаунчер | не начато | +| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово | +| Сервер, клиент, лаунчер | не начаты | ## Требования @@ -48,7 +49,7 @@ OK: ошибок 0, предупреждений 0 ``` /server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры /server/src/Vanguard.Content загрузка и проверка YAML-контента -/server/src/Vanguard.Shared общие контракты (netstandard2.1, для Unity) +/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0) /server/src/Vanguard.Cli консольные команды /server/tests тесты правил и контента /content/sets карты в YAML @@ -114,4 +115,5 @@ dotnet run --project server/src/Vanguard.Cli -- translations check - `DEVELOPMENT_CHECKLIST.md` — решения, механики, экономика, риски. - `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам. - `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов. +- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект. - `content/cards-mvp.md` — каталог карт с разбором состава сетов. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index f10e77f..9fae7af 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -38,11 +38,12 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты | **117/117** | +| Тесты | **129/129** | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | | Карты | **94/94** (66 с текстом + 28 ванильных) | +| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы | | Сервер / клиент / лаунчер | не начаты | Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям: @@ -67,7 +68,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 117 +Пройден! : не пройдено 0, пройдено 129 OK: ошибок 0, предупреждений 0 Переведено: 94/94 Партий: 1000, завершено: 1000, ошибок: 0 @@ -158,6 +159,37 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - 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()` чужого игрока (иначе утекала бы рука решающего). + --- ## 5. Структура и ключевые файлы @@ -180,10 +212,14 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. /Diagnostics/GameInvariants.cs /server/src/Vanguard.Content загрузка YAML и валидация /server/src/Vanguard.Cli validate, stats, translations, stress -/server/tests/Vanguard.Rules.Tests 117 тестов +/server/src/Vanguard.Shared + /Protocol/*.cs кадры, интенты, события, view, кодек + /Projection/*.cs проекция состояния, фильтр событий, маппинг +/server/tests/Vanguard.Rules.Tests 129 тестов /content/sets карты /content/translations/ru.yaml переводы /docs/translation-rules.md канон перевода +/docs/protocol.md протокол матча, версия 1 /docs/HANDOFF.md этот файл ``` @@ -216,19 +252,18 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. ### 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-резолвер, проекция состояния + на каждого игрока, фильтрация скрытых событий, маппинг интентов. + +Осталось: -1. **`docs/protocol.md`** — спецификация: - - кадры, handshake, версия, ping/pong; - - `intent` с `intentId`, `eventSeq`; - - `resync` по запросу; - - реконнект 0 / 2 / 10 / 30 с; - - отсутствие 5 минут → авто-поражение; - - таймер 90 с + банк 5 мин. -2. **`Vanguard.Shared`**: - - DTO интентов и событий; - - MessagePack-резолвер; - - проекция состояния под клиента. 3. **`Vanguard.Server`**: - ASP.NET Core + WebSocket; - `IGameTicketService` (одноразовый тикет, TTL 60 с); @@ -236,7 +271,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - матчмейкинг: 2 очереди (Classic Ranked / Casual); - скрытый MMR, расширяющееся окно; - идемпотентность по `intentId`. -4. **Проекция состояния**: +4. **Проекция состояния** — контракты готовы, подключить к грану: - своя рука и свой триггер — полностью; - открытые зоны — полностью; - колода, рука противника, закрытый урон — только количество; @@ -289,7 +324,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`. 2. Прогнать проверки из раздела 3. -3. Начать с `docs/protocol.md` и `Vanguard.Shared`. +3. Продолжить с `Vanguard.Server`: ASP.NET Core + WebSocket, тикеты, + `IMatchGrain`, матчмейкинг. `docs/protocol.md` и `Vanguard.Shared` уже готовы. 4. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. @@ -305,6 +341,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - **Solution.** `server/Vanguard.slnx`, не `.sln`. - **Тесты.** `StartedEngine()` принудительно ставит Conroe стартовым Авангардом, чтобы Barcgal не срабатывал. +- **Shared.** Мультитаргет: файлы `Projection/` компилируются только для + `net10.0`; в `netstandard2.1` (Unity) ссылки на `Vanguard.Rules` нет. +- **Видимость.** `LegalCommands()` описывает решающего игрока. Вызывать его + для наблюдателя нельзя — в списке есть printingId из чужой руки. - **Порядок фаз.** Вход в Основную — `EnterMainPhase()` с `OnPhaseStart`. - **Отложенные продолжения.** `State.Continuation` — что выполнить после паузы; `State.PendingQueue` + `_resolvingDepth` запрещают вложенность эффектов. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..9badbad --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,568 @@ +# Протокол матча + +Спецификация сетевого взаимодействия клиента и игрового сервера. +Версия протокола — **1**. Версия растёт при несовместимом изменении кадров; +клиент и сервер обязаны совпадать по версии точно, иначе handshake отклоняется. + +Связанные документы: + +- `docs/HANDOFF.md` — состояние проекта и порядок блоков; +- `IMPLEMENTATION_CHECKLIST.md`, раздел 5 — план сетевого блока; +- `server/src/Vanguard.Shared` — DTO, MessagePack-резолвер и проекция состояния. + +--- + +## 1. Границы + +Протокол описывает **только матч**: подключение, интенты, события, состояние, +таймеры и переподключение. Аккаунты, коллекция, колоды, магазин и выдача +тикетов — HTTP/JSON мета-API, у него отдельная спецификация. + +Инварианты: + +- сервер авторитарный: клиент присылает намерения, а не результаты; +- скрытая информация не покидает сервер; +- порядок событий один на матч, `eventSeq` монотонно растёт; +- повторный интент с тем же `intentId` не применяется дважды; +- клиент может переподключиться на любом шаге и догнать состояние. + +--- + +## 2. Транспорт + +### 2.1. Основной вариант — WebSocket + +- Схема: `wss:///game` в production, `ws://` только локально. +- Каждый **бинарный** кадр WebSocket — ровно один кадр протокола (один объект + MessagePack). Текстовые кадры не принимаются. +- Ping/pong WebSocket-уровня не считается активностью игрока: приложение + использует свои кадры `ClientPing` / `ServerPing` (см. раздел 9). +- Тикет **не передаётся в URL** и не пишется в логи: он уходит внутри + `ClientHello`. + +### 2.2. Резервный вариант — TCP + +Тот же payload можно нести поверх сырого TCP. Формат кадра: + +``` ++----------------+---------------------------+ +| длина, 4 байта | payload, длина байт | +| big-endian | MessagePack | ++----------------+---------------------------+ +``` + +Длина считается только по payload и не включает сами 4 байта. Максимум — +`ProtocolConstants.MaxFrameBytes` (256 КиБ). TCP-вариант не заменяет +WebSocket в MVP: он нужен, если Unity-клиент упрётся в ограничения +WebSocket-транспорта. Логика handshake, кадров и таймеров идентична. + +### 2.3. Лимиты + +| Параметр | Значение | +|---|---| +| Максимальный кадр | 256 КиБ до десериализации | +| Интентов в секунду с соединения | 16 | +| Тикет на подключение | 60 с, одноразовый | +| Сжатие MessagePack | не используется в v1 | + +Кадр больше лимита или мусор вместо MessagePack → `ServerError(MalformedFrame)` +и закрытие `4400`. Сервер не пытается «починить» такой кадр. + +--- + +## 3. Кадры + +Кадр — объект MessagePack с union-дискриминатором. На проводе union выглядит +как массив из двух элементов: `[ключ, тело]`. Ключи зафиксированы и не меняются. + +### 3.1. Клиент → сервер + +| Ключ | Кадр | Назначение | +|---|---|---| +| 0 | `ClientHello` | первый кадр: версия, тикет, догон | +| 1 | `ClientIntent` | игровое действие | +| 2 | `ClientResync` | запрос догона или полного снимка | +| 3 | `ClientPing` | проверка связи и часов | +| 4 | `ClientPong` | ответ на `ServerPing` | +| 5 | `ClientLeave` | добровольный выход (матч не бросается) | + +`ClientHello`: + +| Поле | Тип | Смысл | +|---|---|---| +| `protocolVersion` | int | `ProtocolConstants.Version` | +| `ticket` | string | одноразовый тикет | +| `clientBuild` | string | версия клиента для логов | +| `resumeFromSeq` | long | последний применённый `eventSeq`, 0 — без догона | + +`ClientIntent`: + +| Поле | Тип | Смысл | +|---|---|---| +| `intentId` | string | GUID, уникальный в рамках матча | +| `basedOnSeq` | long | `eventSeq`, на котором построен интент | +| `intent` | union | полезная нагрузка (раздел 4) | + +`ClientResync`: + +| Поле | Тип | Смысл | +|---|---|---| +| `fromSeq` | long | с какого номера догонять | +| `fullSnapshot` | bool | true — прислать полный снимок | + +`ClientPing`: `nonce`, `clientTimeMs`. `ClientPong`: `nonce`. +`ClientLeave`: `reason`. + +### 3.2. Сервер → клиент + +| Ключ | Кадр | Назначение | +|---|---|---| +| 0 | `ServerHello` | принятое подключение | +| 1 | `ServerSnapshot` | полное состояние под игрока | +| 2 | `ServerEvents` | порция событий | +| 3 | `ServerIntentResult` | результат интента | +| 4 | `ServerClock` | синхронизация таймеров | +| 5 | `ServerPing` | проверка связи | +| 6 | `ServerPong` | ответ на `ClientPing` | +| 7 | `ServerError` | ошибка протокола | + +`ServerHello`: + +| Поле | Тип | Смысл | +|---|---|---| +| `protocolVersion` | int | версия сервера | +| `matchId` | string | идентификатор матча | +| `playerIndex` | int | место игрока: 0 или 1 | +| `resumed` | bool | true — догон журналом, false — будет снимок | +| `eventSeq` | long | текущий последний номер события | +| `serverTimeMs` | long | UTC, миллисекунды | + +`ServerSnapshot`: `view` — `MatchView` (раздел 6). +`ServerEvents`: `fromSeq`, `toSeq`, `events[]` (`seq` + `event`). +`ServerIntentResult`: `intentId`, `accepted`, `code`, `reason`, `eventSeq`, +`duplicate`. +`ServerClock`: `clock` (`ClockView`), `eventSeq`. +`ServerPing`: `nonce`, `serverTimeMs`. `ServerPong`: `nonce`, `serverTimeMs`. +`ServerError`: `code`, `message`, `fatal`, `eventSeq`. + +--- + +## 4. Интенты + +Полезная нагрузка повторяет команды движка один в один. Union-ключи: + +| Ключ | Интент | Поля | +|---|---|---| +| 0 | `MulliganIntent` | `handIndexes[]` | +| 1 | `FinishMulliganIntent` | — | +| 2 | `RideIntent` | `printingId` | +| 3 | `SkipRideIntent` | — | +| 4 | `CallIntent` | `printingId`, `slot` | +| 5 | `MoveIntent` | `fromSlot`, `toSlot` | +| 6 | `EnterBattleIntent` | — | +| 7 | `AttackIntent` | `attackerSlot`, `targetSlot`, `boosterSlot` | +| 8 | `GuardIntent` | `printingId`, `discardPrintingId?` | +| 9 | `InterceptIntent` | `slot` | +| 10 | `PassGuardIntent` | — | +| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` | +| 12 | `SelectTriggerIntent` | `index` | +| 13 | `ResolveAbilityIntent` | `accept` | +| 14 | `ChooseIntent` | `index` | +| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?` | +| 16 | `EndTurnIntent` | — | +| 17 | `ConcedeIntent` | — | + +Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний. +`GuardIntent.discardPrintingId` заполняется только для идеальной защиты: +без него Sentinel не отменяет атаку; с ним указывается карта того же клана. + +`ConcedeIntent` обрабатывает сервер, а не движок: матч завершается, +победителем становится противник. + +### 4.1. Обработка + +1. Сервер проверяет, что соединение прошло handshake и привязано к этому матчу. +2. Ищет `intentId` в журнале идемпотентности. Нашёл — возвращает сохранённый + результат с `duplicate = true`, состояние не меняется. +3. Проверяет, что сейчас решает этот игрок. Во время шага защиты решает + обороняющийся, а не атакующий. +4. Переводит DTO в `GameCommand` и вызывает `GameEngine.Execute`. +5. Пишет принятый `intentId` и результат в журнал. +6. Отправляет `ServerIntentResult`, затем `ServerEvents` с событиями, + порождёнными интентом (включая автоматические разрешения). + +Интенты одного игрока обрабатываются строго в порядке прихода. Ждать +`ServerIntentResult` перед отправкой следующего не обязательно, но клиенту +проще: при отказе не придётся откатывать оптимистичный UI. + +### 4.2. `intentId` + +- GUID в виде строки, генерируется клиентом заново для каждого действия. +- Уникален в рамках матча и игрока. Повтор после обрыва связи — тот же id. +- Журнал идемпотентности живёт весь матч: интентов за партию сотни, память + не проблема. Чистить его по TTL не нужно. + +### 4.3. `basedOnSeq` + +Это последний `eventSeq`, который клиент применил. Сервер **не** отклоняет +интент только из-за отставания: состояние авторитарно, интент проверяется +по нему. Поле нужно для диагностики и для решения «пора слать resync». + +Если интент отклонён, а `basedOnSeq` меньше текущего `eventSeq`, клиент +обязан запросить `ClientResync`. Если сервер видит, что интент ссылается +на уже недоступное состояние (например, карта давно ушла из руки), он +возвращает `IntentRejectCode.Stale`. + +### 4.4. Причины отказа + +| Код | Значение | +|---|---| +| `None` | принят | +| `Illegal` | движок отклонил действие, причина в `reason` | +| `NotYourTurn` | сейчас решает другой игрок | +| `MatchFinished` | партия завершена | +| `NotSupported` | интент не поддержан этой версией | +| `RateLimited` | превышен лимит частоты | +| `Malformed` | пустой или слишком длинный `intentId`, битые поля | +| `Stale` | клиент отстал, нужен resync | + +Отказ никогда не меняет состояние. `reason` — человекочитаемая строка для +лога и отладки; клиент не должен разбирать её, только показывать. + +--- + +## 5. События и `eventSeq` + +`eventSeq` — номер события в **общем** журнале матча. Нумерация начинается +с 1 (`GameStarted`). Журнал неизменяем и живёт весь матч. + +Событие, скрытое от конкретного игрока, всё равно занимает номер. Поэтому +в `ServerEvents` номера могут идти с пропусками: это не потеря, а скрытое +событие. Клиент обязан: + +- применять события строго по возрастанию `seq`; +- не считать пропуск ошибкой; +- обновлять `lastSeq` по `toSeq` (а не по последнему видимому событию); +- при `seq <= lastSeq` игнорировать событие как повтор. + +События одного интента приходят одним кадром `ServerEvents`. Сервер также +может слать события без интента — например, авто-поражение по таймеру. + +Скрытые события (уходят только владельцу): + +- `CardSearchedView` — поиск в колоде; +- `SearchFailedView` — неудачный поиск вместе со строкой запроса. + +Остальные события открыты обоим игрокам: райд, призыв, перемещение, атака, +поддержка, защита, проверки, урон, уничтожение, сброс, душа, возврат в колоду, +срабатывание и разрешение способностей. + +--- + +## 6. Проекция состояния + +`MatchView` собирается **отдельно для каждого игрока**. Это не «общее +состояние с флагами», а два разных объекта: то, что не положено видеть, +в объект просто не попадает. + +| Данные | Свои | Противник | +|---|---|---| +| Рука | карты полностью | только `handCount` | +| Колода | только `deckCount` | только `deckCount` | +| Урон лицом вниз | только `faceDownDamage` | только `faceDownDamage` | +| Урон лицом вверх | карты | карты | +| Авангард, Арьергард | карты | карты | +| Душа, сброс, зона триггера, круг защиты | карты | карты | +| Сила и крит | эффективные, посчитаны сервером | эффективные | +| Ожидание выбора (`Choice`) | полностью | не отправляется | +| Ожидание способности (`Ability`) | полностью | не отправляется | +| Порядок триггеров (`Order`) | полностью | не отправляется | +| Ожидание триггера (`Trigger`) | полностью | полностью (проверка открыта) | +| Легальные интенты | свои | только «сдаться» | +| Seed, RNG | никогда | никогда | + +`MatchView` содержит: + +| Поле | Смысл | +|---|---| +| `matchId`, `eventSeq` | идентификатор и последний применённый номер | +| `you` | для кого собрано состояние | +| `activePlayer` | кто принимает решения | +| `turnNumber`, `phase`, `step` | ход и фаза | +| `isFinished`, `winner` | итог (`winner = -1`, пока игра идёт) | +| `players[2]` | `PlayerView` обоих игроков | +| `battle` | текущий бой или null | +| `trigger` | ожидающий триггер, если есть | +| `choice`, `ability`, `order` | личные ожидания, только для решающего | +| `opponentChoosing` | противник сейчас что-то выбирает (без деталей) | +| `clock` | таймеры (раздел 8) | +| `legalIntents` | что этот игрок может отправить прямо сейчас | + +`legalIntents` собирается из `GameEngine.LegalCommands()`, но только для +игрока, который сейчас решает. Наблюдателю чужой список не отправляется: +в нём встречаются printingId из руки решающего. Сдача доступна всегда, пока +партия не закончена, поэтому `ConcedeIntent` есть в списке у обоих. + +`PlayerView`: + +| Поле | Смысл | +|---|---| +| `index`, `name` | место и имя | +| `handCount`, `deckCount` | количество, не содержимое | +| `hand` | свои карты; у противника пустой список | +| `vanguard`, `rearGuards[5]` | поле, пустой круг — null | +| `guardian`, `soul`, `drop`, `trigger` | открытые зоны | +| `faceDownDamage` | число закрытых карт урона | +| `faceUpDamage` | открытые карты урона | +| `isDefeated` | поражение | + +`CardView` содержит `instanceId`, `printingId`, `rested`, `faceUp`, +`basePower`, `power`, `baseCritical`, `critical`. Тексты карт клиент берёт +из своего каталога: сервер не дублирует контент в каждом событии. + +--- + +## 7. Resync + +Клиент запрашивает догон, когда: + +- пропустил события (например, после долгой заморозки процесса); +- получил `IntentRejectCode.Stale`; +- подозревает рассинхрон (не сходится `eventSeq`). + +`ClientResync { fromSeq, fullSnapshot }`: + +- `fullSnapshot = true` → сервер отвечает `ServerSnapshot`; +- иначе, если журнал доступен → `ServerEvents` со всеми видимыми событиями + начиная с `fromSeq`; +- если журнал недоступен (например, grain восстановлен из снапшота) → + `ServerSnapshot`. + +`ServerEvents` в ответ на resync может быть пустым: `fromSeq > toSeq` значит, +что клиент уже в актуальном состоянии. + +После `ServerSnapshot` клиент **заменяет** локальное состояние целиком и +выставляет `lastSeq = view.eventSeq`. Догонять снимок событиями не нужно. + +--- + +## 8. Таймеры + +### 8.1. Ход + +- На каждое решение даётся **90 секунд** бесплатного времени. +- Дальше тратится **банк 5 минут** на игрока за матч. +- Банк не пополняется. Неизрасходованные 90 секунд в банк не переходят. +- Банк исчерпан → этот игрок проигрывает. +- Таймер идёт и во время выбора целей, защиты, порядка триггеров и + подтверждения способности: это тоже решения. + +Отсчёт начинается, когда игрок становится `activePlayer`. Смена активного +игрока (в том числе переход хода защиты к обороняющемуся) начинает новый +отсчёт 90 секунд. + +`ClockView`: + +| Поле | Смысл | +|---|---| +| `decisionPlayer` | кто сейчас решает | +| `turnDeadlineMs` | когда истекают 90 секунд (UTC, мс); 0 — таймер стоит | +| `bank0Ms`, `bank1Ms` | остаток банка каждого игрока | +| `isRunning` | идёт ли отсчёт | +| `serverTimeMs` | время сервера для расчёта смещения | + +Сервер шлёт `ServerClock` каждые 5 секунд и сразу после смены активного +игрока. Клиент считает оставшееся время от `turnDeadlineMs` с поправкой +на разницу часов, а не от момента получения кадра. + +При исчерпании времени сервер сам завершает партию, добавляет +`GameFinishedView` и рассылает его обоим. Интент проигравшего после этого +отклоняется с `MatchFinished`. + +### 8.2. Отсутствие + +- Пропал транспорт (закрытие, ошибка, heartbeat) → матч продолжается. +- Игрок отсутствует **5 минут** подряд → авто-поражение. +- Таймер хода при этом не останавливается: банк может кончиться раньше. +- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут. +- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает + потраченное время. + +--- + +## 9. Heartbeat + +- Если от клиента 20 секунд нет **любого** кадра, сервер отправляет + `ServerPing`. +- Клиент обязан ответить `ClientPong` в течение 20 секунд. +- Нет ответа 40 секунд от последнего кадра → закрытие `4408`. +- Клиент, у которого 20 секунд нет трафика, отправляет `ClientPing`; + сервер отвечает `ServerPong`. Это же синхронизирует часы. +- Любой кадр считается активностью и сбрасывает оба таймера. +- Штатное закрытие — `ClientLeave` + WebSocket close `1000`; матч при этом + не завершается, а ждёт возвращения по общим правилам. + +--- + +## 10. Переподключение + +1. Клиент замечает обрыв (ошибка сокета, `4408`, закрытие). +2. Ждёт по задержкам: **0, 2, 10, 30 секунд**. +3. На каждую попытку получает **новый** тикет через мета-API + (`POST /api/game/tickets`, TTL 60 секунд). Тикет одноразовый. +4. Подключается и отправляет `ClientHello` с `resumeFromSeq = lastSeq`. +5. Сервер отвечает `ServerHello`. + +Далее: + +- `resumed = true` → сразу `ServerEvents` с пропущенными событиями; +- `resumed = false` → `ServerSnapshot`, затем обычная игра; +- если игрок уже отсутствовал 5 минут, матч завершён: сервер присылает + `ServerHello` с актуальным `eventSeq`, затем снимок с `isFinished = true`. + +После четырёх неудачных попыток клиент показывает экран «не удалось +подключиться» с кнопкой ручного повтора, но продолжает считать время +отсутствия. Матч на сервере при этом не удаляется до истечения 5 минут. + +### 10.1. Захват сессии + +Если для места уже есть живое соединение, новое `ClientHello` с валидным +тикетом **заменяет** его: старое закрывается кодом `4409`, новое получает +`ServerHello`. Это защищает от ситуации «старый сокет ещё не умер, а клиент +уже переподключился». Тикет при этом всё равно одноразовый. + +### 10.2. Восстановление grain + +После рестарта сервера матч поднимается из снапшота PostgreSQL. Журнал +событий может быть недоступен: тогда `resumed = false`, и клиент получает +`ServerSnapshot`. Хранить весь журнал в БД не обязательно — снимок и +`eventSeq` достаточны для продолжения. + +--- + +## 11. Ошибки и коды закрытия + +`ServerError` несёт `code`, `message`, `fatal`, `eventSeq`. Если `fatal`, +сервер закрывает соединение сразу после кадра. + +| Код | Когда | +|---|---| +| `UnsupportedVersion` | версия клиента не равна версии сервера | +| `InvalidTicket` | тикет неизвестен, использован или чужой | +| `TicketExpired` | прошло больше 60 секунд | +| `MatchNotFound` | матч не найден или удалён | +| `MalformedFrame` | мусор, пустой кадр, превышен размер | +| `RateLimited` | слишком много интентов | +| `MatchFinished` | партия закончена, соединение больше не нужно | +| `Internal` | непредвиденная ошибка сервера | + +Коды закрытия WebSocket (диапазон приложения 4000–4999): + +| Код | Значение | +|---|---| +| `4400` | нарушение протокола (handshake, битый кадр) | +| `4401` | не авторизован (тикет) | +| `4408` | heartbeat не получен | +| `4409` | соединение заменено новым | +| `4410` | неподдерживаемая версия протокола | +| `4413` | превышен лимит частоты | +| `4500` | внутренняя ошибка | + +Причина закрытия — короткая ASCII-строка. Секреты и содержимое руки в неё +не попадают. + +--- + +## 12. Схема MessagePack + +- Все DTO — `[MessagePackObject]` с целочисленными `[Key]`; на проводе это + массивы, а не словари. +- Union — `[Union(ключ, тип)]` на абстрактном кадре или полезной нагрузке. + Union-ключи и ключи полей **никогда не переиспользуются**. +- Новые поля добавляются только в конец с новым номером ключа. Старый клиент + получит для них значение по умолчанию, новый клиент переживёт отсутствие + поля. +- `MessagePackSecurity.UntrustedData`: глубина и размер структур ограничены. +- Typeless-режим запрещён: он позволяет данным выбирать тип при десериализации. +- Строки интернируются форматтером: `printingId` повторяется в событиях. +- Сжатие LZ4 не используется: кадры малы, а совместимость важнее. + +Кодек — `Vanguard.Shared.Protocol.ProtocolSerializer`. Резолвер — +`ProtocolResolver`; он собирает `StandardResolver` (включая сгенерированные +форматтеры) и интернирование строк. + +--- + +## 13. Сценарии + +### 13.1. Первое подключение + +``` +клиент сервер + | POST /api/game/tickets (HTTP) | + |---------------------------------------->| + | { ticket, matchId, expiresAt } | + |<----------------------------------------| + | | + | WS /game | + |---------------------------------------->| + | ClientHello(version, ticket, 0) | + |---------------------------------------->| + | ServerHello(resumed=false, seq=N) + |<----------------------------------------| + | ServerSnapshot(view, seq=N) + |<----------------------------------------| +``` + +### 13.2. Ход + +``` + | ClientIntent(id, seq, RideIntent) | + |---------------------------------------->| + | ServerIntentResult(id, accepted=true, seq=M) + |<----------------------------------------| + | ServerEvents(seq=M-2..M) + |<----------------------------------------| +``` + +Если интент отклонён, `ServerEvents` не приходит: состояние не менялось. + +### 13.3. Обрыв и возврат + +``` + | X связь пропала | + | | + | (0 с) новый тикет, WS, Hello(resume=N) | + |---------------------------------------->| + | ServerHello(resumed=true, seq=M) + |<----------------------------------------| + | ServerEvents(N+1..M) + |<----------------------------------------| +``` + +### 13.4. Resync после рассинхрона + +``` + | ClientResync(fromSeq=N, fullSnapshot=false) + |---------------------------------------->| + | ServerEvents(N..M) (или ServerSnapshot) + |<----------------------------------------| +``` + +--- + +## 14. Критерии приёмки + +1. Два клиента играют полный матч по сети: райд, призыв, атака, защита, + проверки, урон, победа. +2. Kill клиента на любом шаге: после перезапуска и переподключения состояние + совпадает с сервером, `eventSeq` непрерывен. +3. Дамп трафика не содержит: seed и RNG, карт колоды, карт руки противника, + закрытых карт урона, вариантов чужого выбора. +4. Повторная отправка одного `intentId` не меняет состояние и возвращает + `duplicate = true`. +5. Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие + 5 минут завершает матч поражением отсутствующего. +6. Истечение 90 секунд и банка корректно завершает партию; `ServerClock` + приходит не реже раза в 5 секунд. +7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера. diff --git a/server/src/Vanguard.Shared/Projection/EventProjection.cs b/server/src/Vanguard.Shared/Projection/EventProjection.cs new file mode 100644 index 0000000..ca3e226 --- /dev/null +++ b/server/src/Vanguard.Shared/Projection/EventProjection.cs @@ -0,0 +1,125 @@ +using Vanguard.Rules.Engine; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Shared.Projection; + +/// +/// Превращает событие движка в то, что разрешено видеть конкретному игроку. +/// null — событие целиком скрыто от него. +/// +public static class EventProjection +{ + public static EventView? ForViewer(GameEvent gameEvent, int viewer) => gameEvent switch + { + GameStartedEvent e => new GameStartedView { FirstPlayer = e.FirstPlayer }, + PhaseChangedEvent e => new PhaseChangedView { Player = e.Player, Phase = RuleMapping.Map(e.Phase) }, + MulliganEvent e => new MulliganView { Player = e.Player, Count = e.Count }, + CardDrawnEvent e => new CardDrawnView { Player = e.Player }, + UnitRodeEvent e => new UnitRodeView { Player = e.Player, PrintingId = e.PrintingId }, + UnitCalledEvent e => new UnitCalledView { Player = e.Player, Slot = e.Slot, PrintingId = e.PrintingId }, + UnitMovedEvent e => new UnitMovedView { Player = e.Player, FromSlot = e.FromSlot, ToSlot = e.ToSlot }, + DriveCheckedEvent e => new DriveCheckedView + { + Player = e.Player, + PrintingId = e.PrintingId, + Trigger = RuleMapping.Map(e.Trigger), + }, + AttackDeclaredEvent e => new AttackDeclaredView + { + Player = e.Player, + AttackerSlot = e.AttackerSlot, + TargetSlot = e.TargetSlot, + }, + BoostedEvent e => new BoostedView + { + Player = e.Player, + BoosterSlot = e.BoosterSlot, + AttackerSlot = e.AttackerSlot, + }, + GuardAddedEvent e => new GuardAddedView + { + Player = e.Player, + PrintingId = e.PrintingId, + Shield = e.Shield, + }, + PerfectGuardEvent e => new PerfectGuardView + { + Player = e.Player, + SentinelPrintingId = e.SentinelPrintingId, + DiscardedPrintingId = e.DiscardedPrintingId, + }, + InterceptedEvent e => new InterceptedView { Player = e.Player, Slot = e.Slot, PrintingId = e.PrintingId }, + InterceptorRetiredEvent e => new InterceptorRetiredView + { + Player = e.Player, + PrintingId = e.PrintingId, + }, + TriggerResolvedEvent e => new TriggerResolvedView + { + Player = e.Player, + PrintingId = e.PrintingId, + Trigger = RuleMapping.Map(e.Trigger), + }, + TriggerAppliedEvent e => new TriggerAppliedView + { + Player = e.Player, + PowerTarget = e.PowerTarget, + EffectTarget = e.EffectTarget, + }, + BattleResolvedEvent e => new BattleResolvedView { Hit = e.Hit }, + DamageTakenEvent e => new DamageTakenView { Player = e.Player, Amount = e.Amount, Total = e.Total }, + HealedEvent e => new HealedView { Player = e.Player }, + UnitRetiredEvent e => new UnitRetiredView { Player = e.Player, Slot = e.Slot }, + + // Поиск в колоде — личное действие: противник не должен знать ни карту, ни запрос. + CardSearchedEvent e when e.Player != viewer => null, + CardSearchedEvent e => new CardSearchedView { Player = e.Player, PrintingId = e.PrintingId }, + SearchFailedEvent e when e.Player != viewer => null, + SearchFailedEvent e => new SearchFailedView { Player = e.Player, Query = e.Query }, + + CardRevealedEvent e => new CardRevealedView { Player = e.Player, PrintingId = e.PrintingId }, + CardReturnedToDeckEvent e => new CardReturnedToDeckView + { + Player = e.Player, + PrintingId = e.PrintingId, + }, + CardDiscardedEvent e => new CardDiscardedView { Player = e.Player, PrintingId = e.PrintingId }, + UnitReturnedToDeckEvent e => new UnitReturnedToDeckView + { + Player = e.Player, + PrintingId = e.PrintingId, + }, + CardMovedToSoulEvent e => new CardMovedToSoulView { Player = e.Player, PrintingId = e.PrintingId }, + GameFinishedEvent e => new GameFinishedView { Winner = e.Winner }, + AbilityTriggeredEvent e => new AbilityTriggeredView + { + Player = e.Player, + PrintingId = e.PrintingId, + Trigger = RuleMapping.Map(e.Trigger), + }, + AbilityResolvedEvent e => new AbilityResolvedView + { + Player = e.Player, + PrintingId = e.PrintingId, + Accepted = e.Accepted, + }, + _ => throw new ArgumentOutOfRangeException(nameof(gameEvent), gameEvent, "Неизвестное событие."), + }; + + /// Все видимые события игрока из общего журнала, начиная с номера . + public static List ProjectRange(IReadOnlyList events, int viewer, long fromSeq) + { + var result = new List(); + if (fromSeq < 1) + fromSeq = 1; + + for (var i = (int)Math.Min(fromSeq - 1, events.Count); i < events.Count; i++) + { + var view = ForViewer(events[i], viewer); + if (view is not null) + result.Add(new EventEnvelope { Seq = i + 1, Event = view }); + } + + return result; + } +} diff --git a/server/src/Vanguard.Shared/Projection/IntentMapping.cs b/server/src/Vanguard.Shared/Projection/IntentMapping.cs new file mode 100644 index 0000000..51407fe --- /dev/null +++ b/server/src/Vanguard.Shared/Projection/IntentMapping.cs @@ -0,0 +1,76 @@ +using Vanguard.Rules.Engine; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Shared.Projection; + +/// +/// Перевод между командами движка и сетевыми интентами. Единственное место, +/// где сервер соприкасается с внутренним API правил. +/// +public static class IntentMapping +{ + public static IntentPayload FromCommand(GameCommand command) => command switch + { + MulliganCommand c => new MulliganIntent { HandIndexes = c.HandIndexes.ToList() }, + FinishMulliganCommand => new FinishMulliganIntent(), + RideCommand c => new RideIntent { PrintingId = c.PrintingId }, + SkipRideCommand => new SkipRideIntent(), + CallCommand c => new CallIntent { PrintingId = c.PrintingId, Slot = c.Slot }, + MoveCommand c => new MoveIntent { FromSlot = c.FromSlot, ToSlot = c.ToSlot }, + EnterBattleCommand => new EnterBattleIntent(), + AttackCommand c => new AttackIntent + { + AttackerSlot = c.AttackerSlot, + TargetSlot = c.TargetSlot, + BoosterSlot = c.BoosterSlot, + }, + GuardCommand c => new GuardIntent + { + PrintingId = c.PrintingId, + DiscardPrintingId = c.DiscardPrintingId, + }, + InterceptCommand c => new InterceptIntent { Slot = c.Slot }, + PassGuardCommand => new PassGuardIntent(), + ResolveTriggerCommand c => new ResolveTriggerIntent + { + PowerTarget = c.PowerTarget, + EffectTarget = c.EffectTarget, + }, + SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index }, + ResolveAbilityCommand c => new ResolveAbilityIntent { Accept = c.Accept }, + ChooseCommand c => new ChooseIntent { Index = c.Index }, + ActivateAbilityCommand c => new ActivateAbilityIntent + { + InstanceId = c.InstanceId, + DiscardPrintingId = c.DiscardPrintingId, + }, + EndTurnCommand => new EndTurnIntent(), + _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."), + }; + + /// + /// null — интент обрабатывается сервером, а не движком (сдача). + /// + public static GameCommand? ToCommand(IntentPayload intent) => intent switch + { + MulliganIntent c => new MulliganCommand(c.HandIndexes), + FinishMulliganIntent => new FinishMulliganCommand(), + RideIntent c => new RideCommand(c.PrintingId), + SkipRideIntent => new SkipRideCommand(), + CallIntent c => new CallCommand(c.PrintingId, c.Slot), + MoveIntent c => new MoveCommand(c.FromSlot, c.ToSlot), + EnterBattleIntent => new EnterBattleCommand(), + AttackIntent c => new AttackCommand(c.AttackerSlot, c.TargetSlot, c.BoosterSlot), + GuardIntent c => new GuardCommand(c.PrintingId, c.DiscardPrintingId), + InterceptIntent c => new InterceptCommand(c.Slot), + PassGuardIntent => new PassGuardCommand(), + ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget), + SelectTriggerIntent c => new SelectTriggerCommand(c.Index), + ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept), + ChooseIntent c => new ChooseCommand(c.Index), + ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId), + EndTurnIntent => new EndTurnCommand(), + ConcedeIntent => null, + _ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."), + }; +} diff --git a/server/src/Vanguard.Shared/Projection/MatchProjection.cs b/server/src/Vanguard.Shared/Projection/MatchProjection.cs new file mode 100644 index 0000000..3db61d0 --- /dev/null +++ b/server/src/Vanguard.Shared/Projection/MatchProjection.cs @@ -0,0 +1,232 @@ +using Vanguard.Rules.Engine; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Shared.Projection; + +/// +/// Проекция авторитарного состояния на одного игрока. +/// +/// Правила видимости: +/// * своя рука — полностью, рука противника — только количество; +/// * колода — только количество у обоих; +/// * урон лицом вниз — только количество у обоих (правила Classic его не открывают); +/// * урон лицом вверх, душа, сброс, зона триггера и круг защиты — полностью; +/// * seed и состояние RNG в проекцию не попадают никогда; +/// * выборы, способности и порядок триггеров видны только тому, кто их разрешает. +/// +public static class MatchProjection +{ + public static MatchView ForViewer( + GameEngine engine, + int viewer, + string matchId, + long eventSeq, + ClockView clock) + { + if (viewer is not (0 or 1)) + throw new ArgumentOutOfRangeException(nameof(viewer), viewer, "Индекс игрока — 0 или 1."); + + var state = engine.State; + var isDecisionMaker = state.ActivePlayer == viewer && !state.IsFinished; + + return new MatchView + { + MatchId = matchId, + EventSeq = eventSeq, + You = viewer, + ActivePlayer = state.ActivePlayer, + TurnNumber = state.TurnNumber, + Phase = RuleMapping.Map(state.Phase), + Step = RuleMapping.Map(state.Step), + IsFinished = state.IsFinished, + Winner = state.Winner ?? -1, + Players = new List + { + ProjectPlayer(engine, 0, viewer), + ProjectPlayer(engine, 1, viewer), + }, + Battle = ProjectBattle(engine), + Trigger = ProjectTrigger(state.PendingTrigger), + Choice = ProjectChoice(state, viewer), + Ability = ProjectAbility(state, viewer), + Order = ProjectOrder(state, viewer), + OpponentChoosing = IsOpponentChoosing(state, viewer), + Clock = clock, + LegalIntents = ProjectLegalIntents(engine, viewer, isDecisionMaker), + }; + } + + private static PlayerView ProjectPlayer(GameEngine engine, int index, int viewer) + { + var player = engine.State.Players[index]; + var isOwn = index == viewer; + + return new PlayerView + { + Index = index, + Name = player.Name, + HandCount = player.Hand.Count, + DeckCount = player.Deck.Count, + + // Рука противника не покидает сервер: даже пустые слоты не намекают на состав. + Hand = isOwn + ? player.Hand.Select(card => ProjectCard(engine, card)).ToList() + : new List(), + + Vanguard = player.Vanguard is null ? null : ProjectCard(engine, player.Vanguard), + RearGuards = player.RearGuards + .Select(card => card is null ? null : ProjectCard(engine, card)) + .ToArray(), + + Guardian = player.Guardian.Select(card => ProjectCard(engine, card)).ToList(), + Soul = player.Soul.Select(card => ProjectCard(engine, card)).ToList(), + + FaceDownDamage = player.Damage.Count(card => !card.FaceUp), + FaceUpDamage = player.Damage + .Where(card => card.FaceUp) + .Select(card => ProjectCard(engine, card)) + .ToList(), + + Drop = player.Drop.Select(card => ProjectCard(engine, card)).ToList(), + Trigger = player.Trigger.Select(card => ProjectCard(engine, card)).ToList(), + IsDefeated = player.IsDefeated, + }; + } + + private static CardView ProjectCard(GameEngine engine, CardInstance card) + { + var definition = engine.State.Catalog.CardOf(card.PrintingId); + + return new CardView + { + InstanceId = card.Id, + PrintingId = card.PrintingId, + Rested = card.Rested, + FaceUp = card.FaceUp, + BasePower = definition.Power, + Power = engine.EffectivePower(card), + BaseCritical = definition.Critical, + Critical = engine.EffectiveCritical(card), + }; + } + + private static BattleView? ProjectBattle(GameEngine engine) + { + var battle = engine.State.Battle; + if (battle is null) + return null; + + var defender = engine.State.Players[1 - battle.AttackerPlayer]; + + return new BattleView + { + AttackerPlayer = battle.AttackerPlayer, + AttackerSlot = battle.AttackerSlot, + TargetSlot = battle.TargetSlot, + GuardShield = battle.GuardShield, + ChecksRemaining = battle.ChecksRemaining, + Nullified = battle.Nullified, + InterceptorSlot = battle.Interceptor is null + ? -1 + : Array.IndexOf(defender.RearGuards, battle.Interceptor), + BoosterSlot = battle.BoosterSlot, + GuardCards = battle.GuardCards.Select(card => ProjectCard(engine, card)).ToList(), + }; + } + + private static TriggerView? ProjectTrigger(PendingTrigger? trigger) => trigger is null + ? null + : new TriggerView + { + Player = trigger.Player, + PrintingId = trigger.PrintingId, + Trigger = RuleMapping.Map(trigger.Trigger), + Context = RuleMapping.Map(trigger.Context), + GrantsPower = trigger.GrantsPower, + RemainingDamage = trigger.RemainingDamage, + }; + + private static ChoiceView? ProjectChoice(GameState state, int viewer) + { + var choice = state.PendingChoice; + if (choice is null || choice.Player != viewer) + return null; + + return new ChoiceView + { + Player = choice.Player, + Prompt = choice.Prompt, + Options = choice.Options + .Select(option => new ChoiceOptionView { Slot = option.Slot, Label = option.Label }) + .ToList(), + }; + } + + private static AbilityView? ProjectAbility(GameState state, int viewer) + { + var ability = state.PendingAbility; + if (ability is null || ability.Owner != viewer) + return null; + + return new AbilityView + { + Player = ability.Owner, + InstanceId = ability.Context.Source.Id, + PrintingId = ability.Context.Source.PrintingId, + Trigger = RuleMapping.Map(ability.Definition.Trigger), + Optional = ability.Definition.Optional, + }; + } + + private static OrderView? ProjectOrder(GameState state, int viewer) + { + var order = state.PendingOrder; + if (order is null || order.Count == 0 || state.ActivePlayer != viewer || order[0].Owner != viewer) + return null; + + return new OrderView + { + Player = viewer, + Options = order + .Select((ability, index) => new { ability, index }) + .Where(item => item.ability.Owner == viewer) + .Select(item => new OrderOptionView + { + Index = item.index, + InstanceId = item.ability.Context.Source.Id, + PrintingId = item.ability.Context.Source.PrintingId, + Trigger = RuleMapping.Map(item.ability.Definition.Trigger), + }) + .ToList(), + }; + } + + private static bool IsOpponentChoosing(GameState state, int viewer) + { + var opponent = 1 - viewer; + + return state.PendingChoice?.Player == opponent + || state.PendingAbility?.Owner == opponent + || (state.PendingOrder is { Count: > 0 } order && order[0].Owner == opponent); + } + + private static List ProjectLegalIntents(GameEngine engine, int viewer, bool isDecisionMaker) + { + var intents = new List(); + if (engine.State.IsFinished) + return intents; + + // Сдаться можно в любой момент, независимо от очереди хода. + intents.Add(new ConcedeIntent()); + + if (!isDecisionMaker) + return intents; + + // LegalCommands() всегда описывает состояние того, кто сейчас решает, + // поэтому вызывать его для наблюдателя нельзя: там бывают карты чужой руки. + foreach (var command in engine.LegalCommands()) + intents.Add(IntentMapping.FromCommand(command)); + + return intents; + } +} diff --git a/server/src/Vanguard.Shared/Projection/RuleMapping.cs b/server/src/Vanguard.Shared/Projection/RuleMapping.cs new file mode 100644 index 0000000..9ced069 --- /dev/null +++ b/server/src/Vanguard.Shared/Projection/RuleMapping.cs @@ -0,0 +1,69 @@ +using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine; +using Vanguard.Rules.Engine.Abilities; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Shared.Projection; + +/// Перевод внутренних перечислений правил в стабильные значения протокола. +internal static class RuleMapping +{ + public static GamePhase Map(Phase phase) => phase switch + { + Phase.Mulligan => GamePhase.Mulligan, + Phase.Stand => GamePhase.Stand, + Phase.Draw => GamePhase.Draw, + Phase.Ride => GamePhase.Ride, + Phase.Main => GamePhase.Main, + Phase.Battle => GamePhase.Battle, + Phase.End => GamePhase.End, + _ => throw new ArgumentOutOfRangeException(nameof(phase), phase, null), + }; + + public static GameStep Map(Step step) => step switch + { + Step.None => GameStep.None, + Step.Guard => GameStep.Guard, + Step.Drive => GameStep.Drive, + Step.Trigger => GameStep.Trigger, + Step.Ability => GameStep.Ability, + Step.Choice => GameStep.Choice, + _ => throw new ArgumentOutOfRangeException(nameof(step), step, null), + }; + + public static TriggerKind Map(TriggerType trigger) => trigger switch + { + TriggerType.None => TriggerKind.None, + TriggerType.Critical => TriggerKind.Critical, + TriggerType.Draw => TriggerKind.Draw, + TriggerType.Stand => TriggerKind.Stand, + TriggerType.Heal => TriggerKind.Heal, + _ => throw new ArgumentOutOfRangeException(nameof(trigger), trigger, null), + }; + + public static TriggerContextKind Map(TriggerContext context) => context switch + { + TriggerContext.DriveCheck => TriggerContextKind.DriveCheck, + TriggerContext.DamageCheck => TriggerContextKind.DamageCheck, + _ => throw new ArgumentOutOfRangeException(nameof(context), context, null), + }; + + public static AbilityTriggerKind Map(AbilityTrigger trigger) => trigger switch + { + AbilityTrigger.OnRideOver => AbilityTriggerKind.OnRideOver, + AbilityTrigger.OnPlace => AbilityTriggerKind.OnPlace, + AbilityTrigger.OnBoost => AbilityTriggerKind.OnBoost, + AbilityTrigger.OnPhaseStart => AbilityTriggerKind.OnPhaseStart, + AbilityTrigger.OnAttackDeclared => AbilityTriggerKind.OnAttackDeclared, + AbilityTrigger.OnAttackHit => AbilityTriggerKind.OnAttackHit, + AbilityTrigger.OnBoosted => AbilityTriggerKind.OnBoosted, + AbilityTrigger.OnBoostedHit => AbilityTriggerKind.OnBoostedHit, + AbilityTrigger.OnDriveCheck => AbilityTriggerKind.OnDriveCheck, + AbilityTrigger.OnGuard => AbilityTriggerKind.OnGuard, + AbilityTrigger.OnRetireFromRC => AbilityTriggerKind.OnRetireFromRC, + AbilityTrigger.OnOpponentRetire => AbilityTriggerKind.OnOpponentRetire, + AbilityTrigger.OnLeaveGuardian => AbilityTriggerKind.OnLeaveGuardian, + AbilityTrigger.OnDamageTaken => AbilityTriggerKind.OnDamageTaken, + _ => throw new ArgumentOutOfRangeException(nameof(trigger), trigger, null), + }; +} diff --git a/server/src/Vanguard.Shared/Protocol/Events.cs b/server/src/Vanguard.Shared/Protocol/Events.cs new file mode 100644 index 0000000..9fcb7b5 --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/Events.cs @@ -0,0 +1,272 @@ +using MessagePack; + +namespace Vanguard.Shared.Protocol; + +/// +/// Событие матча в клиентском виде. Набор повторяет GameEvent, +/// но скрытые события (например, поиск в колоде) не покидают сервер, +/// поэтому часть событий приходит только их владельцу. +/// +/// Union-ключи стабильны и совпадают с порядком событий в правилах. +/// +[Union(0, typeof(GameStartedView))] +[Union(1, typeof(PhaseChangedView))] +[Union(2, typeof(MulliganView))] +[Union(3, typeof(CardDrawnView))] +[Union(4, typeof(UnitRodeView))] +[Union(5, typeof(UnitCalledView))] +[Union(6, typeof(UnitMovedView))] +[Union(7, typeof(DriveCheckedView))] +[Union(8, typeof(AttackDeclaredView))] +[Union(9, typeof(BoostedView))] +[Union(10, typeof(GuardAddedView))] +[Union(11, typeof(PerfectGuardView))] +[Union(12, typeof(InterceptedView))] +[Union(13, typeof(InterceptorRetiredView))] +[Union(14, typeof(TriggerResolvedView))] +[Union(15, typeof(TriggerAppliedView))] +[Union(16, typeof(BattleResolvedView))] +[Union(17, typeof(DamageTakenView))] +[Union(18, typeof(HealedView))] +[Union(19, typeof(UnitRetiredView))] +[Union(20, typeof(CardSearchedView))] +[Union(21, typeof(SearchFailedView))] +[Union(22, typeof(CardRevealedView))] +[Union(23, typeof(CardReturnedToDeckView))] +[Union(24, typeof(CardDiscardedView))] +[Union(25, typeof(UnitReturnedToDeckView))] +[Union(26, typeof(CardMovedToSoulView))] +[Union(27, typeof(GameFinishedView))] +[Union(28, typeof(AbilityTriggeredView))] +[Union(29, typeof(AbilityResolvedView))] +public abstract class EventView +{ +} + +/// Событие вместе с его номером в общем журнале матча. +[MessagePackObject] +public sealed class EventEnvelope +{ + [Key(0)] public long Seq { get; set; } + [Key(1)] public EventView Event { get; set; } = null!; +} + +[MessagePackObject] +public sealed class GameStartedView : EventView +{ + [Key(0)] public int FirstPlayer { get; set; } +} + +[MessagePackObject] +public sealed class PhaseChangedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public GamePhase Phase { get; set; } +} + +[MessagePackObject] +public sealed class MulliganView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int Count { get; set; } +} + +[MessagePackObject] +public sealed class CardDrawnView : EventView +{ + [Key(0)] public int Player { get; set; } +} + +[MessagePackObject] +public sealed class UnitRodeView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class UnitCalledView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int Slot { get; set; } + [Key(2)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class UnitMovedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int FromSlot { get; set; } + [Key(2)] public int ToSlot { get; set; } +} + +[MessagePackObject] +public sealed class DriveCheckedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public TriggerKind Trigger { get; set; } +} + +[MessagePackObject] +public sealed class AttackDeclaredView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int AttackerSlot { get; set; } + [Key(2)] public int TargetSlot { get; set; } +} + +[MessagePackObject] +public sealed class BoostedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int BoosterSlot { get; set; } + [Key(2)] public int AttackerSlot { get; set; } +} + +[MessagePackObject] +public sealed class GuardAddedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public int Shield { get; set; } +} + +[MessagePackObject] +public sealed class PerfectGuardView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string SentinelPrintingId { get; set; } = string.Empty; + [Key(2)] public string? DiscardedPrintingId { get; set; } +} + +[MessagePackObject] +public sealed class InterceptedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int Slot { get; set; } + [Key(2)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class InterceptorRetiredView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class TriggerResolvedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public TriggerKind Trigger { get; set; } +} + +[MessagePackObject] +public sealed class TriggerAppliedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int PowerTarget { get; set; } + [Key(2)] public int EffectTarget { get; set; } +} + +[MessagePackObject] +public sealed class BattleResolvedView : EventView +{ + [Key(0)] public bool Hit { get; set; } +} + +[MessagePackObject] +public sealed class DamageTakenView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int Amount { get; set; } + [Key(2)] public int Total { get; set; } +} + +[MessagePackObject] +public sealed class HealedView : EventView +{ + [Key(0)] public int Player { get; set; } +} + +[MessagePackObject] +public sealed class UnitRetiredView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int Slot { get; set; } +} + +/// Поиск в колоде. Уходит только искавшему. +[MessagePackObject] +public sealed class CardSearchedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +/// Неудачный поиск. Уходит только искавшему: строка запроса — скрытая информация. +[MessagePackObject] +public sealed class SearchFailedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string Query { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class CardRevealedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class CardReturnedToDeckView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class CardDiscardedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class UnitReturnedToDeckView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class CardMovedToSoulView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class GameFinishedView : EventView +{ + [Key(0)] public int Winner { get; set; } +} + +[MessagePackObject] +public sealed class AbilityTriggeredView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public AbilityTriggerKind Trigger { get; set; } +} + +[MessagePackObject] +public sealed class AbilityResolvedView : EventView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public bool Accepted { get; set; } +} diff --git a/server/src/Vanguard.Shared/Protocol/Frames.cs b/server/src/Vanguard.Shared/Protocol/Frames.cs new file mode 100644 index 0000000..1128e6c --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/Frames.cs @@ -0,0 +1,180 @@ +using MessagePack; + +namespace Vanguard.Shared.Protocol; + +/// +/// Кадр клиента. Каждый бинарный кадр транспорта — ровно один объект этого типа. +/// Union-ключи стабильны. +/// +[Union(0, typeof(ClientHello))] +[Union(1, typeof(ClientIntent))] +[Union(2, typeof(ClientResync))] +[Union(3, typeof(ClientPing))] +[Union(4, typeof(ClientPong))] +[Union(5, typeof(ClientLeave))] +public abstract class ClientFrame +{ +} + +/// Кадр сервера. Union-ключи стабильны. +[Union(0, typeof(ServerHello))] +[Union(1, typeof(ServerSnapshot))] +[Union(2, typeof(ServerEvents))] +[Union(3, typeof(ServerIntentResult))] +[Union(4, typeof(ServerClock))] +[Union(5, typeof(ServerPing))] +[Union(6, typeof(ServerPong))] +[Union(7, typeof(ServerError))] +public abstract class ServerFrame +{ +} + +/// +/// Первый кадр соединения. Пока сервер не принял hello, любые другие кадры +/// считаются нарушением протокола. +/// +[MessagePackObject] +public sealed class ClientHello : ClientFrame +{ + [Key(0)] public int ProtocolVersion { get; set; } + + /// Одноразовый тикет, выданный HTTP-API. TTL 60 секунд. + [Key(1)] public string Ticket { get; set; } = string.Empty; + + /// Версия клиента для логов и диагностики, на совместимость не влияет. + [Key(2)] public string ClientBuild { get; set; } = string.Empty; + + /// Последний применённый eventSeq. 0 — новое подключение без догона. + [Key(3)] public long ResumeFromSeq { get; set; } +} + +/// +/// Интент игрока. — клиентский GUID: по нему сервер +/// отсекает повторы (ретрай после обрыва не применяет действие дважды). +/// +[MessagePackObject] +public sealed class ClientIntent : ClientFrame +{ + [Key(0)] public string IntentId { get; set; } = string.Empty; + + /// eventSeq, на котором построен интент. Для диагностики и resync. + [Key(1)] public long BasedOnSeq { get; set; } + + [Key(2)] public IntentPayload Intent { get; set; } = null!; +} + +/// Запрос догона состояния. +[MessagePackObject] +public sealed class ClientResync : ClientFrame +{ + /// С какого события догонять. Сервер пришлёт всё, что видно этому игроку. + [Key(0)] public long FromSeq { get; set; } + + /// true — прислать полный снимок, даже если журнал ещё доступен. + [Key(1)] public bool FullSnapshot { get; set; } +} + +[MessagePackObject] +public sealed class ClientPing : ClientFrame +{ + [Key(0)] public long Nonce { get; set; } + [Key(1)] public long ClientTimeMs { get; set; } +} + +[MessagePackObject] +public sealed class ClientPong : ClientFrame +{ + [Key(0)] public long Nonce { get; set; } +} + +/// Клиент уходит сам. Матч ждёт его возвращения и идёт по таймерам. +[MessagePackObject] +public sealed class ClientLeave : ClientFrame +{ + [Key(0)] public string Reason { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class ServerHello : ServerFrame +{ + [Key(0)] public int ProtocolVersion { get; set; } + [Key(1)] public string MatchId { get; set; } = string.Empty; + + /// 0 или 1 — место игрока в матче. + [Key(2)] public int PlayerIndex { get; set; } + + /// true — журнал догнан без полного снимка. + [Key(3)] public bool Resumed { get; set; } + + /// Текущий последний eventSeq матча. + [Key(4)] public long EventSeq { get; set; } + + [Key(5)] public long ServerTimeMs { get; set; } +} + +[MessagePackObject] +public sealed class ServerSnapshot : ServerFrame +{ + [Key(0)] public MatchView View { get; set; } = null!; +} + +/// +/// Порция событий. Номера внутри могут идти с пропусками: событие с чужим +/// скрытым содержимым не отправляется, но свой номер в журнале занимает. +/// +[MessagePackObject] +public sealed class ServerEvents : ServerFrame +{ + [Key(0)] public long FromSeq { get; set; } + [Key(1)] public long ToSeq { get; set; } + [Key(2)] public List Events { get; set; } = new(); +} + +[MessagePackObject] +public sealed class ServerIntentResult : ServerFrame +{ + [Key(0)] public string IntentId { get; set; } = string.Empty; + [Key(1)] public bool Accepted { get; set; } + [Key(2)] public IntentRejectCode Code { get; set; } + [Key(3)] public string? Reason { get; set; } + + /// Последний eventSeq после обработки интента. + [Key(4)] public long EventSeq { get; set; } + + /// true — это повтор уже обработанного intentId, состояние не менялось. + [Key(5)] public bool Duplicate { get; set; } +} + +/// Периодическая синхронизация часов. Приходит и без игровых событий. +[MessagePackObject] +public sealed class ServerClock : ServerFrame +{ + [Key(0)] public ClockView Clock { get; set; } = new(); + [Key(1)] public long EventSeq { get; set; } +} + +[MessagePackObject] +public sealed class ServerPing : ServerFrame +{ + [Key(0)] public long Nonce { get; set; } + [Key(1)] public long ServerTimeMs { get; set; } +} + +[MessagePackObject] +public sealed class ServerPong : ServerFrame +{ + [Key(0)] public long Nonce { get; set; } + [Key(1)] public long ServerTimeMs { get; set; } +} + +[MessagePackObject] +public sealed class ServerError : ServerFrame +{ + [Key(0)] public ProtocolErrorCode Code { get; set; } + [Key(1)] public string Message { get; set; } = string.Empty; + + /// true — сервер закроет соединение после этого кадра. + [Key(2)] public bool Fatal { get; set; } + + [Key(3)] public long EventSeq { get; set; } +} diff --git a/server/src/Vanguard.Shared/Protocol/Intents.cs b/server/src/Vanguard.Shared/Protocol/Intents.cs new file mode 100644 index 0000000..4717023 --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/Intents.cs @@ -0,0 +1,149 @@ +using MessagePack; + +namespace Vanguard.Shared.Protocol; + +/// +/// Действие игрока. Один в один повторяет GameCommand из правил, +/// но без ссылки на сборку правил: клиент на Unity не тащит движок. +/// +/// Union-ключи стабильны. Новые интенты добавляются только в конец. +/// +[Union(0, typeof(MulliganIntent))] +[Union(1, typeof(FinishMulliganIntent))] +[Union(2, typeof(RideIntent))] +[Union(3, typeof(SkipRideIntent))] +[Union(4, typeof(CallIntent))] +[Union(5, typeof(MoveIntent))] +[Union(6, typeof(EnterBattleIntent))] +[Union(7, typeof(AttackIntent))] +[Union(8, typeof(GuardIntent))] +[Union(9, typeof(InterceptIntent))] +[Union(10, typeof(PassGuardIntent))] +[Union(11, typeof(ResolveTriggerIntent))] +[Union(12, typeof(SelectTriggerIntent))] +[Union(13, typeof(ResolveAbilityIntent))] +[Union(14, typeof(ChooseIntent))] +[Union(15, typeof(ActivateAbilityIntent))] +[Union(16, typeof(EndTurnIntent))] +[Union(17, typeof(ConcedeIntent))] +public abstract class IntentPayload +{ +} + +[MessagePackObject] +public sealed class MulliganIntent : IntentPayload +{ + [Key(0)] public List HandIndexes { get; set; } = new(); +} + +[MessagePackObject] +public sealed class FinishMulliganIntent : IntentPayload +{ +} + +[MessagePackObject] +public sealed class RideIntent : IntentPayload +{ + [Key(0)] public string PrintingId { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class SkipRideIntent : IntentPayload +{ +} + +[MessagePackObject] +public sealed class CallIntent : IntentPayload +{ + [Key(0)] public string PrintingId { get; set; } = string.Empty; + [Key(1)] public int Slot { get; set; } +} + +[MessagePackObject] +public sealed class MoveIntent : IntentPayload +{ + [Key(0)] public int FromSlot { get; set; } + [Key(1)] public int ToSlot { get; set; } +} + +[MessagePackObject] +public sealed class EnterBattleIntent : IntentPayload +{ +} + +[MessagePackObject] +public sealed class AttackIntent : IntentPayload +{ + [Key(0)] public int AttackerSlot { get; set; } + [Key(1)] public int TargetSlot { get; set; } + [Key(2)] public int BoosterSlot { get; set; } = -1; +} + +[MessagePackObject] +public sealed class GuardIntent : IntentPayload +{ + [Key(0)] public string PrintingId { get; set; } = string.Empty; + + /// + /// Карта, которой оплачивается идеальная защита. null — обычный щит + /// или Sentinel без оплаты (тогда атака не отменяется). + /// + [Key(1)] public string? DiscardPrintingId { get; set; } +} + +[MessagePackObject] +public sealed class InterceptIntent : IntentPayload +{ + [Key(0)] public int Slot { get; set; } +} + +[MessagePackObject] +public sealed class PassGuardIntent : IntentPayload +{ +} + +[MessagePackObject] +public sealed class ResolveTriggerIntent : IntentPayload +{ + [Key(0)] public int PowerTarget { get; set; } + [Key(1)] public int EffectTarget { get; set; } +} + +[MessagePackObject] +public sealed class SelectTriggerIntent : IntentPayload +{ + [Key(0)] public int Index { get; set; } +} + +[MessagePackObject] +public sealed class ResolveAbilityIntent : IntentPayload +{ + [Key(0)] public bool Accept { get; set; } +} + +[MessagePackObject] +public sealed class ChooseIntent : IntentPayload +{ + [Key(0)] public int Index { get; set; } +} + +[MessagePackObject] +public sealed class ActivateAbilityIntent : IntentPayload +{ + [Key(0)] public int InstanceId { get; set; } + [Key(1)] public string? DiscardPrintingId { get; set; } +} + +[MessagePackObject] +public sealed class EndTurnIntent : IntentPayload +{ +} + +/// +/// Сдача. Обрабатывается сервером, а не движком: партии это не игровая команда, +/// а завершение матча. +/// +[MessagePackObject] +public sealed class ConcedeIntent : IntentPayload +{ +} diff --git a/server/src/Vanguard.Shared/Protocol/ProtocolConstants.cs b/server/src/Vanguard.Shared/Protocol/ProtocolConstants.cs new file mode 100644 index 0000000..1c6d231 --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/ProtocolConstants.cs @@ -0,0 +1,41 @@ +namespace Vanguard.Shared.Protocol; + +/// +/// Числа протокола, которые должны совпадать у клиента и сервера. +/// Любое изменение здесь — повод поднять . +/// +public static class ProtocolConstants +{ + /// Версия бинарного протокола. Несовпадение — отказ в handshake. + public const int Version = 1; + + /// Максимальный размер одного кадра до десериализации. + public const int MaxFrameBytes = 256 * 1024; + + /// Не больше стольких интентов в секунду принимает сервер с одного соединения. + public const int MaxIntentsPerSecond = 16; + + /// Одноразовый тикет на подключение живёт 60 секунд. + public const int TicketTtlSeconds = 60; + + /// Бесплатное время на ход. + public const int TurnSeconds = 90; + + /// Банк времени на всю партию. + public const int TurnBankSeconds = 5 * 60; + + /// Сколько отсутствия игрока допускается до авто-поражения. + public const int AbsenceLossSeconds = 5 * 60; + + /// Период клиентского ping, если нет другого трафика. + public const int ClientPingIntervalMs = 20_000; + + /// Период синхронизации часов сервером. + public const int ClockSyncIntervalMs = 5_000; + + /// Задержки переподключения: 0, 2, 10, 30 секунд. + public static IReadOnlyList ReconnectDelaysMs { get; } = new[] { 0, 2_000, 10_000, 30_000 }; + + /// Байт длины кадра для TCP-варианта транспорта (big-endian). + public const int TcpLengthPrefixBytes = 4; +} diff --git a/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs b/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs new file mode 100644 index 0000000..996ea9b --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/ProtocolEnums.cs @@ -0,0 +1,118 @@ +namespace Vanguard.Shared.Protocol; + +/// +/// Фазы партии в том виде, в котором они уходят клиенту. +/// Числа зафиксированы: менять их нельзя, только добавлять новые значения в конец. +/// +public enum GamePhase +{ + Mulligan = 0, + Stand = 1, + Draw = 2, + Ride = 3, + Main = 4, + Battle = 5, + End = 6, +} + +/// Текущий шаг разрешения. Числа стабильны. +public enum GameStep +{ + None = 0, + Guard = 1, + Drive = 2, + Trigger = 3, + Ability = 4, + Choice = 5, +} + +/// Тип триггера. Значения совпадают с Vanguard.Rules.Cards.TriggerType. +public enum TriggerKind +{ + None = 0, + Critical = 1, + Draw = 2, + Stand = 3, + Heal = 4, +} + +/// Откуда взят триггер: проверка драйва или проверка урона. +public enum TriggerContextKind +{ + DriveCheck = 0, + DamageCheck = 1, +} + +/// Триггер способности. Значения совпадают с AbilityTrigger в правилах. +public enum AbilityTriggerKind +{ + OnRideOver = 0, + OnPlace = 1, + OnBoost = 2, + OnPhaseStart = 3, + OnAttackDeclared = 4, + OnAttackHit = 5, + OnBoosted = 6, + OnBoostedHit = 7, + OnDriveCheck = 8, + OnGuard = 9, + OnRetireFromRC = 10, + OnOpponentRetire = 11, + OnLeaveGuardian = 12, + OnDamageTaken = 13, +} + +/// Причина отказа в интенте. Клиенту нужна машиночитаемая причина. +public enum IntentRejectCode +{ + None = 0, + + /// Интент неприменим в текущем состоянии (движок объяснил причину). + Illegal = 1, + + /// Сейчас решает другой игрок. + NotYourTurn = 2, + + /// Партия уже завершена. + MatchFinished = 3, + + /// Этот интент не поддержан протоколом. + NotSupported = 4, + + /// Слишком много интентов за единицу времени. + RateLimited = 5, + + /// Некорректный интент (неизвестные поля, пустой intentId и т. п.). + Malformed = 6, + + /// Клиент отстал от состояния: нужен resync, интент не применён. + Stale = 7, +} + +/// Код фатальной или предупреждающей ошибки соединения. +public enum ProtocolErrorCode +{ + /// Версия протокола клиента не поддерживается. + UnsupportedVersion = 0, + + /// Тикет неизвестен, уже использован или не принадлежит этому матчу. + InvalidTicket = 1, + + /// Срок жизни тикета истёк (TTL 60 с). + TicketExpired = 2, + + /// Матч не найден или уже удалён. + MatchNotFound = 3, + + /// Нарушен порядок кадров (hello не первым, мусор в кадре). + MalformedFrame = 4, + + /// Превышен лимит частоты кадров. + RateLimited = 5, + + /// Партия завершена, соединение закрывается. + MatchFinished = 6, + + /// Внутренняя ошибка сервера. + Internal = 7, +} diff --git a/server/src/Vanguard.Shared/Protocol/ProtocolSerializer.cs b/server/src/Vanguard.Shared/Protocol/ProtocolSerializer.cs new file mode 100644 index 0000000..83828af --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/ProtocolSerializer.cs @@ -0,0 +1,97 @@ +using MessagePack; +using MessagePack.Formatters; +using MessagePack.Resolvers; + +namespace Vanguard.Shared.Protocol; + +/// +/// Резолвер протокола. Своих рукописных форматтеров нет: MessagePack 3 +/// генерирует их на этапе компиляции для типов с [MessagePackObject], +/// а подхватывает сгенерированный резолвер. +/// +/// Сверху добавлено интернирование строк: printingId и названия повторяются +/// в каждом событии, и это заметно экономит аллокации на длинной партии. +/// +public static class ProtocolResolver +{ + public static readonly IFormatterResolver Instance = CompositeResolver.Create( + new IMessagePackFormatter[] { new StringInterningFormatter() }, + new IFormatterResolver[] { StandardResolver.Instance }); +} + +/// Ошибка разбора кадра: не MessagePack, обрезанный кадр, лишние байты. +public sealed class ProtocolFormatException : Exception +{ + public ProtocolFormatException(string message) + : base(message) + { + } + + public ProtocolFormatException(string message, Exception inner) + : base(message, inner) + { + } +} + +/// +/// Кодек кадров. Транспорт отвечает только за доставку байтов, +/// вся сериализация собрана здесь. +/// +public static class ProtocolSerializer +{ + /// + /// UntrustedData ограничивает глубину и размер структур, которые может + /// прислать клиент. Typeless-режим не используется нигде. + /// + public static MessagePackSerializerOptions Options { get; } = + MessagePackSerializerOptions.Standard + .WithResolver(ProtocolResolver.Instance) + .WithSecurity(MessagePackSecurity.UntrustedData); + + public static byte[] Encode(ClientFrame frame) => EncodeFrame(frame); + + public static byte[] Encode(ServerFrame frame) => EncodeFrame(frame); + + public static ClientFrame DecodeClient(ReadOnlyMemory payload) => DecodeFrame(payload); + + public static ServerFrame DecodeServer(ReadOnlyMemory payload) => DecodeFrame(payload); + + private static byte[] EncodeFrame(T frame) + where T : class + { + if (frame is null) + throw new ArgumentNullException(nameof(frame)); + + var bytes = MessagePackSerializer.Serialize(frame, Options); + if (bytes.Length > ProtocolConstants.MaxFrameBytes) + { + throw new ProtocolFormatException( + $"Кадр слишком велик: {bytes.Length} байт, лимит {ProtocolConstants.MaxFrameBytes}."); + } + + return bytes; + } + + private static T DecodeFrame(ReadOnlyMemory payload) + where T : class + { + if (payload.Length == 0) + throw new ProtocolFormatException("Пустой кадр."); + + if (payload.Length > ProtocolConstants.MaxFrameBytes) + { + throw new ProtocolFormatException( + $"Кадр слишком велик: {payload.Length} байт, лимит {ProtocolConstants.MaxFrameBytes}."); + } + + try + { + var frame = MessagePackSerializer.Deserialize(payload, Options); + return frame ?? throw new ProtocolFormatException("Кадр разобран в null."); + } + catch (MessagePackSerializationException exception) + { + throw new ProtocolFormatException("Не удалось разобрать кадр.", exception); + } + } +} diff --git a/server/src/Vanguard.Shared/Protocol/Views.cs b/server/src/Vanguard.Shared/Protocol/Views.cs new file mode 100644 index 0000000..3f88799 --- /dev/null +++ b/server/src/Vanguard.Shared/Protocol/Views.cs @@ -0,0 +1,229 @@ +using MessagePack; + +namespace Vanguard.Shared.Protocol; + +/// +/// Карта, как её видит клиент. Каталог карт у клиента свой, поэтому передаются +/// только идентификаторы и рантайм-состояние. +/// +/// и уже посчитаны сервером: +/// клиент не пересчитывает непрерывные эффекты и не может ошибиться. +/// +[MessagePackObject] +public sealed class CardView +{ + [Key(0)] public int InstanceId { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + + /// Повёрнута ли карта (Rest). + [Key(2)] public bool Rested { get; set; } + + /// Открыта ли карта. Значимо для зоны урона. + [Key(3)] public bool FaceUp { get; set; } + + [Key(4)] public int BasePower { get; set; } + [Key(5)] public int Power { get; set; } + [Key(6)] public int BaseCritical { get; set; } + [Key(7)] public int Critical { get; set; } +} + +/// +/// Состояние одного игрока. Для противника всегда пуст, +/// — только число, закрытый урон — только счётчик. +/// +[MessagePackObject] +public sealed class PlayerView +{ + [Key(0)] public int Index { get; set; } + [Key(1)] public string Name { get; set; } = string.Empty; + + [Key(2)] public int HandCount { get; set; } + [Key(3)] public int DeckCount { get; set; } + + /// Своя рука — полностью. Рука противника — пустой список. + [Key(4)] public List Hand { get; set; } = new(); + + [Key(5)] public CardView? Vanguard { get; set; } + + /// Пять кругов Арьергарда: 0–2 задний ряд, 3–4 передний. + [Key(6)] public CardView?[] RearGuards { get; set; } = new CardView?[5]; + + [Key(7)] public List Guardian { get; set; } = new(); + [Key(8)] public List Soul { get; set; } = new(); + + /// Сколько карт урона лежит лицом вниз. Сами карты не передаются. + [Key(9)] public int FaceDownDamage { get; set; } + + /// Урон лицом вверх — открытая информация. + [Key(10)] public List FaceUpDamage { get; set; } = new(); + + [Key(11)] public List Drop { get; set; } = new(); + [Key(12)] public List Trigger { get; set; } = new(); + + [Key(13)] public bool IsDefeated { get; set; } +} + +/// Идущий бой. Все поля открыты обоим игрокам. +[MessagePackObject] +public sealed class BattleView +{ + [Key(0)] public int AttackerPlayer { get; set; } + + /// -1 — Авангард, 0..4 — круг Арьергарда. + [Key(1)] public int AttackerSlot { get; set; } + + /// -1 — Авангард противника. + [Key(2)] public int TargetSlot { get; set; } + + [Key(3)] public int GuardShield { get; set; } + [Key(4)] public int ChecksRemaining { get; set; } + [Key(5)] public bool Nullified { get; set; } + + /// Круг перехватчика или -1. + [Key(6)] public int InterceptorSlot { get; set; } = -1; + + [Key(7)] public int BoosterSlot { get; set; } = -1; + [Key(8)] public List GuardCards { get; set; } = new(); +} + +/// Вариант выбора. Метка приходит с сервера, чтобы клиент не собирал текст сам. +[MessagePackObject] +public sealed class ChoiceOptionView +{ + [Key(0)] public int Slot { get; set; } + [Key(1)] public string Label { get; set; } = string.Empty; +} + +/// +/// Ожидание выбора. Отправляется только выбирающему игроку: варианты могут +/// содержать карты его руки. +/// +[MessagePackObject] +public sealed class ChoiceView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string Prompt { get; set; } = string.Empty; + [Key(2)] public List Options { get; set; } = new(); +} + +/// +/// Триггер, ждущий распределения силы и эффекта. Проверка драйва и урона +/// открыта обоим игрокам, поэтому view общий. +/// +[MessagePackObject] +public sealed class TriggerView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public string PrintingId { get; set; } = string.Empty; + [Key(2)] public TriggerKind Trigger { get; set; } + [Key(3)] public TriggerContextKind Context { get; set; } + [Key(4)] public bool GrantsPower { get; set; } + [Key(5)] public int RemainingDamage { get; set; } +} + +/// +/// «Вы можете применить способность?» Отправляется только владельцу: +/// в тексте способности бывает скрытая информация (например, карта в руке). +/// +[MessagePackObject] +public sealed class AbilityView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public int InstanceId { get; set; } + [Key(2)] public string PrintingId { get; set; } = string.Empty; + [Key(3)] public AbilityTriggerKind Trigger { get; set; } + [Key(4)] public bool Optional { get; set; } +} + +/// Один из сработавших эффектов в очереди выбора порядка. +[MessagePackObject] +public sealed class OrderOptionView +{ + /// Индекс в полной очереди движка. Его и передают в SelectTriggerIntent. + [Key(0)] public int Index { get; set; } + + [Key(1)] public int InstanceId { get; set; } + [Key(2)] public string PrintingId { get; set; } = string.Empty; + [Key(3)] public AbilityTriggerKind Trigger { get; set; } +} + +/// Выбор порядка сработавших способностей. Только для выбирающего игрока. +[MessagePackObject] +public sealed class OrderView +{ + [Key(0)] public int Player { get; set; } + [Key(1)] public List Options { get; set; } = new(); +} + +/// Часы партии. Сервер — единственный источник времени. +[MessagePackObject] +public sealed class ClockView +{ + /// Кто сейчас принимает решения. + [Key(0)] public int DecisionPlayer { get; set; } + + /// + /// Момент, когда истекают бесплатные 90 секунд хода (UTC, миллисекунды). + /// 0 — партия не идёт или таймер остановлен. + /// + [Key(1)] public long TurnDeadlineMs { get; set; } + + /// Остаток банка времени каждого игрока, миллисекунды. + [Key(2)] public long Bank0Ms { get; set; } + [Key(3)] public long Bank1Ms { get; set; } + + [Key(4)] public bool IsRunning { get; set; } + + /// Время сервера в момент отправки: клиент считает по нему смещение. + [Key(5)] public long ServerTimeMs { get; set; } +} + +/// +/// Полное состояние матча глазами одного игрока. Это же — ответ на resync +/// и тело первого снимка после подключения. +/// +[MessagePackObject] +public sealed class MatchView +{ + [Key(0)] public string MatchId { get; set; } = string.Empty; + + /// Номер последнего события в общем журнале матча. + [Key(1)] public long EventSeq { get; set; } + + /// Индекс игрока, для которого собрано это состояние (0 или 1). + [Key(2)] public int You { get; set; } + + /// Кто сейчас принимает решения. + [Key(3)] public int ActivePlayer { get; set; } + + [Key(4)] public int TurnNumber { get; set; } + [Key(5)] public GamePhase Phase { get; set; } + [Key(6)] public GameStep Step { get; set; } + + [Key(7)] public bool IsFinished { get; set; } + + /// Индекс победителя или -1. + [Key(8)] public int Winner { get; set; } = -1; + + [Key(9)] public List Players { get; set; } = new(); + + [Key(10)] public BattleView? Battle { get; set; } + [Key(11)] public TriggerView? Trigger { get; set; } + [Key(12)] public ChoiceView? Choice { get; set; } + [Key(13)] public AbilityView? Ability { get; set; } + [Key(14)] public OrderView? Order { get; set; } + + /// Противник сейчас что-то выбирает (детали ему не показываем). + [Key(15)] public bool OpponentChoosing { get; set; } + + [Key(16)] public ClockView Clock { get; set; } = new(); + + /// + /// Легальные интенты этого игрока. Готовый список позволяет клиенту + /// не дублировать правила движка. + /// + /// Пока партия идёт, здесь всегда есть «сдаться». Если сейчас решает + /// противник, список этим и ограничивается: его ходы сюда не попадают. + /// + [Key(17)] public List LegalIntents { get; set; } = new(); +} diff --git a/server/src/Vanguard.Shared/Vanguard.Shared.csproj b/server/src/Vanguard.Shared/Vanguard.Shared.csproj index b4b43f4..9380e41 100644 --- a/server/src/Vanguard.Shared/Vanguard.Shared.csproj +++ b/server/src/Vanguard.Shared/Vanguard.Shared.csproj @@ -1,8 +1,26 @@  - netstandard2.1 + netstandard2.1;net10.0 enable + Vanguard.Shared + Сетевые контракты Vanguard: DTO протокола, MessagePack и проекция состояния. + + + + + + + + + + + + + diff --git a/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs b/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs new file mode 100644 index 0000000..f2881f9 --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs @@ -0,0 +1,455 @@ +using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine; +using Vanguard.Rules.Replay; +using Vanguard.Shared.Projection; +using Vanguard.Shared.Protocol; +using Xunit; + +namespace Vanguard.Rules.Tests; + +public class ProtocolTests +{ + private const string Conroe = "BT01/016EN"; + private const string Marron = "BT01/042EN"; + + private static readonly ClockView Clock = new() + { + DecisionPlayer = 0, + TurnDeadlineMs = 1_760_000_090_000, + Bank0Ms = ProtocolConstants.TurnBankSeconds * 1000L, + Bank1Ms = ProtocolConstants.TurnBankSeconds * 1000L, + IsRunning = true, + ServerTimeMs = 1_760_000_000_000, + }; + + // ---------- сериализация ---------- + + [Fact] + public void Client_Frames_Survive_MessagePack_RoundTrip() + { + var frames = new ClientFrame[] + { + new ClientHello + { + ProtocolVersion = ProtocolConstants.Version, + Ticket = "ticket-1", + ClientBuild = "0.1.0", + ResumeFromSeq = 42, + }, + new ClientIntent + { + IntentId = "intent-1", + BasedOnSeq = 42, + Intent = new CallIntent { PrintingId = Marron, Slot = 3 }, + }, + new ClientResync { FromSeq = 43, FullSnapshot = true }, + new ClientPing { Nonce = 9, ClientTimeMs = 1_760_000_000_000 }, + new ClientPong { Nonce = 10 }, + new ClientLeave { Reason = "exit" }, + }; + + foreach (var frame in frames) + { + var bytes = ProtocolSerializer.Encode(frame); + var decoded = ProtocolSerializer.DecodeClient(bytes); + + Assert.Equal(frame.GetType(), decoded.GetType()); + } + + var hello = (ClientHello)ProtocolSerializer.DecodeClient( + ProtocolSerializer.Encode(frames[0])); + Assert.Equal("ticket-1", hello.Ticket); + Assert.Equal(42, hello.ResumeFromSeq); + + var intent = (ClientIntent)ProtocolSerializer.DecodeClient( + ProtocolSerializer.Encode(frames[1])); + var call = Assert.IsType(intent.Intent); + Assert.Equal(Marron, call.PrintingId); + Assert.Equal(3, call.Slot); + Assert.Equal("intent-1", intent.IntentId); + } + + [Fact] + public void Server_Frames_With_Nested_Unions_Survive_MessagePack_RoundTrip() + { + var view = new MatchView + { + MatchId = "match-1", + EventSeq = 7, + You = 0, + ActivePlayer = 0, + TurnNumber = 2, + Phase = GamePhase.Main, + Step = GameStep.None, + Winner = -1, + Clock = Clock, + Players = + { + new PlayerView + { + Index = 0, + Name = "Player 1", + HandCount = 2, + DeckCount = 30, + Hand = { new CardView { InstanceId = 1, PrintingId = Marron, Power = 8000 } }, + RearGuards = new CardView?[] + { + new() { InstanceId = 2, PrintingId = Marron }, + null, + null, + null, + null, + }, + }, + new PlayerView { Index = 1, Name = "Player 2", HandCount = 3, DeckCount = 29 }, + }, + LegalIntents = + { + new ConcedeIntent(), + new AttackIntent { AttackerSlot = -1, TargetSlot = -1, BoosterSlot = -1 }, + }, + }; + + var frames = new ServerFrame[] + { + new ServerHello + { + ProtocolVersion = ProtocolConstants.Version, + MatchId = "match-1", + PlayerIndex = 0, + Resumed = true, + EventSeq = 7, + ServerTimeMs = Clock.ServerTimeMs, + }, + new ServerSnapshot { View = view }, + new ServerEvents + { + FromSeq = 4, + ToSeq = 5, + Events = + { + new EventEnvelope + { + Seq = 4, + Event = new UnitCalledView { Player = 0, Slot = 3, PrintingId = Marron }, + }, + new EventEnvelope + { + Seq = 5, + Event = new DriveCheckedView + { + Player = 0, + PrintingId = "BT01/056EN", + Trigger = TriggerKind.Critical, + }, + }, + }, + }, + new ServerIntentResult + { + IntentId = "intent-1", + Accepted = false, + Code = IntentRejectCode.Illegal, + Reason = "Нет цели.", + EventSeq = 7, + }, + new ServerClock { Clock = Clock, EventSeq = 7 }, + new ServerPing { Nonce = 1, ServerTimeMs = Clock.ServerTimeMs }, + new ServerPong { Nonce = 2, ServerTimeMs = Clock.ServerTimeMs }, + new ServerError + { + Code = ProtocolErrorCode.InvalidTicket, + Message = "bad ticket", + Fatal = true, + EventSeq = 7, + }, + }; + + foreach (var frame in frames) + { + var bytes = ProtocolSerializer.Encode(frame); + var decoded = ProtocolSerializer.DecodeServer(bytes); + + Assert.Equal(frame.GetType(), decoded.GetType()); + } + + var snapshot = (ServerSnapshot)ProtocolSerializer.DecodeServer( + ProtocolSerializer.Encode(frames[1])); + + Assert.Equal("match-1", snapshot.View.MatchId); + Assert.Equal(7, snapshot.View.EventSeq); + Assert.Equal(2, snapshot.View.Players.Count); + Assert.Single(snapshot.View.Players[0].Hand); + Assert.Null(snapshot.View.Players[0].RearGuards[1]); + Assert.IsType(snapshot.View.LegalIntents[1]); + + var events = (ServerEvents)ProtocolSerializer.DecodeServer(ProtocolSerializer.Encode(frames[2])); + Assert.Equal(4, events.FromSeq); + Assert.Equal(5, events.ToSeq); + Assert.IsType(events.Events[0].Event); + Assert.IsType(events.Events[1].Event); + } + + [Fact] + public void Decode_Rejects_Empty_And_Oversized_Frames() + { + Assert.Throws(() => ProtocolSerializer.DecodeClient(ReadOnlyMemory.Empty)); + + var oversized = new byte[ProtocolConstants.MaxFrameBytes + 1]; + Assert.Throws(() => ProtocolSerializer.DecodeClient(oversized)); + + var garbage = new byte[] { 0xC1, 0xFF, 0x00 }; + Assert.Throws(() => ProtocolSerializer.DecodeClient(garbage)); + } + + // ---------- интенты ---------- + + [Fact] + public void Every_Game_Command_Maps_To_Intent_And_Back() + { + var commands = new GameCommand[] + { + new MulliganCommand(new[] { 0, 2 }), + new FinishMulliganCommand(), + new RideCommand(Marron), + new SkipRideCommand(), + new CallCommand(Marron, 3), + new MoveCommand(3, 0), + new EnterBattleCommand(), + new AttackCommand(3, -1, 0), + new GuardCommand("BT01/011EN", "BT01/042EN"), + new InterceptCommand(3), + new PassGuardCommand(), + new ResolveTriggerCommand(-1, 3), + new SelectTriggerCommand(1), + new ResolveAbilityCommand(true), + new ChooseCommand(2), + new ActivateAbilityCommand(12, Marron), + new EndTurnCommand(), + }; + + foreach (var command in commands) + { + var intent = IntentMapping.FromCommand(command); + var restored = IntentMapping.ToCommand(intent); + + Assert.NotNull(restored); + Assert.Equal(CommandCodec.Encode(command), CommandCodec.Encode(restored!)); + } + } + + [Fact] + public void Concede_Has_No_Game_Command() + { + Assert.Null(IntentMapping.ToCommand(new ConcedeIntent())); + } + + // ---------- проекция ---------- + + [Fact] + public void Projection_Hides_Opponent_Hand_And_Both_Decks() + { + var engine = StartedEngine(); + + var view = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); + + Assert.Equal(engine.State.Players[0].Hand.Count, view.Players[0].Hand.Count); + Assert.Equal(engine.State.Players[1].Hand.Count, view.Players[1].HandCount); + Assert.Empty(view.Players[1].Hand); + Assert.Equal(engine.State.Players[1].Deck.Count, view.Players[1].DeckCount); + Assert.Equal(engine.State.Players[0].Deck.Count, view.Players[0].DeckCount); + + // ни одного printingId из чужой руки + var ownIds = view.Players[0].Hand.Select(c => c.PrintingId).ToHashSet(); + var opponentHandIds = engine.State.Players[1].Hand.Select(c => c.PrintingId).ToHashSet(); + Assert.DoesNotContain(view.Players[1].Hand, card => opponentHandIds.Contains(card.PrintingId)); + Assert.True(ownIds.Count > 0); + } + + [Fact] + public void Projection_Shows_FaceDown_Damage_Only_As_Count() + { + var engine = StartedEngine(); + var damage = engine.State.Players[0].Damage; + var hidden = TakeFromDeck(engine.State.Players[0]); + hidden.FaceUp = false; + damage.Add(hidden); + + var visible = TakeFromDeck(engine.State.Players[0]); + visible.FaceUp = true; + damage.Add(visible); + + var ownView = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); + var opponentView = MatchProjection.ForViewer(engine, 1, "match-1", engine.Events.Count, Clock); + + Assert.Equal(1, ownView.Players[0].FaceDownDamage); + Assert.Single(ownView.Players[0].FaceUpDamage); + Assert.Equal(visible.PrintingId, ownView.Players[0].FaceUpDamage[0].PrintingId); + + Assert.Equal(1, opponentView.Players[0].FaceDownDamage); + Assert.Single(opponentView.Players[0].FaceUpDamage); + Assert.Equal(visible.PrintingId, opponentView.Players[0].FaceUpDamage[0].PrintingId); + } + + [Fact] + public void Projection_Does_Not_Contain_Seed_Or_Rng() + { + var names = typeof(MatchView).Assembly.GetTypes() + .Where(type => type.Namespace == "Vanguard.Shared.Protocol") + .SelectMany(type => type.GetProperties()) + .Select(property => property.Name) + .ToList(); + + Assert.DoesNotContain(names, name => name.Contains("Seed", StringComparison.Ordinal)); + Assert.DoesNotContain(names, name => name.Contains("Rng", StringComparison.Ordinal)); + } + + [Fact] + public void Legal_Intents_Belong_Only_To_The_Deciding_Player() + { + var engine = StartedEngine(); + Give(engine, 0, Marron); + engine.Execute(new RideCommand(Marron)); + engine.Execute(new EnterBattleCommand()); + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.Equal(1, engine.State.ActivePlayer); + Assert.Equal(Step.Guard, engine.State.Step); + + var attackerView = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); + var defenderView = MatchProjection.ForViewer(engine, 1, "match-1", engine.Events.Count, Clock); + + // наблюдатель может только сдаться + Assert.Single(attackerView.LegalIntents); + Assert.IsType(attackerView.LegalIntents[0]); + + // обороняющийся видит свои варианты защиты, но не чужую руку + Assert.Contains(defenderView.LegalIntents, intent => intent is PassGuardIntent); + Assert.Empty(attackerView.Players[1].Hand); + Assert.Equal(engine.State.Players[1].Hand.Count, attackerView.Players[1].HandCount); + } + + // ---------- события ---------- + + [Fact] + public void Search_Events_Are_Hidden_From_Opponent() + { + var searched = new CardSearchedEvent(0, "BT01/001EN"); + var failed = new SearchFailedEvent(0, " grade 3"); + + Assert.IsType(EventProjection.ForViewer(searched, 0)); + Assert.Null(EventProjection.ForViewer(searched, 1)); + Assert.IsType(EventProjection.ForViewer(failed, 0)); + Assert.Null(EventProjection.ForViewer(failed, 1)); + } + + [Fact] + public void Open_Events_Are_Visible_To_Both() + { + var called = new UnitCalledEvent(0, 3, Marron); + + Assert.IsType(EventProjection.ForViewer(called, 0)); + Assert.IsType(EventProjection.ForViewer(called, 1)); + } + + [Fact] + public void Projected_Event_Range_Keeps_Global_Sequence_Numbers() + { + var events = new GameEvent[] + { + new GameStartedEvent(0), + new CardSearchedEvent(0, "BT01/001EN"), // скрыто от игрока 1 + new CardDrawnEvent(0), + }; + + var forOpponent = EventProjection.ProjectRange(events, 1, fromSeq: 1); + + Assert.Equal(2, forOpponent.Count); + Assert.Equal(1, forOpponent[0].Seq); + Assert.Equal(3, forOpponent[1].Seq); + Assert.IsType(forOpponent[0].Event); + Assert.IsType(forOpponent[1].Event); + + var fromSecond = EventProjection.ProjectRange(events, 0, fromSeq: 2); + Assert.Equal(2, fromSecond.Count); + Assert.Equal(2, fromSecond[0].Seq); + Assert.IsType(fromSecond[0].Event); + } + + // ---------- обвязка ---------- + + private static GameEngine StartedEngine() + { + var engine = NewEngine(); + engine.Start(); + engine.Execute(new FinishMulliganCommand()); + + // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал + var player = engine.State.Players[0]; + var card = TakeFromDeck(player, Conroe); + player.Vanguard = card; + + return engine; + } + + private static GameEngine NewEngine() => new(TestCatalog.Instance, 7, BuildDeck(), BuildDeck()); + + private static List BuildDeck() + { + var deck = new List(); + + // гарантируем карты, которые используются в сценариях + foreach (var printingId in new[] { Conroe, Marron }) + deck.AddRange(Enumerable.Repeat(printingId, 4)); + + var seeded = new[] { Conroe, Marron } + .Select(id => TestCatalog.Instance.CardOf(id).NameId) + .ToHashSet(StringComparer.Ordinal); + + foreach (var group in TestCatalog.Instance.Printings + .Where(p => !p.IsParallel) + .GroupBy(p => p.NameId) + .OrderBy(g => g.Key, StringComparer.Ordinal)) + { + if (deck.Count >= 50) + break; + + if (seeded.Contains(group.Key)) + continue; + + var card = TestCatalog.Instance.Card(group.Key); + if (card.IsSentinel || card.Trigger == TriggerType.Heal) + continue; + + var printingId = group.First().PrintingId; + for (var i = 0; i < 4 && deck.Count < 50; i++) + deck.Add(printingId); + } + + Assert.Equal(50, deck.Count); + return deck; + } + + private static void Give(GameEngine engine, int player, string printingId) + { + var hand = engine.State.Players[player].Hand; + hand.Add(TakeFromDeck(engine.State.Players[player], printingId)); + } + + private static CardInstance TakeFromDeck(PlayerState player, string printingId) + { + var index = player.Deck.FindIndex(c => c.PrintingId == printingId); + Assert.True(index >= 0, $"В колоде нет {printingId}"); + + var card = player.Deck[index]; + player.Deck.RemoveAt(index); + return card; + } + + private static CardInstance TakeFromDeck(PlayerState player) + { + Assert.NotEmpty(player.Deck); + var card = player.Deck[0]; + player.Deck.RemoveAt(0); + return card; + } +} diff --git a/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj b/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj index c99aa9d..cc787d2 100644 --- a/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj +++ b/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj @@ -21,6 +21,7 @@ + \ No newline at end of file