Add meta API, match history and replays
This commit is contained in:
parent
083cebf978
commit
4d5d7f0423
|
|
@ -1,2 +1,3 @@
|
|||
VANGUARD_PORT=8080
|
||||
VANGUARD_WS_URL=ws://localhost:8080/game
|
||||
VANGUARD_JWT_KEY=change-me-min-32-characters-long-key
|
||||
|
|
|
|||
|
|
@ -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, друзья/чат.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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` — каталог карт с разбором состава сетов.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
booster:
|
||||
commonSlot:
|
||||
count: 4
|
||||
rarity: C
|
||||
rareSlot:
|
||||
count: 1
|
||||
weights:
|
||||
R: 70
|
||||
RR: 22
|
||||
RRR: 7
|
||||
SP: 1
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
147
docs/HANDOFF.md
147
docs/HANDOFF.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,152 +47,310 @@ 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",
|
||||
"waitedSeconds": 7,
|
||||
"window": 100,
|
||||
"matchId": "9f2c...",
|
||||
"seat": 0,
|
||||
"ticket": "c2VjcmV0...",
|
||||
"opponent": "p2",
|
||||
"reason": null,
|
||||
"wsUrl": "ws://localhost:5080/game"
|
||||
"opponentName": "bob",
|
||||
"result": "win",
|
||||
"gold": 30,
|
||||
"xp": 150,
|
||||
"mmrBefore": 1000,
|
||||
"mmrAfter": 1016,
|
||||
"finishReason": "normal",
|
||||
"playedAt": "2026-10-21T12:30:00+00:00",
|
||||
"replayAvailable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status`:
|
||||
- Запись создаётся только после подтверждённого сервером завершения матча.
|
||||
- Одна запись на игрока и матч; повтор результата не дублируется.
|
||||
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
|
||||
- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает
|
||||
в историю.
|
||||
- MMR меняется в ранкеде; casual MMR остаётся прежним.
|
||||
|
||||
| Значение | Смысл |
|
||||
|---|---|
|
||||
| `queued` | ждёт противника, `window` — текущее окно MMR |
|
||||
| `matched` | матч найден, `ticket` — одноразовый тикет |
|
||||
| `cancelled` | игрок отменил очередь или встал в новую |
|
||||
| `expired` | очередь истекла (10 минут) |
|
||||
| `failed` | не удалось создать матч |
|
||||
|
||||
`wsUrl` заполняется только для `matched`. Он строится из `Game:PublicWsUrl`
|
||||
или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел
|
||||
подключиться, он запрашивает новый через `POST /api/game/tickets` с `matchId`.
|
||||
|
||||
Подбор:
|
||||
|
||||
- две независимые очереди — ranked и casual;
|
||||
- скрытый MMR, стартовое окно ±100;
|
||||
- окно расширяется на 50 каждые 5 секунд, максимум 2000;
|
||||
- пара выбирается по ближайшему MMR в пределах взаимных окон;
|
||||
- кто встал раньше — садится за место 0.
|
||||
|
||||
MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32),
|
||||
в casual — нет.
|
||||
|
||||
### `DELETE /api/game/queue/{queueId}?playerId=p1`
|
||||
|
||||
Отменить очередь. `204` — отменена, `409` — уже не активна.
|
||||
|
||||
---
|
||||
|
||||
## 4. Тикеты
|
||||
|
||||
### `POST /api/game/tickets`
|
||||
|
||||
Выдать новый одноразовый тикет на подключение. Нужен для переподключения
|
||||
после обрыва: старый тикет уже израсходован.
|
||||
|
||||
```json
|
||||
{ "playerId": "p1", "matchId": "9f2c..." }
|
||||
```
|
||||
|
||||
Ответ `200`:
|
||||
Реплей:
|
||||
|
||||
```json
|
||||
{
|
||||
"ticket": "c2VjcmV0...",
|
||||
"matchId": "9f2c...",
|
||||
"seat": 0,
|
||||
"wsUrl": "ws://localhost:5080/game",
|
||||
"expiresAt": "2026-10-21T12:01:00+00:00"
|
||||
"matchId": "…",
|
||||
"mode": "classic-ranked",
|
||||
"player0": "alice",
|
||||
"player1": "bob",
|
||||
"viewerSeat": 0,
|
||||
"winner": 0,
|
||||
"finishReason": "normal",
|
||||
"eventHash": "…",
|
||||
"events": [ { "seq": 1, "kind": "GameStarted", "event": { "firstPlayer": 0 } } ],
|
||||
"frames": [ { "eventSeq": 11, "view": { "…": "MatchView" } } ]
|
||||
}
|
||||
```
|
||||
|
||||
- TTL — 60 секунд, тикет одноразовый.
|
||||
- Тикет привязан к `(playerId, matchId, seat)`.
|
||||
- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться
|
||||
и посмотреть итоговое состояние.
|
||||
- `404` — матч не найден, `403` — игрок не сидит в этом матче.
|
||||
- Реплей восстанавливается из seed, колод и журнала принятых команд.
|
||||
- `events` — события, видимые этому игроку (скрытый поиск не раскрывается).
|
||||
- `frames` — состояние до и после каждой принятой команды; `MatchView`
|
||||
собран под зрителя, `legalIntents` пуст, часы обнулены.
|
||||
- Рука противника и закрытый урон остаются скрытыми даже в реплее.
|
||||
- Смотреть может только участник матча; остальным — `403`.
|
||||
- `includeFrames=false` — только таймлайн событий.
|
||||
|
||||
---
|
||||
|
||||
## 5. Health
|
||||
## 9. Health
|
||||
|
||||
### `GET /health`
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}."));
|
||||
|
||||
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,26 +129,44 @@ 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)
|
||||
{
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue