diff --git a/.env.example b/.env.example index 4b0853f..010cc3b 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VANGUARD_PORT=8080 VANGUARD_WS_URL=ws://localhost:8080/game +VANGUARD_JWT_KEY=change-me-min-32-characters-long-key diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 8cf7b72..e7cb71f 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -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, друзья/чат. --- diff --git a/README.md b/README.md index fe9189e..6a413c7 100644 --- a/README.md +++ b/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` — каталог карт с разбором состава сетов. diff --git a/content/pack_odds.yaml b/content/pack_odds.yaml new file mode 100644 index 0000000..3cc0cb5 --- /dev/null +++ b/content/pack_odds.yaml @@ -0,0 +1,11 @@ +booster: + commonSlot: + count: 4 + rarity: C + rareSlot: + count: 1 + weights: + R: 70 + RR: 22 + RRR: 7 + SP: 1 diff --git a/content/trial-decks.yaml b/content/trial-decks.yaml new file mode 100644 index 0000000..f74f19c --- /dev/null +++ b/content/trial-decks.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 8a115b9..d7089a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2d094e2..64622e2 100644 --- a/docs/HANDOFF.md +++ b/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`, играет полный матч по 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`. diff --git a/docs/server-api.md b/docs/server-api.md index b4dab3e..6d83810 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -1,11 +1,11 @@ # HTTP API сервера -Служебный HTTP-слой матчевого блока: очередь, тикеты на подключение и `/health`. -После подключения клиент переходит на WebSocket и работает по `docs/protocol.md`. +Мета-API и матчевый HTTP-слой: аккаунты, коллекция, колоды, магазин, +очередь, тикеты, история матчей и реплеи. После подключения клиент +переходит на WebSocket и работает по `docs/protocol.md`. -Базовая часть стека: **ASP.NET Core 10**, один процесс. -Матчи и очередь сохраняются в PostgreSQL/Redis, если они настроены; без -внешних сервисов сервер работает in-memory (удобно для тестов). +Базовая часть стека: **ASP.NET Core 10**, JWT, PostgreSQL/Redis (опционально), +один процесс. --- @@ -30,14 +30,16 @@ docker compose up --build # http://localhost:8080/health ``` -Порт и внешний адрес WebSocket задаются в `.env`: +`.env`: ``` VANGUARD_PORT=8080 VANGUARD_WS_URL=ws://localhost:8080/game +VANGUARD_JWT_KEY=change-me-min-32-characters-long-key ``` -`VANGUARD_PORT` нужен, если 8080 занят (на тестовом стенде — 18080). +`VANGUARD_PORT` нужен, если 8080 занят. `VANGUARD_JWT_KEY` обязателен +в production (не короче 32 символов). Для ручной проверки колоду на 50 карт можно получить так: @@ -45,153 +47,311 @@ VANGUARD_WS_URL=ws://localhost:8080/game dotnet run --project server\src\Vanguard.Cli -- deck ``` -Каталог карт ищется в `content/sets` вверх от каталога сборки. Если контент -лежит в другом месте, задайте `Content:Path` (или `Content__Path` в окружении). +Каталог карт ищется в `content/sets` вверх от каталога сборки; триал-деки +и веса бустера — в `content/trial-decks.yaml` и `content/pack_odds.yaml`. --- ## 2. Авторизация -Пока это **dev-заглушка**: игрок представляется строкой `playerId`. -Никаких токенов и паролей нет, поэтому сервер нельзя выставлять в интернет -как есть. +JWT: access — 15 минут, refresh — 30 дней с ротацией. Пароли — BCrypt. -Когда появится мета-API, `playerId` будет браться из JWT, а контракты очереди -и тикетов не изменятся. Ограничения на формат: 1–64 символа, буквы, цифры, -`_`, `.`, `-`. +``` +POST /api/auth/register { "userName": "player", "password": "password123" } +POST /api/auth/login { "userName": "player", "password": "password123" } +POST /api/auth/refresh { "refreshToken": "..." } +POST /api/auth/logout { "refreshToken": "..." } → 204 +GET /api/auth/me Authorization: Bearer +``` + +Ответ register/login/refresh: + +```json +{ + "userId": "…", + "userName": "player", + "accessToken": "eyJ…", + "refreshToken": "…", + "accessExpiresAt": "2026-10-21T12:15:00+00:00", + "refreshExpiresAt": "2026-11-20T12:00:00+00:00", + "onboardingCompleted": false +} +``` + +Правила: + +- имя: 3–32 символа, буквы, цифры, `_`, `.`, `-`; уникально без учёта регистра; +- пароль: 8–128 символов; +- refresh одноразовый: при `/refresh` старый отзывается, выдаётся новый; +- повторное использование старого refresh → `401`; +- `me` возвращает балансы, MMR, XP, статистику и состояние онбординга. + +```json +{ + "userId": "…", + "userName": "player", + "createdAt": "2026-10-21T12:00:00+00:00", + "onboardingCompleted": true, + "selectedTrialDeckId": "td01", + "gold": 30, + "dust": 5, + "tokens": 4, + "xp": 150, + "rankedMmr": 1016, + "casualMmr": 1000, + "matches": 1, + "wins": 1 +} +``` --- -## 3. Очередь +## 3. Онбординг -### `POST /api/game/queue` +``` +POST /api/meta/onboarding { "deckId": "td01" } [Bearer] +``` -Поставить игрока в очередь. +- Выдаёт все 50 карт выбранной триал-деки в коллекцию. +- Начисляет **5 токенов** (1 токен = 1 бустер). +- Выбор делается один раз; повтор → `409`. +- Ответ: `{ "balances": { "gold": 0, "dust": 0, "tokens": 5 }, "deckId": "td01" }`. + +Вторая триал-дека покупается в магазине за Gold. + +--- + +## 4. Коллекция + +``` +GET /api/collection [Bearer] +POST /api/collection/dismantle [Bearer] +POST /api/collection/craft [Bearer] +``` + +`GET`: ```json { - "playerId": "p1", - "mode": "classic-ranked", - "deck": ["BT01/001EN", "BT01/002EN", "..."] + "items": [ + { "printingId": "TD01/001EN", "nameId": "…", "nameEn": "…", "rarity": "R", "count": 4 } + ], + "balances": { "gold": 0, "dust": 5, "tokens": 5 } } ``` -- `mode`: `classic-ranked` или `classic-casual`. -- `deck`: ровно 50 `printingId`. Проверяются те же правила, что и в декбилдере: - 50 карт, ≤4 по `nameId`, ≤4 Sentinel, ≤4 Heal, известные принты и хотя бы - одна карта grade 0 без триггера для стартового Авангарда. +Коллекция считается по `printingId` и не имеет верхнего предела. -Ответ `200`: +Разбор (`dismantle`): ```json -{ - "queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f", - "status": "queued", - "mode": "classic-ranked" -} +{ "printingId": "BT01/050EN", "count": 2, "idempotencyKey": "uuid" } ``` -Ответ `400` при плохой колоде: +| Редкость | Пыль за карту | +|---|---| +| C | 5 | +| R | 10 | +| RR | 25 | +| RRR | 100 | +| SP | нельзя разобрать | + +Крафт (`craft`): + +```json +{ "printingId": "BT01/050EN", "idempotencyKey": "uuid" } +``` + +| Редкость | Стоимость | +|---|---| +| C | 20 | +| R | 40 | +| RR | 100 | +| RRR | 400 | +| SP | нельзя скрафтить | + +Оба эндпоинта возвращают актуальные `Balances`; `idempotencyKey` защищает +от двойного списания/начисления. + +--- + +## 5. Колоды + +``` +GET /api/decks [Bearer] +POST /api/decks [Bearer] { "name": "...", "cards": ["..."] } +GET /api/decks/{id} [Bearer] +PUT /api/decks/{id} [Bearer] { "name": "...", "cards": ["..."] } +DELETE /api/decks/{id} [Bearer] +POST /api/decks/{id}/validate [Bearer] { "cards": ["..."] } // можно без cards — возьмёт сохранённые +POST /api/decks/{id}/share [Bearer] → { "code": "ABCD2345" } +GET /api/decks/share/{code} // публичный шаринг +``` + +Проверки: + +- ровно 50 карт; +- ≤4 копий по `nameId`; +- ≤4 Sentinel и ≤4 Heal; +- известные принты; +- есть grade 0 без триггера для стартового Авангарда; +- **все карты есть в коллекции** (`deck.not-owned`); +- ≤30 колод на пользователя; +- имя колоды уникально у пользователя, 1–40 символов. + +`validate` возвращает `{ "valid": true|false, "violations": [{ "code", "message" }] }`. + +--- + +## 6. Магазин + +``` +GET /api/store [Bearer] +POST /api/store/buy/booster [Bearer] { "payment": "gold"|"token", "idempotencyKey": "uuid" } +POST /api/store/buy/trialdeck [Bearer] { "deckId": "td02", "idempotencyKey": "uuid" } +POST /api/store/redeem/token [Bearer] { "idempotencyKey": "uuid" } +``` + +`GET /api/store`: ```json { - "error": "deck.invalid", - "message": "Колода не проходит проверку.", - "violations": [ - { "code": "deck.size", "message": "В колоде 49 карт, требуется 50." } + "balances": { "gold": 30, "dust": 0, "tokens": 4 }, + "boosterPriceGold": 100, + "trialDecks": [ + { + "id": "td01", + "name": "Blaster Blade", + "clan": "RoyalPaladin", + "cardCount": 50, + "owned": true, + "priceGold": 0, + "cards": [ { "printingId": "TD01/001EN", "count": 4 } ] + } ] } ``` -Повторный запрос от того же `playerId` отменяет его предыдущую очередь -в любом режиме: один игрок — одна активная очередь. +- Бустер: **100 Gold** или **1 токен**. +- `redeem/token` — то же, что бустер за токен. +- Вторая триал-дека: **500 Gold**. +- `idempotencyKey` обязателен для покупок; повтор возвращает тот же + результат и не списывает второй раз. -### `GET /api/game/queue/{queueId}?playerId=p1` - -Статус очереди. Клиент опрашивает, пока не получит `matched`. +Ответ бустера: ```json { - "queueId": "5b5c7a4e-9d5a-4f7b-a0d8-6f1b2c3d4e5f", - "status": "matched", + "cards": [ + { "printingId": "BT01/050EN", "nameId": "…", "rarity": "R", "isNew": true } + ], + "balances": { "gold": 0, "dust": 0, "tokens": 3 } +} +``` + +Состав: **4 карты C + 1 карта R+**. Веса R+: +**R 70 % / RR 22 % / RRR 7 % / SP 1 %** (`content/pack_odds.yaml`). +Duplicate protection: ключ — `printingId`, порог 4; замена внутри редкости, +при насыщении редкости — перевыбор; если насыщены все — дубль. + +--- + +## 7. Матчмейкинг + +``` +POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" } +GET /api/game/queue/{queueId} [Bearer] +DELETE /api/game/queue/{queueId} [Bearer] +POST /api/game/tickets [Bearer] { "matchId": "…" } +``` + +С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку +и валидна на момент постановки). Без JWT работает dev-режим +(`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере +он выключен. + +Ответ очереди — `{ queueId, status: "queued", mode }`. Статус matched +содержит `matchId`, `seat`, `ticket`, `opponent`, `wsUrl`. + +Подбор: две очереди, скрытый MMR, окно ±100 → +50 каждые 5 с до 2000, +ранкед меняет MMR по Эло (K = 32), casual — нет. + +Тикет одноразовый, TTL 60 с, привязан к `(player, matchId, seat)`. + +Dev-формат (если разрешён): `{ "playerId": "p1", "mode": "...", "deck": ["..."] }`. + +--- + +## 8. История матчей и реплеи + +``` +GET /api/matches?limit=20&offset=0 [Bearer] +GET /api/matches/{matchId} [Bearer] +GET /api/replays/{matchId}?includeFrames=true [Bearer] +``` + +История: + +```json +{ + "items": [ + { + "matchId": "…", + "mode": "classic-ranked", + "opponentName": "bob", + "result": "win", + "gold": 30, + "xp": 150, + "mmrBefore": 1000, + "mmrAfter": 1016, + "finishReason": "normal", + "playedAt": "2026-10-21T12:30:00+00:00", + "replayAvailable": true + } + ] +} +``` + +- Запись создаётся только после подтверждённого сервером завершения матча. +- Одна запись на игрока и матч; повтор результата не дублируется. +- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`. +- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает + в историю. +- MMR меняется в ранкеде; casual MMR остаётся прежним. + +Реплей: + +```json +{ + "matchId": "…", "mode": "classic-ranked", - "waitedSeconds": 7, - "window": 100, - "matchId": "9f2c...", - "seat": 0, - "ticket": "c2VjcmV0...", - "opponent": "p2", - "reason": null, - "wsUrl": "ws://localhost:5080/game" + "player0": "alice", + "player1": "bob", + "viewerSeat": 0, + "winner": 0, + "finishReason": "normal", + "eventHash": "…", + "events": [ { "seq": 1, "kind": "GameStarted", "event": { "firstPlayer": 0 } } ], + "frames": [ { "eventSeq": 11, "view": { "…": "MatchView" } } ] } ``` -`status`: - -| Значение | Смысл | -|---|---| -| `queued` | ждёт противника, `window` — текущее окно MMR | -| `matched` | матч найден, `ticket` — одноразовый тикет | -| `cancelled` | игрок отменил очередь или встал в новую | -| `expired` | очередь истекла (10 минут) | -| `failed` | не удалось создать матч | - -`wsUrl` заполняется только для `matched`. Он строится из `Game:PublicWsUrl` -или из текущего запроса. Тикет живёт 60 секунд: если клиент не успел -подключиться, он запрашивает новый через `POST /api/game/tickets` с `matchId`. - -Подбор: - -- две независимые очереди — ranked и casual; -- скрытый MMR, стартовое окно ±100; -- окно расширяется на 50 каждые 5 секунд, максимум 2000; -- пара выбирается по ближайшему MMR в пределах взаимных окон; -- кто встал раньше — садится за место 0. - -MMR наружу не отдаётся. В ранкеде после матча он меняется по Эло (K = 32), -в casual — нет. - -### `DELETE /api/game/queue/{queueId}?playerId=p1` - -Отменить очередь. `204` — отменена, `409` — уже не активна. +- Реплей восстанавливается из seed, колод и журнала принятых команд. +- `events` — события, видимые этому игроку (скрытый поиск не раскрывается). +- `frames` — состояние до и после каждой принятой команды; `MatchView` + собран под зрителя, `legalIntents` пуст, часы обнулены. +- Рука противника и закрытый урон остаются скрытыми даже в реплее. +- Смотреть может только участник матча; остальным — `403`. +- `includeFrames=false` — только таймлайн событий. --- -## 4. Тикеты +## 9. Health -### `POST /api/game/tickets` - -Выдать новый одноразовый тикет на подключение. Нужен для переподключения -после обрыва: старый тикет уже израсходован. - -```json -{ "playerId": "p1", "matchId": "9f2c..." } ``` - -Ответ `200`: - -```json -{ - "ticket": "c2VjcmV0...", - "matchId": "9f2c...", - "seat": 0, - "wsUrl": "ws://localhost:5080/game", - "expiresAt": "2026-10-21T12:01:00+00:00" -} +GET /health ``` -- TTL — 60 секунд, тикет одноразовый. -- Тикет привязан к `(playerId, matchId, seat)`. -- Если матч уже завершён, тикет всё равно выдаётся: клиент может подключиться - и посмотреть итоговое состояние. -- `404` — матч не найден, `403` — игрок не сидит в этом матче. - ---- - -## 5. Health - -### `GET /health` - ```json { "status": "ok", @@ -207,20 +367,20 @@ MMR наружу не отдаётся. В ранкеде после матча --- -## 6. Полный сценарий +## 10. Полный сценарий ``` -POST /api/game/queue → queueId -GET /api/game/queue/{id} → matched + ticket + wsUrl -WS /game → ClientHello(ticket) - ← ServerHello + ServerSnapshot - → ClientIntent(...) - ← ServerIntentResult + ServerEvents -... -обрыв -POST /api/game/tickets → новый ticket -WS /game → ClientHello(ticket, resumeFromSeq) - ← ServerHello + ServerEvents (догон) +POST /api/auth/register → access + refresh +POST /api/meta/onboarding {td01} → 50 карт + 5 токенов +GET /api/collection → 50 карт +POST /api/decks {cards} → колода +POST /api/store/redeem/token → бустер, 4 токена +GET /api/store → витрина +POST /api/game/queue {deckId} → queueId +GET /api/game/queue/{id} → matched + ticket +WS /game + ClientHello(ticket) → матч +GET /api/matches → история, Gold/XP +GET /api/replays/{matchId} → реплей ``` Формат WebSocket-кадров, интентов, событий, таймеров и кодов ошибок — @@ -228,55 +388,40 @@ WS /game → ClientHello(ticket, resumeFromSeq) --- -## 7. Хранилище и восстановление - -### 7.1. Конфигурация +## 11. Хранилище и восстановление | Ключ | Значения | Смысл | |---|---|---| -| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — без сохранения, `Memory` — EF InMemory, `Postgres` — постоянное | +| `Storage:Provider` | `None`, `Memory`, `Postgres` | `None` — матчи без сохранения, мета в памяти; `Memory` — EF InMemory; `Postgres` — постоянное | | `Storage:ConnectionString` | строка Npgsql | обязательно для `Postgres` | | `Redis:Configuration` | `host:port` | пусто — тикеты и очередь в памяти | | `Redis:Prefix` | `vanguard:` | префикс ключей | +| `Jwt:SigningKey` | ≥32 символа | подпись access-токенов | -В контейнере переменные задаются как `Storage__Provider`, -`Storage__ConnectionString`, `Redis__Configuration`. +Одна БД, один `MatchDbContext`: -### 7.2. Матчи в PostgreSQL +- `matches`, `match_intents` — заголовок и журнал матча; +- `users`, `refresh_tokens` — аккаунты и сессии; +- `collection`, `decks`, `transactions`, `booster_openings`, + `trial_deck_ownership` — коллекция, колоды, экономика, покупки; +- `match_history` — история и награды. -- `matches` — заголовок: игроки, колоды, seed, часы, банки, статус, `eventSeq`. -- `match_intents` — журнал: `intentId`, место, команда, результат, код отказа. -- На каждый интент пишется строка журнала и обновляется заголовок. -- После рестарта `MatchRecoveryService` поднимает незавершённые матчи: - создаёт движок с сохранённым seed и проигрывает принятые команды заново. - `GameFinished`, ожидания выбора и продолжения восстанавливаются реплеем, - делегаты никуда не сериализуются. -- `eventSeq` сверяется: если реплей разошёлся со снапшотом, матч пропускается - с ошибкой в логе. -- Банк и дедлайн сохраняются раз в ~5 секунд; авария теряет не больше тика. -- Завершённые матчи остаются историей и при старте в память не поднимаются. +Восстановление матча: при старте `MatchRecoveryService` поднимает +незавершённые матчи и проигрывает принятые команды на детерминированном +движке; `eventSeq` сверяется. Схема создаётся `EnsureCreated`; миграций +пока нет, при изменении схемы тестовую БД нужно пересоздать. -### 7.3. Redis - -- Тикеты: ключ `vanguard:ticket:{token}`, TTL 60 с, изъятие через `GETDEL` - (одноразовость сохраняется между инстансами). -- Очередь: запись `vanguard:queue:entry:{id}` (JSON) и sorted set - `vanguard:queue:{mode}` по номеру постановки. При старте очередь - загружается обратно. -- Состояние профилей (MMR) пока остаётся в памяти: это закроет мета-API - вместе с аккаунтами. +Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set). --- -## 8. Ограничения текущего этапа +## 12. Ограничения текущего этапа -- PostgreSQL/Redis опциональны. По умолчанию (`Storage:Provider=None`) - матчи, тикеты и очередь живут только в памяти. -- Профили и MMR пока не персистентны: после рестарта рейтинг начинается - с 1000. Матчи и очередь при этом восстанавливаются. -- Один процесс: горизонтальное масштабирование и Orleans-хостинг ещё впереди. -- `IMatchGrain` уже отделён от ASP.NET Core и WebSocket: замена на Orleans - grain не затронет транспорт и правила. -- Нет JWT, коллекций и наград — это следующие блоки. -- Rate limit: 16 интентов в секунду с одного соединения. Живой UI в него - не упирается; тестовый бот специально притормаживается. +- `Storage:Provider=None` — всё в памяти, рестарт теряет данные. + История матча пишется, но реплей при `None` недоступен: строки матча нет. + Для истории и реплеев нужен `Memory` или `Postgres`. +- `EnsureCreated`, а не миграции: схема меняется вместе с пересозданием БД. +- Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён. +- Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата. +- Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы. +- Dev-очередь включается только `Game:AllowDevQueue=true` и не для production. diff --git a/server/src/Vanguard.Content/PackOdds.cs b/server/src/Vanguard.Content/PackOdds.cs new file mode 100644 index 0000000..b30145c --- /dev/null +++ b/server/src/Vanguard.Content/PackOdds.cs @@ -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? Weights); + +/// Веса бустера из content/pack_odds.yaml. +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(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(); + + foreach (var (key, value) in entry.Weights) + { + if (!Enum.TryParse(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(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? Weights { get; set; } + } +} diff --git a/server/src/Vanguard.Content/TrialDeckCatalog.cs b/server/src/Vanguard.Content/TrialDeckCatalog.cs new file mode 100644 index 0000000..892b27e --- /dev/null +++ b/server/src/Vanguard.Content/TrialDeckCatalog.cs @@ -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 Cards) +{ + public int TotalCards => Cards.Sum(card => card.Count); + + /// Разворачивает counts в список printingId длиной 50. + public IReadOnlyList ToDeck() => + Cards.SelectMany(card => Enumerable.Repeat(card.PrintingId, card.Count)).ToList(); +} + +/// Две триал-деки из content/trial-decks.yaml. +public sealed class TrialDeckCatalog +{ + private readonly Dictionary _decks; + + private TrialDeckCatalog(IEnumerable decks) + { + _decks = decks.ToDictionary(deck => deck.Id, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList 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(File.ReadAllText(path)) + ?? throw new InvalidOperationException($"Пустой файл триал-дек: {path}"); + + if (file.Decks.Count == 0) + throw new InvalidOperationException("В trial-decks.yaml нет дек."); + + var decks = new List(); + var ids = new HashSet(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 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 Cards { get; set; } = new(); + } + + private sealed class TrialDeckCardEntry + { + public string PrintingId { get; set; } = string.Empty; + public int Count { get; set; } + } +} diff --git a/server/src/Vanguard.Server/Api/ApiContracts.cs b/server/src/Vanguard.Server/Api/ApiContracts.cs index 23bdc99..6f5ac3e 100644 --- a/server/src/Vanguard.Server/Api/ApiContracts.cs +++ b/server/src/Vanguard.Server/Api/ApiContracts.cs @@ -2,7 +2,11 @@ using Vanguard.Rules.Decks; namespace Vanguard.Server.Api; -public sealed record QueueRequest(string? PlayerId, string? Mode, List? Deck); +public sealed record QueueRequest( + string? PlayerId, + string? Mode, + List? Deck, + Guid? DeckId = null); public sealed record QueueAccepted(Guid QueueId, string Status, string Mode); diff --git a/server/src/Vanguard.Server/Api/ApiEndpoints.cs b/server/src/Vanguard.Server/Api/ApiEndpoints.cs index 3928c9c..f7cfb42 100644 --- a/server/src/Vanguard.Server/Api/ApiEndpoints.cs +++ b/server/src/Vanguard.Server/Api/ApiEndpoints.cs @@ -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; /// /// HTTP-часть матчевого блока: очередь, тикеты, здоровье. -/// Авторизация здесь ещё dev-заглушка: игрок называется playerId. -/// JWT из мета-API встанет на это место без изменения контрактов очереди. +/// Если пришёл JWT — игрок и его колода берутся из мета-API. +/// Без JWT работает dev-режим (playerId + готовая колода), если он разрешён. /// public static class ApiEndpoints { @@ -23,20 +26,61 @@ public static class ApiEndpoints private static async Task Enqueue( QueueRequest request, - MatchmakingService matchmaking) + ClaimsPrincipal principal, + MatchmakingService matchmaking, + DeckService decks, + GameOptions options) { - if (!IsValidPlayerId(request.PlayerId)) - return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен: 1–64 символа, буквы, цифры, _ . -")); - if (!GameModes.TryParse(request.Mode, out var mode)) return Results.BadRequest(new ApiError("mode.invalid", $"Режим: {GameModes.Ranked} или {GameModes.Casual}.")); - if (request.Deck is not { Count: > 0 }) - return Results.BadRequest(new ApiError("deck.missing", "Нужен список printingId на 50 карт.")); + string playerId; + IReadOnlyList 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 PollQueue( Guid queueId, string? playerId, + ClaimsPrincipal principal, MatchmakingService matchmaking, HttpRequest http, GameOptions options) { - if (!IsValidPlayerId(playerId)) + var effectivePlayer = principal.GetUserName() ?? playerId; + + if (principal.GetUserName() is null && !options.AllowDevQueue) + return Results.Unauthorized(); + + if (!IsValidPlayerId(effectivePlayer)) return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); - var snapshot = await matchmaking.PollAsync(queueId, playerId!); + var snapshot = await matchmaking.PollAsync(queueId, effectivePlayer!); if (snapshot is null) return Results.NotFound(new ApiError("queue.not-found", "Очередь не найдена.")); @@ -79,25 +129,43 @@ public static class ApiEndpoints private static async Task 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 IssueTicket( TicketRequest request, + ClaimsPrincipal principal, MatchGrainRegistry matches, GameTicketService tickets, HttpRequest http, GameOptions options) { - if (!IsValidPlayerId(request.PlayerId)) - return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); + var playerId = principal.GetUserName(); + + if (playerId is null) + { + if (!options.AllowDevQueue) + return Results.Unauthorized(); + + if (!IsValidPlayerId(request.PlayerId)) + return Results.BadRequest(new ApiError("player.invalid", "playerId обязателен.")); + + playerId = request.PlayerId!; + } if (string.IsNullOrWhiteSpace(request.MatchId)) return Results.BadRequest(new ApiError("match.missing", "matchId обязателен.")); @@ -106,9 +174,9 @@ public static class ApiEndpoints return Results.NotFound(new ApiError("match.not-found", "Матч не найден.")); var summary = match.Summary; - var seat = summary.Player0Id == request.PlayerId + var seat = summary.Player0Id == playerId ? 0 - : summary.Player1Id == request.PlayerId + : summary.Player1Id == playerId ? 1 : -1; @@ -117,7 +185,7 @@ public static class ApiEndpoints new ApiError("match.forbidden", "Этот игрок не участвует в матче."), statusCode: StatusCodes.Status403Forbidden); - var ticket = await tickets.IssueAsync(request.PlayerId!, request.MatchId!, seat); + var ticket = await tickets.IssueAsync(playerId, request.MatchId!, seat); return Results.Ok(new TicketResponse( ticket.Token, diff --git a/server/src/Vanguard.Server/Auth/AuthEndpoints.cs b/server/src/Vanguard.Server/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..6ec34c1 --- /dev/null +++ b/server/src/Vanguard.Server/Auth/AuthEndpoints.cs @@ -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 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 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 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 Logout( + RefreshRequest request, + AuthService auth) + { + await auth.LogoutAsync(request.RefreshToken); + return Results.NoContent(); + } + + private static async Task 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); +} diff --git a/server/src/Vanguard.Server/Auth/AuthService.cs b/server/src/Vanguard.Server/Auth/AuthService.cs new file mode 100644 index 0000000..4954aea --- /dev/null +++ b/server/src/Vanguard.Server/Auth/AuthService.cs @@ -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 _factory; + private readonly TokenService _tokens; + private readonly JwtOptions _jwt; + private readonly TimeProvider _time; + + public AuthService( + IDbContextFactory factory, + TokenService tokens, + JwtOptions jwt, + TimeProvider time) + { + _factory = factory; + _tokens = tokens; + _jwt = jwt; + _time = time; + } + + public async Task 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 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 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 GetUserAsync(Guid userId) + { + await using var db = await _factory.CreateDbContextAsync(); + return await db.Users.AsNoTracking().SingleOrDefaultAsync(user => user.Id == userId); + } + + private async Task 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; } +} diff --git a/server/src/Vanguard.Server/Auth/TokenService.cs b/server/src/Vanguard.Server/Auth/TokenService.cs new file mode 100644 index 0000000..25ff934 --- /dev/null +++ b/server/src/Vanguard.Server/Auth/TokenService.cs @@ -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); + +/// Создание access-JWT и одноразовых refresh-токенов (в БД хранится хэш). +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 + { + 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); +} diff --git a/server/src/Vanguard.Server/Configuration/GameOptions.cs b/server/src/Vanguard.Server/Configuration/GameOptions.cs index 2e175dd..f37e242 100644 --- a/server/src/Vanguard.Server/Configuration/GameOptions.cs +++ b/server/src/Vanguard.Server/Configuration/GameOptions.cs @@ -28,6 +28,9 @@ public sealed class GameOptions public int MmrWindowIntervalSeconds { get; set; } = 5; public int MmrMaxWindow { get; set; } = 2000; + /// Разрешить очередь без JWT по playerId. Только для локальной разработки. + public bool AllowDevQueue { get; set; } = true; + /// Внешний адрес WebSocket. Пусто — строится из запроса. public string PublicWsUrl { get; set; } = string.Empty; } diff --git a/server/src/Vanguard.Server/Configuration/MetaOptions.cs b/server/src/Vanguard.Server/Configuration/MetaOptions.cs new file mode 100644 index 0000000..26a81be --- /dev/null +++ b/server/src/Vanguard.Server/Configuration/MetaOptions.cs @@ -0,0 +1,34 @@ +namespace Vanguard.Server.Configuration; + +/// JWT и сроки жизни токенов. +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; +} + +/// Экономика и мета-ограничения. +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; + + /// Gold = XP / GoldPerXp для матчей (30/15/20 при 5). + public int GoldPerXp { get; set; } = 5; + + /// Начислять ли награды за сдачу/таймер/отсутствие. По правилам — нет. + public bool RewardsForForfeit { get; set; } + + public int InitialMmr { get; set; } = 1000; + public int EloK { get; set; } = 32; +} diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs index 791dff4..2dbe679 100644 --- a/server/src/Vanguard.Server/Matches/IMatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -36,13 +36,23 @@ public sealed record MatchSummary( DateTimeOffset CreatedAt, DateTimeOffset? FinishedAt); -/// Результат для записи в профили. MMR меняется только в ранкеде. +/// Почему матч завершился. От этого зависят награды и вид реплея. +public enum MatchFinishReason +{ + Normal = 0, + Concede = 1, + Timeout = 2, + Absence = 3, +} + +/// Результат для записи в профили и историю. MMR меняется только в ранкеде. 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; } diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs index 682c675..74c5176 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -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); diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs index ad024c7..d65edcf 100644 --- a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs @@ -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, }; diff --git a/server/src/Vanguard.Server/Meta/BoosterService.cs b/server/src/Vanguard.Server/Meta/BoosterService.cs new file mode 100644 index 0000000..322304f --- /dev/null +++ b/server/src/Vanguard.Server/Meta/BoosterService.cs @@ -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 Cards, + Balances Balances); + +public sealed class BoosterService +{ + private readonly IDbContextFactory _factory; + private readonly CardCatalog _catalog; + private readonly PackOdds _odds; + private readonly MetaOptions _options; + private readonly TimeProvider _time; + private readonly Dictionary> _pools; + + public BoosterService( + IDbContextFactory 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 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>(existing.CardsJson) + ?? new List(); + 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 Generate(Dictionary counts) + { + var cards = new List(); + + 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 counts) => + _pools[rarity].Any(printing => counts.GetValueOrDefault(printing.PrintingId) < 4); + + private static (string PrintingId, bool IsNew) Pick( + IReadOnlyList pool, + Dictionary 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 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> BuildPools(CardCatalog catalog) + { + var bt01 = catalog.Printings + .Where(printing => printing.PrintingId.StartsWith("BT01/", StringComparison.Ordinal)) + .ToList(); + + var pools = new Dictionary> + { + [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; + } +} diff --git a/server/src/Vanguard.Server/Meta/CollectionService.cs b/server/src/Vanguard.Server/Meta/CollectionService.cs new file mode 100644 index 0000000..d70e7d7 --- /dev/null +++ b/server/src/Vanguard.Server/Meta/CollectionService.cs @@ -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 _factory; + private readonly CardCatalog _catalog; + private readonly TimeProvider _time; + + public CollectionService( + IDbContextFactory factory, + CardCatalog catalog, + TimeProvider time) + { + _factory = factory; + _catalog = catalog; + _time = time; + } + + public async Task> 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 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 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; } +} diff --git a/server/src/Vanguard.Server/Meta/DeckService.cs b/server/src/Vanguard.Server/Meta/DeckService.cs new file mode 100644 index 0000000..0248de9 --- /dev/null +++ b/server/src/Vanguard.Server/Meta/DeckService.cs @@ -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 Cards, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + string? ShareCode); + +public sealed class DeckService +{ + private const string ShareAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private readonly IDbContextFactory _factory; + private readonly CardCatalog _catalog; + private readonly MetaOptions _options; + private readonly TimeProvider _time; + + public DeckService( + IDbContextFactory factory, + CardCatalog catalog, + MetaOptions options, + TimeProvider time) + { + _factory = factory; + _catalog = catalog; + _options = options; + _time = time; + } + + public async Task> 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 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 CreateAsync( + Guid userId, + string? name, + IReadOnlyList? cards) + { + var cleanName = CleanName(name); + var list = cards?.ToList() ?? new List(); + + 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 UpdateAsync( + Guid userId, + Guid deckId, + string? name, + IReadOnlyList? cards) + { + var cleanName = CleanName(name); + var list = cards?.ToList() ?? new List(); + + 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> ValidateAsync( + Guid userId, + IReadOnlyList 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 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 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 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 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>(deck.CardsJson) ?? new List(); + return new DeckDto( + deck.Id, + deck.Name, + deck.Format, + cards, + deck.CreatedAt, + deck.UpdatedAt, + deck.ShareCode); + } +} + +public sealed class DeckValidationException : Exception +{ + public DeckValidationException(IReadOnlyList violations) + : base("Колода не проходит проверку.") + { + Violations = violations; + } + + public IReadOnlyList Violations { get; } +} diff --git a/server/src/Vanguard.Server/Meta/EconomyService.cs b/server/src/Vanguard.Server/Meta/EconomyService.cs new file mode 100644 index 0000000..9ee41ee --- /dev/null +++ b/server/src/Vanguard.Server/Meta/EconomyService.cs @@ -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); + +/// Валюты считаются по журналу транзакций, а не хранятся балансом. +public sealed class EconomyService +{ + public const string Gold = "gold"; + public const string Dust = "dust"; + public const string Token = "token"; + + private readonly IDbContextFactory _factory; + private readonly TimeProvider _time; + + public EconomyService(IDbContextFactory factory, TimeProvider time) + { + _factory = factory; + _time = time; + } + + public async Task GetBalancesAsync(Guid userId) + { + await using var db = await _factory.CreateDbContextAsync(); + return await GetBalancesAsync(db, userId); + } + + public static async Task 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 GetBalanceAsync( + MatchDbContext db, + Guid userId, + string currency) => + await db.Transactions + .Where(transaction => transaction.UserId == userId && transaction.Currency == currency) + .SumAsync(transaction => transaction.Amount); + + /// + /// Добавить транзакцию. false — такой idempotencyKey уже был, повторно не пишем. + /// + public static async Task 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; + } +} diff --git a/server/src/Vanguard.Server/Meta/MetaEndpoints.cs b/server/src/Vanguard.Server/Meta/MetaEndpoints.cs new file mode 100644 index 0000000..eded36d --- /dev/null +++ b/server/src/Vanguard.Server/Meta/MetaEndpoints.cs @@ -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? 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 Items, + Balances Balances); + +public sealed record StoreResponse( + Balances Balances, + int BoosterPriceGold, + IReadOnlyList TrialDecks); + +public sealed record OnboardingResponse(Balances Balances, string DeckId); +public sealed record ShareResponse(string Code); +public sealed record MatchHistoryResponse(IReadOnlyList 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 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 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 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 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 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 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 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 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 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(); + + var violations = await decks.ValidateAsync(userId, cards); + return Results.Ok(new { valid = violations.Count == 0, violations }); + } + + private static async Task 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 GetSharedDeck( + string code, + DeckService decks) + { + var deck = await decks.GetByShareCodeAsync(code); + return deck is null ? Results.NotFound() : Results.Ok(deck); + } + + private static async Task 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 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 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 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 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 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 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 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 violations) => + Results.BadRequest(new ApiError( + "deck.invalid", + "Колода не проходит проверку.", + violations)); +} diff --git a/server/src/Vanguard.Server/Meta/MetaEntities.cs b/server/src/Vanguard.Server/Meta/MetaEntities.cs new file mode 100644 index 0000000..1a3465c --- /dev/null +++ b/server/src/Vanguard.Server/Meta/MetaEntities.cs @@ -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; } +} diff --git a/server/src/Vanguard.Server/Meta/MetaProfileService.cs b/server/src/Vanguard.Server/Meta/MetaProfileService.cs new file mode 100644 index 0000000..a78b058 --- /dev/null +++ b/server/src/Vanguard.Server/Meta/MetaProfileService.cs @@ -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; + +/// +/// MMR, история матчей и награды. Одна запись на матч и игрока; повторный +/// вызов для того же matchId ничего не меняет. +/// +public sealed class MetaProfileService : IProfileService, IMatchResultSink +{ + private readonly IDbContextFactory _factory; + private readonly MetaOptions _options; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public MetaProfileService( + IDbContextFactory factory, + MetaOptions options, + TimeProvider time, + ILogger logger) + { + _factory = factory; + _options = options; + _time = time; + _logger = logger; + } + + public async Task 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); + } + } +} diff --git a/server/src/Vanguard.Server/Meta/ReplayService.cs b/server/src/Vanguard.Server/Meta/ReplayService.cs new file mode 100644 index 0000000..152a02b --- /dev/null +++ b/server/src/Vanguard.Server/Meta/ReplayService.cs @@ -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 Events, + IReadOnlyList 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); + +/// +/// Восстанавливает реплей из seed, колод и журнала принятых команд. +/// Наружу отдаётся проекция под конкретного игрока: скрытые события +/// и рука противника не раскрываются даже в реплее. +/// +public sealed class ReplayService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly IDbContextFactory _matchFactory; + private readonly CardCatalog _catalog; + private readonly ILogger _logger; + + public ReplayService( + IDbContextFactory matchFactory, + CardCatalog catalog, + ILogger logger) + { + _matchFactory = matchFactory; + _catalog = catalog; + _logger = logger; + } + + public async Task 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(match.Deck0Json) ?? Array.Empty(); + var deck1 = JsonSerializer.Deserialize(match.Deck1Json) ?? Array.Empty(); + 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(); + 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(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()); + } + + private static void AddFrame( + GameEngine engine, + int viewerSeat, + string matchId, + List 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 _metaFactory; + private readonly IDbContextFactory _matchFactory; + + public MatchHistoryService( + IDbContextFactory metaFactory, + IDbContextFactory matchFactory) + { + _metaFactory = metaFactory; + _matchFactory = matchFactory; + } + + public async Task> 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(); + + var replayable = await GetReplayableIdsAsync(rows.Select(row => row.MatchId).ToList()); + + return rows.Select(row => ToDto(row, replayable.Contains(row.MatchId))).ToList(); + } + + public async Task 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 { matchId }); + return ToDto(row, replayable.Contains(matchId)); + } + + private async Task> GetReplayableIdsAsync(IReadOnlyList 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); +} diff --git a/server/src/Vanguard.Server/Meta/TrialDeckService.cs b/server/src/Vanguard.Server/Meta/TrialDeckService.cs new file mode 100644 index 0000000..ba085f9 --- /dev/null +++ b/server/src/Vanguard.Server/Meta/TrialDeckService.cs @@ -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 Cards); + +public sealed class TrialDeckService +{ + private readonly IDbContextFactory _factory; + private readonly TrialDeckCatalog _decks; + private readonly MetaOptions _options; + private readonly TimeProvider _time; + + public TrialDeckService( + IDbContextFactory factory, + TrialDeckCatalog decks, + MetaOptions options, + TimeProvider time) + { + _factory = factory; + _decks = decks; + _options = options; + _time = time; + } + + public IReadOnlyList Decks => _decks.Decks; + + public async Task> 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 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 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); + } +} diff --git a/server/src/Vanguard.Server/Profiles/IProfileService.cs b/server/src/Vanguard.Server/Profiles/IProfileService.cs new file mode 100644 index 0000000..faa2e03 --- /dev/null +++ b/server/src/Vanguard.Server/Profiles/IProfileService.cs @@ -0,0 +1,9 @@ +using Vanguard.Server.Configuration; + +namespace Vanguard.Server.Profiles; + +/// Скрытый MMR по режимам. Нужен матчмейкингу. +public interface IProfileService +{ + Task GetMmrAsync(string playerId, GameMode mode); +} diff --git a/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs b/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs index d2c87c2..2a92fb2 100644 --- a/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs +++ b/server/src/Vanguard.Server/Profiles/PlayerProfileStore.cs @@ -5,13 +5,10 @@ using Vanguard.Server.Matches; namespace Vanguard.Server.Profiles; /// -/// Скрытый MMR и простая статистика. Наружу MMR не отдаётся: -/// клиент видит только результат матча. -/// -/// Хранилище в памяти — до появления PostgreSQL. Контракт уже отделён, -/// чтобы заменить реализацию, не трогая матч. +/// In-memory профиль для тестов и dev-режима. В production используется +/// MetaProfileService поверх общей БД. /// -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 _profiles = new(StringComparer.Ordinal); + public Task 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) diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs index 41185e0..07d6312 100644 --- a/server/src/Vanguard.Server/Program.cs +++ b/server/src/Vanguard.Server/Program.cs @@ -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(builder.Configuration.GetSection("Game") builder.Services.Configure(builder.Configuration.GetSection("Content")); builder.Services.Configure(builder.Configuration.GetSection("Storage")); builder.Services.Configure(builder.Configuration.GetSection("Redis")); +builder.Services.Configure(builder.Configuration.GetSection("Meta")); +builder.Services.Configure(builder.Configuration.GetSection("Jwt")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +builder.Services.AddSingleton(sp => sp.GetRequiredService>().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() ?? new ContentOptions(); +var setsDirectory = ContentPaths.Resolve(contentOptions.Path); +var contentDirectory = Path.GetDirectoryName(setsDirectory)!; + builder.Services.AddSingleton(sp => { var content = sp.GetRequiredService(); - 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() ?? 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(options => - options.UseInMemoryDatabase("vanguard-matches")); + options.UseInMemoryDatabase("vanguard")); builder.Services.AddSingleton(); } else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase)) { + // мета-API всё равно работает в памяти, а матчи — без сохранения + builder.Services.AddDbContextFactory(options => + options.UseInMemoryDatabase("vanguard-dev")); builder.Services.AddSingleton(); } else @@ -79,24 +106,99 @@ else builder.Services.AddSingleton(); } +var jwt = builder.Configuration.GetSection("Jwt").Get() ?? 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(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(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); var app = builder.Build(); -// каталог грузим сразу: сервер без карт бесполезен, лучше упасть на старте -app.Services.GetRequiredService(); +var catalog = app.Services.GetRequiredService(); -if (app.Services.GetRequiredService() is EfMatchStore) +foreach (var deck in app.Services.GetRequiredService().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>(); +await using (var db = await dbFactory.CreateDbContextAsync()) { - var factory = app.Services.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); await db.Database.EnsureCreatedAsync(); } @@ -104,11 +206,18 @@ if (app.Services.GetRequiredService() is EfMatchStore) await app.Services.GetRequiredService().RecoverAsync(); await app.Services.GetRequiredService().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); diff --git a/server/src/Vanguard.Server/Storage/EfMatchStore.cs b/server/src/Vanguard.Server/Storage/EfMatchStore.cs index 6887ca5..6c3a63c 100644 --- a/server/src/Vanguard.Server/Storage/EfMatchStore.cs +++ b/server/src/Vanguard.Server/Storage/EfMatchStore.cs @@ -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(entity.FinishReason, out var reason) + ? reason + : MatchFinishReason.Normal, entity.EventSeq); var persisted = intents diff --git a/server/src/Vanguard.Server/Storage/MatchDbContext.cs b/server/src/Vanguard.Server/Storage/MatchDbContext.cs index ffd5b45..358b429 100644 --- a/server/src/Vanguard.Server/Storage/MatchDbContext.cs +++ b/server/src/Vanguard.Server/Storage/MatchDbContext.cs @@ -1,7 +1,12 @@ using Microsoft.EntityFrameworkCore; +using Vanguard.Server.Meta; namespace Vanguard.Server.Storage; +/// +/// Единый контекст игры и мета-данных: матчи, пользователи, коллекция, +/// колоды, экономика, история. Одна БД — один EnsureCreated. +/// public sealed class MatchDbContext : DbContext { public MatchDbContext(DbContextOptions options) @@ -12,7 +17,22 @@ public sealed class MatchDbContext : DbContext public DbSet Matches => Set(); public DbSet Intents => Set(); + public DbSet Users => Set(); + public DbSet RefreshTokens => Set(); + public DbSet Collection => Set(); + public DbSet Decks => Set(); + public DbSet Transactions => Set(); + public DbSet BoosterOpenings => Set(); + public DbSet TrialDeckOwnerships => Set(); + public DbSet MatchHistory => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureMatches(modelBuilder); + ConfigureMeta(modelBuilder); + } + + private static void ConfigureMatches(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { @@ -39,6 +59,98 @@ public sealed class MatchDbContext : DbContext entity.HasIndex(e => e.MatchId); }); } + + private static void ConfigureMeta(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(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(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(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(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(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(entity => + { + entity.ToTable("trial_deck_ownership"); + entity.HasKey(ownership => new { ownership.UserId, ownership.DeckId }); + entity.Property(ownership => ownership.DeckId).HasMaxLength(32); + }); + + modelBuilder.Entity(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; } } diff --git a/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs b/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs index bcfa4b4..7705ddc 100644 --- a/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs +++ b/server/src/Vanguard.Server/Storage/MatchStoreContracts.cs @@ -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); /// Запись журнала интентов. Принятые команды воспроизводятся при восстановлении. diff --git a/server/src/Vanguard.Server/Vanguard.Server.csproj b/server/src/Vanguard.Server/Vanguard.Server.csproj index 0a3a0c5..9efb0fa 100644 --- a/server/src/Vanguard.Server/Vanguard.Server.csproj +++ b/server/src/Vanguard.Server/Vanguard.Server.csproj @@ -14,6 +14,8 @@ + + diff --git a/server/src/Vanguard.Server/appsettings.json b/server/src/Vanguard.Server/appsettings.json index 0c93590..cf93612 100644 --- a/server/src/Vanguard.Server/appsettings.json +++ b/server/src/Vanguard.Server/appsettings.json @@ -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 } } diff --git a/server/src/Vanguard.Shared/Projection/MatchProjection.cs b/server/src/Vanguard.Shared/Projection/MatchProjection.cs index 3db61d0..f8733b2 100644 --- a/server/src/Vanguard.Shared/Projection/MatchProjection.cs +++ b/server/src/Vanguard.Shared/Projection/MatchProjection.cs @@ -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(), }; } diff --git a/server/tests/Vanguard.Server.Tests/AuthTests.cs b/server/tests/Vanguard.Server.Tests/AuthTests.cs new file mode 100644 index 0000000..3e92abb --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/AuthTests.cs @@ -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(); + 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("/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(Json))!; + + var refreshResponse = await http.PostAsJsonAsync( + "/api/auth/refresh", + new { refreshToken = login.RefreshToken }, + Json); + refreshResponse.EnsureSuccessStatusCode(); + var refreshed = (await refreshResponse.Content.ReadFromJsonAsync(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(); + 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 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(Json))!; + } +} diff --git a/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs index 7f2004b..0de6d1c 100644 --- a/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs +++ b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs @@ -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( diff --git a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs index db234f6..45c8bc6 100644 --- a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs +++ b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs @@ -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); - } - } } diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs index 0961e45..7bc34eb 100644 --- a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -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().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().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 0 }); } diff --git a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs index e1e18fd..b8fef6b 100644 --- a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs @@ -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( diff --git a/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs b/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs new file mode 100644 index 0000000..3bed96b --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs @@ -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() + .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( + aliceHttp, "/api/meta/onboarding", new { deckId = "td01" }); + var bobOnboarding = await PostAsync( + 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(aliceHttp, "/api/auth/me"); + Assert.True(aliceMe.OnboardingCompleted); + Assert.Equal("td01", aliceMe.SelectedTrialDeckId); + Assert.Equal(5, aliceMe.Tokens); + + var collection = await GetAsync(aliceHttp, "/api/collection"); + Assert.Equal(50, collection.Items.Sum(item => item.Count)); + + var store = await GetAsync(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( + 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( + 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>(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(aliceHttp, "/api/auth/me"); + Assert.Equal(aliceRow.Gold, aliceAfter.Gold); + Assert.Equal(aliceRow.Xp, aliceAfter.Xp); + + var replay = await GetAsync( + 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( + 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 factory, + AuthResponse auth) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", auth.AccessToken); + return client; + } + + private static async Task 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(Json))!; + } + + private static async Task CreateDeckAsync( + HttpClient http, + string name, + List 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(Json))!; + } + + private static async Task 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(Json))!; + } + + private static async Task WaitForMatchAsync( + HttpClient http, + Guid queueId) + { + for (var attempt = 0; attempt < 200; attempt++) + { + var status = await http.GetFromJsonAsync( + $"/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 WaitForHistoryAsync(HttpClient http) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var history = await http.GetFromJsonAsync("/api/matches", Json); + if (history is { Items.Count: > 0 }) + return history; + + await Task.Delay(50); + } + + throw new XunitException("История матча не появилась."); + } + + private static async Task GetAsync(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(Json))!; + } + + private static async Task PostAsync(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(Json))!; + } +} diff --git a/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs b/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs new file mode 100644 index 0000000..9dac306 --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs @@ -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.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 + { + private readonly DbContextOptions _options; + + public TestFactory() + { + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase("meta-profile-" + Guid.NewGuid().ToString("N")) + .Options; + } + + public MatchDbContext CreateDbContext() => new(_options); + } +} diff --git a/server/tests/Vanguard.Server.Tests/ServerTestData.cs b/server/tests/Vanguard.Server.Tests/ServerTestData.cs index 31246bc..570afa3 100644 --- a/server/tests/Vanguard.Server.Tests/ServerTestData.cs +++ b/server/tests/Vanguard.Server.Tests/ServerTestData.cs @@ -10,8 +10,15 @@ internal static class ServerTestData () => CatalogLoader.LoadDirectory(ContentPaths.Resolve(null)), isThreadSafe: true); + private static readonly Lazy 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; + /// Колода без Sentinel и Heal, ровно 50 карт, до 4 копий на nameId. public static List BuildDeck() { diff --git a/server/tests/Vanguard.Server.Tests/TestGameClient.cs b/server/tests/Vanguard.Server.Tests/TestGameClient.cs index 40f3822..6ab6ea7 100644 --- a/server/tests/Vanguard.Server.Tests/TestGameClient.cs +++ b/server/tests/Vanguard.Server.Tests/TestGameClient.cs @@ -115,6 +115,51 @@ internal sealed class TestGameClient : IAsyncDisposable LastSeq = snapshot.View.EventSeq; } + /// Случайный бот: играет легальными интентами до конца партии. + 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 ReadUntilAsync(CancellationToken cancellationToken) where T : ServerFrame {