Add meta API, match history and replays

This commit is contained in:
MpakobecEx 2026-10-21 15:27:19 +05:00
parent 083cebf978
commit 4d5d7f0423
47 changed files with 4061 additions and 308 deletions

View File

@ -1,2 +1,3 @@
VANGUARD_PORT=8080 VANGUARD_PORT=8080
VANGUARD_WS_URL=ws://localhost:8080/game VANGUARD_WS_URL=ws://localhost:8080/game
VANGUARD_JWT_KEY=change-me-min-32-characters-long-key

View File

@ -195,21 +195,26 @@
## 6. Мета-API `[10–14 дней]` ## 6. Мета-API `[10–14 дней]`
- [ ] Модель `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация). - [x] Модель `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация).
- [ ] Эндпоинты: `register`, `login`, `refresh`, `logout`, `me`. - [x] Эндпоинты: `register`, `login`, `refresh`, `logout`, `me`.
- [ ] Коллекция: `GET /collection` (сгруппировано по принтам), счётчики без верхней границы. - [x] Коллекция: `GET /collection` (сгруппировано по принтам), счётчики без верхней границы.
- [ ] Колоды: CRUD, `validate`, лимит 30 на формат, шаринг по коду. - [x] Колоды: CRUD, `validate`, лимит 30 на формат, шаринг по коду.
- [ ] Магазин: `GET /store`, `POST /buy/booster`, `POST /buy/trialdeck`, `POST /redeem/token`. - [x] Магазин: `GET /store`, `POST /buy/booster`, `POST /buy/trialdeck`, `POST /redeem/token`.
- [ ] Генератор бустера: слот `4 × C` + `1 × R+` (R 70 / RR 22 / RRR 7 / SP 1). - [x] Генератор бустера: слот `4 × C` + `1 × R+` (R 70 / RR 22 / RRR 7 / SP 1).
- [ ] Duplicate protection по `printingId`, замена внутри редкости, переброс редкости при её насыщении. - [x] Duplicate protection по `printingId`, замена внутри редкости, переброс редкости при её насыщении.
- [ ] Экономика: журнал транзакций, `idempotencyKey`, баланс вычисляемый. - [x] Экономика: журнал транзакций, `idempotencyKey`, баланс вычисляемый.
- [ ] Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт ×4, SP не крафтится. - [x] Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт ×4, SP не крафтится.
- [ ] Онбординг: выбор триал-деки + 5 токенов, флаг «выбор сделан». - [x] Онбординг: выбор триал-деки + 5 токенов, флаг «выбор сделан».
- [ ] Матчмейкинг: 2 очереди, скрытый MMR, расширяющееся окно. - [x] Матчмейкинг: 2 очереди, скрытый MMR, расширяющееся окно; с JWT по `deckId`.
- [ ] Результат матча: начисление Gold 30/15/20 и XP 150/75/100, запись в историю. - [x] Результат матча: начисление Gold 30/15/20 и XP 150/75/100, запись в историю.
- [ ] Rate limiting на все мутирующие эндпоинты. - [x] История матчей и реплеи: `GET /matches`, `GET /replays/{id}`, проекция под игрока.
- [x] Rate limiting на все мутирующие эндпоинты.
**Готово, когда:** новый игрок через API проходит путь регистрация → триал-дека → токены → бустеры → колода → очередь. **Готово, когда:** новый игрок через API проходит путь регистрация → триал-дека → токены → бустеры → колода → очередь.
— выполнено: `MetaGameE2ETests` проходит регистрацию, онбординг, бустер, колоду,
очередь, полный матч, историю и реплей; проверено и на реальном PostgreSQL.
После MVP: косметика, квесты, баттлпасс, Draft, друзья/чат.
--- ---

View File

@ -15,7 +15,8 @@
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово | | Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
| Сервер: матч по сети, матчмейкинг, реконнект | готово | | Сервер: матч по сети, матчмейкинг, реконнект | готово |
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | | PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
| Orleans и мета-API (JWT, коллекции, магазин) | не начато | | Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
| Orleans, CI и миграции | не начато |
| Клиент и лаунчер | не начато | | Клиент и лаунчер | не начато |
## Требования ## Требования
@ -64,18 +65,22 @@ OK: ошибок 0, предупреждений 0
/server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры /server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры
/server/src/Vanguard.Content загрузка и проверка YAML-контента /server/src/Vanguard.Content загрузка и проверка YAML-контента
/server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0) /server/src/Vanguard.Shared сетевые контракты (netstandard2.1 для Unity + net10.0)
/server/src/Vanguard.Server ASP.NET Core: WebSocket, очередь, тикеты, матчи /server/src/Vanguard.Server ASP.NET Core: WebSocket, мета-API, магазин, матчи
/Auth JWT, register/login/refresh, BCrypt
/Meta коллекция, колоды, экономика, история, реплеи
/Storage PostgreSQL-снапшоты и восстановление матчей /Storage PostgreSQL-снапшоты и восстановление матчей
/Matchmaking очередь, MMR, Redis-хранилище очереди /Matchmaking очередь, MMR, Redis-хранилище очереди
/Tickets тикеты: память или Redis /Tickets тикеты: память или Redis
/server/src/Vanguard.Cli консольные команды /server/src/Vanguard.Cli консольные команды
/server/tests тесты правил, контрактов и сервера (включая E2E) /server/tests тесты правил, контрактов и сервера (включая E2E)
/content/sets карты в YAML /content/sets карты в YAML
/content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера
/content/keywords.yaml ключевые слова и иконки /content/keywords.yaml ключевые слова и иконки
/content/translations/ru.yaml русские тексты /content/translations/ru.yaml русские тексты
/tools импорт и сид переводов /tools импорт и сид переводов
/docs протокол, API, правила перевода /docs протокол, API, правила перевода
/docker-compose.yml сервер в контейнере /docker-compose.yml PostgreSQL, Redis и сервер
``` ```
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять `Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
@ -135,5 +140,6 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам. - `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов. - `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект. - `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`. - `docs/server-api.md` — HTTP API: JWT, коллекция, колоды, магазин, очередь,
история и реплеи.
- `content/cards-mvp.md` — каталог карт с разбором состава сетов. - `content/cards-mvp.md` — каталог карт с разбором состава сетов.

11
content/pack_odds.yaml Normal file
View File

@ -0,0 +1,11 @@
booster:
commonSlot:
count: 4
rarity: C
rareSlot:
count: 1
weights:
R: 70
RR: 22
RRR: 7
SP: 1

74
content/trial-decks.yaml Normal file
View File

@ -0,0 +1,74 @@
decks:
- id: td01
name: "Blaster Blade"
clan: RoyalPaladin
cards:
- printingId: TD01/001EN
count: 4
- printingId: TD01/002EN
count: 1
- printingId: TD01/003EN
count: 2
- printingId: TD01/004EN
count: 4
- printingId: TD01/005EN
count: 1
- printingId: TD01/006EN
count: 3
- printingId: TD01/007EN
count: 4
- printingId: TD01/008EN
count: 4
- printingId: TD01/009EN
count: 2
- printingId: TD01/010EN
count: 4
- printingId: TD01/011EN
count: 4
- printingId: TD01/012EN
count: 1
- printingId: TD01/013EN
count: 4
- printingId: TD01/014EN
count: 4
- printingId: TD01/015EN
count: 4
- printingId: TD01/016EN
count: 4
- id: td02
name: "Dragonic Overlord"
clan: Kagero
cards:
- printingId: TD02/001EN
count: 2
- printingId: TD02/002EN
count: 1
- printingId: TD02/003EN
count: 4
- printingId: TD02/004EN
count: 4
- printingId: TD02/005EN
count: 4
- printingId: TD02/006EN
count: 4
- printingId: TD02/007EN
count: 4
- printingId: TD02/008EN
count: 2
- printingId: TD02/009EN
count: 4
- printingId: TD02/010EN
count: 2
- printingId: TD02/011EN
count: 2
- printingId: TD02/012EN
count: 1
- printingId: TD02/013EN
count: 4
- printingId: TD02/014EN
count: 4
- printingId: TD02/015EN
count: 4
- printingId: TD02/016EN
count: 4

View File

@ -33,9 +33,11 @@ services:
environment: environment:
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
Game__PublicWsUrl: ${VANGUARD_WS_URL:-ws://localhost:8080/game} Game__PublicWsUrl: ${VANGUARD_WS_URL:-ws://localhost:8080/game}
Game__AllowDevQueue: "false"
Storage__Provider: Postgres Storage__Provider: Postgres
Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard
Redis__Configuration: redis:6379 Redis__Configuration: redis:6379
Jwt__SigningKey: ${VANGUARD_JWT_KEY:-dev-only-vanguard-remaster-signing-key-change-me}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@ -38,13 +38,14 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты | **164/164** (131 правила/контракты + 33 сервер) | | Тесты | **169/169** (131 правила/контракты + 38 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |
| Карты | **94/94** (66 с текстом + 28 ванильных) | | Карты | **94/94** (66 с текстом + 28 ванильных) |
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы | | Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker | | Сервер | сеть, матчмейкинг, PostgreSQL + Redis, Docker |
| Мета-API | JWT, коллекция, колоды, магазин, экономика, история, реплеи |
| Клиент / лаунчер | не начаты | | Клиент / лаунчер | не начаты |
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям: Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
@ -70,7 +71,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
``` ```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests) Пройден! : не пройдено 0, пройдено 38 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0 Партий: 1000, завершено: 1000, ошибок: 0
@ -255,7 +256,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis - **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется (AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`). через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
- **Тесты** (`Vanguard.Server.Tests`, 33): - **Тесты серверного ядра** (33 из 38 в `Vanguard.Server.Tests`):
- тикеты: одноразовость и TTL; - тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача, - grain: attach, чужое место, duplicate intent, чужой ход, сдача,
@ -272,10 +273,54 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе; - E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния. обрыв и возврат по новому тикету с догоном состояния.
Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis; Проверено на `ssh home`: 169/169 тестов, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, `docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`). а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
### 4.9. Мета-API
- **Аккаунты**: `register`, `login`, `refresh`, `logout`, `me`.
BCrypt, JWT access 15 минут, refresh 30 дней с ротацией; в БД хранится
хэш refresh-токена, старый после ротации отзывается.
- **Онбординг**: одна из двух триал-дек, 50 карт в коллекцию и 5 токенов;
повтор невозможен. Триал-деки — `content/trial-decks.yaml`.
- **Коллекция**: по `printingId`, без верхнего предела; разбор
C 5 / R 10 / RR 25 / RRR 100 пыли, крафт 20 / 40 / 100 / 400, SP нельзя.
- **Колоды**: CRUD, валидация 50 карт / ≤4 по `nameId` / Sentinel / Heal /
стартовый Авангард, проверка владения картами, лимит 30, шаринг по коду.
- **Магазин**: бустер 100 Gold или 1 токен; вторая триал-дека 500 Gold;
состав бустера 4 C + 1 R+ (R 70 / RR 22 / RRR 7 / SP 1) из
`content/pack_odds.yaml`; duplicate protection по `printingId` с порогом 4.
- **Экономика**: журнал транзакций, баланс вычисляется, `idempotencyKey`
на всех покупках, разборе и крафте.
- **Очередь с JWT**: `{ mode, deckId }`, игрок и колода берутся из токена
и коллекции. Dev-режим `playerId + deck` остался только при
`Game:AllowDevQueue=true`.
- **Rate limiting**: 60/мин на auth, 300/мин на прочие мутации, плюс
глобальный лимит по IP.
### 4.10. История матчей и реплеи
- `MetaProfileService` после подтверждённого завершения матча пишет
историю обоим игрокам, меняет MMR в ранкеде и начисляет Gold/XP.
Победа 30 Gold / 150 XP, поражение 15 / 75; сдача, таймер и отсутствие
дают 0, но остаются в истории.
- `GET /api/matches`, `GET /api/matches/{id}` — история игрока.
- `GET /api/replays/{id}` восстанавливает матч из seed, колод и журнала
принятых команд и отдаёт:
- `events` — события, видимые этому игроку;
- `frames` — `MatchView` до и после каждой команды.
- Реплей собирается под зрителя: рука противника и закрытый урон скрыты,
`legalIntents` пуст, часы обнулены. Смотреть может только участник.
- Проверено на `ssh home`: живой Postgres, регистрация → онбординг →
бустер за токен → колода → очередь → матч; `/api/replays/{id}` отдаёт
11 стартовых событий и кадр, рука противника пуста. После рестарта
сервера матч восстановлен (`matches: 1`).
Счётчик `Vanguard.Server.Tests` вырос до 38: добавлены auth, мета-профиль,
мета-E2E (онбординг → бустер → колода → матч → история → реплей)
и проверки реплея.
--- ---
## 5. Структура и ключевые файлы ## 5. Структура и ключевые файлы
@ -304,16 +349,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/server/src/Vanguard.Server /server/src/Vanguard.Server
/Program.cs ASP.NET Core, DI, WebSocket endpoint /Program.cs ASP.NET Core, DI, WebSocket endpoint
/Api/ очередь, тикеты, /health /Api/ очередь, тикеты, /health
/Auth/ JWT, register/login/refresh, BCrypt
/Meta/ коллекция, колоды, магазин, экономика, история, реплеи
/Matchmaking/ очереди, MMR, Redis-хранилище очереди /Matchmaking/ очереди, MMR, Redis-хранилище очереди
/Matches/ IMatchGrain, таймеры, тикер, регистр /Matches/ IMatchGrain, таймеры, тикер, регистр
/Realtime/ WebSocket-обвязка, чтение кадров /Realtime/ WebSocket-обвязка, чтение кадров
/Tickets/ одноразовые тикеты: память или Redis /Tickets/ одноразовые тикеты: память или Redis
/Storage/ EF-хранилище, восстановление матчей /Storage/ EF-хранилище, восстановление матчей
/Profiles/ скрытый MMR и статистика /Profiles/ интерфейс MMR
/Dockerfile /Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест /server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища /server/tests/Vanguard.Server.Tests 38 тестов, включая E2E и внешние хранилища
/content/sets карты /content/sets карты
/content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера
/content/translations/ru.yaml переводы /content/translations/ru.yaml переводы
/docs/translation-rules.md канон перевода /docs/translation-rules.md канон перевода
/docs/protocol.md протокол матча, версия 1 /docs/protocol.md протокол матча, версия 1
@ -351,6 +400,17 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
от транспорта, Orleans подключается поверх. от транспорта, Orleans подключается поверх.
- Rate limit — 16 интентов в секунду на соединение. - Rate limit — 16 интентов в секунду на соединение.
- Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет. - Скрытый MMR: старт 1000, Эло K = 32, casual рейтинг не меняет.
- Аккаунты: BCrypt, access JWT 15 минут, refresh 30 дней с ротацией;
в БД хранится только хэш refresh-токена.
- Онбординг: одна триал-дека бесплатно + 5 токенов; вторая триал-дека
за 500 Gold; бустер 100 Gold или 1 токен.
- Коллекция по `printingId` без лимита; лимит 4 по `nameId` — только декбилдер.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт 20 / 40 / 100 / 400,
SP не разбирается и не крафтится.
- Матч: победа 30 Gold / 150 XP, поражение 15 / 75; Gold = XP / 5.
Сдача, таймер и отсутствие наград не дают.
- История матча пишется обоим игрокам один раз; реплей доступен только
участникам и собирается под зрителя, без руки противника.
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются. - Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
- Непрерывные эффекты пересчитываются при каждом запросе. - Непрерывные эффекты пересчитываются при каждом запросе.
- Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор. - Поиск в колоде: 0 — уведомление и перемешивание, 1 — сразу, 2+ — выбор.
@ -388,34 +448,47 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
Осталось в серверном направлении: Осталось в серверном направлении:
7. **Orleans** — заменить in-process регистр на настоящий silo и граны. 7. **Мета-API** — сделано: JWT, аккаунты, коллекция, колоды, магазин,
экономика, онбординг, матчмейкинг по `deckId`, история и реплеи.
`playerId` остался только как dev-режим при `Game:AllowDevQueue=true`.
8. **Orleans** — заменить in-process регистр на настоящий silo и граны.
`IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность `IMatchGrain` уже отделён от транспорта и ASP.NET Core; персистентность
и проекция от хостинга не зависят. и проекция от хостинга не зависят.
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
Критерий готовности серверного блока выполнен: два клиента играют полный Критерий готовности серверного блока выполнен: два клиента играют полный
матч по сети; kill клиента на любом шаге восстанавливается через новый матч по сети; kill клиента восстанавливается через новый тикет и
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это `resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
закрывается пунктом 7. Остались Orleans и инфраструктурные мелочи (CI, миграции).
### 7.2. Мета-API ### 7.2. Мета-API
Сделано:
- `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация). - `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация).
- `register`, `login`, `refresh`, `logout`, `me`. - `register`, `login`, `refresh`, `logout`, `me`.
- Коллекция по принтам, без верхнего предела. - Коллекция по принтам, без верхнего предела; разбор и крафт.
- Колоды: CRUD, `validate`, лимит 30, шаринг по коду. - Колоды: CRUD, `validate`, лимит 30, шаринг по коду, проверка владения.
- Магазин, бустеры, триал-деки, токены. - Магазин: бустеры, триал-деки, токены; duplicate protection.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт ×4, SP не крафтится. - Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт 20 / 40 / 100 / 400;
SP не разбирается и не крафтится.
- Онбординг: триал-дека + 5 токенов. - Онбординг: триал-дека + 5 токенов.
- Матчмейкинг, результат матча, Gold/XP, история. - Матчмейкинг по JWT и `deckId`; dev-режим только для разработки.
- Результат матча: Gold/XP, MMR, история, реплеи.
- Rate limiting. - Rate limiting.
Осталось после MVP: косметика, квесты, баттлпасс, Draft, друзья/чат,
история покупок в клиенте.
### 7.3. Клиент ### 7.3. Клиент
Следующий блок — **клиент**:
- Unity 6 LTS, URP, Addressables, Input System, Localization, DOTween. - Unity 6 LTS, URP, Addressables, Input System, Localization, DOTween.
- Сцены `Boot`, `Meta`, `Battle`. - Сцены `Boot`, `Meta`, `Battle`.
- VContainer, `ApiClient`, `GameConnection`. - VContainer, `ApiClient`, `GameConnection`.
- Экраны: логин, меню, магазин, коллекция, декбилдер, очередь, настройки. - Экраны: логин, меню, магазин, коллекция, декбилдер, очередь, настройки.
- История матчей и экран реплея: `GET /api/matches`, `GET /api/replays/{id}`,
проигрывание `frames` + `events`.
- Боевая сцена: поле, рука, зоны, анимации, таймер. - Боевая сцена: поле, рука, зоны, анимации, таймер.
- Локализация RU/EN на лету. - Локализация RU/EN на лету.
@ -438,12 +511,13 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`. 1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
2. Прогнать проверки из раздела 3. 2. Прогнать проверки из раздела 3.
3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo, 3. Следующий блок — **клиент** (Unity): `Boot`/`Meta`/`Battle`, `ApiClient`,
затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч, `GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`, история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
`Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`. `docs/server-api.md`, `Vanguard.Shared`.
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 4. Orleans, CI и миграции — отдельные серверные долги, не блокируют клиент.
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. 5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
--- ---
@ -463,9 +537,11 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
для наблюдателя нельзя — в списке есть printingId из чужой руки. для наблюдателя нельзя — в списке есть printingId из чужой руки.
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот - **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
в E2E специально притормаживается; живой UI в лимит не упирается. в E2E специально притормаживается; живой UI в лимит не упирается.
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут - **Сервер: хранилище.** По умолчанию `Storage:Provider=None`: матчи живут
в памяти. Для рестарта нужен `Postgres` + `Storage:ConnectionString`; в памяти (`NullMatchStore`), мета-API — в EF InMemory. Для рестарта нужен
схема создаётся `EnsureCreated`, миграций пока нет. `Memory` — EF InMemory. `Postgres` + `Storage:ConnectionString`; `Memory` — EF InMemory для всего.
Схема создаётся `EnsureCreated` для одного общего `MatchDbContext`;
миграций пока нет, при изменении схемы тестовую БД нужно пересоздать.
- **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды, - **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды,
игроки, часы и журнал интентов. При восстановлении команды проигрываются игроки, часы и журнал интентов. При восстановлении команды проигрываются
заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой. заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой.
@ -486,6 +562,25 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`, - **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
играет полный матч по WebSocket и проверяет, что победитель совпал. играет полный матч по WebSocket и проверяет, что победитель совпал.
Случайные партии завершаются за ~350 действий; таймаут теста — 120 с. Случайные партии завершаются за ~350 действий; таймаут теста — 120 с.
- **Мета: JWT.** `Jwt:SigningKey` — не короче 32 символов; дефолтный ключ
только для разработки. Access 15 минут, refresh 30 дней с ротацией;
в БД лежит SHA-256 от refresh, старый токен после ротации недействителен.
- **Мета: dev-очередь.** `Game:AllowDevQueue=true` разрешает очередь по
`playerId` без JWT. В compose выключено; не включать в production.
- **Мета: колода.** Сохранение проверяет не только правила Classic, но и
владение каждой картой. Триал-дека даёт карты в коллекцию, а не готовую
колоду — её собирают декбилдером.
- **Мета: бустер.** 4 C + 1 R+, веса в `content/pack_odds.yaml`.
Duplicate protection считает `printingId` и порог 4; при насыщении
редкости выбирается другая, иначе допускается дубль. SP не крафтится
и не разбирается.
- **Мета: награды.** Gold/XP только за `normal`; сдача, таймер и отсутствие
дают 0. История пишется один раз на `(userId, matchId)`, повтор результата
не дублирует валюту.
- **Реплей.** Доступен только участнику; собирается из seed, колод и журнала
принятых команд. `frames` — состояние после каждой команды, `events` —
только видимые зрителю события; рука противника и закрытый урон скрыты.
Сырые колоды и seed наружу не отдаются.
- **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается - **Сервер: таймеры.** Тик раз в секунду. После 90 с банк списывается
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается, по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
поэтому UI должен показывать ещё и `BankMs`. поэтому UI должен показывать ещё и `BankMs`.

View File

@ -1,11 +1,11 @@
# HTTP API сервера # HTTP API сервера
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`. Мета-API и матчевый HTTP-слой: аккаунты, коллекция, колоды, магазин,
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`. очередь, тикеты, история матчей и реплеи. После подключения клиент
переходит на WebSocket и работает по `docs/protocol.md`.
Базовая часть стека: **ASP.NET Core 10**, один процесс. Базовая часть стека: **ASP.NET Core 10**, JWT, PostgreSQL/Redis (опционально),
Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без один процесс.
внешних сервисов сервер работает in-memory (удобно для тестов).
--- ---
@ -30,14 +30,16 @@ docker compose up --build
# http://localhost:8080/health # http://localhost:8080/health
``` ```
Порт и внешний адрес WebSocket задаются в `.env`: `.env`:
``` ```
VANGUARD_PORT=8080 VANGUARD_PORT=8080
VANGUARD_WS_URL=ws://localhost:8080/game VANGUARD_WS_URL=ws://localhost:8080/game
VANGUARD_JWT_KEY=change-me-min-32-characters-long-key
``` ```
`VANGUARD_PORT` нужен, если 8080 занят (на тестовом стенде — 18080). `VANGUARD_PORT` нужен, если 8080 занят. `VANGUARD_JWT_KEY` обязателен
в production (не короче 32 символов).
Для ручной проверки колоду на 50 карт можно получить так: Для ручной проверки колоду на 50 карт можно получить так:
@ -45,152 +47,310 @@ VANGUARD_WS_URL=ws://localhost:8080/game
dotnet run --project server\src\Vanguard.Cli -- deck dotnet run --project server\src\Vanguard.Cli -- deck
``` ```
Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент Каталог карт ищется в `content/sets` вверх от каталога сборки; триал-деки
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении). и веса бустера — в `content/trial-decks.yaml` и `content/pack_odds.yaml`.
--- ---
## 2. Авторизация ## 2. Авторизация
Пока это **dev-заглушка**: игрок представляется строкой `playerId`. JWT: access — 15 минут, refresh — 30 дней с ротацией. Пароли — BCrypt.
Никаких токенов и паролей нет, поэтому сервер нельзя выставлять в интернет
как есть.
Когда появится мета-API, `playerId` будет браться из JWT, а контракты очереди ```
и тикетов не изменятся. Ограничения на формат: 1–64 символа, буквы, цифры, POST /api/auth/register { "userName": "player", "password": "password123" }
`_`, `.`, `-`. POST /api/auth/login { "userName": "player", "password": "password123" }
POST /api/auth/refresh { "refreshToken": "..." }
POST /api/auth/logout { "refreshToken": "..." } → 204
GET /api/auth/me Authorization: Bearer <access>
```
Ответ register/login/refresh:
```json
{
"userId": "…",
"userName": "player",
"accessToken": "eyJ…",
"refreshToken": "…",
"accessExpiresAt": "2026-10-21T12:15:00+00:00",
"refreshExpiresAt": "2026-11-20T12:00:00+00:00",
"onboardingCompleted": false
}
```
Правила:
- имя: 3–32 символа, буквы, цифры, `_`, `.`, `-`; уникально без учёта регистра;
- пароль: 8–128 символов;
- refresh одноразовый: при `/refresh` старый отзывается, выдаётся новый;
- повторное использование старого refresh → `401`;
- `me` возвращает балансы, MMR, XP, статистику и состояние онбординга.
```json
{
"userId": "…",
"userName": "player",
"createdAt": "2026-10-21T12:00:00+00:00",
"onboardingCompleted": true,
"selectedTrialDeckId": "td01",
"gold": 30,
"dust": 5,
"tokens": 4,
"xp": 150,
"rankedMmr": 1016,
"casualMmr": 1000,
"matches": 1,
"wins": 1
}
```
--- ---
## 3. Очередь ## 3. Онбординг
### `POST /api/game/queue` ```
POST /api/meta/onboarding { "deckId": "td01" } [Bearer]
```
Поставить игрока в очередь. - Выдаёт все 50 карт выбранной триал-деки в коллекцию.
- Начисляет **5 токенов** (1 токен = 1 бустер).
- Выбор делается один раз; повтор → `409`.
- Ответ: `{ "balances": { "gold": 0, "dust": 0, "tokens": 5 }, "deckId": "td01" }`.
Вторая триал-дека покупается в магазине за Gold.
---
## 4. Коллекция
```
GET /api/collection [Bearer]
POST /api/collection/dismantle [Bearer]
POST /api/collection/craft [Bearer]
```
`GET`:
```json ```json
{ {
"playerId": "p1", "items": [
"mode": "classic-ranked", { "printingId": "TD01/001EN", "nameId": "…", "nameEn": "…", "rarity": "R", "count": 4 }
"deck": ["BT01/001EN", "BT01/002EN", "..."] ],
"balances": { "gold": 0, "dust": 5, "tokens": 5 }
} }
``` ```
- `mode`: `classic-ranked` или `classic-casual`. Коллекция считается по `printingId` и не имеет верхнего предела.
- `deck`: ровно 50 `printingId`. Проверяются те же правила, что и в декбилдере:
50 карт, ≤4 по `nameId`, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы
одна карта grade 0 без триггера для стартового Авангарда.
Ответ `200`: Разбор (`dismantle`):
```json ```json
{ { "printingId": "BT01/050EN", "count": 2, "idempotencyKey": "uuid" }
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "queued",
"mode": "classic-ranked"
}
``` ```
Ответ `400` при плохой колоде: | Редкость | Пыль за карту |
|---|---|
| C | 5 |
| R | 10 |
| RR | 25 |
| RRR | 100 |
| SP | нельзя разобрать |
Крафт (`craft`):
```json
{ "printingId": "BT01/050EN", "idempotencyKey": "uuid" }
```
| Редкость | Стоимость |
|---|---|
| C | 20 |
| R | 40 |
| RR | 100 |
| RRR | 400 |
| SP | нельзя скрафтить |
Оба эндпоинта возвращают актуальные `Balances`; `idempotencyKey` защищает
от двойного списания/начисления.
---
## 5. Колоды
```
GET /api/decks [Bearer]
POST /api/decks [Bearer] { "name": "...", "cards": ["..."] }
GET /api/decks/{id} [Bearer]
PUT /api/decks/{id} [Bearer] { "name": "...", "cards": ["..."] }
DELETE /api/decks/{id} [Bearer]
POST /api/decks/{id}/validate [Bearer] { "cards": ["..."] } // можно без cards — возьмёт сохранённые
POST /api/decks/{id}/share [Bearer] → { "code": "ABCD2345" }
GET /api/decks/share/{code} // публичный шаринг
```
Проверки:
- ровно 50 карт;
- ≤4 копий по `nameId`;
- ≤4 Sentinel и ≤4 Heal;
- известные принты;
- есть grade 0 без триггера для стартового Авангарда;
- **все карты есть в коллекции** (`deck.not-owned`);
- ≤30 колод на пользователя;
- имя колоды уникально у пользователя, 1–40 символов.
`validate` возвращает `{ "valid": true|false, "violations": [{ "code", "message" }] }`.
---
## 6. Магазин
```
GET /api/store [Bearer]
POST /api/store/buy/booster [Bearer] { "payment": "gold"|"token", "idempotencyKey": "uuid" }
POST /api/store/buy/trialdeck [Bearer] { "deckId": "td02", "idempotencyKey": "uuid" }
POST /api/store/redeem/token [Bearer] { "idempotencyKey": "uuid" }
```
`GET /api/store`:
```json ```json
{ {
"error": "deck.invalid", "balances": { "gold": 30, "dust": 0, "tokens": 4 },
"message": "Колода не проходит проверку.", "boosterPriceGold": 100,
"violations": [ "trialDecks": [
{ "code": "deck.size", "message": "В колоде 49 карт, требуется 50." } {
"id": "td01",
"name": "Blaster Blade",
"clan": "RoyalPaladin",
"cardCount": 50,
"owned": true,
"priceGold": 0,
"cards": [ { "printingId": "TD01/001EN", "count": 4 } ]
}
] ]
} }
``` ```
Повторный запрос от того же `playerId` отменяет его предыдущую очередь - Бустер: **100 Gold** или **1 токен**.
в любом режиме: один игрок — одна активная очередь. - `redeem/token` — то же, что бустер за токен.
- Вторая триал-дека: **500 Gold**.
- `idempotencyKey` обязателен для покупок; повтор возвращает тот же
результат и не списывает второй раз.
### `GET /api/game/queue/{queueId}?playerId=p1` Ответ бустера:
Статус очереди. Клиент опрашивает, пока не получит `matched`.
```json ```json
{ {
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f", "cards": [
"status": "matched", { "printingId": "BT01/050EN", "nameId": "…", "rarity": "R", "isNew": true }
],
"balances": { "gold": 0, "dust": 0, "tokens": 3 }
}
```
Состав: **4 карты C + 1 карта R+**. Веса R+:
**R 70 % / RR 22 % / RRR 7 % / SP 1 %** (`content/pack_odds.yaml`).
Duplicate protection: ключ — `printingId`, порог 4; замена внутри редкости,
при насыщении редкости — перевыбор; если насыщены все — дубль.
---
## 7. Матчмейкинг
```
POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" }
GET /api/game/queue/{queueId} [Bearer]
DELETE /api/game/queue/{queueId} [Bearer]
POST /api/game/tickets [Bearer] { "matchId": "…" }
```
С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку
и валидна на момент постановки). Без JWT работает dev-режим
(`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере
он выключен.
Ответ очереди — `{ queueId, status: "queued", mode }`. Статус matched
содержит `matchId`, `seat`, `ticket`, `opponent`, `wsUrl`.
Подбор: две очереди, скрытый MMR, окно ±100 → +50 каждые 5 с до 2000,
ранкед меняет MMR по Эло (K = 32), casual — нет.
Тикет одноразовый, TTL 60 с, привязан к `(player, matchId, seat)`.
Dev-формат (если разрешён): `{ "playerId": "p1", "mode": "...", "deck": ["..."] }`.
---
## 8. История матчей и реплеи
```
GET /api/matches?limit=20&offset=0 [Bearer]
GET /api/matches/{matchId} [Bearer]
GET /api/replays/{matchId}?includeFrames=true [Bearer]
```
История:
```json
{
"items": [
{
"matchId": "…",
"mode": "classic-ranked", "mode": "classic-ranked",
"waitedSeconds": 7, "opponentName": "bob",
"window": 100, "result": "win",
"matchId": "9f2c...", "gold": 30,
"seat": 0, "xp": 150,
"ticket": "c2VjcmV0...", "mmrBefore": 1000,
"opponent": "p2", "mmrAfter": 1016,
"reason": null, "finishReason": "normal",
"wsUrl": "ws://localhost:5080/game" "playedAt": "2026-10-21T12:30:00+00:00",
"replayAvailable": true
}
]
} }
``` ```
`status`: - Запись создаётся только после подтверждённого сервером завершения матча.
- Одна запись на игрока и матч; повтор результата не дублируется.
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает
в историю.
- MMR меняется в ранкеде; casual MMR остаётся прежним.
| Значение | Смысл | Реплей:
|---|---|
| `queued` | ждёт противника, `window` — текущее окно MMR |
| `matched` | матч найден, `ticket` — одноразовый тикет |
| `cancelled` | игрок отменил очередь или встал в новую |
| `expired` | очередь истекла (10 минут) |
| `failed` | не удалось создать матч |
`wsUrl` заполняется только для `matched`. Он строится из `Game:PublicWsUrl`
или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел
подключиться, он запрашивает новый через `POST /api/game/tickets` с `matchId`.
Подбор:
- две независимые очереди — ranked и casual;
- скрытый MMR, стартовое окно ±100;
- окно расширяется на 50 каждые 5 секунд, максимум 2000;
- пара выбирается по ближайшему MMR в пределах взаимных окон;
- кто встал раньше — садится за место 0.
MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32),
в casual — нет.
### `DELETE /api/game/queue/{queueId}?playerId=p1`
Отменить очередь. `204` — отменена, `409` — уже не активна.
---
## 4. Тикеты
### `POST /api/game/tickets`
Выдать новый одноразовый тикет на подключение. Нужен для переподключения
после обрыва: старый тикет уже израсходован.
```json
{ "playerId": "p1", "matchId": "9f2c..." }
```
Ответ `200`:
```json ```json
{ {
"ticket": "c2VjcmV0...", "matchId": "…",
"matchId": "9f2c...", "mode": "classic-ranked",
"seat": 0, "player0": "alice",
"wsUrl": "ws://localhost:5080/game", "player1": "bob",
"expiresAt": "2026-10-21T12:01:00+00:00" "viewerSeat": 0,
"winner": 0,
"finishReason": "normal",
"eventHash": "…",
"events": [ { "seq": 1, "kind": "GameStarted", "event": { "firstPlayer": 0 } } ],
"frames": [ { "eventSeq": 11, "view": { "…": "MatchView" } } ]
} }
``` ```
- TTL — 60 секунд, тикет одноразовый. - Реплей восстанавливается из seed, колод и журнала принятых команд.
- Тикет привязан к `(playerId, matchId, seat)`. - `events` — события, видимые этому игроку (скрытый поиск не раскрывается).
- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться - `frames` — состояние до и после каждой принятой команды; `MatchView`
и посмотреть итоговое состояние. собран под зрителя, `legalIntents` пуст, часы обнулены.
- `404` — матч не найден, `403` — игрок не сидит в этом матче. - Рука противника и закрытый урон остаются скрытыми даже в реплее.
- Смотреть может только участник матча; остальным — `403`.
- `includeFrames=false` — только таймлайн событий.
--- ---
## 5. Health ## 9. Health
### `GET /health` ```
GET /health
```
```json ```json
{ {
@ -207,20 +367,20 @@ MMR наружу не отдаётся. В ранкеде после матча
--- ---
## 6. Полный сценарий ## 10. Полный сценарий
``` ```
POST /api/game/queue → queueId POST /api/auth/register → access + refresh
GET /api/game/queue/{id} → matched + ticket + wsUrl POST /api/meta/onboarding {td01} → 50 карт + 5 токенов
WS /game → ClientHello(ticket) GET /api/collection → 50 карт
← ServerHello + ServerSnapshot POST /api/decks {cards} → колода
→ ClientIntent(...) POST /api/store/redeem/token → бустер, 4 токена
← ServerIntentResult + ServerEvents GET /api/store → витрина
... POST /api/game/queue {deckId} → queueId
обрыв GET /api/game/queue/{id} → matched + ticket
POST /api/game/tickets → новый ticket WS /game + ClientHello(ticket) → матч
WS /game → ClientHello(ticket, resumeFromSeq) GET /api/matches → история, Gold/XP
← ServerHello + ServerEvents (догон) GET /api/replays/{matchId} → реплей
``` ```
Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок — Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок —
@ -228,55 +388,40 @@ WS /game → ClientHello(ticket, resumeFromSeq)
--- ---
## 7. Хранилище и восстановление ## 11. Хранилище и восстановление
### 7.1. Конфигурация
| Ключ | Значения | Смысл | | Ключ | Значения | Смысл |
|---|---|---| |---|---|---|
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное | | `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — матчи без сохранения, мета в памяти; `Memory` — EF InMemory; `Postgres` — постоянное |
| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` | | `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` |
| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти | | `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти |
| `Redis:Prefix` | `vanguard:` | префикс ключей | | `Redis:Prefix` | `vanguard:` | префикс ключей |
| `Jwt:SigningKey` | ≥32 символа | подпись access-токенов |
В контейнере переменные задаются как `Storage__Provider`, Одна БД, один `MatchDbContext`:
`Storage__ConnectionString`, `Redis__Configuration`.
### 7.2. Матчи в PostgreSQL - `matches`, `match_intents` — заголовок и журнал матча;
- `users`, `refresh_tokens` — аккаунты и сессии;
- `collection`, `decks`, `transactions`, `booster_openings`,
`trial_deck_ownership` — коллекция, колоды, экономика, покупки;
- `match_history` — история и награды.
- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`. Восстановление матча: при старте `MatchRecoveryService` поднимает
- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа. незавершённые матчи и проигрывает принятые команды на детерминированном
- На каждый интент пишется строка журнала и обновляется заголовок. движке; `eventSeq` сверяется. Схема создаётся `EnsureCreated`; миграций
- После рестарта `MatchRecoveryService` поднимает незавершённые матчи: пока нет, при изменении схемы тестовую БД нужно пересоздать.
создаёт движок с сохранённым seed и проигрывает принятые команды заново.
`GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем,
делегаты никуда не сериализуются.
- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается
с ошибкой в логе.
- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика.
- Завершённые матчи остаются историей и при старте в память не поднимаются.
### 7.3. Redis Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set).
- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL`
(одноразовость сохраняется между инстансами).
- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set
`vanguard:queue:{mode}` по номеру постановки. При старте очередь
загружается обратно.
- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API
вместе с аккаунтами.
--- ---
## 8. Ограничения текущего этапа ## 12. Ограничения текущего этапа
- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`) - `Storage:Provider=None` — всё в памяти, рестарт теряет данные.
матчи, тикеты и очередь живут только в памяти. История матча пишется, но реплей при `None` недоступен: строки матча нет.
- Профили и MMR пока не персистентны: после рестарта рейтинг начинается Для истории и реплеев нужен `Memory` или `Postgres`.
с 1000. Матчи и очередь при этом восстанавливаются. - `EnsureCreated`, а не миграции: схема меняется вместе с пересозданием БД.
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди. - Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён.
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans - Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
grain не затронет транспорт и правила. - Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
- Нет JWT, коллекций и наград — это следующие блоки. - Dev-очередь включается только `Game:AllowDevQueue=true` и не для production.
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
не упирается; тестовый бот специально притормаживается.

View File

@ -0,0 +1,93 @@
using Vanguard.Rules.Cards;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Vanguard.Content;
public sealed record BoosterSlotOdds(
int Count,
Rarity? Rarity,
IReadOnlyDictionary<Rarity, int>? Weights);
/// <summary>Веса бустера из content/pack_odds.yaml.</summary>
public sealed class PackOdds
{
private PackOdds(BoosterSlotOdds commonSlot, BoosterSlotOdds rareSlot)
{
CommonSlot = commonSlot;
RareSlot = rareSlot;
}
public BoosterSlotOdds CommonSlot { get; }
public BoosterSlotOdds RareSlot { get; }
public int TotalCards => CommonSlot.Count + RareSlot.Count;
public static PackOdds Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Файл весов бустера не найден: {path}");
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var file = deserializer.Deserialize<PackOddsFile>(File.ReadAllText(path))
?? throw new InvalidOperationException($"Пустой файл весов: {path}");
if (file.Booster is null)
throw new InvalidOperationException("В pack_odds.yaml нет секции booster.");
return new PackOdds(
ParseSlot(file.Booster.CommonSlot, "commonSlot"),
ParseSlot(file.Booster.RareSlot, "rareSlot"));
}
private static BoosterSlotOdds ParseSlot(SlotEntry? entry, string name)
{
if (entry is null || entry.Count <= 0)
throw new InvalidOperationException($"Слот {name}: count должен быть > 0.");
if (entry.Weights is { Count: > 0 })
{
var weights = new Dictionary<Rarity, int>();
foreach (var (key, value) in entry.Weights)
{
if (!Enum.TryParse<Rarity>(key, ignoreCase: true, out var rarity))
throw new InvalidOperationException($"Слот {name}: неизвестная редкость {key}.");
if (value <= 0)
throw new InvalidOperationException($"Слот {name}: вес {key} должен быть > 0.");
weights[rarity] = value;
}
return new BoosterSlotOdds(entry.Count, null, weights);
}
if (!Enum.TryParse<Rarity>(entry.Rarity, ignoreCase: true, out var fixedRarity))
throw new InvalidOperationException($"Слот {name}: не задана редкость или веса.");
return new BoosterSlotOdds(entry.Count, fixedRarity, null);
}
private sealed class PackOddsFile
{
public BoosterEntry? Booster { get; set; }
}
private sealed class BoosterEntry
{
public SlotEntry? CommonSlot { get; set; }
public SlotEntry? RareSlot { get; set; }
}
private sealed class SlotEntry
{
public int Count { get; set; }
public string? Rarity { get; set; }
public Dictionary<string, int>? Weights { get; set; }
}
}

View File

@ -0,0 +1,97 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Vanguard.Content;
public sealed record TrialDeckCard(string PrintingId, int Count);
public sealed record TrialDeckDefinition(
string Id,
string Name,
string? Clan,
IReadOnlyList<TrialDeckCard> Cards)
{
public int TotalCards => Cards.Sum(card => card.Count);
/// <summary>Разворачивает counts в список printingId длиной 50.</summary>
public IReadOnlyList<string> ToDeck() =>
Cards.SelectMany(card => Enumerable.Repeat(card.PrintingId, card.Count)).ToList();
}
/// <summary>Две триал-деки из content/trial-decks.yaml.</summary>
public sealed class TrialDeckCatalog
{
private readonly Dictionary<string, TrialDeckDefinition> _decks;
private TrialDeckCatalog(IEnumerable<TrialDeckDefinition> decks)
{
_decks = decks.ToDictionary(deck => deck.Id, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<TrialDeckDefinition> Decks => _decks.Values.ToList();
public bool TryGet(string id, out TrialDeckDefinition deck) =>
_decks.TryGetValue(id, out deck!);
public static TrialDeckCatalog Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Файл триал-дек не найден: {path}");
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var file = deserializer.Deserialize<TrialDeckFile>(File.ReadAllText(path))
?? throw new InvalidOperationException($"Пустой файл триал-дек: {path}");
if (file.Decks.Count == 0)
throw new InvalidOperationException("В trial-decks.yaml нет дек.");
var decks = new List<TrialDeckDefinition>();
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in file.Decks)
{
if (string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.Name))
throw new InvalidOperationException("У триал-деки должны быть id и name.");
if (!ids.Add(entry.Id))
throw new InvalidOperationException($"Дубликат id триал-деки: {entry.Id}");
var cards = entry.Cards
.Where(card => !string.IsNullOrWhiteSpace(card.PrintingId) && card.Count > 0)
.Select(card => new TrialDeckCard(card.PrintingId, card.Count))
.ToList();
var total = cards.Sum(card => card.Count);
if (total != 50)
throw new InvalidOperationException(
$"Триал-дека {entry.Id}: {total} карт, требуется 50.");
decks.Add(new TrialDeckDefinition(entry.Id, entry.Name, entry.Clan, cards));
}
return new TrialDeckCatalog(decks);
}
private sealed class TrialDeckFile
{
public List<TrialDeckEntry> Decks { get; set; } = new();
}
private sealed class TrialDeckEntry
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Clan { get; set; }
public List<TrialDeckCardEntry> Cards { get; set; } = new();
}
private sealed class TrialDeckCardEntry
{
public string PrintingId { get; set; } = string.Empty;
public int Count { get; set; }
}
}

View File

@ -2,7 +2,11 @@ using Vanguard.Rules.Decks;
namespace Vanguard.Server.Api; namespace Vanguard.Server.Api;
public sealed record QueueRequest(string? PlayerId, string? Mode, List<string>? Deck); public sealed record QueueRequest(
string? PlayerId,
string? Mode,
List<string>? Deck,
Guid? DeckId = null);
public sealed record QueueAccepted(Guid QueueId, string Status, string Mode); public sealed record QueueAccepted(Guid QueueId, string Status, string Mode);

View File

@ -1,14 +1,17 @@
using System.Security.Claims;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches; using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking; using Vanguard.Server.Matchmaking;
using Vanguard.Server.Meta;
using Vanguard.Server.Tickets; using Vanguard.Server.Tickets;
namespace Vanguard.Server.Api; namespace Vanguard.Server.Api;
/// <summary> /// <summary>
/// HTTP-часть матчевого блока: очередь, тикеты, здоровье. /// HTTP-часть матчевого блока: очередь, тикеты, здоровье.
/// Авторизация здесь ещё dev-заглушка: игрок называется playerId. /// Если пришёл JWT — игрок и его колода берутся из мета-API.
/// JWT из мета-API встанет на это место без изменения контрактов очереди. /// Без JWT работает dev-режим (playerId + готовая колода), если он разрешён.
/// </summary> /// </summary>
public static class ApiEndpoints public static class ApiEndpoints
{ {
@ -23,20 +26,61 @@ public static class ApiEndpoints
private static async Task<IResult> Enqueue( private static async Task<IResult> Enqueue(
QueueRequest request, QueueRequest request,
MatchmakingService matchmaking) ClaimsPrincipal principal,
MatchmakingService matchmaking,
DeckService decks,
GameOptions options)
{ {
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен: 1–64 символа, буквы, цифры, _ . -"));
if (!GameModes.TryParse(request.Mode, out var mode)) if (!GameModes.TryParse(request.Mode, out var mode))
return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}.")); return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}."));
string playerId;
IReadOnlyList<string> deck;
var userName = principal.GetUserName();
if (userName is not null)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
if (request.DeckId is not { } deckId)
return Results.BadRequest(new ApiError("deck.missing", "Нужен deckId."));
try
{
var (_, cards) = await decks.GetForQueueAsync(userId, deckId);
var violations = await decks.ValidateAsync(userId, cards);
if (violations.Count > 0)
return Results.BadRequest(new ApiError("deck.invalid", "Колода не проходит проверку.", violations));
playerId = userName;
deck = cards;
}
catch (MetaException exception)
{
return Results.Json(
new ApiError(exception.Code, exception.Message),
statusCode: exception.StatusCode);
}
}
else
{
if (!options.AllowDevQueue)
return Results.Unauthorized();
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен: 1–64 символа, буквы, цифры, _ . -"));
if (request.Deck is not { Count: > 0 }) if (request.Deck is not { Count: > 0 })
return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт.")); return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт."));
playerId = request.PlayerId!;
deck = request.Deck;
}
try try
{ {
var ticket = await matchmaking.EnqueueAsync(request.PlayerId!, request.Deck, mode); var ticket = await matchmaking.EnqueueAsync(playerId, deck, mode);
return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode)); return Results.Ok(new QueueAccepted(ticket.QueueId, "queued", ticket.Mode));
} }
catch (DeckRejectedException exception) catch (DeckRejectedException exception)
@ -51,14 +95,20 @@ public static class ApiEndpoints
private static async Task<IResult> PollQueue( private static async Task<IResult> PollQueue(
Guid queueId, Guid queueId,
string? playerId, string? playerId,
ClaimsPrincipal principal,
MatchmakingService matchmaking, MatchmakingService matchmaking,
HttpRequest http, HttpRequest http,
GameOptions options) GameOptions options)
{ {
if (!IsValidPlayerId(playerId)) var effectivePlayer = principal.GetUserName() ?? playerId;
if (principal.GetUserName() is null && !options.AllowDevQueue)
return Results.Unauthorized();
if (!IsValidPlayerId(effectivePlayer))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
var snapshot = await matchmaking.PollAsync(queueId, playerId!); var snapshot = await matchmaking.PollAsync(queueId, effectivePlayer!);
if (snapshot is null) if (snapshot is null)
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена.")); return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
@ -79,26 +129,44 @@ public static class ApiEndpoints
private static async Task<IResult> CancelQueue( private static async Task<IResult> CancelQueue(
Guid queueId, Guid queueId,
string? playerId, string? playerId,
MatchmakingService matchmaking) ClaimsPrincipal principal,
MatchmakingService matchmaking,
GameOptions options)
{ {
if (!IsValidPlayerId(playerId)) var effectivePlayer = principal.GetUserName() ?? playerId;
if (principal.GetUserName() is null && !options.AllowDevQueue)
return Results.Unauthorized();
if (!IsValidPlayerId(effectivePlayer))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
return await matchmaking.CancelAsync(queueId, playerId!) return await matchmaking.CancelAsync(queueId, effectivePlayer!)
? Results.NoContent() ? Results.NoContent()
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна.")); : Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
} }
private static async Task<IResult> IssueTicket( private static async Task<IResult> IssueTicket(
TicketRequest request, TicketRequest request,
ClaimsPrincipal principal,
MatchGrainRegistry matches, MatchGrainRegistry matches,
GameTicketService tickets, GameTicketService tickets,
HttpRequest http, HttpRequest http,
GameOptions options) GameOptions options)
{ {
var playerId = principal.GetUserName();
if (playerId is null)
{
if (!options.AllowDevQueue)
return Results.Unauthorized();
if (!IsValidPlayerId(request.PlayerId)) if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
playerId = request.PlayerId!;
}
if (string.IsNullOrWhiteSpace(request.MatchId)) if (string.IsNullOrWhiteSpace(request.MatchId))
return Results.BadRequest(new ApiError("match.missing", "matchId обязателен.")); return Results.BadRequest(new ApiError("match.missing", "matchId обязателен."));
@ -106,9 +174,9 @@ public static class ApiEndpoints
return Results.NotFound(new ApiError("match.not-found", "Матч не найден.")); return Results.NotFound(new ApiError("match.not-found", "Матч не найден."));
var summary = match.Summary; var summary = match.Summary;
var seat = summary.Player0Id == request.PlayerId var seat = summary.Player0Id == playerId
? 0 ? 0
: summary.Player1Id == request.PlayerId : summary.Player1Id == playerId
? 1 ? 1
: -1; : -1;
@ -117,7 +185,7 @@ public static class ApiEndpoints
new ApiError("match.forbidden", "Этот игрок не участвует в матче."), new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
var ticket = await tickets.IssueAsync(request.PlayerId!, request.MatchId!, seat); var ticket = await tickets.IssueAsync(playerId, request.MatchId!, seat);
return Results.Ok(new TicketResponse( return Results.Ok(new TicketResponse(
ticket.Token, ticket.Token,

View File

@ -0,0 +1,147 @@
using System.Security.Claims;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Meta;
namespace Vanguard.Server.Auth;
public sealed record RegisterRequest(string? UserName, string? Password);
public sealed record LoginRequest(string? UserName, string? Password);
public sealed record RefreshRequest(string? RefreshToken);
public sealed record AuthResponse(
Guid UserId,
string UserName,
string AccessToken,
string RefreshToken,
DateTimeOffset AccessExpiresAt,
DateTimeOffset RefreshExpiresAt,
bool OnboardingCompleted);
public sealed record MeResponse(
Guid UserId,
string UserName,
DateTimeOffset CreatedAt,
bool OnboardingCompleted,
string? SelectedTrialDeckId,
int Gold,
int Dust,
int Tokens,
int Xp,
int RankedMmr,
int CasualMmr,
int Matches,
int Wins);
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/auth");
group.MapPost("/register", Register).RequireRateLimiting("auth");
group.MapPost("/login", Login).RequireRateLimiting("auth");
group.MapPost("/refresh", Refresh).RequireRateLimiting("auth");
group.MapPost("/logout", Logout);
group.MapGet("/me", Me).RequireAuthorization();
}
private static async Task<IResult> Register(
RegisterRequest request,
AuthService auth)
{
try
{
var result = await auth.RegisterAsync(request.UserName, request.Password);
return Results.Ok(ToResponse(result));
}
catch (AuthException exception)
{
return Results.Json(
new ApiError(exception.Code, exception.Message),
statusCode: exception.StatusCode);
}
}
private static async Task<IResult> Login(
LoginRequest request,
AuthService auth)
{
try
{
var result = await auth.LoginAsync(request.UserName, request.Password);
return Results.Ok(ToResponse(result));
}
catch (AuthException exception)
{
return Results.Json(
new ApiError(exception.Code, exception.Message),
statusCode: exception.StatusCode);
}
}
private static async Task<IResult> Refresh(
RefreshRequest request,
AuthService auth)
{
try
{
var result = await auth.RefreshAsync(request.RefreshToken);
return Results.Ok(ToResponse(result));
}
catch (AuthException exception)
{
return Results.Json(
new ApiError(exception.Code, exception.Message),
statusCode: exception.StatusCode);
}
}
private static async Task<IResult> Logout(
RefreshRequest request,
AuthService auth)
{
await auth.LogoutAsync(request.RefreshToken);
return Results.NoContent();
}
private static async Task<IResult> Me(
ClaimsPrincipal principal,
AuthService auth,
EconomyService economy)
{
var userId = principal.GetUserId();
if (userId is null)
return Results.Unauthorized();
var user = await auth.GetUserAsync(userId.Value);
if (user is null)
return Results.Unauthorized();
var balances = await economy.GetBalancesAsync(user.Id);
return Results.Ok(new MeResponse(
user.Id,
user.UserName,
user.CreatedAt,
user.OnboardingCompleted,
user.SelectedTrialDeckId,
balances.Gold,
balances.Dust,
balances.Tokens,
user.Xp,
user.RankedMmr,
user.CasualMmr,
user.Matches,
user.Wins));
}
private static AuthResponse ToResponse(AuthResult result) => new(
result.User.Id,
result.User.UserName,
result.Tokens.AccessToken,
result.Tokens.RefreshToken,
result.Tokens.AccessExpiresAt,
result.Tokens.RefreshExpiresAt,
result.User.OnboardingCompleted);
}

View File

@ -0,0 +1,198 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Configuration;
using Vanguard.Server.Meta;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Auth;
public sealed record AuthResult(UserEntity User, IssuedTokens Tokens);
public sealed class AuthService
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly TokenService _tokens;
private readonly JwtOptions _jwt;
private readonly TimeProvider _time;
public AuthService(
IDbContextFactory<MatchDbContext> factory,
TokenService tokens,
JwtOptions jwt,
TimeProvider time)
{
_factory = factory;
_tokens = tokens;
_jwt = jwt;
_time = time;
}
public async Task<AuthResult> RegisterAsync(string? userName, string? password)
{
var name = ValidateUserName(userName);
ValidatePassword(password);
var normalized = name.ToUpperInvariant();
await using var db = await _factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(user => user.NormalizedUserName == normalized))
throw new AuthException("auth.username-taken", "Такое имя уже занято.", 409);
var now = _time.GetUtcNow();
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = name,
NormalizedUserName = normalized,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11),
CreatedAt = now,
RankedMmr = 1000,
CasualMmr = 1000,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return new AuthResult(user, await IssueAsync(db, user));
}
public async Task<AuthResult> LoginAsync(string? userName, string? password)
{
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(password))
throw new AuthException("auth.invalid-credentials", "Неверное имя или пароль.", 401);
var normalized = userName.Trim().ToUpperInvariant();
await using var db = await _factory.CreateDbContextAsync();
var user = await db.Users.SingleOrDefaultAsync(item => item.NormalizedUserName == normalized);
if (user is null)
throw new AuthException("auth.invalid-credentials", "Неверное имя или пароль.", 401);
bool valid;
try
{
valid = BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
}
catch (Exception)
{
valid = false;
}
if (!valid)
throw new AuthException("auth.invalid-credentials", "Неверное имя или пароль.", 401);
user.LastLoginAt = _time.GetUtcNow();
await db.SaveChangesAsync();
return new AuthResult(user, await IssueAsync(db, user));
}
public async Task<AuthResult> RefreshAsync(string? refreshToken)
{
if (string.IsNullOrWhiteSpace(refreshToken))
throw new AuthException("auth.invalid-refresh", "Неверный refresh-токен.", 401);
var hash = TokenService.HashRefreshToken(refreshToken);
await using var db = await _factory.CreateDbContextAsync();
var stored = await db.RefreshTokens.SingleOrDefaultAsync(token => token.TokenHash == hash);
var now = _time.GetUtcNow();
if (stored is null || stored.RevokedAt is not null || stored.ExpiresAt <= now)
throw new AuthException("auth.invalid-refresh", "Неверный refresh-токен.", 401);
var user = await db.Users.SingleOrDefaultAsync(item => item.Id == stored.UserId)
?? throw new AuthException("auth.invalid-refresh", "Неверный refresh-токен.", 401);
var issued = await IssueAsync(db, user, revoke: stored);
return new AuthResult(user, issued);
}
public async Task LogoutAsync(string? refreshToken)
{
if (string.IsNullOrWhiteSpace(refreshToken))
return;
var hash = TokenService.HashRefreshToken(refreshToken);
await using var db = await _factory.CreateDbContextAsync();
var stored = await db.RefreshTokens.SingleOrDefaultAsync(token => token.TokenHash == hash);
if (stored is null || stored.RevokedAt is not null)
return;
stored.RevokedAt = _time.GetUtcNow();
await db.SaveChangesAsync();
}
public async Task<UserEntity?> GetUserAsync(Guid userId)
{
await using var db = await _factory.CreateDbContextAsync();
return await db.Users.AsNoTracking().SingleOrDefaultAsync(user => user.Id == userId);
}
private async Task<IssuedTokens> IssueAsync(
MatchDbContext db,
UserEntity user,
RefreshTokenEntity? revoke = null)
{
var now = _time.GetUtcNow();
var refresh = _tokens.CreateRefreshToken();
if (revoke is not null)
{
revoke.RevokedAt = now;
revoke.ReplacedByHash = refresh.Hash;
}
db.RefreshTokens.Add(new RefreshTokenEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
TokenHash = refresh.Hash,
CreatedAt = now,
ExpiresAt = refresh.ExpiresAt,
});
await db.SaveChangesAsync();
return new IssuedTokens(
_tokens.CreateAccessToken(user),
refresh.Raw,
now.AddMinutes(_jwt.AccessMinutes),
refresh.ExpiresAt);
}
private static string ValidateUserName(string? userName)
{
var name = userName?.Trim() ?? string.Empty;
if (name.Length is < 3 or > 32
|| !name.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' or '.'))
{
throw new AuthException(
"auth.username-invalid",
"Имя: 3–32 символа, буквы, цифры, _ . -",
400);
}
return name;
}
private static void ValidatePassword(string? password)
{
if (password is null || password.Length is < 8 or > 128)
throw new AuthException("auth.password-invalid", "Пароль: 8–128 символов.", 400);
}
}
public sealed class AuthException : Exception
{
public AuthException(string code, string message, int statusCode)
: base(message)
{
Code = code;
StatusCode = statusCode;
}
public string Code { get; }
public int StatusCode { get; }
}

View File

@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Vanguard.Server.Configuration;
using Vanguard.Server.Meta;
namespace Vanguard.Server.Auth;
public sealed record IssuedTokens(
string AccessToken,
string RefreshToken,
DateTimeOffset AccessExpiresAt,
DateTimeOffset RefreshExpiresAt);
/// <summary>Создание access-JWT и одноразовых refresh-токенов (в БД хранится хэш).</summary>
public sealed class TokenService
{
private readonly JwtOptions _options;
private readonly TimeProvider _time;
private readonly SymmetricSecurityKey _key;
public TokenService(JwtOptions options, TimeProvider time)
{
_options = options;
_time = time;
if (string.IsNullOrWhiteSpace(options.SigningKey) || options.SigningKey.Length < 32)
throw new InvalidOperationException("Jwt:SigningKey должен быть не короче 32 символов.");
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey));
}
public string CreateAccessToken(UserEntity user)
{
var now = _time.GetUtcNow();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(JwtRegisteredClaimNames.UniqueName, user.UserName),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
};
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now.UtcDateTime,
expires: now.AddMinutes(_options.AccessMinutes).UtcDateTime,
signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
public (string Raw, string Hash, DateTimeOffset ExpiresAt) CreateRefreshToken()
{
var raw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
return (raw, HashRefreshToken(raw), _time.GetUtcNow().AddDays(_options.RefreshDays));
}
public static string HashRefreshToken(string raw) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
}
public static class CurrentUser
{
public static Guid? GetUserId(this ClaimsPrincipal principal)
{
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub);
return Guid.TryParse(value, out var id) ? id : null;
}
public static string? GetUserName(this ClaimsPrincipal principal) =>
principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName);
}

View File

@ -28,6 +28,9 @@ public sealed class GameOptions
public int MmrWindowIntervalSeconds { get; set; } = 5; public int MmrWindowIntervalSeconds { get; set; } = 5;
public int MmrMaxWindow { get; set; } = 2000; public int MmrMaxWindow { get; set; } = 2000;
/// <summary>Разрешить очередь без JWT по playerId. Только для локальной разработки.</summary>
public bool AllowDevQueue { get; set; } = true;
/// <summary>Внешний адрес WebSocket. Пусто — строится из запроса.</summary> /// <summary>Внешний адрес WebSocket. Пусто — строится из запроса.</summary>
public string PublicWsUrl { get; set; } = string.Empty; public string PublicWsUrl { get; set; } = string.Empty;
} }

View File

@ -0,0 +1,34 @@
namespace Vanguard.Server.Configuration;
/// <summary>JWT и сроки жизни токенов.</summary>
public sealed class JwtOptions
{
public string Issuer { get; set; } = "VanguardReMaster";
public string Audience { get; set; } = "VanguardClient";
public string SigningKey { get; set; } = "dev-only-vanguard-remaster-signing-key-change-me";
public int AccessMinutes { get; set; } = 15;
public int RefreshDays { get; set; } = 30;
}
/// <summary>Экономика и мета-ограничения.</summary>
public sealed class MetaOptions
{
public int StarterTokens { get; set; } = 5;
public int BoosterPriceGold { get; set; } = 100;
public int TrialDeckPriceGold { get; set; } = 500;
public int MaxDecksPerUser { get; set; } = 30;
public int MaxDeckNameLength { get; set; } = 40;
public int MatchWinXp { get; set; } = 150;
public int MatchLossXp { get; set; } = 75;
public int MatchDrawXp { get; set; } = 100;
/// <summary>Gold = XP / GoldPerXp для матчей (30/15/20 при 5).</summary>
public int GoldPerXp { get; set; } = 5;
/// <summary>Начислять ли награды за сдачу/таймер/отсутствие. По правилам — нет.</summary>
public bool RewardsForForfeit { get; set; }
public int InitialMmr { get; set; } = 1000;
public int EloK { get; set; } = 32;
}

View File

@ -36,13 +36,23 @@ public sealed record MatchSummary(
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset? FinishedAt); DateTimeOffset? FinishedAt);
/// <summary>Результат для записи в профили. MMR меняется только в ранкеде.</summary> /// <summary>Почему матч завершился. От этого зависят награды и вид реплея.</summary>
public enum MatchFinishReason
{
Normal = 0,
Concede = 1,
Timeout = 2,
Absence = 3,
}
/// <summary>Результат для записи в профили и историю. MMR меняется только в ранкеде.</summary>
public sealed record MatchResult( public sealed record MatchResult(
string MatchId, string MatchId,
GameMode Mode, GameMode Mode,
string Player0Id, string Player0Id,
string Player1Id, string Player1Id,
int WinnerSeat) int WinnerSeat,
MatchFinishReason FinishReason = MatchFinishReason.Normal)
{ {
public string WinnerPlayerId => WinnerSeat == 0 ? Player0Id : Player1Id; public string WinnerPlayerId => WinnerSeat == 0 ? Player0Id : Player1Id;
} }

View File

@ -54,6 +54,7 @@ public sealed class MatchGrain : IMatchGrain
private DateTimeOffset? _finishedAt; private DateTimeOffset? _finishedAt;
private bool _resultReported; private bool _resultReported;
private bool _restoring; private bool _restoring;
private MatchFinishReason _finishReason = MatchFinishReason.Normal;
private const string ConcedeCommand = "concede"; private const string ConcedeCommand = "concede";
@ -270,6 +271,7 @@ public sealed class MatchGrain : IMatchGrain
if (isConcede) if (isConcede)
{ {
_finishReason = MatchFinishReason.Concede;
_engine.Finish(1 - seat); _engine.Finish(1 - seat);
commandText = ConcedeCommand; commandText = ConcedeCommand;
result = CommandResult.Ok(); result = CommandResult.Ok();
@ -305,12 +307,14 @@ public sealed class MatchGrain : IMatchGrain
if (!_engine.State.IsFinished && _engine.State.ActivePlayer != decisionBefore) if (!_engine.State.IsFinished && _engine.State.ActivePlayer != decisionBefore)
ResetTurnClock(now); ResetTurnClock(now);
// историю и награды пишем до финального события: клиент, увидев
// GameFinished, сразу может запросить историю и реплей
if (_engine.State.IsFinished)
await OnFinishedAsync(now);
await PushEventsAsync(0); await PushEventsAsync(0);
await PushEventsAsync(1); await PushEventsAsync(1);
await SendClockToAllAsync(now); await SendClockToAllAsync(now);
if (_engine.State.IsFinished)
await OnFinishedAsync(now);
} }
finally finally
{ {
@ -365,7 +369,7 @@ public sealed class MatchGrain : IMatchGrain
"Match {MatchId}: seat {Seat} absent for too long, opponent wins.", "Match {MatchId}: seat {Seat} absent for too long, opponent wins.",
_matchId, seat); _matchId, seat);
await FinishFromServerAsync(1 - seat, now); await FinishFromServerAsync(1 - seat, now, MatchFinishReason.Absence);
return; return;
} }
} }
@ -384,7 +388,7 @@ public sealed class MatchGrain : IMatchGrain
_seats[active].BankMs = 0; _seats[active].BankMs = 0;
_logger.LogInformation( _logger.LogInformation(
"Match {MatchId}: seat {Seat} ran out of time.", _matchId, active); "Match {MatchId}: seat {Seat} ran out of time.", _matchId, active);
await FinishFromServerAsync(1 - active, now); await FinishFromServerAsync(1 - active, now, MatchFinishReason.Timeout);
return; return;
} }
@ -519,15 +523,20 @@ public sealed class MatchGrain : IMatchGrain
} }
} }
private async Task FinishFromServerAsync(int winner, DateTimeOffset now) private async Task FinishFromServerAsync(
int winner,
DateTimeOffset now,
MatchFinishReason reason)
{ {
_finishReason = reason;
_engine.Finish(winner); _engine.Finish(winner);
RefreshSummary(); RefreshSummary();
await OnFinishedAsync(now);
await PushEventsAsync(0); await PushEventsAsync(0);
await PushEventsAsync(1); await PushEventsAsync(1);
await SendClockToAllAsync(now); await SendClockToAllAsync(now);
await OnFinishedAsync(now);
} }
private async Task OnFinishedAsync(DateTimeOffset now) private async Task OnFinishedAsync(DateTimeOffset now)
@ -551,7 +560,8 @@ public sealed class MatchGrain : IMatchGrain
_mode, _mode,
_seats[0].PlayerId, _seats[0].PlayerId,
_seats[1].PlayerId, _seats[1].PlayerId,
winner)); winner,
_finishReason));
} }
catch (Exception exception) catch (Exception exception)
{ {
@ -606,6 +616,7 @@ public sealed class MatchGrain : IMatchGrain
_engine.State.IsFinished, _engine.State.IsFinished,
_engine.State.Winner ?? -1, _engine.State.Winner ?? -1,
_finishedAt, _finishedAt,
_finishReason,
_engine.Events.Count); _engine.Events.Count);
private async Task PersistHeaderAsync() private async Task PersistHeaderAsync()
@ -703,6 +714,7 @@ public sealed class MatchGrain : IMatchGrain
_lastClockSent = header.LastClockSent; _lastClockSent = header.LastClockSent;
_finishedAt = header.FinishedAt; _finishedAt = header.FinishedAt;
_resultReported = header.IsFinished; _resultReported = header.IsFinished;
_finishReason = header.FinishReason;
if (_started) if (_started)
_turnDeadline = header.TurnDeadline ?? now.AddSeconds(_options.TurnSeconds); _turnDeadline = header.TurnDeadline ?? now.AddSeconds(_options.TurnSeconds);

View File

@ -80,7 +80,7 @@ public sealed class MatchmakingService
private readonly CardCatalog _catalog; private readonly CardCatalog _catalog;
private readonly MatchGrainRegistry _matches; private readonly MatchGrainRegistry _matches;
private readonly GameTicketService _tickets; private readonly GameTicketService _tickets;
private readonly PlayerProfileStore _profiles; private readonly IProfileService _profiles;
private readonly GameOptions _options; private readonly GameOptions _options;
private readonly IQueueStore _queueStore; private readonly IQueueStore _queueStore;
private readonly TimeProvider _time; private readonly TimeProvider _time;
@ -91,7 +91,7 @@ public sealed class MatchmakingService
CardCatalog catalog, CardCatalog catalog,
MatchGrainRegistry matches, MatchGrainRegistry matches,
GameTicketService tickets, GameTicketService tickets,
PlayerProfileStore profiles, IProfileService profiles,
GameOptions options, GameOptions options,
IQueueStore queueStore, IQueueStore queueStore,
TimeProvider time, TimeProvider time,
@ -162,7 +162,7 @@ public sealed class MatchmakingService
PlayerId = playerId, PlayerId = playerId,
Deck = deck.ToList(), Deck = deck.ToList(),
Mode = mode, Mode = mode,
Mmr = _profiles.GetMmr(playerId), Mmr = await _profiles.GetMmrAsync(playerId, mode),
EnqueuedAt = _time.GetUtcNow(), EnqueuedAt = _time.GetUtcNow(),
Sequence = ++_sequence, Sequence = ++_sequence,
}; };

View File

@ -0,0 +1,222 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Server.Configuration;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Meta;
public sealed record BoosterCardDto(
string PrintingId,
string NameId,
Rarity Rarity,
bool IsNew);
public sealed record BoosterResult(
IReadOnlyList<BoosterCardDto> Cards,
Balances Balances);
public sealed class BoosterService
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly CardCatalog _catalog;
private readonly PackOdds _odds;
private readonly MetaOptions _options;
private readonly TimeProvider _time;
private readonly Dictionary<Rarity, List<CardPrinting>> _pools;
public BoosterService(
IDbContextFactory<MatchDbContext> factory,
CardCatalog catalog,
PackOdds odds,
MetaOptions options,
TimeProvider time)
{
_factory = factory;
_catalog = catalog;
_odds = odds;
_options = options;
_time = time;
_pools = BuildPools(catalog);
var weights = odds.RareSlot.Weights;
if (weights is null)
throw new InvalidOperationException("rareSlot должен задавать веса.");
foreach (var rarity in weights.Keys)
{
if (!_pools.ContainsKey(rarity))
throw new InvalidOperationException($"В BT01 нет принтов редкости {rarity}.");
}
}
public async Task<BoosterResult> BuyAsync(
Guid userId,
string? payment,
string? idempotencyKey)
{
var kind = payment?.Trim().ToLowerInvariant();
if (kind is not ("gold" or "token"))
throw new MetaException("booster.payment", "Оплата: gold или token.", 400);
if (string.IsNullOrWhiteSpace(idempotencyKey))
throw new MetaException("idempotency.required", "Нужен idempotencyKey.", 400);
await using var db = await _factory.CreateDbContextAsync();
var existing = await db.BoosterOpenings.AsNoTracking()
.SingleOrDefaultAsync(opening =>
opening.UserId == userId && opening.IdempotencyKey == idempotencyKey);
if (existing is not null)
{
var saved = JsonSerializer.Deserialize<List<BoosterCardDto>>(existing.CardsJson)
?? new List<BoosterCardDto>();
return new BoosterResult(saved, await EconomyService.GetBalancesAsync(db, userId));
}
var currency = kind == "gold" ? EconomyService.Gold : EconomyService.Token;
var price = kind == "gold" ? _options.BoosterPriceGold : 1;
var balance = await EconomyService.GetBalanceAsync(db, userId, currency);
if (balance < price)
{
throw new MetaException(
kind == "gold" ? "economy.not-enough-gold" : "economy.not-enough-tokens",
kind == "gold" ? "Недостаточно Gold." : "Нет токенов.",
400);
}
var counts = await db.Collection
.Where(card => card.UserId == userId)
.ToDictionaryAsync(card => card.PrintingId, card => card.Count, StringComparer.Ordinal);
var cards = Generate(counts);
foreach (var card in cards)
await CollectionService.AddAsync(db, userId, card.PrintingId, 1);
await EconomyService.AddTransactionAsync(
db, userId, currency, -price, "booster", $"booster:{idempotencyKey}", _time,
JsonSerializer.Serialize(cards));
db.BoosterOpenings.Add(new BoosterOpeningEntity
{
Id = Guid.NewGuid(),
UserId = userId,
IdempotencyKey = idempotencyKey,
Payment = kind,
CardsJson = JsonSerializer.Serialize(cards),
CreatedAt = _time.GetUtcNow(),
});
await db.SaveChangesAsync();
return new BoosterResult(cards, await EconomyService.GetBalancesAsync(db, userId));
}
private List<BoosterCardDto> Generate(Dictionary<string, int> counts)
{
var cards = new List<BoosterCardDto>();
var commonRarity = _odds.CommonSlot.Rarity
?? throw new InvalidOperationException("commonSlot должен задавать редкость.");
for (var i = 0; i < _odds.CommonSlot.Count; i++)
{
var (printingId, isNew) = Pick(_pools[commonRarity], counts);
cards.Add(ToDto(printingId, isNew));
}
var weights = _odds.RareSlot.Weights
?? throw new InvalidOperationException("rareSlot должен задавать веса.");
var rarity = PickRarity(weights);
if (!HasOpenCard(rarity, counts))
{
var openRarities = weights.Keys.Where(candidate => HasOpenCard(candidate, counts)).ToList();
if (openRarities.Count > 0)
rarity = openRarities[Random.Shared.Next(openRarities.Count)];
}
var rare = Pick(_pools[rarity], counts);
cards.Add(ToDto(rare.PrintingId, rare.IsNew));
return cards;
}
private BoosterCardDto ToDto(string printingId, bool isNew)
{
var printing = _catalog.Printing(printingId);
return new BoosterCardDto(
printingId,
printing.NameId,
printing.Rarity,
isNew);
}
private bool HasOpenCard(Rarity rarity, Dictionary<string, int> counts) =>
_pools[rarity].Any(printing => counts.GetValueOrDefault(printing.PrintingId) < 4);
private static (string PrintingId, bool IsNew) Pick(
IReadOnlyList<CardPrinting> pool,
Dictionary<string, int> counts)
{
var candidates = pool
.Where(printing => counts.GetValueOrDefault(printing.PrintingId) < 4)
.ToList();
if (candidates.Count == 0)
candidates = pool.ToList();
var printing = candidates[Random.Shared.Next(candidates.Count)];
var before = counts.GetValueOrDefault(printing.PrintingId);
counts[printing.PrintingId] = before + 1;
return (printing.PrintingId, before == 0);
}
private static Rarity PickRarity(IReadOnlyDictionary<Rarity, int> weights)
{
var total = weights.Values.Sum();
if (total <= 0)
throw new InvalidOperationException("Сумма весов R+ должна быть больше нуля.");
var roll = Random.Shared.Next(total);
foreach (var (rarity, weight) in weights)
{
roll -= weight;
if (roll < 0)
return rarity;
}
return weights.Keys.First();
}
private static Dictionary<Rarity, List<CardPrinting>> BuildPools(CardCatalog catalog)
{
var bt01 = catalog.Printings
.Where(printing => printing.PrintingId.StartsWith("BT01/", StringComparison.Ordinal))
.ToList();
var pools = new Dictionary<Rarity, List<CardPrinting>>
{
[Rarity.C] = bt01.Where(p => p.Rarity == Rarity.C && !p.IsParallel).ToList(),
[Rarity.R] = bt01.Where(p => p.Rarity == Rarity.R && !p.IsParallel).ToList(),
[Rarity.RR] = bt01.Where(p => p.Rarity == Rarity.RR && !p.IsParallel).ToList(),
[Rarity.RRR] = bt01.Where(p => p.Rarity == Rarity.RRR && !p.IsParallel).ToList(),
[Rarity.SP] = bt01.Where(p => p.Rarity == Rarity.SP).ToList(),
};
foreach (var (rarity, pool) in pools)
{
if (pool.Count == 0)
throw new InvalidOperationException($"В BT01 нет принтов редкости {rarity}.");
}
return pools;
}
}

View File

@ -0,0 +1,180 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Rules.Cards;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Meta;
public sealed record CollectionItem(
string PrintingId,
string NameId,
string NameEn,
Rarity Rarity,
int Count);
public sealed class CollectionService
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly CardCatalog _catalog;
private readonly TimeProvider _time;
public CollectionService(
IDbContextFactory<MatchDbContext> factory,
CardCatalog catalog,
TimeProvider time)
{
_factory = factory;
_catalog = catalog;
_time = time;
}
public async Task<IReadOnlyList<CollectionItem>> GetAsync(Guid userId)
{
await using var db = await _factory.CreateDbContextAsync();
var rows = await db.Collection.AsNoTracking()
.Where(card => card.UserId == userId && card.Count > 0)
.ToListAsync();
return rows
.Where(row => _catalog.TryPrinting(row.PrintingId, out _))
.Select(row =>
{
var printing = _catalog.Printing(row.PrintingId);
var card = _catalog.Card(printing.NameId);
return new CollectionItem(
row.PrintingId,
card.NameId,
card.NameEn,
printing.Rarity,
row.Count);
})
.OrderBy(item => item.Rarity)
.ThenBy(item => item.NameEn, StringComparer.Ordinal)
.ToList();
}
public async Task<Balances> DismantleAsync(
Guid userId,
string printingId,
int count,
string idempotencyKey)
{
if (count <= 0)
throw new MetaException("collection.count", "Количество должно быть больше нуля.", 400);
if (!_catalog.TryPrinting(printingId, out var printing))
throw new MetaException("collection.unknown-card", "Неизвестный принт.", 404);
if (printing.Rarity == Rarity.SP)
throw new MetaException("collection.sp", "SP-принт нельзя разобрать.", 400);
var dust = printing.Rarity switch
{
Rarity.C => 5,
Rarity.R => 10,
Rarity.RR => 25,
Rarity.RRR => 100,
_ => throw new MetaException("collection.rarity", "Эту редкость нельзя разобрать.", 400),
};
await using var db = await _factory.CreateDbContextAsync();
if (await db.Transactions.AnyAsync(transaction =>
transaction.UserId == userId && transaction.IdempotencyKey == idempotencyKey))
{
return await EconomyService.GetBalancesAsync(db, userId);
}
var owned = await db.Collection.FindAsync(userId, printingId);
if (owned is null || owned.Count < count)
throw new MetaException("collection.not-enough", "Недостаточно копий для разбора.", 400);
owned.Count -= count;
if (owned.Count == 0)
db.Collection.Remove(owned);
await EconomyService.AddTransactionAsync(
db, userId, EconomyService.Dust, dust * count, "dismantle", idempotencyKey, _time,
$"{{\"printingId\":\"{printingId}\",\"count\":{count}}}");
await db.SaveChangesAsync();
return await EconomyService.GetBalancesAsync(db, userId);
}
public async Task<Balances> CraftAsync(
Guid userId,
string printingId,
string idempotencyKey)
{
if (!_catalog.TryPrinting(printingId, out var printing))
throw new MetaException("collection.unknown-card", "Неизвестный принт.", 404);
if (printing.Rarity == Rarity.SP)
throw new MetaException("collection.sp", "SP-принт нельзя скрафтить.", 400);
var cost = printing.Rarity switch
{
Rarity.C => 20,
Rarity.R => 40,
Rarity.RR => 100,
Rarity.RRR => 400,
_ => throw new MetaException("collection.rarity", "Эту редкость нельзя скрафтить.", 400),
};
await using var db = await _factory.CreateDbContextAsync();
if (await db.Transactions.AnyAsync(transaction =>
transaction.UserId == userId && transaction.IdempotencyKey == idempotencyKey))
{
return await EconomyService.GetBalancesAsync(db, userId);
}
var dust = await EconomyService.GetBalanceAsync(db, userId, EconomyService.Dust);
if (dust < cost)
throw new MetaException("collection.not-enough-dust", "Недостаточно пыли.", 400);
await EconomyService.AddTransactionAsync(
db, userId, EconomyService.Dust, -cost, "craft", idempotencyKey, _time,
$"{{\"printingId\":\"{printingId}\"}}");
await AddAsync(db, userId, printingId, 1);
await db.SaveChangesAsync();
return await EconomyService.GetBalancesAsync(db, userId);
}
public static async Task AddAsync(
MatchDbContext db,
Guid userId,
string printingId,
int count)
{
var owned = await db.Collection.FindAsync(userId, printingId);
if (owned is null)
{
db.Collection.Add(new CollectionEntity
{
UserId = userId,
PrintingId = printingId,
Count = count,
});
}
else
{
owned.Count += count;
}
}
}
public sealed class MetaException : Exception
{
public MetaException(string code, string message, int statusCode)
: base(message)
{
Code = code;
StatusCode = statusCode;
}
public string Code { get; }
public int StatusCode { get; }
}

View File

@ -0,0 +1,265 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Configuration;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Meta;
public sealed record DeckDto(
Guid Id,
string Name,
string Format,
IReadOnlyList<string> Cards,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? ShareCode);
public sealed class DeckService
{
private const string ShareAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly CardCatalog _catalog;
private readonly MetaOptions _options;
private readonly TimeProvider _time;
public DeckService(
IDbContextFactory<MatchDbContext> factory,
CardCatalog catalog,
MetaOptions options,
TimeProvider time)
{
_factory = factory;
_catalog = catalog;
_options = options;
_time = time;
}
public async Task<IReadOnlyList<DeckDto>> ListAsync(Guid userId)
{
await using var db = await _factory.CreateDbContextAsync();
var decks = await db.Decks.AsNoTracking()
.Where(deck => deck.UserId == userId)
.OrderByDescending(deck => deck.UpdatedAt)
.ToListAsync();
return decks.Select(ToDto).ToList();
}
public async Task<DeckDto?> GetAsync(Guid userId, Guid deckId)
{
await using var db = await _factory.CreateDbContextAsync();
var deck = await db.Decks.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == deckId && item.UserId == userId);
return deck is null ? null : ToDto(deck);
}
public async Task<DeckDto> CreateAsync(
Guid userId,
string? name,
IReadOnlyList<string>? cards)
{
var cleanName = CleanName(name);
var list = cards?.ToList() ?? new List<string>();
var violations = await ValidateAsync(userId, list);
if (violations.Count > 0)
throw new DeckValidationException(violations);
await using var db = await _factory.CreateDbContextAsync();
var count = await db.Decks.CountAsync(deck => deck.UserId == userId);
if (count >= _options.MaxDecksPerUser)
throw new MetaException("deck.limit", $"Не больше {_options.MaxDecksPerUser} колод.", 400);
if (await db.Decks.AnyAsync(deck => deck.UserId == userId && deck.Name == cleanName))
throw new MetaException("deck.name-taken", "Колода с таким именем уже есть.", 409);
var now = _time.GetUtcNow();
var entity = new DeckEntity
{
Id = Guid.NewGuid(),
UserId = userId,
Name = cleanName,
Format = "classic",
CardsJson = JsonSerializer.Serialize(list),
CreatedAt = now,
UpdatedAt = now,
};
db.Decks.Add(entity);
await db.SaveChangesAsync();
return ToDto(entity);
}
public async Task<DeckDto> UpdateAsync(
Guid userId,
Guid deckId,
string? name,
IReadOnlyList<string>? cards)
{
var cleanName = CleanName(name);
var list = cards?.ToList() ?? new List<string>();
var violations = await ValidateAsync(userId, list);
if (violations.Count > 0)
throw new DeckValidationException(violations);
await using var db = await _factory.CreateDbContextAsync();
var deck = await db.Decks.SingleOrDefaultAsync(item => item.Id == deckId && item.UserId == userId)
?? throw new MetaException("deck.not-found", "Колода не найдена.", 404);
if (await db.Decks.AnyAsync(item =>
item.UserId == userId && item.Name == cleanName && item.Id != deckId))
{
throw new MetaException("deck.name-taken", "Колода с таким именем уже есть.", 409);
}
deck.Name = cleanName;
deck.CardsJson = JsonSerializer.Serialize(list);
deck.UpdatedAt = _time.GetUtcNow();
await db.SaveChangesAsync();
return ToDto(deck);
}
public async Task DeleteAsync(Guid userId, Guid deckId)
{
await using var db = await _factory.CreateDbContextAsync();
var deck = await db.Decks.SingleOrDefaultAsync(item => item.Id == deckId && item.UserId == userId)
?? throw new MetaException("deck.not-found", "Колода не найдена.", 404);
db.Decks.Remove(deck);
await db.SaveChangesAsync();
}
public async Task<IReadOnlyList<DeckViolation>> ValidateAsync(
Guid userId,
IReadOnlyList<string> cards)
{
var violations = DeckValidator.Validate(_catalog, cards.ToList()).ToList();
if (violations.Count > 0)
return violations;
await using var db = await _factory.CreateDbContextAsync();
var ownedIds = cards.Distinct(StringComparer.Ordinal).ToList();
var owned = await db.Collection.AsNoTracking()
.Where(card => card.UserId == userId && ownedIds.Contains(card.PrintingId))
.ToDictionaryAsync(card => card.PrintingId, card => card.Count, StringComparer.Ordinal);
foreach (var group in cards.GroupBy(card => card, StringComparer.Ordinal))
{
if (!owned.TryGetValue(group.Key, out var count) || count < group.Count())
{
var name = _catalog.TryPrinting(group.Key, out var printing)
? _catalog.Card(printing.NameId).NameEn
: group.Key;
violations.Add(new DeckViolation(
"deck.not-owned",
$"«{name}»: в колоде {group.Count()}, в коллекции {count}."));
}
}
return violations;
}
public async Task<string> ShareAsync(Guid userId, Guid deckId)
{
await using var db = await _factory.CreateDbContextAsync();
var deck = await db.Decks.SingleOrDefaultAsync(item => item.Id == deckId && item.UserId == userId)
?? throw new MetaException("deck.not-found", "Колода не найдена.", 404);
if (!string.IsNullOrWhiteSpace(deck.ShareCode))
return deck.ShareCode;
for (var attempt = 0; attempt < 20; attempt++)
{
var code = NewShareCode();
if (await db.Decks.AnyAsync(item => item.ShareCode == code))
continue;
deck.ShareCode = code;
deck.UpdatedAt = _time.GetUtcNow();
await db.SaveChangesAsync();
return code;
}
throw new MetaException("deck.share-failed", "Не удалось создать код шаринга.", 500);
}
public async Task<DeckDto?> GetByShareCodeAsync(string code)
{
await using var db = await _factory.CreateDbContextAsync();
var deck = await db.Decks.AsNoTracking()
.SingleOrDefaultAsync(item => item.ShareCode == code);
return deck is null ? null : ToDto(deck);
}
public async Task<(DeckDto Deck, IReadOnlyList<string> Cards)> GetForQueueAsync(
Guid userId,
Guid deckId)
{
var deck = await GetAsync(userId, deckId)
?? throw new MetaException("deck.not-found", "Колода не найдена.", 404);
return (deck, deck.Cards);
}
private string CleanName(string? name)
{
var clean = name?.Trim() ?? string.Empty;
if (clean.Length is 0 || clean.Length > _options.MaxDeckNameLength)
throw new MetaException(
"deck.name-invalid",
$"Имя колоды: 1–{_options.MaxDeckNameLength} символов.",
400);
return clean;
}
private static string NewShareCode()
{
Span<char> chars = stackalloc char[8];
for (var i = 0; i < chars.Length; i++)
chars[i] = ShareAlphabet[Random.Shared.Next(ShareAlphabet.Length)];
return new string(chars);
}
private static DeckDto ToDto(DeckEntity deck)
{
var cards = JsonSerializer.Deserialize<List<string>>(deck.CardsJson) ?? new List<string>();
return new DeckDto(
deck.Id,
deck.Name,
deck.Format,
cards,
deck.CreatedAt,
deck.UpdatedAt,
deck.ShareCode);
}
}
public sealed class DeckValidationException : Exception
{
public DeckValidationException(IReadOnlyList<DeckViolation> violations)
: base("Колода не проходит проверку.")
{
Violations = violations;
}
public IReadOnlyList<DeckViolation> Violations { get; }
}

View File

@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Meta;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Meta;
public sealed record Balances(int Gold, int Dust, int Tokens);
/// <summary>Валюты считаются по журналу транзакций, а не хранятся балансом.</summary>
public sealed class EconomyService
{
public const string Gold = "gold";
public const string Dust = "dust";
public const string Token = "token";
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly TimeProvider _time;
public EconomyService(IDbContextFactory<MatchDbContext> factory, TimeProvider time)
{
_factory = factory;
_time = time;
}
public async Task<Balances> GetBalancesAsync(Guid userId)
{
await using var db = await _factory.CreateDbContextAsync();
return await GetBalancesAsync(db, userId);
}
public static async Task<Balances> GetBalancesAsync(MatchDbContext db, Guid userId)
{
var sums = await db.Transactions
.Where(transaction => transaction.UserId == userId)
.GroupBy(transaction => transaction.Currency)
.Select(group => new { Currency = group.Key, Amount = group.Sum(item => item.Amount) })
.ToListAsync();
var gold = sums.FirstOrDefault(item => item.Currency == Gold)?.Amount ?? 0;
var dust = sums.FirstOrDefault(item => item.Currency == Dust)?.Amount ?? 0;
var tokens = sums.FirstOrDefault(item => item.Currency == Token)?.Amount ?? 0;
return new Balances(gold, dust, tokens);
}
public static async Task<int> GetBalanceAsync(
MatchDbContext db,
Guid userId,
string currency) =>
await db.Transactions
.Where(transaction => transaction.UserId == userId && transaction.Currency == currency)
.SumAsync(transaction => transaction.Amount);
/// <summary>
/// Добавить транзакцию. false — такой idempotencyKey уже был, повторно не пишем.
/// </summary>
public static async Task<bool> AddTransactionAsync(
MatchDbContext db,
Guid userId,
string currency,
int amount,
string reason,
string idempotencyKey,
TimeProvider time,
string? metadataJson = null)
{
if (await db.Transactions.AnyAsync(transaction =>
transaction.UserId == userId && transaction.IdempotencyKey == idempotencyKey))
{
return false;
}
db.Transactions.Add(new TransactionEntity
{
Id = Guid.NewGuid(),
UserId = userId,
Currency = currency,
Amount = amount,
Reason = reason,
IdempotencyKey = idempotencyKey,
CreatedAt = time.GetUtcNow(),
MetadataJson = metadataJson,
});
return true;
}
}

View File

@ -0,0 +1,408 @@
using System.Security.Claims;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Meta;
public sealed record DismantleRequest(string? PrintingId, int Count, string? IdempotencyKey);
public sealed record CraftRequest(string? PrintingId, string? IdempotencyKey);
public sealed record DeckRequest(string? Name, List<string>? Cards);
public sealed record BoosterBuyRequest(string? Payment, string? IdempotencyKey);
public sealed record TrialDeckBuyRequest(string? DeckId, string? IdempotencyKey);
public sealed record OnboardingRequest(string? DeckId);
public sealed record CollectionResponse(
IReadOnlyList<CollectionItem> Items,
Balances Balances);
public sealed record StoreResponse(
Balances Balances,
int BoosterPriceGold,
IReadOnlyList<StoreTrialDeckDto> TrialDecks);
public sealed record OnboardingResponse(Balances Balances, string DeckId);
public sealed record ShareResponse(string Code);
public sealed record MatchHistoryResponse(IReadOnlyList<MatchHistoryDto> Items);
public static class MetaEndpoints
{
public static void MapMetaEndpoints(this WebApplication app)
{
var collection = app.MapGroup("/api/collection").RequireAuthorization();
collection.MapGet("", GetCollection);
collection.MapPost("/dismantle", Dismantle)
.RequireRateLimiting("mutating");
collection.MapPost("/craft", Craft)
.RequireRateLimiting("mutating");
var decks = app.MapGroup("/api/decks").RequireAuthorization();
decks.MapGet("", ListDecks);
decks.MapPost("", CreateDeck).RequireRateLimiting("mutating");
decks.MapGet("/{deckId:guid}", GetDeck);
decks.MapPut("/{deckId:guid}", UpdateDeck).RequireRateLimiting("mutating");
decks.MapDelete("/{deckId:guid}", DeleteDeck).RequireRateLimiting("mutating");
decks.MapPost("/{deckId:guid}/validate", ValidateDeck).RequireRateLimiting("mutating");
decks.MapPost("/{deckId:guid}/share", ShareDeck).RequireRateLimiting("mutating");
app.MapGet("/api/decks/share/{code}", GetSharedDeck);
var store = app.MapGroup("/api/store").RequireAuthorization();
store.MapGet("", GetStore);
store.MapPost("/buy/booster", BuyBooster).RequireRateLimiting("mutating");
store.MapPost("/buy/trialdeck", BuyTrialDeck).RequireRateLimiting("mutating");
store.MapPost("/redeem/token", RedeemToken).RequireRateLimiting("mutating");
app.MapPost("/api/meta/onboarding", Onboard)
.RequireAuthorization()
.RequireRateLimiting("mutating");
app.MapGet("/api/matches", ListMatches).RequireAuthorization();
app.MapGet("/api/matches/{matchId}", GetMatch).RequireAuthorization();
app.MapGet("/api/replays/{matchId}", GetReplay).RequireAuthorization();
}
private static async Task<IResult> GetCollection(
ClaimsPrincipal principal,
CollectionService collection,
EconomyService economy)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var items = await collection.GetAsync(userId);
var balances = await economy.GetBalancesAsync(userId);
return Results.Ok(new CollectionResponse(items, balances));
}
private static async Task<IResult> Dismantle(
DismantleRequest request,
ClaimsPrincipal principal,
CollectionService collection)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(request.IdempotencyKey))
return BadRequest("idempotency.required", "Нужен idempotencyKey.");
try
{
var balances = await collection.DismantleAsync(
userId,
request.PrintingId ?? string.Empty,
request.Count,
request.IdempotencyKey);
return Results.Ok(balances);
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> Craft(
CraftRequest request,
ClaimsPrincipal principal,
CollectionService collection)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(request.IdempotencyKey))
return BadRequest("idempotency.required", "Нужен idempotencyKey.");
try
{
var balances = await collection.CraftAsync(
userId,
request.PrintingId ?? string.Empty,
request.IdempotencyKey);
return Results.Ok(balances);
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> ListDecks(
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
return Results.Ok(await decks.ListAsync(userId));
}
private static async Task<IResult> GetDeck(
Guid deckId,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var deck = await decks.GetAsync(userId, deckId);
return deck is null ? Results.NotFound() : Results.Ok(deck);
}
private static async Task<IResult> CreateDeck(
DeckRequest request,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
return Results.Ok(await decks.CreateAsync(userId, request.Name, request.Cards));
}
catch (MetaException exception)
{
return Error(exception);
}
catch (DeckValidationException exception)
{
return ValidationError(exception.Violations);
}
}
private static async Task<IResult> UpdateDeck(
Guid deckId,
DeckRequest request,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
return Results.Ok(await decks.UpdateAsync(userId, deckId, request.Name, request.Cards));
}
catch (MetaException exception)
{
return Error(exception);
}
catch (DeckValidationException exception)
{
return ValidationError(exception.Violations);
}
}
private static async Task<IResult> DeleteDeck(
Guid deckId,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
await decks.DeleteAsync(userId, deckId);
return Results.NoContent();
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> ValidateDeck(
Guid deckId,
DeckRequest request,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var cards = request.Cards
?? (await decks.GetAsync(userId, deckId))?.Cards
?? new List<string>();
var violations = await decks.ValidateAsync(userId, cards);
return Results.Ok(new { valid = violations.Count == 0, violations });
}
private static async Task<IResult> ShareDeck(
Guid deckId,
ClaimsPrincipal principal,
DeckService decks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
return Results.Ok(new ShareResponse(await decks.ShareAsync(userId, deckId)));
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> GetSharedDeck(
string code,
DeckService decks)
{
var deck = await decks.GetByShareCodeAsync(code);
return deck is null ? Results.NotFound() : Results.Ok(deck);
}
private static async Task<IResult> GetStore(
ClaimsPrincipal principal,
TrialDeckService trialDecks,
EconomyService economy,
MetaOptions options)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var balances = await economy.GetBalancesAsync(userId);
var decks = await trialDecks.GetStoreDecksAsync(userId);
return Results.Ok(new StoreResponse(balances, options.BoosterPriceGold, decks));
}
private static async Task<IResult> BuyBooster(
BoosterBuyRequest request,
ClaimsPrincipal principal,
BoosterService boosters)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
return Results.Ok(await boosters.BuyAsync(userId, request.Payment, request.IdempotencyKey));
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> RedeemToken(
BoosterBuyRequest request,
ClaimsPrincipal principal,
BoosterService boosters)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
return Results.Ok(await boosters.BuyAsync(userId, "token", request.IdempotencyKey));
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> BuyTrialDeck(
TrialDeckBuyRequest request,
ClaimsPrincipal principal,
TrialDeckService trialDecks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
var balances = await trialDecks.BuyAsync(
userId, request.DeckId, request.IdempotencyKey);
return Results.Ok(balances);
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> Onboard(
OnboardingRequest request,
ClaimsPrincipal principal,
TrialDeckService trialDecks)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
try
{
var balances = await trialDecks.OnboardAsync(userId, request.DeckId);
return Results.Ok(new OnboardingResponse(balances, request.DeckId!));
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static async Task<IResult> ListMatches(
int? limit,
int? offset,
ClaimsPrincipal principal,
MatchHistoryService history)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var items = await history.ListAsync(userId, limit ?? 20, offset ?? 0);
return Results.Ok(new MatchHistoryResponse(items));
}
private static async Task<IResult> GetMatch(
string matchId,
ClaimsPrincipal principal,
MatchHistoryService history)
{
if (principal.GetUserId() is not { } userId)
return Results.Unauthorized();
var item = await history.GetAsync(userId, matchId);
return item is null ? Results.NotFound() : Results.Ok(item);
}
private static async Task<IResult> GetReplay(
string matchId,
bool? includeFrames,
ClaimsPrincipal principal,
ReplayService replays)
{
var userName = principal.GetUserName();
if (userName is null)
return Results.Unauthorized();
try
{
var replay = await replays.GetAsync(matchId, userName, includeFrames ?? true);
return replay is null ? Results.NotFound() : Results.Ok(replay);
}
catch (MetaException exception)
{
return Error(exception);
}
}
private static IResult Error(MetaException exception) =>
Results.Json(
new ApiError(exception.Code, exception.Message),
statusCode: exception.StatusCode);
private static IResult BadRequest(string code, string message) =>
Results.BadRequest(new ApiError(code, message));
private static IResult ValidationError(IReadOnlyList<Vanguard.Rules.Decks.DeckViolation> violations) =>
Results.BadRequest(new ApiError(
"deck.invalid",
"Колода не проходит проверку.",
violations));
}

View File

@ -0,0 +1,95 @@
namespace Vanguard.Server.Meta;
public sealed class UserEntity
{
public Guid Id { get; set; }
public string UserName { get; set; } = string.Empty;
public string NormalizedUserName { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
public bool OnboardingCompleted { get; set; }
public string? SelectedTrialDeckId { get; set; }
public int RankedMmr { get; set; } = 1000;
public int CasualMmr { get; set; } = 1000;
public int Matches { get; set; }
public int Wins { get; set; }
public int Xp { get; set; }
}
public sealed class RefreshTokenEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? ReplacedByHash { get; set; }
}
public sealed class CollectionEntity
{
public Guid UserId { get; set; }
public string PrintingId { get; set; } = string.Empty;
public int Count { get; set; }
}
public sealed class DeckEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Name { get; set; } = string.Empty;
public string Format { get; set; } = "classic";
public string CardsJson { get; set; } = "[]";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? ShareCode { get; set; }
}
public sealed class TransactionEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Currency { get; set; } = string.Empty;
public int Amount { get; set; }
public string Reason { get; set; } = string.Empty;
public string IdempotencyKey { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public string? MetadataJson { get; set; }
}
public sealed class BoosterOpeningEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string IdempotencyKey { get; set; } = string.Empty;
public string Payment { get; set; } = string.Empty;
public string CardsJson { get; set; } = "[]";
public DateTimeOffset CreatedAt { get; set; }
}
public sealed class TrialDeckOwnershipEntity
{
public Guid UserId { get; set; }
public string DeckId { get; set; } = string.Empty;
public DateTimeOffset AcquiredAt { get; set; }
}
public sealed class MatchHistoryEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string MatchId { get; set; } = string.Empty;
public string Mode { get; set; } = string.Empty;
public string OpponentName { get; set; } = string.Empty;
public string Result { get; set; } = string.Empty;
public int Gold { get; set; }
public int Xp { get; set; }
public int MmrBefore { get; set; }
public int MmrAfter { get; set; }
public string FinishReason { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Profiles;
using Vanguard.Server.Storage;
using Vanguard.Rules.Engine;
namespace Vanguard.Server.Meta;
/// <summary>
/// MMR, история матчей и награды. Одна запись на матч и игрока; повторный
/// вызов для того же matchId ничего не меняет.
/// </summary>
public sealed class MetaProfileService : IProfileService, IMatchResultSink
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly MetaOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<MetaProfileService> _logger;
public MetaProfileService(
IDbContextFactory<MatchDbContext> factory,
MetaOptions options,
TimeProvider time,
ILogger<MetaProfileService> logger)
{
_factory = factory;
_options = options;
_time = time;
_logger = logger;
}
public async Task<int> GetMmrAsync(string playerId, GameMode mode)
{
var normalized = playerId.Trim().ToUpperInvariant();
await using var db = await _factory.CreateDbContextAsync();
var user = await db.Users.AsNoTracking()
.SingleOrDefaultAsync(item => item.NormalizedUserName == normalized);
if (user is null)
return _options.InitialMmr;
return mode == GameMode.ClassicRanked ? user.RankedMmr : user.CasualMmr;
}
public async Task RecordAsync(MatchResult result)
{
var normalized0 = result.Player0Id.Trim().ToUpperInvariant();
var normalized1 = result.Player1Id.Trim().ToUpperInvariant();
await using var db = await _factory.CreateDbContextAsync();
var users = await db.Users
.Where(user => user.NormalizedUserName == normalized0
|| user.NormalizedUserName == normalized1)
.ToListAsync();
var player0 = users.FirstOrDefault(user => user.NormalizedUserName == normalized0);
var player1 = users.FirstOrDefault(user => user.NormalizedUserName == normalized1);
if (player0 is null || player1 is null)
{
_logger.LogWarning(
"Match {MatchId}: result skipped, user not found ({Player0}, {Player1}).",
result.MatchId, result.Player0Id, result.Player1Id);
return;
}
if (await db.MatchHistory.AnyAsync(history =>
history.MatchId == result.MatchId
&& (history.UserId == player0.Id || history.UserId == player1.Id)))
{
return;
}
var winner = result.WinnerSeat == 0 ? player0 : player1;
var loser = result.WinnerSeat == 0 ? player1 : player0;
var ranked = result.Mode == GameMode.ClassicRanked;
var winnerMmrBefore = ranked ? winner.RankedMmr : winner.CasualMmr;
var loserMmrBefore = ranked ? loser.RankedMmr : loser.CasualMmr;
var expected = 1.0 / (1.0 + Math.Pow(10.0, (loserMmrBefore - winnerMmrBefore) / 400.0));
var delta = (int)Math.Round(_options.EloK * (1.0 - expected));
var winnerMmrAfter = winnerMmrBefore;
var loserMmrAfter = loserMmrBefore;
if (ranked)
{
winnerMmrAfter = Math.Max(0, winnerMmrBefore + delta);
loserMmrAfter = Math.Max(0, loserMmrBefore - delta);
winner.RankedMmr = winnerMmrAfter;
loser.RankedMmr = loserMmrAfter;
}
var reward = _options.RewardsForForfeit || result.FinishReason == MatchFinishReason.Normal;
var winnerXp = reward ? _options.MatchWinXp : 0;
var loserXp = reward ? _options.MatchLossXp : 0;
var winnerGold = reward ? winnerXp / Math.Max(1, _options.GoldPerXp) : 0;
var loserGold = reward ? loserXp / Math.Max(1, _options.GoldPerXp) : 0;
winner.Xp += winnerXp;
winner.Matches++;
winner.Wins++;
loser.Xp += loserXp;
loser.Matches++;
var now = _time.GetUtcNow();
var reason = result.FinishReason.ToString().ToLowerInvariant();
if (winnerGold > 0)
{
db.Transactions.Add(new TransactionEntity
{
Id = Guid.NewGuid(),
UserId = winner.Id,
Currency = EconomyService.Gold,
Amount = winnerGold,
Reason = "match",
IdempotencyKey = $"match:{result.MatchId}:{winner.Id}:gold",
CreatedAt = now,
MetadataJson = $"{{\"matchId\":\"{result.MatchId}\"}}",
});
}
if (loserGold > 0)
{
db.Transactions.Add(new TransactionEntity
{
Id = Guid.NewGuid(),
UserId = loser.Id,
Currency = EconomyService.Gold,
Amount = loserGold,
Reason = "match",
IdempotencyKey = $"match:{result.MatchId}:{loser.Id}:gold",
CreatedAt = now,
MetadataJson = $"{{\"matchId\":\"{result.MatchId}\"}}",
});
}
db.MatchHistory.Add(new MatchHistoryEntity
{
Id = Guid.NewGuid(),
UserId = winner.Id,
MatchId = result.MatchId,
Mode = GameModes.ToWire(result.Mode),
OpponentName = loser.UserName,
Result = "win",
Gold = winnerGold,
Xp = winnerXp,
MmrBefore = winnerMmrBefore,
MmrAfter = winnerMmrAfter,
FinishReason = reason,
CreatedAt = now,
});
db.MatchHistory.Add(new MatchHistoryEntity
{
Id = Guid.NewGuid(),
UserId = loser.Id,
MatchId = result.MatchId,
Mode = GameModes.ToWire(result.Mode),
OpponentName = winner.UserName,
Result = "loss",
Gold = loserGold,
Xp = loserXp,
MmrBefore = loserMmrBefore,
MmrAfter = loserMmrAfter,
FinishReason = reason,
CreatedAt = now,
});
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException exception)
{
// параллельный вызов уже записал результат — это нормально для идемпотентности
_logger.LogDebug(exception, "Match {MatchId}: result already recorded.", result.MatchId);
}
}
}

View File

@ -0,0 +1,288 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Rules.Replay;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Storage;
using Vanguard.Shared.Projection;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Meta;
public sealed record ReplayEventDto(long Seq, string Kind, JsonElement Event);
public sealed record ReplayFrameDto(long EventSeq, MatchView View);
public sealed record ReplayResponse(
string MatchId,
string Mode,
string Player0,
string Player1,
int ViewerSeat,
int Winner,
string FinishReason,
string EventHash,
IReadOnlyList<ReplayEventDto> Events,
IReadOnlyList<ReplayFrameDto> Frames);
public sealed record MatchHistoryDto(
string MatchId,
string Mode,
string OpponentName,
string Result,
int Gold,
int Xp,
int MmrBefore,
int MmrAfter,
string FinishReason,
DateTimeOffset PlayedAt,
bool ReplayAvailable);
/// <summary>
/// Восстанавливает реплей из seed, колод и журнала принятых команд.
/// Наружу отдаётся проекция под конкретного игрока: скрытые события
/// и рука противника не раскрываются даже в реплее.
/// </summary>
public sealed class ReplayService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new JsonStringEnumConverter() },
};
private readonly IDbContextFactory<MatchDbContext> _matchFactory;
private readonly CardCatalog _catalog;
private readonly ILogger<ReplayService> _logger;
public ReplayService(
IDbContextFactory<MatchDbContext> matchFactory,
CardCatalog catalog,
ILogger<ReplayService> logger)
{
_matchFactory = matchFactory;
_catalog = catalog;
_logger = logger;
}
public async Task<ReplayResponse?> GetAsync(
string matchId,
string viewerName,
bool includeFrames)
{
await using var db = await _matchFactory.CreateDbContextAsync();
var match = await db.Matches.AsNoTracking()
.SingleOrDefaultAsync(item => item.MatchId == matchId);
if (match is null)
return null;
var viewerSeat = match.Player0Id.Equals(viewerName, StringComparison.OrdinalIgnoreCase)
? 0
: match.Player1Id.Equals(viewerName, StringComparison.OrdinalIgnoreCase)
? 1
: -1;
if (viewerSeat < 0)
throw new MetaException("replay.forbidden", "Реплей доступен только участникам матча.", 403);
var intents = await db.Intents.AsNoTracking()
.Where(intent => intent.MatchId == matchId && intent.Accepted)
.OrderBy(intent => intent.Id)
.ToListAsync();
var deck0 = JsonSerializer.Deserialize<string[]>(match.Deck0Json) ?? Array.Empty<string>();
var deck1 = JsonSerializer.Deserialize<string[]>(match.Deck1Json) ?? Array.Empty<string>();
var seed = ulong.Parse(match.Seed, System.Globalization.CultureInfo.InvariantCulture);
var engine = new GameEngine(
_catalog,
seed,
deck0,
deck1,
player0Name: match.Player0Id,
player1Name: match.Player1Id);
engine.Start();
var frames = new List<ReplayFrameDto>();
AddFrame(engine, viewerSeat, matchId, frames);
foreach (var intent in intents)
{
if (intent.Command == "concede")
{
engine.Finish(intent.Seat == 0 ? 1 : 0);
}
else
{
if (string.IsNullOrWhiteSpace(intent.Command))
continue;
var command = CommandCodec.Decode(intent.Command);
var result = engine.Execute(command);
if (!result.Accepted)
{
throw new MetaException(
"replay.corrupt",
$"Реплей разошёлся на интенте {intent.IntentId}: {result.Reason}",
500);
}
}
AddFrame(engine, viewerSeat, matchId, frames);
}
if (match.IsFinished && !engine.State.IsFinished)
{
engine.Finish(match.Winner);
AddFrame(engine, viewerSeat, matchId, frames);
}
if (engine.Events.Count != match.EventSeq)
{
_logger.LogWarning(
"Replay {MatchId}: event sequence {Replay} differs from stored {Stored}.",
matchId, engine.Events.Count, match.EventSeq);
}
var events = EventProjection.ProjectRange(engine.Events, viewerSeat, 1)
.Select(envelope => new ReplayEventDto(
envelope.Seq,
KindOf(envelope.Event),
JsonSerializer.SerializeToElement(envelope.Event, JsonOptions)))
.ToList();
var finishReason = Enum.TryParse<MatchFinishReason>(match.FinishReason, ignoreCase: true, out var reason)
? reason.ToString().ToLowerInvariant()
: "normal";
return new ReplayResponse(
matchId,
match.Mode,
match.Player0Id,
match.Player1Id,
viewerSeat,
engine.State.Winner ?? match.Winner,
finishReason,
EventHasher.Hash(engine.Events),
events,
includeFrames ? frames : Array.Empty<ReplayFrameDto>());
}
private static void AddFrame(
GameEngine engine,
int viewerSeat,
string matchId,
List<ReplayFrameDto> frames)
{
var clock = new ClockView
{
DecisionPlayer = engine.State.ActivePlayer,
TurnDeadlineMs = 0,
Bank0Ms = 0,
Bank1Ms = 0,
IsRunning = false,
ServerTimeMs = 0,
};
var view = MatchProjection.ForViewer(
engine,
viewerSeat,
matchId,
engine.Events.Count,
clock,
includeLegalIntents: false);
frames.Add(new ReplayFrameDto(engine.Events.Count, view));
}
private static string KindOf(EventView view)
{
var name = view.GetType().Name;
return name.EndsWith("View", StringComparison.Ordinal)
? name[..^4]
: name;
}
}
public sealed class MatchHistoryService
{
private readonly IDbContextFactory<MatchDbContext> _metaFactory;
private readonly IDbContextFactory<MatchDbContext> _matchFactory;
public MatchHistoryService(
IDbContextFactory<MatchDbContext> metaFactory,
IDbContextFactory<MatchDbContext> matchFactory)
{
_metaFactory = metaFactory;
_matchFactory = matchFactory;
}
public async Task<IReadOnlyList<MatchHistoryDto>> ListAsync(
Guid userId,
int limit,
int offset)
{
limit = Math.Clamp(limit, 1, 100);
offset = Math.Max(0, offset);
await using var meta = await _metaFactory.CreateDbContextAsync();
var rows = await meta.MatchHistory.AsNoTracking()
.Where(history => history.UserId == userId)
.OrderByDescending(history => history.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync();
if (rows.Count == 0)
return Array.Empty<MatchHistoryDto>();
var replayable = await GetReplayableIdsAsync(rows.Select(row => row.MatchId).ToList());
return rows.Select(row => ToDto(row, replayable.Contains(row.MatchId))).ToList();
}
public async Task<MatchHistoryDto?> GetAsync(Guid userId, string matchId)
{
await using var meta = await _metaFactory.CreateDbContextAsync();
var row = await meta.MatchHistory.AsNoTracking()
.SingleOrDefaultAsync(history => history.UserId == userId && history.MatchId == matchId);
if (row is null)
return null;
var replayable = await GetReplayableIdsAsync(new List<string> { matchId });
return ToDto(row, replayable.Contains(matchId));
}
private async Task<HashSet<string>> GetReplayableIdsAsync(IReadOnlyList<string> matchIds)
{
await using var matches = await _matchFactory.CreateDbContextAsync();
var ids = await matches.Matches.AsNoTracking()
.Where(match => matchIds.Contains(match.MatchId) && match.IsFinished)
.Select(match => match.MatchId)
.ToListAsync();
return ids.ToHashSet(StringComparer.Ordinal);
}
private static MatchHistoryDto ToDto(MatchHistoryEntity row, bool replayAvailable) => new(
row.MatchId,
row.Mode,
row.OpponentName,
row.Result,
row.Gold,
row.Xp,
row.MmrBefore,
row.MmrAfter,
row.FinishReason,
row.CreatedAt,
replayAvailable);
}

View File

@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Content;
using Vanguard.Server.Configuration;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Meta;
public sealed record StoreTrialDeckDto(
string Id,
string Name,
string? Clan,
int CardCount,
bool Owned,
int PriceGold,
IReadOnlyList<TrialDeckCard> Cards);
public sealed class TrialDeckService
{
private readonly IDbContextFactory<MatchDbContext> _factory;
private readonly TrialDeckCatalog _decks;
private readonly MetaOptions _options;
private readonly TimeProvider _time;
public TrialDeckService(
IDbContextFactory<MatchDbContext> factory,
TrialDeckCatalog decks,
MetaOptions options,
TimeProvider time)
{
_factory = factory;
_decks = decks;
_options = options;
_time = time;
}
public IReadOnlyList<TrialDeckDefinition> Decks => _decks.Decks;
public async Task<IReadOnlyList<StoreTrialDeckDto>> GetStoreDecksAsync(Guid userId)
{
await using var db = await _factory.CreateDbContextAsync();
var owned = await db.TrialDeckOwnerships
.Where(ownership => ownership.UserId == userId)
.Select(ownership => ownership.DeckId)
.ToListAsync();
var ownedSet = owned.ToHashSet(StringComparer.OrdinalIgnoreCase);
return _decks.Decks
.Select(deck => new StoreTrialDeckDto(
deck.Id,
deck.Name,
deck.Clan,
deck.TotalCards,
ownedSet.Contains(deck.Id),
ownedSet.Contains(deck.Id) ? 0 : _options.TrialDeckPriceGold,
deck.Cards))
.ToList();
}
public async Task<Balances> OnboardAsync(Guid userId, string? deckId)
{
if (string.IsNullOrWhiteSpace(deckId) || !_decks.TryGet(deckId, out var deck))
throw new MetaException("trial.unknown-deck", "Неизвестная триал-дека.", 404);
await using var db = await _factory.CreateDbContextAsync();
var user = await db.Users.SingleOrDefaultAsync(item => item.Id == userId)
?? throw new MetaException("user.not-found", "Пользователь не найден.", 404);
if (user.OnboardingCompleted)
throw new MetaException("onboarding.completed", "Выбор триал-деки уже сделан.", 409);
var now = _time.GetUtcNow();
db.TrialDeckOwnerships.Add(new TrialDeckOwnershipEntity
{
UserId = userId,
DeckId = deck.Id,
AcquiredAt = now,
});
foreach (var card in deck.Cards)
await CollectionService.AddAsync(db, userId, card.PrintingId, card.Count);
await EconomyService.AddTransactionAsync(
db, userId, EconomyService.Token, _options.StarterTokens,
"onboarding", $"onboarding:{userId}", _time,
$"{{\"trialDeck\":\"{deck.Id}\"}}");
user.OnboardingCompleted = true;
user.SelectedTrialDeckId = deck.Id;
await db.SaveChangesAsync();
return await EconomyService.GetBalancesAsync(db, userId);
}
public async Task<Balances> BuyAsync(Guid userId, string? deckId, string? idempotencyKey)
{
if (string.IsNullOrWhiteSpace(deckId) || !_decks.TryGet(deckId, out var deck))
throw new MetaException("trial.unknown-deck", "Неизвестная триал-дека.", 404);
if (string.IsNullOrWhiteSpace(idempotencyKey))
throw new MetaException("idempotency.required", "Нужен idempotencyKey.", 400);
await using var db = await _factory.CreateDbContextAsync();
var owned = await db.TrialDeckOwnerships
.AnyAsync(ownership => ownership.UserId == userId && ownership.DeckId == deck.Id);
if (owned)
return await EconomyService.GetBalancesAsync(db, userId);
var key = $"trial:{deck.Id}:{idempotencyKey}";
if (await db.Transactions.AnyAsync(transaction =>
transaction.UserId == userId && transaction.IdempotencyKey == key))
{
return await EconomyService.GetBalancesAsync(db, userId);
}
var gold = await EconomyService.GetBalanceAsync(db, userId, EconomyService.Gold);
if (gold < _options.TrialDeckPriceGold)
throw new MetaException("economy.not-enough-gold", "Недостаточно Gold.", 400);
await EconomyService.AddTransactionAsync(
db, userId, EconomyService.Gold, -_options.TrialDeckPriceGold,
"trial-deck", key, _time,
$"{{\"trialDeck\":\"{deck.Id}\"}}");
db.TrialDeckOwnerships.Add(new TrialDeckOwnershipEntity
{
UserId = userId,
DeckId = deck.Id,
AcquiredAt = _time.GetUtcNow(),
});
foreach (var card in deck.Cards)
await CollectionService.AddAsync(db, userId, card.PrintingId, card.Count);
await db.SaveChangesAsync();
return await EconomyService.GetBalancesAsync(db, userId);
}
}

View File

@ -0,0 +1,9 @@
using Vanguard.Server.Configuration;
namespace Vanguard.Server.Profiles;
/// <summary>Скрытый MMR по режимам. Нужен матчмейкингу.</summary>
public interface IProfileService
{
Task<int> GetMmrAsync(string playerId, GameMode mode);
}

View File

@ -5,13 +5,10 @@ using Vanguard.Server.Matches;
namespace Vanguard.Server.Profiles; namespace Vanguard.Server.Profiles;
/// <summary> /// <summary>
/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся: /// In-memory профиль для тестов и dev-режима. В production используется
/// клиент видит только результат матча. /// <c>MetaProfileService</c> поверх общей БД.
///
/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён,
/// чтобы заменить реализацию, не трогая матч.
/// </summary> /// </summary>
public sealed class PlayerProfileStore : IMatchResultSink public sealed class PlayerProfileStore : IMatchResultSink, IProfileService
{ {
private const int InitialMmr = 1000; private const int InitialMmr = 1000;
private const int EloK = 32; private const int EloK = 32;
@ -19,6 +16,15 @@ public sealed class PlayerProfileStore : IMatchResultSink
private readonly object _gate = new(); private readonly object _gate = new();
private readonly Dictionary<string, Profile> _profiles = new(StringComparer.Ordinal); private readonly Dictionary<string, Profile> _profiles = new(StringComparer.Ordinal);
public Task<int> GetMmrAsync(string playerId, GameMode mode)
{
lock (_gate)
{
return Task.FromResult(
_profiles.TryGetValue(playerId, out var profile) ? profile.Mmr : InitialMmr);
}
}
public int GetMmr(string playerId) public int GetMmr(string playerId)
{ {
lock (_gate) lock (_gate)

View File

@ -1,12 +1,21 @@
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis; using StackExchange.Redis;
using Vanguard.Content; using Vanguard.Content;
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Api; using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches; using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking; using Vanguard.Server.Matchmaking;
using Vanguard.Server.Meta;
using Vanguard.Server.Profiles; using Vanguard.Server.Profiles;
using Vanguard.Server.Realtime; using Vanguard.Server.Realtime;
using Vanguard.Server.Storage; using Vanguard.Server.Storage;
@ -18,22 +27,37 @@ builder.Services.Configure<GameOptions>(builder.Configuration.GetSection("Game")
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content")); builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content"));
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage")); builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis")); builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
builder.Services.Configure<MetaOptions>(builder.Configuration.GetSection("Meta"));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<StorageOptions>>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<StorageOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisOptions>>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<MetaOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddResponseCompression(options => options.EnableForHttps = true);
var contentOptions = builder.Configuration.GetSection("Content").Get<ContentOptions>() ?? new ContentOptions();
var setsDirectory = ContentPaths.Resolve(contentOptions.Path);
var contentDirectory = Path.GetDirectoryName(setsDirectory)!;
builder.Services.AddSingleton(sp => builder.Services.AddSingleton(sp =>
{ {
var content = sp.GetRequiredService<ContentOptions>(); var content = sp.GetRequiredService<ContentOptions>();
var directory = ContentPaths.Resolve(content.Path); return CatalogLoader.LoadDirectory(ContentPaths.Resolve(content.Path));
return CatalogLoader.LoadDirectory(directory);
}); });
builder.Services.AddSingleton(TrialDeckCatalog.Load(Path.Combine(contentDirectory, "trial-decks.yaml")));
builder.Services.AddSingleton(PackOdds.Load(Path.Combine(contentDirectory, "pack_odds.yaml")));
var storage = builder.Configuration.GetSection("Storage").Get<StorageOptions>() ?? new StorageOptions(); var storage = builder.Configuration.GetSection("Storage").Get<StorageOptions>() ?? new StorageOptions();
var storageProvider = storage.Provider?.Trim() ?? "None"; var storageProvider = storage.Provider?.Trim() ?? "Memory";
if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase)) if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
{ {
@ -47,11 +71,14 @@ if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase)) else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase))
{ {
builder.Services.AddDbContextFactory<MatchDbContext>(options => builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-matches")); options.UseInMemoryDatabase("vanguard"));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>(); builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
} }
else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase)) else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase))
{ {
// мета-API всё равно работает в памяти, а матчи — без сохранения
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-dev"));
builder.Services.AddSingleton<IMatchStore, NullMatchStore>(); builder.Services.AddSingleton<IMatchStore, NullMatchStore>();
} }
else else
@ -79,24 +106,99 @@ else
builder.Services.AddSingleton<IQueueStore, InMemoryQueueStore>(); builder.Services.AddSingleton<IQueueStore, InMemoryQueueStore>();
} }
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
if (jwt.SigningKey.Length < 32)
throw new InvalidOperationException("Jwt:SigningKey должен быть не короче 32 символов.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
};
});
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("auth", limiter =>
{
limiter.PermitLimit = 60;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("mutating", limiter =>
{
limiter.PermitLimit = 300;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method))
return RateLimitPartition.GetNoLimiter("read");
var key = context.Connection.RemoteIpAddress?.ToString() ?? "local";
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 300,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
});
});
});
builder.Services.AddSingleton<GameTicketService>(); builder.Services.AddSingleton<GameTicketService>();
builder.Services.AddSingleton<PlayerProfileStore>();
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
builder.Services.AddSingleton<MatchGrainRegistry>(); builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchRecoveryService>(); builder.Services.AddSingleton<MatchRecoveryService>();
builder.Services.AddSingleton<MatchmakingService>(); builder.Services.AddSingleton<MatchmakingService>();
builder.Services.AddSingleton<EconomyService>();
builder.Services.AddSingleton<CollectionService>();
builder.Services.AddSingleton<DeckService>();
builder.Services.AddSingleton<TrialDeckService>();
builder.Services.AddSingleton<BoosterService>();
builder.Services.AddSingleton<ReplayService>();
builder.Services.AddSingleton<MatchHistoryService>();
builder.Services.AddSingleton<MetaProfileService>();
builder.Services.AddSingleton<IProfileService>(sp => sp.GetRequiredService<MetaProfileService>());
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<MetaProfileService>());
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<AuthService>();
builder.Services.AddHostedService<MatchTicker>(); builder.Services.AddHostedService<MatchTicker>();
builder.Services.AddHostedService<MatchmakingWorker>(); builder.Services.AddHostedService<MatchmakingWorker>();
var app = builder.Build(); var app = builder.Build();
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте var catalog = app.Services.GetRequiredService<CardCatalog>();
app.Services.GetRequiredService<CardCatalog>();
if (app.Services.GetRequiredService<IMatchStore>() is EfMatchStore) foreach (var deck in app.Services.GetRequiredService<TrialDeckCatalog>().Decks)
{
var violations = DeckValidator.Validate(catalog, deck.ToDeck());
if (violations.Count > 0)
{
throw new InvalidOperationException(
$"Триал-дека {deck.Id}: " + string.Join("; ", violations.Select(item => item.Message)));
}
}
var dbFactory = app.Services.GetRequiredService<IDbContextFactory<MatchDbContext>>();
await using (var db = await dbFactory.CreateDbContextAsync())
{ {
var factory = app.Services.GetRequiredService<IDbContextFactory<MatchDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
} }
@ -104,11 +206,18 @@ if (app.Services.GetRequiredService<IMatchStore>() is EfMatchStore)
await app.Services.GetRequiredService<MatchRecoveryService>().RecoverAsync(); await app.Services.GetRequiredService<MatchRecoveryService>().RecoverAsync();
await app.Services.GetRequiredService<MatchmakingService>().LoadQueuedAsync(); await app.Services.GetRequiredService<MatchmakingService>().LoadQueuedAsync();
app.UseResponseCompression();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions app.UseWebSockets(new WebSocketOptions
{ {
KeepAliveInterval = TimeSpan.FromSeconds(30), KeepAliveInterval = TimeSpan.FromSeconds(30),
}); });
app.MapAuthEndpoints();
app.MapMetaEndpoints();
app.MapGameApi(); app.MapGameApi();
app.Map("/game", GameWebSocketEndpoint.HandleAsync); app.Map("/game", GameWebSocketEndpoint.HandleAsync);

View File

@ -2,6 +2,7 @@ using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Shared.Protocol; using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Storage; namespace Vanguard.Server.Storage;
@ -134,6 +135,7 @@ public sealed class EfMatchStore : IMatchStore
entity.IsFinished = header.IsFinished; entity.IsFinished = header.IsFinished;
entity.Winner = header.Winner; entity.Winner = header.Winner;
entity.FinishedAt = header.FinishedAt; entity.FinishedAt = header.FinishedAt;
entity.FinishReason = header.FinishReason.ToString();
entity.EventSeq = header.EventSeq; entity.EventSeq = header.EventSeq;
} }
@ -162,6 +164,9 @@ public sealed class EfMatchStore : IMatchStore
entity.IsFinished, entity.IsFinished,
entity.Winner, entity.Winner,
entity.FinishedAt, entity.FinishedAt,
Enum.TryParse<MatchFinishReason>(entity.FinishReason, out var reason)
? reason
: MatchFinishReason.Normal,
entity.EventSeq); entity.EventSeq);
var persisted = intents var persisted = intents

View File

@ -1,7 +1,12 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Meta;
namespace Vanguard.Server.Storage; namespace Vanguard.Server.Storage;
/// <summary>
/// Единый контекст игры и мета-данных: матчи, пользователи, коллекция,
/// колоды, экономика, история. Одна БД — один EnsureCreated.
/// </summary>
public sealed class MatchDbContext : DbContext public sealed class MatchDbContext : DbContext
{ {
public MatchDbContext(DbContextOptions<MatchDbContext> options) public MatchDbContext(DbContextOptions<MatchDbContext> options)
@ -12,7 +17,22 @@ public sealed class MatchDbContext : DbContext
public DbSet<MatchEntity> Matches => Set<MatchEntity>(); public DbSet<MatchEntity> Matches => Set<MatchEntity>();
public DbSet<MatchIntentEntity> Intents => Set<MatchIntentEntity>(); public DbSet<MatchIntentEntity> Intents => Set<MatchIntentEntity>();
public DbSet<UserEntity> Users => Set<UserEntity>();
public DbSet<RefreshTokenEntity> RefreshTokens => Set<RefreshTokenEntity>();
public DbSet<CollectionEntity> Collection => Set<CollectionEntity>();
public DbSet<DeckEntity> Decks => Set<DeckEntity>();
public DbSet<TransactionEntity> Transactions => Set<TransactionEntity>();
public DbSet<BoosterOpeningEntity> BoosterOpenings => Set<BoosterOpeningEntity>();
public DbSet<TrialDeckOwnershipEntity> TrialDeckOwnerships => Set<TrialDeckOwnershipEntity>();
public DbSet<MatchHistoryEntity> MatchHistory => Set<MatchHistoryEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ConfigureMatches(modelBuilder);
ConfigureMeta(modelBuilder);
}
private static void ConfigureMatches(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<MatchEntity>(entity => modelBuilder.Entity<MatchEntity>(entity =>
{ {
@ -39,6 +59,98 @@ public sealed class MatchDbContext : DbContext
entity.HasIndex(e => e.MatchId); entity.HasIndex(e => e.MatchId);
}); });
} }
private static void ConfigureMeta(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("users");
entity.HasKey(user => user.Id);
entity.Property(user => user.Id).ValueGeneratedNever();
entity.Property(user => user.UserName).HasMaxLength(64);
entity.Property(user => user.NormalizedUserName).HasMaxLength(64);
entity.Property(user => user.PasswordHash).HasMaxLength(256);
entity.Property(user => user.SelectedTrialDeckId).HasMaxLength(32);
entity.HasIndex(user => user.NormalizedUserName).IsUnique();
});
modelBuilder.Entity<RefreshTokenEntity>(entity =>
{
entity.ToTable("refresh_tokens");
entity.HasKey(token => token.Id);
entity.Property(token => token.Id).ValueGeneratedNever();
entity.Property(token => token.TokenHash).HasMaxLength(64);
entity.HasIndex(token => token.TokenHash).IsUnique();
entity.HasIndex(token => token.UserId);
});
modelBuilder.Entity<CollectionEntity>(entity =>
{
entity.ToTable("collection");
entity.HasKey(card => new { card.UserId, card.PrintingId });
entity.Property(card => card.PrintingId).HasMaxLength(32);
entity.HasIndex(card => card.UserId);
});
modelBuilder.Entity<DeckEntity>(entity =>
{
entity.ToTable("decks");
entity.HasKey(deck => deck.Id);
entity.Property(deck => deck.Id).ValueGeneratedNever();
entity.Property(deck => deck.Name).HasMaxLength(64);
entity.Property(deck => deck.Format).HasMaxLength(32);
entity.Property(deck => deck.CardsJson).HasColumnType("text");
entity.Property(deck => deck.ShareCode).HasMaxLength(16);
entity.HasIndex(deck => new { deck.UserId, deck.Name }).IsUnique();
entity.HasIndex(deck => deck.ShareCode).IsUnique();
entity.HasIndex(deck => deck.UserId);
});
modelBuilder.Entity<TransactionEntity>(entity =>
{
entity.ToTable("transactions");
entity.HasKey(transaction => transaction.Id);
entity.Property(transaction => transaction.Id).ValueGeneratedNever();
entity.Property(transaction => transaction.Currency).HasMaxLength(16);
entity.Property(transaction => transaction.Reason).HasMaxLength(64);
entity.Property(transaction => transaction.IdempotencyKey).HasMaxLength(128);
entity.Property(transaction => transaction.MetadataJson).HasColumnType("text");
entity.HasIndex(transaction => new { transaction.UserId, transaction.IdempotencyKey }).IsUnique();
entity.HasIndex(transaction => new { transaction.UserId, transaction.Currency });
});
modelBuilder.Entity<BoosterOpeningEntity>(entity =>
{
entity.ToTable("booster_openings");
entity.HasKey(opening => opening.Id);
entity.Property(opening => opening.Id).ValueGeneratedNever();
entity.Property(opening => opening.IdempotencyKey).HasMaxLength(128);
entity.Property(opening => opening.Payment).HasMaxLength(16);
entity.Property(opening => opening.CardsJson).HasColumnType("text");
entity.HasIndex(opening => new { opening.UserId, opening.IdempotencyKey }).IsUnique();
});
modelBuilder.Entity<TrialDeckOwnershipEntity>(entity =>
{
entity.ToTable("trial_deck_ownership");
entity.HasKey(ownership => new { ownership.UserId, ownership.DeckId });
entity.Property(ownership => ownership.DeckId).HasMaxLength(32);
});
modelBuilder.Entity<MatchHistoryEntity>(entity =>
{
entity.ToTable("match_history");
entity.HasKey(history => history.Id);
entity.Property(history => history.Id).ValueGeneratedNever();
entity.Property(history => history.MatchId).HasMaxLength(64);
entity.Property(history => history.Mode).HasMaxLength(32);
entity.Property(history => history.OpponentName).HasMaxLength(64);
entity.Property(history => history.Result).HasMaxLength(16);
entity.Property(history => history.FinishReason).HasMaxLength(16);
entity.HasIndex(history => new { history.UserId, history.MatchId }).IsUnique();
entity.HasIndex(history => new { history.UserId, history.CreatedAt });
});
}
} }
public sealed class MatchEntity public sealed class MatchEntity
@ -65,6 +177,7 @@ public sealed class MatchEntity
public bool IsFinished { get; set; } public bool IsFinished { get; set; }
public int Winner { get; set; } public int Winner { get; set; }
public DateTimeOffset? FinishedAt { get; set; } public DateTimeOffset? FinishedAt { get; set; }
public string FinishReason { get; set; } = "normal";
public long EventSeq { get; set; } public long EventSeq { get; set; }
} }

View File

@ -1,4 +1,5 @@
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Shared.Protocol; using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Storage; namespace Vanguard.Server.Storage;
@ -23,6 +24,7 @@ public sealed record MatchHeader(
bool IsFinished, bool IsFinished,
int Winner, int Winner,
DateTimeOffset? FinishedAt, DateTimeOffset? FinishedAt,
MatchFinishReason FinishReason,
long EventSeq); long EventSeq);
/// <summary>Запись журнала интентов. Принятые команды воспроизводятся при восстановлении.</summary> /// <summary>Запись журнала интентов. Принятые команды воспроизводятся при восстановлении.</summary>

View File

@ -14,6 +14,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />

View File

@ -23,6 +23,7 @@
"MmrWindowStep": 50, "MmrWindowStep": 50,
"MmrWindowIntervalSeconds": 5, "MmrWindowIntervalSeconds": 5,
"MmrMaxWindow": 2000, "MmrMaxWindow": 2000,
"AllowDevQueue": true,
"PublicWsUrl": "" "PublicWsUrl": ""
}, },
"Content": { "Content": {
@ -35,5 +36,19 @@
"Redis": { "Redis": {
"Configuration": "", "Configuration": "",
"Prefix": "vanguard:" "Prefix": "vanguard:"
},
"Meta": {
"StarterTokens": 5,
"BoosterPriceGold": 100,
"TrialDeckPriceGold": 500,
"MaxDecksPerUser": 30,
"MaxDeckNameLength": 40
},
"Jwt": {
"Issuer": "VanguardReMaster",
"Audience": "VanguardClient",
"SigningKey": "dev-only-vanguard-remaster-signing-key-change-me",
"AccessMinutes": 15,
"RefreshDays": 30
} }
} }

View File

@ -21,7 +21,8 @@ public static class MatchProjection
int viewer, int viewer,
string matchId, string matchId,
long eventSeq, long eventSeq,
ClockView clock) ClockView clock,
bool includeLegalIntents = true)
{ {
if (viewer is not (0 or 1)) if (viewer is not (0 or 1))
throw new ArgumentOutOfRangeException(nameof(viewer), viewer, "Индекс игрока — 0 или 1."); throw new ArgumentOutOfRangeException(nameof(viewer), viewer, "Индекс игрока — 0 или 1.");
@ -52,7 +53,9 @@ public static class MatchProjection
Order = ProjectOrder(state, viewer), Order = ProjectOrder(state, viewer),
OpponentChoosing = IsOpponentChoosing(state, viewer), OpponentChoosing = IsOpponentChoosing(state, viewer),
Clock = clock, Clock = clock,
LegalIntents = ProjectLegalIntents(engine, viewer, isDecisionMaker), LegalIntents = includeLegalIntents
? ProjectLegalIntents(engine, viewer, isDecisionMaker)
: new List<IntentPayload>(),
}; };
} }

View File

@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Vanguard.Server.Auth;
using Xunit.Sdk;
namespace Vanguard.Server.Tests;
public class AuthTests
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
[Fact]
public async Task Register_Login_Refresh_Rotation_Logout()
{
using var factory = new WebApplicationFactory<Program>();
using var http = factory.CreateClient();
var userName = "user" + Guid.NewGuid().ToString("N")[..8];
var registered = await RegisterAsync(http, userName);
Assert.False(registered.OnboardingCompleted);
using var authed = factory.CreateClient();
authed.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", registered.AccessToken);
var me = await authed.GetFromJsonAsync<MeResponse>("/api/auth/me", Json);
Assert.NotNull(me);
Assert.Equal(userName, me!.UserName);
Assert.Equal(0, me.Gold);
Assert.Equal(0, me.Tokens);
var loginResponse = await http.PostAsJsonAsync(
"/api/auth/login",
new { userName, password = "password123" },
Json);
loginResponse.EnsureSuccessStatusCode();
var login = (await loginResponse.Content.ReadFromJsonAsync<AuthResponse>(Json))!;
var refreshResponse = await http.PostAsJsonAsync(
"/api/auth/refresh",
new { refreshToken = login.RefreshToken },
Json);
refreshResponse.EnsureSuccessStatusCode();
var refreshed = (await refreshResponse.Content.ReadFromJsonAsync<AuthResponse>(Json))!;
Assert.NotEqual(login.RefreshToken, refreshed.RefreshToken);
Assert.NotEqual(login.AccessToken, refreshed.AccessToken);
// старый refresh после ротации больше не действует
var reused = await http.PostAsJsonAsync(
"/api/auth/refresh",
new { refreshToken = login.RefreshToken },
Json);
Assert.Equal(HttpStatusCode.Unauthorized, reused.StatusCode);
var logout = await http.PostAsJsonAsync(
"/api/auth/logout",
new { refreshToken = refreshed.RefreshToken },
Json);
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
var afterLogout = await http.PostAsJsonAsync(
"/api/auth/refresh",
new { refreshToken = refreshed.RefreshToken },
Json);
Assert.Equal(HttpStatusCode.Unauthorized, afterLogout.StatusCode);
}
[Fact]
public async Task Weak_Password_And_Duplicate_User_Are_Rejected()
{
using var factory = new WebApplicationFactory<Program>();
using var http = factory.CreateClient();
var userName = "weak" + Guid.NewGuid().ToString("N")[..8];
var weak = await http.PostAsJsonAsync(
"/api/auth/register",
new { userName, password = "123" },
Json);
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
var first = await RegisterAsync(http, userName);
Assert.NotNull(first.AccessToken);
var duplicate = await http.PostAsJsonAsync(
"/api/auth/register",
new { userName, password = "password123" },
Json);
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
}
private static async Task<AuthResponse> RegisterAsync(HttpClient http, string userName)
{
var response = await http.PostAsJsonAsync(
"/api/auth/register",
new { userName, password = "password123" },
Json);
if (!response.IsSuccessStatusCode)
throw new XunitException(await response.Content.ReadAsStringAsync());
return (await response.Content.ReadFromJsonAsync<AuthResponse>(Json))!;
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using StackExchange.Redis; using StackExchange.Redis;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking; using Vanguard.Server.Matchmaking;
using Vanguard.Server.Storage; using Vanguard.Server.Storage;
using Vanguard.Server.Tickets; using Vanguard.Server.Tickets;
@ -49,7 +50,8 @@ public class ExternalStorageTests
AbsentSince0: time.GetUtcNow(), AbsentSince0: time.GetUtcNow(),
AbsentSince1: time.GetUtcNow(), AbsentSince1: time.GetUtcNow(),
LastClockSent: time.GetUtcNow(), LastClockSent: time.GetUtcNow(),
IsFinished: false, Winner: -1, FinishedAt: null, EventSeq: 1); IsFinished: false, Winner: -1, FinishedAt: null,
FinishReason: MatchFinishReason.Normal, EventSeq: 1);
await store.CreateAsync(header); await store.CreateAsync(header);
await store.AppendIntentAsync( await store.AppendIntentAsync(

View File

@ -35,8 +35,8 @@ public class GameServerE2ETests
factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token);
await Task.WhenAll( await Task.WhenAll(
PlayAsync(first, seed: 11, cts.Token), first.PlayRandomlyAsync(seed: 11, cts.Token),
PlayAsync(second, seed: 22, cts.Token)); second.PlayRandomlyAsync(seed: 22, cts.Token));
Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии."); Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии.");
Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии."); Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии.");
@ -141,51 +141,4 @@ public class GameServerE2ETests
throw new XunitException("Матч не был найден за 10 секунд."); throw new XunitException("Матч не был найден за 10 секунд.");
} }
private static async Task PlayAsync(
TestGameClient client,
int seed,
CancellationToken cancellationToken)
{
var random = new Random(seed);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (!client.View.IsFinished)
{
if (DateTime.UtcNow > deadline)
throw new XunitException(
$"Тест не доиграл партию за 90 секунд (seat {client.Seat}).");
if (client.View.ActivePlayer == client.Seat)
{
var choices = client.View.LegalIntents
.Where(intent => intent is not ConcedeIntent)
.ToList();
if (choices.Count == 0)
throw new XunitException(
$"Нет легальных интентов для seat {client.Seat}, " +
$"фаза {client.View.Phase}, шаг {client.View.Step}.");
var intent = choices[random.Next(choices.Count)];
var intentId = Guid.NewGuid().ToString("N");
var result = await client.SubmitAsync(intentId, intent, cancellationToken);
if (result.Duplicate)
throw new XunitException($"Неожиданный duplicate для {intentId}.");
if (!result.Accepted)
throw new XunitException(
$"Интент {intent.GetType().Name} отклонён: {result.Code} {result.Reason}");
// держимся ниже лимита 16 интентов в секунду
await Task.Delay(75, cancellationToken);
}
await client.RequestSnapshotAsync(cancellationToken);
if (!client.View.IsFinished && client.View.ActivePlayer != client.Seat)
await Task.Delay(20, cancellationToken);
}
}
} }

View File

@ -126,6 +126,7 @@ public class MatchGrainTests
var recorded = Assert.Single(sink.Results); var recorded = Assert.Single(sink.Results);
Assert.Equal("p1", recorded.WinnerPlayerId); Assert.Equal("p1", recorded.WinnerPlayerId);
Assert.Equal(MatchFinishReason.Concede, recorded.FinishReason);
var loserId = recorded.Player0Id == recorded.WinnerPlayerId var loserId = recorded.Player0Id == recorded.WinnerPlayerId
? recorded.Player1Id ? recorded.Player1Id
: recorded.Player0Id; : recorded.Player0Id;
@ -166,7 +167,8 @@ public class MatchGrainTests
options.TurnSeconds = 10; options.TurnSeconds = 10;
options.BankSeconds = 20; options.BankSeconds = 20;
var grain = NewGrain(time, options: options); var sink = new RecordingResultSink();
var grain = NewGrain(time, sink, options);
var first = await Attach(grain, 0, "p1"); var first = await Attach(grain, 0, "p1");
await Attach(grain, 1, "p2"); await Attach(grain, 1, "p2");
@ -183,6 +185,7 @@ public class MatchGrainTests
Assert.True(grain.Summary.IsFinished); Assert.True(grain.Summary.IsFinished);
Assert.Equal(1, grain.Summary.Winner); Assert.Equal(1, grain.Summary.Winner);
Assert.Equal(MatchFinishReason.Timeout, Assert.Single(sink.Results).FinishReason);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events), Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 1 }); e => e.Event is GameFinishedView { Winner: 1 });
} }
@ -194,7 +197,8 @@ public class MatchGrainTests
var options = ServerTestData.SmallOptions(); var options = ServerTestData.SmallOptions();
options.AbsenceSeconds = 30; options.AbsenceSeconds = 30;
var grain = NewGrain(time, options: options); var sink = new RecordingResultSink();
var grain = NewGrain(time, sink, options);
var first = await Attach(grain, 0, "p1"); var first = await Attach(grain, 0, "p1");
time.Advance(TimeSpan.FromSeconds(31)); time.Advance(TimeSpan.FromSeconds(31));
@ -202,6 +206,7 @@ public class MatchGrainTests
Assert.True(grain.Summary.IsFinished); Assert.True(grain.Summary.IsFinished);
Assert.Equal(0, grain.Summary.Winner); Assert.Equal(0, grain.Summary.Winner);
Assert.Equal(MatchFinishReason.Absence, Assert.Single(sink.Results).FinishReason);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events), Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 }); e => e.Event is GameFinishedView { Winner: 0 });
} }

View File

@ -26,7 +26,8 @@ public class MatchPersistenceTests
AbsentSince0: time.GetUtcNow(), AbsentSince0: time.GetUtcNow(),
AbsentSince1: time.GetUtcNow(), AbsentSince1: time.GetUtcNow(),
LastClockSent: time.GetUtcNow(), LastClockSent: time.GetUtcNow(),
IsFinished: false, Winner: -1, FinishedAt: null, EventSeq: 1); IsFinished: false, Winner: -1, FinishedAt: null,
FinishReason: MatchFinishReason.Normal, EventSeq: 1);
await store.CreateAsync(header); await store.CreateAsync(header);
await store.AppendIntentAsync( await store.AppendIntentAsync(

View File

@ -0,0 +1,284 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Vanguard.Rules.Cards;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
using Vanguard.Server.Meta;
using Vanguard.Shared.Protocol;
using Xunit.Sdk;
namespace Vanguard.Server.Tests;
public class MetaGameE2ETests
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public async Task Onboarding_Booster_Match_History_And_Replay()
{
using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder => builder.UseSetting("Storage:Provider", "Memory"));
using var anonymous = factory.CreateClient();
var alice = await RegisterAsync(anonymous, "alice");
var bob = await RegisterAsync(anonymous, "bob");
var carol = await RegisterAsync(anonymous, "carol");
using var aliceHttp = Authed(factory, alice);
using var bobHttp = Authed(factory, bob);
using var carolHttp = Authed(factory, carol);
var trialDeck = ServerTestData.TrialDecks.Decks.Single(deck => deck.Id == "td01");
var deckCards = trialDeck.ToDeck().ToList();
var aliceOnboarding = await PostAsync<OnboardingResponse>(
aliceHttp, "/api/meta/onboarding", new { deckId = "td01" });
var bobOnboarding = await PostAsync<OnboardingResponse>(
bobHttp, "/api/meta/onboarding", new { deckId = "td01" });
Assert.Equal("td01", aliceOnboarding.DeckId);
Assert.Equal(5, aliceOnboarding.Balances.Tokens);
Assert.Equal(5, bobOnboarding.Balances.Tokens);
var aliceMe = await GetAsync<MeResponse>(aliceHttp, "/api/auth/me");
Assert.True(aliceMe.OnboardingCompleted);
Assert.Equal("td01", aliceMe.SelectedTrialDeckId);
Assert.Equal(5, aliceMe.Tokens);
var collection = await GetAsync<CollectionResponse>(aliceHttp, "/api/collection");
Assert.Equal(50, collection.Items.Sum(item => item.Count));
var store = await GetAsync<StoreResponse>(aliceHttp, "/api/store");
Assert.Equal(2, store.TrialDecks.Count);
Assert.Equal(100, store.BoosterPriceGold);
Assert.Equal(5, store.Balances.Tokens);
Assert.Single(store.TrialDecks, deck => deck.Owned);
var unownedDeck = await aliceHttp.PostAsJsonAsync(
"/api/decks",
new { name = "Unowned", cards = ServerTestData.BuildDeck() },
Json);
Assert.Equal(HttpStatusCode.BadRequest, unownedDeck.StatusCode);
Assert.Contains(
"deck.not-owned",
await unownedDeck.Content.ReadAsStringAsync());
var aliceDeck = await CreateDeckAsync(aliceHttp, "Alice deck", deckCards);
var bobDeck = await CreateDeckAsync(bobHttp, "Bob deck", deckCards);
// 1 токен = 1 бустер; повторный idempotencyKey не тратит второй токен.
var firstBooster = await PostAsync<BoosterResult>(
aliceHttp, "/api/store/redeem/token", new { idempotencyKey = "boost-1" });
Assert.Equal(5, firstBooster.Cards.Count);
Assert.Equal(4, firstBooster.Balances.Tokens);
Assert.All(firstBooster.Cards.Take(4), card => Assert.Equal(Rarity.C, card.Rarity));
Assert.NotEqual(Rarity.C, firstBooster.Cards[4].Rarity);
var secondBooster = await PostAsync<BoosterResult>(
aliceHttp, "/api/store/redeem/token", new { idempotencyKey = "boost-1" });
Assert.Equal(
firstBooster.Cards.Select(card => card.PrintingId),
secondBooster.Cards.Select(card => card.PrintingId));
Assert.Equal(4, secondBooster.Balances.Tokens);
var decks = await GetAsync<List<DeckDto>>(aliceHttp, "/api/decks");
Assert.Single(decks);
var aliceQueue = await QueueAsync(aliceHttp, aliceDeck.Id);
var bobQueue = await QueueAsync(bobHttp, bobDeck.Id);
var aliceStatus = await WaitForMatchAsync(aliceHttp, aliceQueue.QueueId);
var bobStatus = await WaitForMatchAsync(bobHttp, bobQueue.QueueId);
Assert.Equal(aliceStatus.MatchId, bobStatus.MatchId);
Assert.NotNull(aliceStatus.Ticket);
Assert.NotNull(bobStatus.Ticket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
await using var aliceClient = await TestGameClient.ConnectAsync(
factory, aliceStatus.MatchId!, aliceStatus.Ticket!, alice.UserName, 0, cts.Token);
await using var bobClient = await TestGameClient.ConnectAsync(
factory, bobStatus.MatchId!, bobStatus.Ticket!, bob.UserName, 0, cts.Token);
await Task.WhenAll(
aliceClient.PlayRandomlyAsync(11, cts.Token),
bobClient.PlayRandomlyAsync(22, cts.Token));
Assert.True(aliceClient.View.IsFinished);
var aliceHistory = await WaitForHistoryAsync(aliceHttp);
var bobHistory = await WaitForHistoryAsync(bobHttp);
var aliceRow = Assert.Single(aliceHistory.Items);
var bobRow = Assert.Single(bobHistory.Items);
Assert.Equal(aliceStatus.MatchId, aliceRow.MatchId);
Assert.NotEqual(aliceRow.Result, bobRow.Result);
Assert.True(aliceRow.ReplayAvailable);
Assert.True(bobRow.ReplayAvailable);
var winnerRow = aliceRow.Result == "win" ? aliceRow : bobRow;
var loserRow = aliceRow.Result == "win" ? bobRow : aliceRow;
Assert.Equal(30, winnerRow.Gold);
Assert.Equal(150, winnerRow.Xp);
Assert.Equal(15, loserRow.Gold);
Assert.Equal(75, loserRow.Xp);
var aliceAfter = await GetAsync<MeResponse>(aliceHttp, "/api/auth/me");
Assert.Equal(aliceRow.Gold, aliceAfter.Gold);
Assert.Equal(aliceRow.Xp, aliceAfter.Xp);
var replay = await GetAsync<ReplayResponse>(
aliceHttp, $"/api/replays/{aliceStatus.MatchId}");
Assert.Equal(aliceStatus.MatchId, replay.MatchId);
Assert.Equal(0, replay.ViewerSeat);
Assert.NotEmpty(replay.Events);
Assert.NotEmpty(replay.Frames);
Assert.Equal(11, replay.Frames[0].EventSeq);
Assert.NotEmpty(replay.Frames[0].View.Players[0].Hand);
Assert.Empty(replay.Frames[0].View.Players[1].Hand);
Assert.Equal(5, replay.Frames[0].View.Players[1].HandCount);
Assert.True(replay.Frames[^1].View.IsFinished);
Assert.Equal(replay.Winner, replay.Frames[^1].View.Winner);
Assert.Contains(replay.Events, item => item.Kind == "GameFinished");
var detail = await GetAsync<MatchHistoryDto>(
aliceHttp, $"/api/matches/{aliceStatus.MatchId}");
Assert.Equal(aliceRow.MatchId, detail.MatchId);
var forbidden = await carolHttp.GetAsync($"/api/replays/{aliceStatus.MatchId}");
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
}
private static HttpClient Authed(
WebApplicationFactory<Program> factory,
AuthResponse auth)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth.AccessToken);
return client;
}
private static async Task<AuthResponse> RegisterAsync(HttpClient http, string prefix)
{
var userName = prefix + Guid.NewGuid().ToString("N")[..8];
var response = await http.PostAsJsonAsync(
"/api/auth/register",
new { userName, password = "password123" },
Json);
if (!response.IsSuccessStatusCode)
throw new XunitException(await response.Content.ReadAsStringAsync());
return (await response.Content.ReadFromJsonAsync<AuthResponse>(Json))!;
}
private static async Task<DeckDto> CreateDeckAsync(
HttpClient http,
string name,
List<string> cards)
{
var response = await http.PostAsJsonAsync(
"/api/decks",
new { name, cards },
Json);
if (!response.IsSuccessStatusCode)
throw new XunitException(
$"deck.create: {response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<DeckDto>(Json))!;
}
private static async Task<QueueAccepted> QueueAsync(HttpClient http, Guid deckId)
{
var response = await http.PostAsJsonAsync(
"/api/game/queue",
new { mode = GameModes.Ranked, deckId },
Json);
if (!response.IsSuccessStatusCode)
throw new XunitException(
$"queue: {response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<QueueAccepted>(Json))!;
}
private static async Task<QueueStatusResponse> WaitForMatchAsync(
HttpClient http,
Guid queueId)
{
for (var attempt = 0; attempt < 200; attempt++)
{
var status = await http.GetFromJsonAsync<QueueStatusResponse>(
$"/api/game/queue/{queueId}", Json);
if (status is null)
throw new XunitException("Очередь исчезла.");
if (status.Status == "matched")
return status;
if (status.Status is "failed" or "expired" or "cancelled")
throw new XunitException($"Очередь в состоянии {status.Status}: {status.Reason}");
await Task.Delay(50);
}
throw new XunitException("Матч не был найден за 10 секунд.");
}
private static async Task<MatchHistoryResponse> WaitForHistoryAsync(HttpClient http)
{
for (var attempt = 0; attempt < 100; attempt++)
{
var history = await http.GetFromJsonAsync<MatchHistoryResponse>("/api/matches", Json);
if (history is { Items.Count: > 0 })
return history;
await Task.Delay(50);
}
throw new XunitException("История матча не появилась.");
}
private static async Task<T> GetAsync<T>(HttpClient http, string path)
{
var response = await http.GetAsync(path);
if (!response.IsSuccessStatusCode)
throw new XunitException(
$"{path}: {response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<T>(Json))!;
}
private static async Task<T> PostAsync<T>(HttpClient http, string path, object body)
{
var response = await http.PostAsJsonAsync(path, body, Json);
if (!response.IsSuccessStatusCode)
throw new XunitException(
$"{path}: {response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<T>(Json))!;
}
}

View File

@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Meta;
using Vanguard.Server.Storage;
namespace Vanguard.Server.Tests;
public class MetaProfileServiceTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Forfeit_Records_History_Without_Gold_Or_Xp()
{
var factory = new TestFactory();
await CreateUsersAsync(factory);
var service = NewService(factory);
await service.RecordAsync(new MatchResult(
"m-forfeit", GameMode.ClassicRanked, "p1", "p2", 0,
MatchFinishReason.Concede));
await using var db = factory.CreateDbContext();
var rows = await db.MatchHistory.OrderBy(row => row.UserId).ToListAsync();
Assert.Equal(2, rows.Count);
Assert.All(rows, row =>
{
Assert.Equal(0, row.Gold);
Assert.Equal(0, row.Xp);
Assert.Equal("concede", row.FinishReason);
});
}
[Fact]
public async Task Normal_Finish_Gives_Gold_Xp_And_Is_Idempotent()
{
var factory = new TestFactory();
await CreateUsersAsync(factory);
var service = NewService(factory);
var result = new MatchResult(
"m-normal", GameMode.ClassicRanked, "p1", "p2", 0,
MatchFinishReason.Normal);
await service.RecordAsync(result);
await service.RecordAsync(result);
await using var db = factory.CreateDbContext();
var rows = await db.MatchHistory.OrderBy(row => row.Result).ToListAsync();
Assert.Equal(2, rows.Count);
var win = Assert.Single(rows, row => row.Result == "win");
var loss = Assert.Single(rows, row => row.Result == "loss");
Assert.Equal(30, win.Gold);
Assert.Equal(150, win.Xp);
Assert.Equal(15, loss.Gold);
Assert.Equal(75, loss.Xp);
var winner = await db.Users.SingleAsync(user => user.NormalizedUserName == "P1");
var loser = await db.Users.SingleAsync(user => user.NormalizedUserName == "P2");
Assert.Equal(150, winner.Xp);
Assert.Equal(75, loser.Xp);
Assert.True(winner.RankedMmr > 1000);
Assert.True(loser.RankedMmr < 1000);
}
private static MetaProfileService NewService(TestFactory factory) => new(
factory,
new MetaOptions(),
new FakeTimeProvider(Start),
NullLogger<MetaProfileService>.Instance);
private static async Task CreateUsersAsync(TestFactory factory)
{
await using var db = factory.CreateDbContext();
db.Users.AddRange(
new UserEntity
{
Id = Guid.NewGuid(),
UserName = "p1",
NormalizedUserName = "P1",
PasswordHash = "x",
CreatedAt = Start,
RankedMmr = 1000,
CasualMmr = 1000,
},
new UserEntity
{
Id = Guid.NewGuid(),
UserName = "p2",
NormalizedUserName = "P2",
PasswordHash = "x",
CreatedAt = Start,
RankedMmr = 1000,
CasualMmr = 1000,
});
await db.SaveChangesAsync();
}
private sealed class TestFactory : IDbContextFactory<MatchDbContext>
{
private readonly DbContextOptions<MatchDbContext> _options;
public TestFactory()
{
_options = new DbContextOptionsBuilder<MatchDbContext>()
.UseInMemoryDatabase("meta-profile-" + Guid.NewGuid().ToString("N"))
.Options;
}
public MatchDbContext CreateDbContext() => new(_options);
}
}

View File

@ -10,8 +10,15 @@ internal static class ServerTestData
() => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)), () => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)),
isThreadSafe: true); isThreadSafe: true);
private static readonly Lazy<TrialDeckCatalog> LazyTrialDecks = new(
() => TrialDeckCatalog.Load(
Path.Combine(Path.GetDirectoryName(ContentPaths.Resolve(null))!, "trial-decks.yaml")),
isThreadSafe: true);
public static CardCatalog Catalog => LazyCatalog.Value; public static CardCatalog Catalog => LazyCatalog.Value;
public static TrialDeckCatalog TrialDecks => LazyTrialDecks.Value;
/// <summary>Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId.</summary> /// <summary>Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId.</summary>
public static List<string> BuildDeck() public static List<string> BuildDeck()
{ {

View File

@ -115,6 +115,51 @@ internal sealed class TestGameClient : IAsyncDisposable
LastSeq = snapshot.View.EventSeq; LastSeq = snapshot.View.EventSeq;
} }
/// <summary>Случайный бот: играет легальными интентами до конца партии.</summary>
public async Task PlayRandomlyAsync(int seed, CancellationToken cancellationToken)
{
var random = new Random(seed);
var deadline = DateTime.UtcNow.AddSeconds(90);
while (!View.IsFinished)
{
if (DateTime.UtcNow > deadline)
throw new XunitException(
$"Тест не доиграл партию за 90 секунд (seat {Seat}).");
if (View.ActivePlayer == Seat)
{
var choices = View.LegalIntents
.Where(intent => intent is not ConcedeIntent)
.ToList();
if (choices.Count == 0)
throw new XunitException(
$"Нет легальных интентов для seat {Seat}, " +
$"фаза {View.Phase}, шаг {View.Step}.");
var intent = choices[random.Next(choices.Count)];
var intentId = Guid.NewGuid().ToString("N");
var result = await SubmitAsync(intentId, intent, cancellationToken);
if (result.Duplicate)
throw new XunitException($"Неожиданный duplicate для {intentId}.");
if (!result.Accepted)
throw new XunitException(
$"Интент {intent.GetType().Name} отклонён: {result.Code} {result.Reason}");
// держимся ниже лимита 16 интентов в секунду
await Task.Delay(75, cancellationToken);
}
await RequestSnapshotAsync(cancellationToken);
if (!View.IsFinished && View.ActivePlayer != Seat)
await Task.Delay(20, cancellationToken);
}
}
public async Task<T> ReadUntilAsync<T>(CancellationToken cancellationToken) public async Task<T> ReadUntilAsync<T>(CancellationToken cancellationToken)
where T : ServerFrame where T : ServerFrame
{ {