Add match protocol spec and shared network contracts

This commit is contained in:
MpakobecEx 2026-10-21 14:14:52 +05:00
parent 9e1ed87d5d
commit d4e6e9d8c0
18 changed files with 2701 additions and 29 deletions

View File

@ -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 процесса клиента на любом шаге восстанавливается.

View File

@ -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` — каталог карт с разбором состава сетов.

View File

@ -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` запрещают вложенность эффектов.

568
docs/protocol.md Normal file
View File

@ -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://<host>/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 КиБ и мусорный кадр отклоняются без падения сервера.

View File

@ -0,0 +1,125 @@
using Vanguard.Rules.Engine;
using Vanguard.Shared.Protocol;
namespace Vanguard.Shared.Projection;
/// <summary>
/// Превращает событие движка в то, что разрешено видеть конкретному игроку.
/// null — событие целиком скрыто от него.
/// </summary>
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, "Неизвестное событие."),
};
/// <summary>Все видимые события игрока из общего журнала, начиная с номера <paramref name="fromSeq"/>.</summary>
public static List<EventEnvelope> ProjectRange(IReadOnlyList<GameEvent> events, int viewer, long fromSeq)
{
var result = new List<EventEnvelope>();
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;
}
}

View File

@ -0,0 +1,76 @@
using Vanguard.Rules.Engine;
using Vanguard.Shared.Protocol;
namespace Vanguard.Shared.Projection;
/// <summary>
/// Перевод между командами движка и сетевыми интентами. Единственное место,
/// где сервер соприкасается с внутренним API правил.
/// </summary>
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, "Неизвестная команда."),
};
/// <summary>
/// null — интент обрабатывается сервером, а не движком (сдача).
/// </summary>
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, "Неизвестный интент."),
};
}

View File

@ -0,0 +1,232 @@
using Vanguard.Rules.Engine;
using Vanguard.Shared.Protocol;
namespace Vanguard.Shared.Projection;
/// <summary>
/// Проекция авторитарного состояния на одного игрока.
///
/// Правила видимости:
/// * своя рука — полностью, рука противника — только количество;
/// * колода — только количество у обоих;
/// * урон лицом вниз — только количество у обоих (правила Classic его не открывают);
/// * урон лицом вверх, душа, сброс, зона триггера и круг защиты — полностью;
/// * seed и состояние RNG в проекцию не попадают никогда;
/// * выборы, способности и порядок триггеров видны только тому, кто их разрешает.
/// </summary>
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<PlayerView>
{
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<CardView>(),
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<IntentPayload> ProjectLegalIntents(GameEngine engine, int viewer, bool isDecisionMaker)
{
var intents = new List<IntentPayload>();
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;
}
}

View File

@ -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;
/// <summary>Перевод внутренних перечислений правил в стабильные значения протокола.</summary>
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),
};
}

View File

@ -0,0 +1,272 @@
using MessagePack;
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Событие матча в клиентском виде. Набор повторяет <c>GameEvent</c>,
/// но скрытые события (например, поиск в колоде) не покидают сервер,
/// поэтому часть событий приходит только их владельцу.
///
/// Union-ключи стабильны и совпадают с порядком событий в правилах.
/// </summary>
[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
{
}
/// <summary>Событие вместе с его номером в общем журнале матча.</summary>
[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; }
}
/// <summary>Поиск в колоде. Уходит только искавшему.</summary>
[MessagePackObject]
public sealed class CardSearchedView : EventView
{
[Key(0)] public int Player { get; set; }
[Key(1)] public string PrintingId { get; set; } = string.Empty;
}
/// <summary>Неудачный поиск. Уходит только искавшему: строка запроса — скрытая информация.</summary>
[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; }
}

View File

@ -0,0 +1,180 @@
using MessagePack;
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Кадр клиента. Каждый бинарный кадр транспорта — ровно один объект этого типа.
/// Union-ключи стабильны.
/// </summary>
[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
{
}
/// <summary>Кадр сервера. Union-ключи стабильны.</summary>
[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
{
}
/// <summary>
/// Первый кадр соединения. Пока сервер не принял hello, любые другие кадры
/// считаются нарушением протокола.
/// </summary>
[MessagePackObject]
public sealed class ClientHello : ClientFrame
{
[Key(0)] public int ProtocolVersion { get; set; }
/// <summary>Одноразовый тикет, выданный HTTP-API. TTL 60 секунд.</summary>
[Key(1)] public string Ticket { get; set; } = string.Empty;
/// <summary>Версия клиента для логов и диагностики, на совместимость не влияет.</summary>
[Key(2)] public string ClientBuild { get; set; } = string.Empty;
/// <summary>Последний применённый eventSeq. 0 — новое подключение без догона.</summary>
[Key(3)] public long ResumeFromSeq { get; set; }
}
/// <summary>
/// Интент игрока. <see cref="IntentId"/> — клиентский GUID: по нему сервер
/// отсекает повторы (ретрай после обрыва не применяет действие дважды).
/// </summary>
[MessagePackObject]
public sealed class ClientIntent : ClientFrame
{
[Key(0)] public string IntentId { get; set; } = string.Empty;
/// <summary>eventSeq, на котором построен интент. Для диагностики и resync.</summary>
[Key(1)] public long BasedOnSeq { get; set; }
[Key(2)] public IntentPayload Intent { get; set; } = null!;
}
/// <summary>Запрос догона состояния.</summary>
[MessagePackObject]
public sealed class ClientResync : ClientFrame
{
/// <summary>С какого события догонять. Сервер пришлёт всё, что видно этому игроку.</summary>
[Key(0)] public long FromSeq { get; set; }
/// <summary>true — прислать полный снимок, даже если журнал ещё доступен.</summary>
[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; }
}
/// <summary>Клиент уходит сам. Матч ждёт его возвращения и идёт по таймерам.</summary>
[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;
/// <summary>0 или 1 — место игрока в матче.</summary>
[Key(2)] public int PlayerIndex { get; set; }
/// <summary>true — журнал догнан без полного снимка.</summary>
[Key(3)] public bool Resumed { get; set; }
/// <summary>Текущий последний eventSeq матча.</summary>
[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!;
}
/// <summary>
/// Порция событий. Номера внутри могут идти с пропусками: событие с чужим
/// скрытым содержимым не отправляется, но свой номер в журнале занимает.
/// </summary>
[MessagePackObject]
public sealed class ServerEvents : ServerFrame
{
[Key(0)] public long FromSeq { get; set; }
[Key(1)] public long ToSeq { get; set; }
[Key(2)] public List<EventEnvelope> 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; }
/// <summary>Последний eventSeq после обработки интента.</summary>
[Key(4)] public long EventSeq { get; set; }
/// <summary>true — это повтор уже обработанного intentId, состояние не менялось.</summary>
[Key(5)] public bool Duplicate { get; set; }
}
/// <summary>Периодическая синхронизация часов. Приходит и без игровых событий.</summary>
[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;
/// <summary>true — сервер закроет соединение после этого кадра.</summary>
[Key(2)] public bool Fatal { get; set; }
[Key(3)] public long EventSeq { get; set; }
}

View File

@ -0,0 +1,149 @@
using MessagePack;
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Действие игрока. Один в один повторяет <c>GameCommand</c> из правил,
/// но без ссылки на сборку правил: клиент на Unity не тащит движок.
///
/// Union-ключи стабильны. Новые интенты добавляются только в конец.
/// </summary>
[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<int> 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;
/// <summary>
/// Карта, которой оплачивается идеальная защита. null — обычный щит
/// или Sentinel без оплаты (тогда атака не отменяется).
/// </summary>
[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
{
}
/// <summary>
/// Сдача. Обрабатывается сервером, а не движком: партии это не игровая команда,
/// а завершение матча.
/// </summary>
[MessagePackObject]
public sealed class ConcedeIntent : IntentPayload
{
}

View File

@ -0,0 +1,41 @@
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Числа протокола, которые должны совпадать у клиента и сервера.
/// Любое изменение здесь — повод поднять <see cref="Version"/>.
/// </summary>
public static class ProtocolConstants
{
/// <summary>Версия бинарного протокола. Несовпадение — отказ в handshake.</summary>
public const int Version = 1;
/// <summary>Максимальный размер одного кадра до десериализации.</summary>
public const int MaxFrameBytes = 256 * 1024;
/// <summary>Не больше стольких интентов в секунду принимает сервер с одного соединения.</summary>
public const int MaxIntentsPerSecond = 16;
/// <summary>Одноразовый тикет на подключение живёт 60 секунд.</summary>
public const int TicketTtlSeconds = 60;
/// <summary>Бесплатное время на ход.</summary>
public const int TurnSeconds = 90;
/// <summary>Банк времени на всю партию.</summary>
public const int TurnBankSeconds = 5 * 60;
/// <summary>Сколько отсутствия игрока допускается до авто-поражения.</summary>
public const int AbsenceLossSeconds = 5 * 60;
/// <summary>Период клиентского ping, если нет другого трафика.</summary>
public const int ClientPingIntervalMs = 20_000;
/// <summary>Период синхронизации часов сервером.</summary>
public const int ClockSyncIntervalMs = 5_000;
/// <summary>Задержки переподключения: 0, 2, 10, 30 секунд.</summary>
public static IReadOnlyList<int> ReconnectDelaysMs { get; } = new[] { 0, 2_000, 10_000, 30_000 };
/// <summary>Байт длины кадра для TCP-варианта транспорта (big-endian).</summary>
public const int TcpLengthPrefixBytes = 4;
}

View File

@ -0,0 +1,118 @@
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Фазы партии в том виде, в котором они уходят клиенту.
/// Числа зафиксированы: менять их нельзя, только добавлять новые значения в конец.
/// </summary>
public enum GamePhase
{
Mulligan = 0,
Stand = 1,
Draw = 2,
Ride = 3,
Main = 4,
Battle = 5,
End = 6,
}
/// <summary>Текущий шаг разрешения. Числа стабильны.</summary>
public enum GameStep
{
None = 0,
Guard = 1,
Drive = 2,
Trigger = 3,
Ability = 4,
Choice = 5,
}
/// <summary>Тип триггера. Значения совпадают с <c>Vanguard.Rules.Cards.TriggerType</c>.</summary>
public enum TriggerKind
{
None = 0,
Critical = 1,
Draw = 2,
Stand = 3,
Heal = 4,
}
/// <summary>Откуда взят триггер: проверка драйва или проверка урона.</summary>
public enum TriggerContextKind
{
DriveCheck = 0,
DamageCheck = 1,
}
/// <summary>Триггер способности. Значения совпадают с <c>AbilityTrigger</c> в правилах.</summary>
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,
}
/// <summary>Причина отказа в интенте. Клиенту нужна машиночитаемая причина.</summary>
public enum IntentRejectCode
{
None = 0,
/// <summary>Интент неприменим в текущем состоянии (движок объяснил причину).</summary>
Illegal = 1,
/// <summary>Сейчас решает другой игрок.</summary>
NotYourTurn = 2,
/// <summary>Партия уже завершена.</summary>
MatchFinished = 3,
/// <summary>Этот интент не поддержан протоколом.</summary>
NotSupported = 4,
/// <summary>Слишком много интентов за единицу времени.</summary>
RateLimited = 5,
/// <summary>Некорректный интент (неизвестные поля, пустой intentId и т. п.).</summary>
Malformed = 6,
/// <summary>Клиент отстал от состояния: нужен resync, интент не применён.</summary>
Stale = 7,
}
/// <summary>Код фатальной или предупреждающей ошибки соединения.</summary>
public enum ProtocolErrorCode
{
/// <summary>Версия протокола клиента не поддерживается.</summary>
UnsupportedVersion = 0,
/// <summary>Тикет неизвестен, уже использован или не принадлежит этому матчу.</summary>
InvalidTicket = 1,
/// <summary>Срок жизни тикета истёк (TTL 60 с).</summary>
TicketExpired = 2,
/// <summary>Матч не найден или уже удалён.</summary>
MatchNotFound = 3,
/// <summary>Нарушен порядок кадров (hello не первым, мусор в кадре).</summary>
MalformedFrame = 4,
/// <summary>Превышен лимит частоты кадров.</summary>
RateLimited = 5,
/// <summary>Партия завершена, соединение закрывается.</summary>
MatchFinished = 6,
/// <summary>Внутренняя ошибка сервера.</summary>
Internal = 7,
}

View File

@ -0,0 +1,97 @@
using MessagePack;
using MessagePack.Formatters;
using MessagePack.Resolvers;
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Резолвер протокола. Своих рукописных форматтеров нет: MessagePack 3
/// генерирует их на этапе компиляции для типов с <c>[MessagePackObject]</c>,
/// а <see cref="StandardResolver"/> подхватывает сгенерированный резолвер.
///
/// Сверху добавлено интернирование строк: printingId и названия повторяются
/// в каждом событии, и это заметно экономит аллокации на длинной партии.
/// </summary>
public static class ProtocolResolver
{
public static readonly IFormatterResolver Instance = CompositeResolver.Create(
new IMessagePackFormatter[] { new StringInterningFormatter() },
new IFormatterResolver[] { StandardResolver.Instance });
}
/// <summary>Ошибка разбора кадра: не MessagePack, обрезанный кадр, лишние байты.</summary>
public sealed class ProtocolFormatException : Exception
{
public ProtocolFormatException(string message)
: base(message)
{
}
public ProtocolFormatException(string message, Exception inner)
: base(message, inner)
{
}
}
/// <summary>
/// Кодек кадров. Транспорт отвечает только за доставку байтов,
/// вся сериализация собрана здесь.
/// </summary>
public static class ProtocolSerializer
{
/// <summary>
/// UntrustedData ограничивает глубину и размер структур, которые может
/// прислать клиент. Typeless-режим не используется нигде.
/// </summary>
public static MessagePackSerializerOptions Options { get; } =
MessagePackSerializerOptions.Standard
.WithResolver(ProtocolResolver.Instance)
.WithSecurity(MessagePackSecurity.UntrustedData);
public static byte[] Encode(ClientFrame frame) => EncodeFrame<ClientFrame>(frame);
public static byte[] Encode(ServerFrame frame) => EncodeFrame<ServerFrame>(frame);
public static ClientFrame DecodeClient(ReadOnlyMemory<byte> payload) => DecodeFrame<ClientFrame>(payload);
public static ServerFrame DecodeServer(ReadOnlyMemory<byte> payload) => DecodeFrame<ServerFrame>(payload);
private static byte[] EncodeFrame<T>(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<T>(ReadOnlyMemory<byte> 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<T>(payload, Options);
return frame ?? throw new ProtocolFormatException("Кадр разобран в null.");
}
catch (MessagePackSerializationException exception)
{
throw new ProtocolFormatException("Не удалось разобрать кадр.", exception);
}
}
}

View File

@ -0,0 +1,229 @@
using MessagePack;
namespace Vanguard.Shared.Protocol;
/// <summary>
/// Карта, как её видит клиент. Каталог карт у клиента свой, поэтому передаются
/// только идентификаторы и рантайм-состояние.
///
/// <see cref="Power"/> и <see cref="Critical"/> уже посчитаны сервером:
/// клиент не пересчитывает непрерывные эффекты и не может ошибиться.
/// </summary>
[MessagePackObject]
public sealed class CardView
{
[Key(0)] public int InstanceId { get; set; }
[Key(1)] public string PrintingId { get; set; } = string.Empty;
/// <summary>Повёрнута ли карта (Rest).</summary>
[Key(2)] public bool Rested { get; set; }
/// <summary>Открыта ли карта. Значимо для зоны урона.</summary>
[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; }
}
/// <summary>
/// Состояние одного игрока. Для противника <see cref="Hand"/> всегда пуст,
/// <see cref="DeckCount"/> — только число, закрытый урон — только счётчик.
/// </summary>
[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; }
/// <summary>Своя рука — полностью. Рука противника — пустой список.</summary>
[Key(4)] public List<CardView> Hand { get; set; } = new();
[Key(5)] public CardView? Vanguard { get; set; }
/// <summary>Пять кругов Арьергарда: 0–2 задний ряд, 3–4 передний.</summary>
[Key(6)] public CardView?[] RearGuards { get; set; } = new CardView?[5];
[Key(7)] public List<CardView> Guardian { get; set; } = new();
[Key(8)] public List<CardView> Soul { get; set; } = new();
/// <summary>Сколько карт урона лежит лицом вниз. Сами карты не передаются.</summary>
[Key(9)] public int FaceDownDamage { get; set; }
/// <summary>Урон лицом вверх — открытая информация.</summary>
[Key(10)] public List<CardView> FaceUpDamage { get; set; } = new();
[Key(11)] public List<CardView> Drop { get; set; } = new();
[Key(12)] public List<CardView> Trigger { get; set; } = new();
[Key(13)] public bool IsDefeated { get; set; }
}
/// <summary>Идущий бой. Все поля открыты обоим игрокам.</summary>
[MessagePackObject]
public sealed class BattleView
{
[Key(0)] public int AttackerPlayer { get; set; }
/// <summary>-1 — Авангард, 0..4 — круг Арьергарда.</summary>
[Key(1)] public int AttackerSlot { get; set; }
/// <summary>-1 — Авангард противника.</summary>
[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; }
/// <summary>Круг перехватчика или -1.</summary>
[Key(6)] public int InterceptorSlot { get; set; } = -1;
[Key(7)] public int BoosterSlot { get; set; } = -1;
[Key(8)] public List<CardView> GuardCards { get; set; } = new();
}
/// <summary>Вариант выбора. Метка приходит с сервера, чтобы клиент не собирал текст сам.</summary>
[MessagePackObject]
public sealed class ChoiceOptionView
{
[Key(0)] public int Slot { get; set; }
[Key(1)] public string Label { get; set; } = string.Empty;
}
/// <summary>
/// Ожидание выбора. Отправляется только выбирающему игроку: варианты могут
/// содержать карты его руки.
/// </summary>
[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<ChoiceOptionView> Options { get; set; } = new();
}
/// <summary>
/// Триггер, ждущий распределения силы и эффекта. Проверка драйва и урона
/// открыта обоим игрокам, поэтому view общий.
/// </summary>
[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; }
}
/// <summary>
/// «Вы можете применить способность?» Отправляется только владельцу:
/// в тексте способности бывает скрытая информация (например, карта в руке).
/// </summary>
[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; }
}
/// <summary>Один из сработавших эффектов в очереди выбора порядка.</summary>
[MessagePackObject]
public sealed class OrderOptionView
{
/// <summary>Индекс в полной очереди движка. Его и передают в SelectTriggerIntent.</summary>
[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; }
}
/// <summary>Выбор порядка сработавших способностей. Только для выбирающего игрока.</summary>
[MessagePackObject]
public sealed class OrderView
{
[Key(0)] public int Player { get; set; }
[Key(1)] public List<OrderOptionView> Options { get; set; } = new();
}
/// <summary>Часы партии. Сервер — единственный источник времени.</summary>
[MessagePackObject]
public sealed class ClockView
{
/// <summary>Кто сейчас принимает решения.</summary>
[Key(0)] public int DecisionPlayer { get; set; }
/// <summary>
/// Момент, когда истекают бесплатные 90 секунд хода (UTC, миллисекунды).
/// 0 — партия не идёт или таймер остановлен.
/// </summary>
[Key(1)] public long TurnDeadlineMs { get; set; }
/// <summary>Остаток банка времени каждого игрока, миллисекунды.</summary>
[Key(2)] public long Bank0Ms { get; set; }
[Key(3)] public long Bank1Ms { get; set; }
[Key(4)] public bool IsRunning { get; set; }
/// <summary>Время сервера в момент отправки: клиент считает по нему смещение.</summary>
[Key(5)] public long ServerTimeMs { get; set; }
}
/// <summary>
/// Полное состояние матча глазами одного игрока. Это же — ответ на resync
/// и тело первого снимка после подключения.
/// </summary>
[MessagePackObject]
public sealed class MatchView
{
[Key(0)] public string MatchId { get; set; } = string.Empty;
/// <summary>Номер последнего события в общем журнале матча.</summary>
[Key(1)] public long EventSeq { get; set; }
/// <summary>Индекс игрока, для которого собрано это состояние (0 или 1).</summary>
[Key(2)] public int You { get; set; }
/// <summary>Кто сейчас принимает решения.</summary>
[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; }
/// <summary>Индекс победителя или -1.</summary>
[Key(8)] public int Winner { get; set; } = -1;
[Key(9)] public List<PlayerView> 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; }
/// <summary>Противник сейчас что-то выбирает (детали ему не показываем).</summary>
[Key(15)] public bool OpponentChoosing { get; set; }
[Key(16)] public ClockView Clock { get; set; } = new();
/// <summary>
/// Легальные интенты этого игрока. Готовый список позволяет клиенту
/// не дублировать правила движка.
///
/// Пока партия идёт, здесь всегда есть «сдаться». Если сейчас решает
/// противник, список этим и ограничивается: его ходы сюда не попадают.
/// </summary>
[Key(17)] public List<IntentPayload> LegalIntents { get; set; } = new();
}

View File

@ -1,8 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<RootNamespace>Vanguard.Shared</RootNamespace>
<Description>Сетевые контракты Vanguard: DTO протокола, MessagePack и проекция состояния.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.12" />
</ItemGroup>
<!--
netstandard2.1 — сборка для Unity: только DTO и сериализация, без ссылки на правила.
net10.0 — серверная сборка: к ней добавляется проекция состояния поверх Vanguard.Rules.
-->
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<ProjectReference Include="..\Vanguard.Rules\Vanguard.Rules.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="Projection\**\*.cs" />
</ItemGroup>
</Project>

View File

@ -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<CallIntent>(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<AttackIntent>(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<UnitCalledView>(events.Events[0].Event);
Assert.IsType<DriveCheckedView>(events.Events[1].Event);
}
[Fact]
public void Decode_Rejects_Empty_And_Oversized_Frames()
{
Assert.Throws<ProtocolFormatException>(() => ProtocolSerializer.DecodeClient(ReadOnlyMemory<byte>.Empty));
var oversized = new byte[ProtocolConstants.MaxFrameBytes + 1];
Assert.Throws<ProtocolFormatException>(() => ProtocolSerializer.DecodeClient(oversized));
var garbage = new byte[] { 0xC1, 0xFF, 0x00 };
Assert.Throws<ProtocolFormatException>(() => 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<ConcedeIntent>(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, "<Royal Paladin> grade 3");
Assert.IsType<CardSearchedView>(EventProjection.ForViewer(searched, 0));
Assert.Null(EventProjection.ForViewer(searched, 1));
Assert.IsType<SearchFailedView>(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<UnitCalledView>(EventProjection.ForViewer(called, 0));
Assert.IsType<UnitCalledView>(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<GameStartedView>(forOpponent[0].Event);
Assert.IsType<CardDrawnView>(forOpponent[1].Event);
var fromSecond = EventProjection.ProjectRange(events, 0, fromSeq: 2);
Assert.Equal(2, fromSecond.Count);
Assert.Equal(2, fromSecond[0].Seq);
Assert.IsType<CardSearchedView>(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<string> BuildDeck()
{
var deck = new List<string>();
// гарантируем карты, которые используются в сценариях
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;
}
}

View File

@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Vanguard.Rules\Vanguard.Rules.csproj" />
<ProjectReference Include="..\..\src\Vanguard.Content\Vanguard.Content.csproj" />
<ProjectReference Include="..\..\src\Vanguard.Shared\Vanguard.Shared.csproj" />
</ItemGroup>
</Project>