Add match protocol spec and shared network contracts
This commit is contained in:
parent
9e1ed87d5d
commit
d4e6e9d8c0
|
|
@ -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 процесса клиента на любом шаге восстанавливается.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` — каталог карт с разбором состава сетов.
|
||||
|
|
|
|||
|
|
@ -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` запрещают вложенность эффектов.
|
||||
|
|
|
|||
|
|
@ -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 КиБ и мусорный кадр отклоняются без падения сервера.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "Неизвестный интент."),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue