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

View File

@ -15,7 +15,8 @@
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
| Orleans и мета-API (JWT, коллекции, магазин) | не начато |
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
| Orleans, CI и миграции | не начато |
| Клиент и лаунчер | не начато |
## Требования
@ -64,18 +65,22 @@ OK: ошибок 0, предупреждений 0
/server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры
/server/src/Vanguard.Content загрузка и проверка YAML-контента
/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-снапшоты и восстановление матчей
/Matchmaking очередь, MMR, Redis-хранилище очереди
/Tickets тикеты: память или Redis
/server/src/Vanguard.Cli консольные команды
/server/tests тесты правил, контрактов и сервера (включая E2E)
/content/sets карты в YAML
/content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера
/content/keywords.yaml ключевые слова и иконки
/content/translations/ru.yaml русские тексты
/tools импорт и сид переводов
/docs протокол, API, правила перевода
/docker-compose.yml сервер в контейнере
/docker-compose.yml PostgreSQL, Redis и сервер
```
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
@ -135,5 +140,6 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
- `docs/server-api.md` — HTTP-очередь, тикеты и `/health`.
- `docs/server-api.md` — HTTP API: JWT, коллекция, колоды, магазин, очередь,
история и реплеи.
- `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:
ASPNETCORE_URLS: http://+:8080
Game__PublicWsUrl: ${VANGUARD_WS_URL:-ws://localhost:8080/game}
Game__AllowDevQueue: "false"
Storage__Provider: Postgres
Storage__ConnectionString: Host=postgres;Port=5432;Database=vanguard;Username=vanguard;Password=vanguard
Redis__Configuration: redis:6379
Jwt__SigningKey: ${VANGUARD_JWT_KEY:-dev-only-vanguard-remaster-signing-key-change-me}
depends_on:
postgres:
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 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
| Карты | **94/94** (66 с текстом + 28 ванильных) |
| Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы |
| Сервер | сеть, матчмейкинг, реконнект, PostgreSQL + Redis, Docker |
| Сервер | сеть, матчмейкинг, PostgreSQL + Redis, Docker |
| Мета-API | JWT, коллекция, колоды, магазин, экономика, история, реплеи |
| Клиент / лаунчер | не начаты |
Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям:
@ -70,7 +71,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 33 (Vanguard.Server.Tests)
Пройден! : не пройдено 0, пройдено 38 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0
@ -255,7 +256,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
- **Тесты** (`Vanguard.Server.Tests`, 33):
- **Тесты серверного ядра** (33 из 38 в `Vanguard.Server.Tests`):
- тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
@ -272,10 +273,54 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния.
Проверено на `ssh home`: 164/164 тестов, включая реальные PostgreSQL и Redis;
Проверено на `ssh home`: 169/169 тестов, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `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. Структура и ключевые файлы
@ -304,16 +349,20 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/server/src/Vanguard.Server
/Program.cs ASP.NET Core, DI, WebSocket endpoint
/Api/ очередь, тикеты, /health
/Auth/ JWT, register/login/refresh, BCrypt
/Meta/ коллекция, колоды, магазин, экономика, история, реплеи
/Matchmaking/ очереди, MMR, Redis-хранилище очереди
/Matches/ IMatchGrain, таймеры, тикер, регистр
/Realtime/ WebSocket-обвязка, чтение кадров
/Tickets/ одноразовые тикеты: память или Redis
/Storage/ EF-хранилище, восстановление матчей
/Profiles/ скрытый MMR и статистика
/Profiles/ интерфейс MMR
/Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 33 теста, включая E2E и внешние хранилища
/server/tests/Vanguard.Server.Tests 38 тестов, включая E2E и внешние хранилища
/content/sets карты
/content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера
/content/translations/ru.yaml переводы
/docs/translation-rules.md канон перевода
/docs/protocol.md протокол матча, версия 1
@ -351,6 +400,17 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
от транспорта, Orleans подключается поверх.
- Rate limit — 16 интентов в секунду на соединение.
- Скрытый 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+ — выбор.
@ -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; персистентность
и проекция от хостинга не зависят.
8. **JWT и мета-API** — `playerId` перестанет быть dev-заглушкой.
Критерий готовности серверного блока выполнен: два клиента играют полный
матч по сети; kill клиента на любом шаге восстанавливается через новый
тикет и `resumeFromSeq`. Рестарт **сервера** пока теряет матчи: это
закрывается пунктом 7.
матч по сети; kill клиента восстанавливается через новый тикет и
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
Остались Orleans и инфраструктурные мелочи (CI, миграции).
### 7.2. Мета-API
Сделано:
- `User`, BCrypt, JWT (access 15 мин + refresh 30 дней, ротация).
- `register`, `login`, `refresh`, `logout`, `me`.
- Коллекция по принтам, без верхнего предела.
- Колоды: CRUD, `validate`, лимит 30, шаринг по коду.
- Магазин, бустеры, триал-деки, токены.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт ×4, SP не крафтится.
- Коллекция по принтам, без верхнего предела; разбор и крафт.
- Колоды: CRUD, `validate`, лимит 30, шаринг по коду, проверка владения.
- Магазин: бустеры, триал-деки, токены; duplicate protection.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт 20 / 40 / 100 / 400;
SP не разбирается и не крафтится.
- Онбординг: триал-дека + 5 токенов.
- Матчмейкинг, результат матча, Gold/XP, история.
- Матчмейкинг по JWT и `deckId`; dev-режим только для разработки.
- Результат матча: Gold/XP, MMR, история, реплеи.
- Rate limiting.
Осталось после MVP: косметика, квесты, баттлпасс, Draft, друзья/чат,
история покупок в клиенте.
### 7.3. Клиент
Следующий блок — **клиент**:
- Unity 6 LTS, URP, Addressables, Input System, Localization, DOTween.
- Сцены `Boot`, `Meta`, `Battle`.
- VContainer, `ApiClient`, `GameConnection`.
- Экраны: логин, меню, магазин, коллекция, декбилдер, очередь, настройки.
- История матчей и экран реплея: `GET /api/matches`, `GET /api/replays/{id}`,
проигрывание `frames` + `events`.
- Боевая сцена: поле, рука, зоны, анимации, таймер.
- Локализация RU/EN на лету.
@ -438,12 +511,13 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
1. Прочитать `docs/HANDOFF.md`, `IMPLEMENTATION_CHECKLIST.md`, `README.md`.
2. Прогнать проверки из раздела 3.
3. Следующий блок — Orleans и мета-API: заменить in-process регистр на silo,
затем JWT, аккаунты, коллекция, колоды и магазин. Сетевой матч,
PostgreSQL/Redis и Docker уже готовы: `docs/protocol.md`,
`Vanguard.Shared`, `Vanguard.Server`, `docs/server-api.md`.
4. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
5. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
3. Следующий блок — **клиент** (Unity): `Boot`/`Meta`/`Battle`, `ApiClient`,
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
`docs/server-api.md`, `Vanguard.Shared`.
4. Orleans, CI и миграции — отдельные серверные долги, не блокируют клиент.
5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
---
@ -463,9 +537,11 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
для наблюдателя нельзя — в списке есть printingId из чужой руки.
- **Сервер: rate limit.** 16 интентов в секунду на соединение. Быстрый бот
в E2E специально притормаживается; живой UI в лимит не упирается.
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None` — матчи живут
в памяти. Для рестарта нужен `Postgres` + `Storage:ConnectionString`;
схема создаётся `EnsureCreated`, миграций пока нет. `Memory` — EF InMemory.
- **Сервер: хранилище.** По умолчанию `Storage:Provider=None`: матчи живут
в памяти (`NullMatchStore`), мета-API — в EF InMemory. Для рестарта нужен
`Postgres` + `Storage:ConnectionString`; `Memory` — EF InMemory для всего.
Схема создаётся `EnsureCreated` для одного общего `MatchDbContext`;
миграций пока нет, при изменении схемы тестовую БД нужно пересоздать.
- **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды,
игроки, часы и журнал интентов. При восстановлении команды проигрываются
заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой.
@ -486,6 +562,25 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- **Сервер: тесты.** E2E поднимает `WebApplicationFactory<Program>`,
играет полный матч по WebSocket и проверяет, что победитель совпал.
Случайные партии завершаются за ~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 с банк списывается
по фактическому превышению; `TurnDeadlineMs` в этот момент сдвигается,
поэтому UI должен показывать ещё и `BankMs`.

View File

@ -1,11 +1,11 @@
# HTTP API сервера
Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`.
После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`.
Мета-API и матчевый HTTP-слой: аккаунты, коллекция, колоды, магазин,
очередь, тикеты, история матчей и реплеи. После подключения клиент
переходит на WebSocket и работает по `docs/protocol.md`.
Базовая часть стека: **ASP.NET Core 10**, один процесс.
Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без
внешних сервисов сервер работает in-memory (удобно для тестов).
Базовая часть стека: **ASP.NET Core 10**, JWT, PostgreSQL/Redis (опционально),
один процесс.
---
@ -30,14 +30,16 @@ docker compose up --build
# http://localhost:8080/health
```
Порт и внешний адрес WebSocket задаются в `.env`:
`.env`:
```
VANGUARD_PORT=8080
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 карт можно получить так:
@ -45,153 +47,311 @@ VANGUARD_WS_URL=ws://localhost:8080/game
dotnet run --project server\src\Vanguard.Cli -- deck
```
Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент
лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении).
Каталог карт ищется в `content/sets` вверх от каталога сборки; триал-деки
и веса бустера — в `content/trial-decks.yaml` и `content/pack_odds.yaml`.
---
## 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
{
"playerId": "p1",
"mode": "classic-ranked",
"deck": ["BT01/001EN", "BT01/002EN", "..."]
"items": [
{ "printingId": "TD01/001EN", "nameId": "…", "nameEn": "…", "rarity": "R", "count": 4 }
],
"balances": { "gold": 0, "dust": 5, "tokens": 5 }
}
```
- `mode`: `classic-ranked` или `classic-casual`.
- `deck`: ровно 50 `printingId`. Проверяются те же правила, что и в декбилдере:
50 карт, ≤4 по `nameId`, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы
одна карта grade 0 без триггера для стартового Авангарда.
Коллекция считается по `printingId` и не имеет верхнего предела.
Ответ `200`:
Разбор (`dismantle`):
```json
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "queued",
"mode": "classic-ranked"
}
{ "printingId": "BT01/050EN", "count": 2, "idempotencyKey": "uuid" }
```
Ответ `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
{
"error": "deck.invalid",
"message": "Колода не проходит проверку.",
"violations": [
{ "code": "deck.size", "message": "В колоде 49 карт, требуется 50." }
"balances": { "gold": 30, "dust": 0, "tokens": 4 },
"boosterPriceGold": 100,
"trialDecks": [
{
"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
{
"queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f",
"status": "matched",
"cards": [
{ "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",
"opponentName": "bob",
"result": "win",
"gold": 30,
"xp": 150,
"mmrBefore": 1000,
"mmrAfter": 1016,
"finishReason": "normal",
"playedAt": "2026-10-21T12:30:00+00:00",
"replayAvailable": true
}
]
}
```
- Запись создаётся только после подтверждённого сервером завершения матча.
- Одна запись на игрока и матч; повтор результата не дублируется.
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает
в историю.
- MMR меняется в ранкеде; casual MMR остаётся прежним.
Реплей:
```json
{
"matchId": "…",
"mode": "classic-ranked",
"waitedSeconds": 7,
"window": 100,
"matchId": "9f2c...",
"seat": 0,
"ticket": "c2VjcmV0...",
"opponent": "p2",
"reason": null,
"wsUrl": "ws://localhost:5080/game"
"player0": "alice",
"player1": "bob",
"viewerSeat": 0,
"winner": 0,
"finishReason": "normal",
"eventHash": "…",
"events": [ { "seq": 1, "kind": "GameStarted", "event": { "firstPlayer": 0 } } ],
"frames": [ { "eventSeq": 11, "view": { "…": "MatchView" } } ]
}
```
`status`:
| Значение | Смысл |
|---|---|
| `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` — уже не активна.
- Реплей восстанавливается из seed, колод и журнала принятых команд.
- `events` — события, видимые этому игроку (скрытый поиск не раскрывается).
- `frames` — состояние до и после каждой принятой команды; `MatchView`
собран под зрителя, `legalIntents` пуст, часы обнулены.
- Рука противника и закрытый урон остаются скрытыми даже в реплее.
- Смотреть может только участник матча; остальным — `403`.
- `includeFrames=false` — только таймлайн событий.
---
## 4. Тикеты
## 9. Health
### `POST /api/game/tickets`
Выдать новый одноразовый тикет на подключение. Нужен для переподключения
после обрыва: старый тикет уже израсходован.
```json
{ "playerId": "p1", "matchId": "9f2c..." }
```
Ответ `200`:
```json
{
"ticket": "c2VjcmV0...",
"matchId": "9f2c...",
"seat": 0,
"wsUrl": "ws://localhost:5080/game",
"expiresAt": "2026-10-21T12:01:00+00:00"
}
GET /health
```
- TTL — 60 секунд, тикет одноразовый.
- Тикет привязан к `(playerId, matchId, seat)`.
- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться
и посмотреть итоговое состояние.
- `404` — матч не найден, `403` — игрок не сидит в этом матче.
---
## 5. Health
### `GET /health`
```json
{
"status": "ok",
@ -207,20 +367,20 @@ MMR наружу не отдаётся. В ранкеде после матча
---
## 6. Полный сценарий
## 10. Полный сценарий
```
POST /api/game/queue → queueId
GET /api/game/queue/{id} → matched + ticket + wsUrl
WS /game → ClientHello(ticket)
← ServerHello + ServerSnapshot
→ ClientIntent(...)
← ServerIntentResult + ServerEvents
...
обрыв
POST /api/game/tickets → новый ticket
WS /game → ClientHello(ticket, resumeFromSeq)
← ServerHello + ServerEvents (догон)
POST /api/auth/register → access + refresh
POST /api/meta/onboarding {td01} → 50 карт + 5 токенов
GET /api/collection → 50 карт
POST /api/decks {cards} → колода
POST /api/store/redeem/token → бустер, 4 токена
GET /api/store → витрина
POST /api/game/queue {deckId} → queueId
GET /api/game/queue/{id} → matched + ticket
WS /game + ClientHello(ticket) → матч
GET /api/matches → история, Gold/XP
GET /api/replays/{matchId} → реплей
```
Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок —
@ -228,55 +388,40 @@ WS /game → ClientHello(ticket, resumeFromSeq)
---
## 7. Хранилище и восстановление
### 7.1. Конфигурация
## 11. Хранилище и восстановление
| Ключ | Значения | Смысл |
|---|---|---|
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное |
| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — матчи без сохранения, мета в памяти; `Memory` — EF InMemory; `Postgres` — постоянное |
| `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` |
| `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти |
| `Redis:Prefix` | `vanguard:` | префикс ключей |
| `Jwt:SigningKey` | ≥32 символа | подпись access-токенов |
В контейнере переменные задаются как `Storage__Provider`,
`Storage__ConnectionString`, `Redis__Configuration`.
Одна БД, один `MatchDbContext`:
### 7.2. Матчи в PostgreSQL
- `matches`, `match_intents` — заголовок и журнал матча;
- `users`, `refresh_tokens` — аккаунты и сессии;
- `collection`, `decks`, `transactions`, `booster_openings`,
`trial_deck_ownership` — коллекция, колоды, экономика, покупки;
- `match_history` — история и награды.
- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`.
- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа.
- На каждый интент пишется строка журнала и обновляется заголовок.
- После рестарта `MatchRecoveryService` поднимает незавершённые матчи:
создаёт движок с сохранённым seed и проигрывает принятые команды заново.
`GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем,
делегаты никуда не сериализуются.
- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается
с ошибкой в логе.
- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика.
- Завершённые матчи остаются историей и при старте в память не поднимаются.
Восстановление матча: при старте `MatchRecoveryService` поднимает
незавершённые матчи и проигрывает принятые команды на детерминированном
движке; `eventSeq` сверяется. Схема создаётся `EnsureCreated`; миграций
пока нет, при изменении схемы тестовую БД нужно пересоздать.
### 7.3. Redis
- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL`
(одноразовость сохраняется между инстансами).
- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set
`vanguard:queue:{mode}` по номеру постановки. При старте очередь
загружается обратно.
- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API
вместе с аккаунтами.
Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set).
---
## 8. Ограничения текущего этапа
## 12. Ограничения текущего этапа
- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`)
матчи, тикеты и очередь живут только в памяти.
- Профили и MMR пока не персистентны: после рестарта рейтинг начинается
с 1000. Матчи и очередь при этом восстанавливаются.
- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди.
- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans
grain не затронет транспорт и правила.
- Нет JWT, коллекций и наград — это следующие блоки.
- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него
не упирается; тестовый бот специально притормаживается.
- `Storage:Provider=None` — всё в памяти, рестарт теряет данные.
История матча пишется, но реплей при `None` недоступен: строки матча нет.
Для истории и реплеев нужен `Memory` или `Postgres`.
- `EnsureCreated`, а не миграции: схема меняется вместе с пересозданием БД.
- Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён.
- Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата.
- Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы.
- Dev-очередь включается только `Game:AllowDevQueue=true` и не для production.

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;
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);

View File

@ -1,14 +1,17 @@
using System.Security.Claims;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Meta;
using Vanguard.Server.Tickets;
namespace Vanguard.Server.Api;
/// <summary>
/// HTTP-часть матчевого блока: очередь, тикеты, здоровье.
/// Авторизация здесь ещё dev-заглушка: игрок называется playerId.
/// JWT из мета-API встанет на это место без изменения контрактов очереди.
/// Если пришёл JWT — игрок и его колода берутся из мета-API.
/// Без JWT работает dev-режим (playerId + готовая колода), если он разрешён.
/// </summary>
public static class ApiEndpoints
{
@ -23,20 +26,61 @@ public static class ApiEndpoints
private static async Task<IResult> Enqueue(
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))
return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}."));
if (request.Deck is not { Count: > 0 })
return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт."));
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 })
return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт."));
playerId = request.PlayerId!;
deck = request.Deck;
}
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));
}
catch (DeckRejectedException exception)
@ -51,14 +95,20 @@ public static class ApiEndpoints
private static async Task<IResult> PollQueue(
Guid queueId,
string? playerId,
ClaimsPrincipal principal,
MatchmakingService matchmaking,
HttpRequest http,
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 обязателен."));
var snapshot = await matchmaking.PollAsync(queueId, playerId!);
var snapshot = await matchmaking.PollAsync(queueId, effectivePlayer!);
if (snapshot is null)
return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена."));
@ -79,25 +129,43 @@ public static class ApiEndpoints
private static async Task<IResult> CancelQueue(
Guid queueId,
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 await matchmaking.CancelAsync(queueId, playerId!)
return await matchmaking.CancelAsync(queueId, effectivePlayer!)
? Results.NoContent()
: Results.Conflict(new ApiError("queue.not-cancellable", "Очередь уже не активна."));
}
private static async Task<IResult> IssueTicket(
TicketRequest request,
ClaimsPrincipal principal,
MatchGrainRegistry matches,
GameTicketService tickets,
HttpRequest http,
GameOptions options)
{
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
var playerId = principal.GetUserName();
if (playerId is null)
{
if (!options.AllowDevQueue)
return Results.Unauthorized();
if (!IsValidPlayerId(request.PlayerId))
return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен."));
playerId = request.PlayerId!;
}
if (string.IsNullOrWhiteSpace(request.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", "Матч не найден."));
var summary = match.Summary;
var seat = summary.Player0Id == request.PlayerId
var seat = summary.Player0Id == playerId
? 0
: summary.Player1Id == request.PlayerId
: summary.Player1Id == playerId
? 1
: -1;
@ -117,7 +185,7 @@ public static class ApiEndpoints
new ApiError("match.forbidden", "Этот игрок не участвует в матче."),
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(
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 MmrMaxWindow { get; set; } = 2000;
/// <summary>Разрешить очередь без JWT по playerId. Только для локальной разработки.</summary>
public bool AllowDevQueue { get; set; } = true;
/// <summary>Внешний адрес WebSocket. Пусто — строится из запроса.</summary>
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? FinishedAt);
/// <summary>Результат для записи в профили. MMR меняется только в ранкеде.</summary>
/// <summary>Почему матч завершился. От этого зависят награды и вид реплея.</summary>
public enum MatchFinishReason
{
Normal = 0,
Concede = 1,
Timeout = 2,
Absence = 3,
}
/// <summary>Результат для записи в профили и историю. MMR меняется только в ранкеде.</summary>
public sealed record MatchResult(
string MatchId,
GameMode Mode,
string Player0Id,
string Player1Id,
int WinnerSeat)
int WinnerSeat,
MatchFinishReason FinishReason = MatchFinishReason.Normal)
{
public string WinnerPlayerId => WinnerSeat == 0 ? Player0Id : Player1Id;
}

View File

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

View File

@ -80,7 +80,7 @@ public sealed class MatchmakingService
private readonly CardCatalog _catalog;
private readonly MatchGrainRegistry _matches;
private readonly GameTicketService _tickets;
private readonly PlayerProfileStore _profiles;
private readonly IProfileService _profiles;
private readonly GameOptions _options;
private readonly IQueueStore _queueStore;
private readonly TimeProvider _time;
@ -91,7 +91,7 @@ public sealed class MatchmakingService
CardCatalog catalog,
MatchGrainRegistry matches,
GameTicketService tickets,
PlayerProfileStore profiles,
IProfileService profiles,
GameOptions options,
IQueueStore queueStore,
TimeProvider time,
@ -162,7 +162,7 @@ public sealed class MatchmakingService
PlayerId = playerId,
Deck = deck.ToList(),
Mode = mode,
Mmr = _profiles.GetMmr(playerId),
Mmr = await _profiles.GetMmrAsync(playerId, mode),
EnqueuedAt = _time.GetUtcNow(),
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;
/// <summary>
/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся:
/// клиент видит только результат матча.
///
/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён,
/// чтобы заменить реализацию, не трогая матч.
/// In-memory профиль для тестов и dev-режима. В production используется
/// <c>MetaProfileService</c> поверх общей БД.
/// </summary>
public sealed class PlayerProfileStore : IMatchResultSink
public sealed class PlayerProfileStore : IMatchResultSink, IProfileService
{
private const int InitialMmr = 1000;
private const int EloK = 32;
@ -19,6 +16,15 @@ public sealed class PlayerProfileStore : IMatchResultSink
private readonly object _gate = new();
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)
{
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.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis;
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Meta;
using Vanguard.Server.Profiles;
using Vanguard.Server.Realtime;
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<StorageOptions>(builder.Configuration.GetSection("Storage"));
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<ContentOptions>>().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<MetaOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
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 =>
{
var content = sp.GetRequiredService<ContentOptions>();
var directory = ContentPaths.Resolve(content.Path);
return CatalogLoader.LoadDirectory(directory);
return CatalogLoader.LoadDirectory(ContentPaths.Resolve(content.Path));
});
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 storageProvider = storage.Provider?.Trim() ?? "None";
var storageProvider = storage.Provider?.Trim() ?? "Memory";
if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
{
@ -47,11 +71,14 @@ if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-matches"));
options.UseInMemoryDatabase("vanguard"));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
}
else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase))
{
// мета-API всё равно работает в памяти, а матчи — без сохранения
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-dev"));
builder.Services.AddSingleton<IMatchStore, NullMatchStore>();
}
else
@ -79,24 +106,99 @@ else
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<PlayerProfileStore>();
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<PlayerProfileStore>());
builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchRecoveryService>();
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<MatchmakingWorker>();
var app = builder.Build();
// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте
app.Services.GetRequiredService<CardCatalog>();
var catalog = 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();
}
@ -104,11 +206,18 @@ if (app.Services.GetRequiredService<IMatchStore>() is EfMatchStore)
await app.Services.GetRequiredService<MatchRecoveryService>().RecoverAsync();
await app.Services.GetRequiredService<MatchmakingService>().LoadQueuedAsync();
app.UseResponseCompression();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
app.MapAuthEndpoints();
app.MapMetaEndpoints();
app.MapGameApi();
app.Map("/game", GameWebSocketEndpoint.HandleAsync);

View File

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

View File

@ -1,7 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Vanguard.Server.Meta;
namespace Vanguard.Server.Storage;
/// <summary>
/// Единый контекст игры и мета-данных: матчи, пользователи, коллекция,
/// колоды, экономика, история. Одна БД — один EnsureCreated.
/// </summary>
public sealed class MatchDbContext : DbContext
{
public MatchDbContext(DbContextOptions<MatchDbContext> options)
@ -12,7 +17,22 @@ public sealed class MatchDbContext : DbContext
public DbSet<MatchEntity> Matches => Set<MatchEntity>();
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)
{
ConfigureMatches(modelBuilder);
ConfigureMeta(modelBuilder);
}
private static void ConfigureMatches(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MatchEntity>(entity =>
{
@ -39,6 +59,98 @@ public sealed class MatchDbContext : DbContext
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
@ -65,6 +177,7 @@ public sealed class MatchEntity
public bool IsFinished { get; set; }
public int Winner { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public string FinishReason { get; set; } = "normal";
public long EventSeq { get; set; }
}

View File

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

View File

@ -14,6 +14,8 @@
</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.InMemory" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />

View File

@ -23,6 +23,7 @@
"MmrWindowStep": 50,
"MmrWindowIntervalSeconds": 5,
"MmrMaxWindow": 2000,
"AllowDevQueue": true,
"PublicWsUrl": ""
},
"Content": {
@ -35,5 +36,19 @@
"Redis": {
"Configuration": "",
"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,
string matchId,
long eventSeq,
ClockView clock)
ClockView clock,
bool includeLegalIntents = true)
{
if (viewer is not (0 or 1))
throw new ArgumentOutOfRangeException(nameof(viewer), viewer, "Индекс игрока — 0 или 1.");
@ -52,7 +53,9 @@ public static class MatchProjection
Order = ProjectOrder(state, viewer),
OpponentChoosing = IsOpponentChoosing(state, viewer),
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 StackExchange.Redis;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Storage;
using Vanguard.Server.Tickets;
@ -49,7 +50,8 @@ public class ExternalStorageTests
AbsentSince0: time.GetUtcNow(),
AbsentSince1: 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.AppendIntentAsync(

View File

@ -35,8 +35,8 @@ public class GameServerE2ETests
factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token);
await Task.WhenAll(
PlayAsync(first, seed: 11, cts.Token),
PlayAsync(second, seed: 22, cts.Token));
first.PlayRandomlyAsync(seed: 11, cts.Token),
second.PlayRandomlyAsync(seed: 22, cts.Token));
Assert.True(first.View.IsFinished, "Первый клиент не увидел завершение партии.");
Assert.True(second.View.IsFinished, "Второй клиент не увидел завершение партии.");
@ -141,51 +141,4 @@ public class GameServerE2ETests
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);
Assert.Equal("p1", recorded.WinnerPlayerId);
Assert.Equal(MatchFinishReason.Concede, recorded.FinishReason);
var loserId = recorded.Player0Id == recorded.WinnerPlayerId
? recorded.Player1Id
: recorded.Player0Id;
@ -166,7 +167,8 @@ public class MatchGrainTests
options.TurnSeconds = 10;
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");
await Attach(grain, 1, "p2");
@ -183,6 +185,7 @@ public class MatchGrainTests
Assert.True(grain.Summary.IsFinished);
Assert.Equal(1, grain.Summary.Winner);
Assert.Equal(MatchFinishReason.Timeout, Assert.Single(sink.Results).FinishReason);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 1 });
}
@ -194,7 +197,8 @@ public class MatchGrainTests
var options = ServerTestData.SmallOptions();
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");
time.Advance(TimeSpan.FromSeconds(31));
@ -202,6 +206,7 @@ public class MatchGrainTests
Assert.True(grain.Summary.IsFinished);
Assert.Equal(0, grain.Summary.Winner);
Assert.Equal(MatchFinishReason.Absence, Assert.Single(sink.Results).FinishReason);
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 });
}

View File

@ -26,7 +26,8 @@ public class MatchPersistenceTests
AbsentSince0: time.GetUtcNow(),
AbsentSince1: 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.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)),
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 TrialDeckCatalog TrialDecks => LazyTrialDecks.Value;
/// <summary>Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId.</summary>
public static List<string> BuildDeck()
{

View File

@ -115,6 +115,51 @@ internal sealed class TestGameClient : IAsyncDisposable
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)
where T : ServerFrame
{