From e0deecacd5b43ae3e3bb9c0cb5abbc15833b0114 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 19:34:46 +0500 Subject: [PATCH] Add practice matches against server bot --- IMPLEMENTATION_CHECKLIST.md | 2 + README.md | 2 +- .../Vanguard/Runtime/Api/ApiContracts.cs | 14 ++ .../Runtime/App/DeckBuilderSceneController.cs | 3 +- .../Vanguard/Runtime/Meta/MetaService.cs | 5 + .../Vanguard/Runtime/UI/DeckBuilderView.cs | 50 ++++- .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 2 +- client/README.md | 2 + docs/HANDOFF.md | 24 ++- docs/SESSION-STATE.md | 7 +- docs/server-api.md | 17 ++ .../src/Vanguard.Server/Api/ApiContracts.cs | 9 + .../src/Vanguard.Server/Api/ApiEndpoints.cs | 77 +++++++- .../Matches/BotMatchService.cs | 172 ++++++++++++++++++ server/src/Vanguard.Server/Program.cs | 1 + .../PracticeBotE2ETests.cs | 80 ++++++++ 16 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 server/src/Vanguard.Server/Matches/BotMatchService.cs create mode 100644 server/tests/Vanguard.Server.Tests/PracticeBotE2ETests.cs diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 78992e6..cb84aca 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -255,6 +255,8 @@ - [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая триал-дека; анимация открытия — дальше. - [x] История матчей и реплей: список результатов, события и кадры. +- [x] Тренировка с ботом: серверный casual-матч, случайный легальный бот, + без истории/MMR/наград. - [ ] Локализация RU/EN, переключение на лету (пакет подключён). - [ ] Настройки: язык, звук, FPS, мьют. diff --git a/README.md b/README.md index 12c7df4..d5d7b22 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | -| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, gray-box бой | +| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, gray-box бой | | Лаунчер | не начато | ## Требования diff --git a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs index ce8e5b1..bce4b37 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs @@ -106,6 +106,20 @@ namespace Vanguard.Api.Contracts [JsonProperty("expiresAt")] public DateTimeOffset ExpiresAt { get; set; } } + public sealed class PracticeRequest + { + [JsonProperty("deckId")] public Guid DeckId { get; set; } + } + + public sealed class PracticeStartResponse + { + [JsonProperty("matchId")] public string MatchId { get; set; } = string.Empty; + [JsonProperty("seat")] public int Seat { get; set; } + [JsonProperty("ticket")] public string Ticket { get; set; } = string.Empty; + [JsonProperty("wsUrl")] public string WsUrl { get; set; } = string.Empty; + [JsonProperty("opponent")] public string Opponent { get; set; } = string.Empty; + } + public sealed class Balances { [JsonProperty("gold")] public int Gold { get; set; } diff --git a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs index 66494f2..3dd2f2c 100644 --- a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs +++ b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs @@ -19,7 +19,8 @@ namespace Vanguard.App Resolve(), Resolve(), () => _ = Flow.LoadLobbyAsync(), - deck => _ = Flow.OpenLobbyWithQueueAsync(deck)); + deck => _ = Flow.OpenLobbyWithQueueAsync(deck), + info => _ = Flow.LoadBattleAsync(info)); _view.Show(); } diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs index 41b884d..a834ce1 100644 --- a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs @@ -108,6 +108,11 @@ namespace Vanguard.Meta await _api.GetAsync( "/api/game/queue/" + queueId.ToString("D")); + public async Task StartPracticeAsync(Guid deckId) => + await _api.PostAsync( + "/api/game/practice", + new PracticeRequest { DeckId = deckId }); + public Task CancelQueueAsync(Guid queueId) => _api.DeleteAsync("/api/game/queue/" + queueId.ToString("D")); diff --git a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs index c2ab602..f77812c 100644 --- a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs +++ b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs @@ -8,6 +8,7 @@ using UnityEngine.UI; using Vanguard.Api; using Vanguard.Api.Contracts; using Vanguard.Auth; +using Vanguard.Battle; using Vanguard.Meta; namespace Vanguard.UI @@ -22,6 +23,7 @@ namespace Vanguard.UI private readonly AuthService _auth; private readonly Action _onBack; private readonly Action _onPlay; + private readonly Action _onPracticeStarted; private readonly RectTransform _root; private readonly RectTransform _listView; @@ -78,12 +80,14 @@ namespace Vanguard.UI MetaService meta, AuthService auth, Action onBack, - Action onPlay) + Action onPlay, + Action onPracticeStarted) { _meta = meta; _auth = auth; _onBack = onBack; _onPlay = onPlay; + _onPracticeStarted = onPracticeStarted; _root = UiKit.CreatePanel(parent, "DeckBuilderScreen", new Color(0.018f, 0.03f, 0.06f, 0.97f)); _listView = BuildListView(); @@ -226,6 +230,9 @@ namespace Vanguard.UI var save = UiKit.CreateButton(top, "Save", "Сохранить", SaveDeck, 17); SetFixedSize(save, 120f, 44f); + var bot = UiKit.CreateButton(top, "Bot", "С ботом", PlayBot, 17); + SetFixedSize(bot, 100f, 44f); + var duel = UiKit.CreateButton(top, "Duel", "В бой", PlayDeck, 17); SetFixedSize(duel, 90f, 44f); @@ -1158,6 +1165,47 @@ namespace Vanguard.UI _onPlay(_editing); } + private async void PlayBot() + { + if (_busy) + return; + + if (_editing == null) + { + SetEditorStatus("Сначала сохраните колоду.", true); + return; + } + + _busy = true; + SetEditorStatus("Готовим тренировочный матч…", false); + try + { + await _auth.EnsureAccessAsync(); + var response = await _meta.StartPracticeAsync(_editing.Id); + _onPracticeStarted(new MatchConnectionInfo + { + MatchId = response.MatchId, + Seat = response.Seat, + Ticket = response.Ticket, + WsUrl = response.WsUrl, + DeckName = _editing.Name, + }); + } + catch (ApiException exception) + { + SetEditorStatus(exception.Message, true); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetEditorStatus("Не удалось начать матч с ботом.", true); + } + finally + { + _busy = false; + } + } + private void RequestDeleteCurrent() { if (_editing == null) diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 6512b1f..27d3d29 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -37,7 +37,7 @@ namespace Vanguard.Tests var onboarding = new OnboardingView( canvas.transform, auth, meta, () => { }, () => { }); var decks = new DeckBuilderView( - canvas.transform, meta, auth, () => { }, _ => { }); + canvas.transform, meta, auth, () => { }, _ => { }, _ => { }); var queue = new QueueView( canvas.transform, meta, auth, () => { }, _ => { }); var store = new StoreView( diff --git a/client/README.md b/client/README.md index b305ad2..9c53c67 100644 --- a/client/README.md +++ b/client/README.md @@ -58,6 +58,8 @@ http://localhost:5080 - История матчей: список результатов, награды, MMR; просмотр реплея по кадрам и событиям. - Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой. +- Тренировка с ботом: casual-матч против серверного случайного бота, + без истории/MMR/наград. - `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, интенты, ответы на ping, закрытие и переподключение. - Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 42ec52a..2e46f63 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **175/175** (131 правила/контракты + 44 сервер) | +| Тесты сервера | **176/176** (131 правила/контракты + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -73,7 +73,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 ``` Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) -Пройден! : не пройдено 0, пройдено 44 (Vanguard.Server.Tests) +Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 Партий: 1000, завершено: 1000, ошибок: 0 @@ -285,7 +285,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. `translations check` на push/PR. Actions включены для репозитория, `home-runner` выполняет job (проверено: все шаги зелёные). `setup-dotnet` указан явным URL с GitHub: в зеркале `code.forgejo.org` его нет. -- **Тесты серверного ядра** (35 из 44 в `Vanguard.Server.Tests`): +- **Тесты серверного ядра** (36 из 45 в `Vanguard.Server.Tests`): - тикеты: одноразовость и TTL; - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; - grain: attach, чужое место, duplicate intent, чужой ход, сдача, @@ -305,7 +305,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. Orleans сериализуется (тестовый `TestCluster`); полный матч двух клиентов через HTTP/WebSocket при `Orleans:Enabled=true`. -Проверено на `ssh home`: 131 + 44 теста, включая реальные PostgreSQL и Redis; +Проверено на `ssh home`: 131 + 45 теста, включая реальные PostgreSQL и Redis; `docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`). @@ -349,9 +349,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. 11 стартовых событий и кадр, рука противника пуста. После рестарта сервера матч восстановлен (`matches: 1`). -Счётчик `Vanguard.Server.Tests` вырос до 44: добавлены auth, мета-профиль, +Счётчик `Vanguard.Server.Tests` вырос до 45: добавлены auth, мета-профиль, мета-E2E (онбординг → бустер → колода → матч → история → реплей), -проверки реплея, явного выхода и истории/реплея после выхода. +проверки реплея, явного выхода и истории/реплея после выхода, +матч против бота. --- @@ -391,7 +392,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. /Profiles/ интерфейс MMR /Dockerfile /server/tests/Vanguard.Rules.Tests 131 тест -/server/tests/Vanguard.Server.Tests 44 теста, включая E2E и внешние хранилища +/server/tests/Vanguard.Server.Tests 45 тестов, включая E2E и внешние хранилища /content/sets карты /content/trial-decks.yaml две триал-деки /content/pack_odds.yaml веса бустера @@ -728,8 +729,15 @@ Battle; Boot и общие сервисы остаются персистент нельзя). - `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`, `critical`, `type`, `textEn/textRu` и флаги способностей. -- Проверки: серверные 131 + 44 на `ssh home`; Unity EditMode 2/2, +- Проверки: серверные 131 + 45 на `ssh home`; Unity EditMode 2/2, `SCENE_SMOKE success=True active=Login`; live `GET /api/cards` — 124 принта; live UI-проба: загрузка 50 карт, создание колоды, добавление/удаление, ПКМ-оверлей, текст способности и разбор карты; live API-проба крафта: разбор 4 C → 20 пыли → крафт C. +- `POST /api/game/practice` и `BotMatchService`: серверный случайный бот + подключается к своему месту через in-process snapshot-соединение, + выбирает легальный интент и играет casual-матч. Практика не пишет + историю, не меняет MMR и не выдаёт награды. +- Клиент: кнопка «С ботом» в редакторе колоды; `MetaService.StartPracticeAsync`, + переход в `Battle`. Серверный E2E-тест `PracticeBotE2ETests` проходит + полный матч против бота. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a7573a4..d3af836 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -18,7 +18,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **131** правил + **44** серверных, CI зелёный. +- Тесты: **131** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Документация: `docs/server-api.md`, `docs/protocol.md`, `docs/HANDOFF.md`. @@ -52,6 +52,9 @@ Изображения карт пока заглушки-плашки по цвету клана. - ПКМ по карте — оверлей с картой, способностями, разбором в пыль и крафтом; SP/TD не разбираются и не крафтятся. +- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер + создаёт casual-матч против случайного бота (`POST /api/game/practice`), + без истории, MMR и наград. - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). - Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween. @@ -138,6 +141,8 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack 4. Локализация RU/EN, настройки, DOTween. 5. Лаунчер WPF. +Тренировка с ботом уже есть — следующий блок можно начинать с боевого UI. + ## Подводные камни - Не запускать `dotnet`/Docker на рабочем ПК; сервер проверяется на `ssh home`. diff --git a/docs/server-api.md b/docs/server-api.md index 8d73cf2..5c1d108 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -302,9 +302,26 @@ Duplicate protection: ключ — `printingId`, порог 4; замена вн POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" } GET /api/game/queue/{queueId} [Bearer] DELETE /api/game/queue/{queueId} [Bearer] +POST /api/game/practice [Bearer] { "deckId": "…" } POST /api/game/tickets [Bearer] { "matchId": "…" } ``` +`POST /api/game/practice` сразу создаёт casual-матч против серверного +случайного бота и возвращает ticket: + +```json +{ + "matchId": "…", + "seat": 0, + "ticket": "…", + "wsUrl": "ws://…/game", + "opponent": "Bot" +} +``` + +Бот играет легальными интентами из своего snapshot. Практика не пишет +историю, не меняет MMR и не выдаёт награды. + С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку и валидна на момент постановки). Без JWT работает dev-режим (`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере diff --git a/server/src/Vanguard.Server/Api/ApiContracts.cs b/server/src/Vanguard.Server/Api/ApiContracts.cs index 6f5ac3e..5d0bd01 100644 --- a/server/src/Vanguard.Server/Api/ApiContracts.cs +++ b/server/src/Vanguard.Server/Api/ApiContracts.cs @@ -32,6 +32,15 @@ public sealed record TicketResponse( string WsUrl, DateTimeOffset ExpiresAt); +public sealed record PracticeRequest(Guid DeckId); + +public sealed record PracticeStartResponse( + string MatchId, + int Seat, + string Ticket, + string WsUrl, + string Opponent); + public sealed record ApiError( string Error, string Message, diff --git a/server/src/Vanguard.Server/Api/ApiEndpoints.cs b/server/src/Vanguard.Server/Api/ApiEndpoints.cs index f7cfb42..ceaff8e 100644 --- a/server/src/Vanguard.Server/Api/ApiEndpoints.cs +++ b/server/src/Vanguard.Server/Api/ApiEndpoints.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Vanguard.Content; using Vanguard.Server.Auth; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; @@ -20,6 +21,9 @@ public static class ApiEndpoints app.MapPost("/api/game/queue", Enqueue); app.MapGet("/api/game/queue/{queueId:guid}", PollQueue); app.MapDelete("/api/game/queue/{queueId:guid}", CancelQueue); + app.MapPost("/api/game/practice", StartPractice) + .RequireAuthorization() + .RequireRateLimiting("mutating"); app.MapPost("/api/game/tickets", IssueTicket); app.MapGet("/health", Health); } @@ -92,8 +96,77 @@ public static class ApiEndpoints } } - private static async Task PollQueue( - Guid queueId, + private static async Task StartPractice( + PracticeRequest request, + ClaimsPrincipal principal, + DeckService decks, + MatchGrainRegistry matches, + TrialDeckCatalog trialDecks, + GameTicketService tickets, + BotMatchService bots, + GameOptions options, + HttpRequest http) + { + var userName = principal.GetUserName(); + if (userName is null) + return Results.Unauthorized(); + + if (principal.GetUserId() is not { } userId) + return Results.Unauthorized(); + + if (request.DeckId == Guid.Empty) + return Results.BadRequest(new ApiError("deck.missing", "Нужен deckId.")); + + if (trialDecks.Decks.Count == 0) + return Results.Problem("Нет триал-дек для бота."); + + string[] cards; + try + { + var (_, deckCards) = await decks.GetForQueueAsync(userId, request.DeckId); + var violations = await decks.ValidateAsync(userId, deckCards); + if (violations.Count > 0) + return Results.BadRequest(new ApiError("deck.invalid", "Колода не проходит проверку.", violations)); + + cards = deckCards.ToArray(); + } + catch (MetaException exception) + { + return Results.Json( + new ApiError(exception.Code, exception.Message), + statusCode: exception.StatusCode); + } + + var matchId = Guid.NewGuid().ToString("N"); + var botDeck = trialDecks.Decks[0].ToDeck(); + + try + { + var match = await matches.CreateAsync(new MatchSetup( + matchId, + GameMode.ClassicCasual, + new MatchSeatSetup(userName, cards, 1000), + new MatchSeatSetup(BotMatchService.BotPlayerId, botDeck, 1000))); + + await bots.StartAsync(match); + var ticket = await tickets.IssueAsync(userName, matchId, 0); + + return Results.Ok(new PracticeStartResponse( + matchId, + 0, + ticket.Token, + BuildWsUrl(http, options), + "Bot")); + } + catch (Exception) + { + bots.Stop(matchId); + matches.Remove(matchId); + throw; + } + } + + private static async Task PollQueue( Guid queueId, string? playerId, ClaimsPrincipal principal, MatchmakingService matchmaking, diff --git a/server/src/Vanguard.Server/Matches/BotMatchService.cs b/server/src/Vanguard.Server/Matches/BotMatchService.cs new file mode 100644 index 0000000..06093a9 --- /dev/null +++ b/server/src/Vanguard.Server/Matches/BotMatchService.cs @@ -0,0 +1,172 @@ +using System.Collections.Concurrent; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Server.Matches; + +/// +/// Простой серверный бот для тренировочных матчей. Он подключается к +/// своему месту через in-process соединение, получает полный снимок +/// и раз в несколько сотен миллисекунд выбирает случайный легальный интент. +/// Это специально «глупый» бот: цель — дать клиенту живого соперника для +/// проверки UI и сетевого потока, а не сильного AI. +/// +public sealed class BotMatchService : IDisposable +{ + public const string BotPlayerId = "Bot"; + + private readonly IMatchConnectionRegistry _connections; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + public BotMatchService( + IMatchConnectionRegistry connections, + ILogger logger) + { + _connections = connections; + _logger = logger; + } + + public async Task StartAsync(IMatchGrain match) + { + var connection = new BotConnection(); + _connections.Register(match.MatchId, 1, connection); + + if (!await match.AttachAsync(1, BotPlayerId, 0)) + { + _connections.Unregister(match.MatchId, 1, connection); + throw new InvalidOperationException( + $"Не удалось подключить бота к матчу {match.MatchId}."); + } + + var session = new Session(match, connection); + if (!_sessions.TryAdd(match.MatchId, session)) + { + session.Cancel(); + _connections.Unregister(match.MatchId, 1, connection); + throw new InvalidOperationException($"Бот для матча {match.MatchId} уже запущен."); + } + + _ = RunAsync(session); + } + + public void Stop(string matchId) + { + if (_sessions.TryRemove(matchId, out var session)) + session.Cancel(); + } + + private async Task RunAsync(Session session) + { + var cancellationToken = session.Token; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (session.Match.Summary.IsFinished) + break; + + session.Connection.ClearSnapshot(); + await session.Match.ResyncAsync(1, 0, true); + + var snapshot = session.Connection.Snapshot; + if (snapshot?.View is { IsFinished: false } view + && view.ActivePlayer == 1) + { + var choices = view.LegalIntents + .Where(intent => intent is not ConcedeIntent) + .ToList(); + + if (choices.Count > 0) + { + var intent = choices[Random.Shared.Next(choices.Count)]; + await session.Match.SubmitAsync(1, new ClientIntent + { + IntentId = "bot-" + Guid.NewGuid().ToString("N"), + BasedOnSeq = view.EventSeq, + Intent = intent, + }); + + await Task.Delay(150, cancellationToken); + continue; + } + } + + await Task.Delay(400, cancellationToken); + } + } + catch (OperationCanceledException) + { + // штатная остановка + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Bot match {MatchId}: loop stopped.", session.Match.MatchId); + } + finally + { + _connections.Unregister(session.Match.MatchId, 1, session.Connection); + _sessions.TryRemove(session.Match.MatchId, out _); + session.Cancel(); + session.Dispose(); + } + } + + public void Dispose() + { + foreach (var session in _sessions.Values) + session.Cancel(); + + _sessions.Clear(); + } + + private sealed class Session : IDisposable + { + private readonly CancellationTokenSource _cts = new(); + + public Session(IMatchGrain match, BotConnection connection) + { + Match = match; + Connection = connection; + } + + public IMatchGrain Match { get; } + public BotConnection Connection { get; } + public CancellationToken Token => _cts.Token; + + public void Cancel() + { + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // уже завершён + } + } + + public void Dispose() => _cts.Dispose(); + } + + private sealed class BotConnection : IMatchConnection + { + private ServerSnapshot? _snapshot; + + public string Id { get; } = "bot-" + Guid.NewGuid().ToString("N"); + + public ServerSnapshot? Snapshot => Volatile.Read(ref _snapshot); + + public Task SendAsync(ServerFrame frame, CancellationToken cancellationToken = default) + { + if (frame is ServerSnapshot snapshot) + Volatile.Write(ref _snapshot, snapshot); + + return Task.CompletedTask; + } + + public Task CloseAsync(int code, string reason) => Task.CompletedTask; + + public void ClearSnapshot() => Volatile.Write(ref _snapshot, null); + } +} diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs index 410f539..b55c41b 100644 --- a/server/src/Vanguard.Server/Program.cs +++ b/server/src/Vanguard.Server/Program.cs @@ -185,6 +185,7 @@ builder.Services.AddSingleton builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/server/tests/Vanguard.Server.Tests/PracticeBotE2ETests.cs b/server/tests/Vanguard.Server.Tests/PracticeBotE2ETests.cs new file mode 100644 index 0000000..fbf2d1d --- /dev/null +++ b/server/tests/Vanguard.Server.Tests/PracticeBotE2ETests.cs @@ -0,0 +1,80 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Vanguard.Server.Api; +using Vanguard.Server.Auth; +using Vanguard.Server.Meta; +using Xunit.Sdk; + +namespace Vanguard.Server.Tests; + +[Collection("Orleans")] +public class PracticeBotE2ETests +{ + [Fact] + public async Task Player_Can_Play_A_Practice_Match_Against_Bot() + { + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseSetting("Orleans:Enabled", "true"); + builder.UseSetting("Orleans:SiloPort", "21112"); + builder.UseSetting("Orleans:GatewayPort", "21001"); + }); + + using var anonymous = factory.CreateClient(); + var register = await anonymous.PostAsJsonAsync( + "/api/auth/register", + new { userName = "practice-player", password = "Practice12345!" }); + + if (!register.IsSuccessStatusCode) + throw new XunitException(await register.Content.ReadAsStringAsync()); + + var auth = (await register.Content.ReadFromJsonAsync())!; + + using var http = factory.CreateClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + + var onboarding = await http.PostAsJsonAsync( + "/api/meta/onboarding", + new { deckId = "td01" }); + + if (!onboarding.IsSuccessStatusCode) + throw new XunitException(await onboarding.Content.ReadAsStringAsync()); + + var trialDeck = ServerTestData.TrialDecks.Decks.Single(deck => deck.Id == "td01"); + var createDeck = await http.PostAsJsonAsync( + "/api/decks", + new { name = "Practice deck", cards = trialDeck.ToDeck() }); + + if (!createDeck.IsSuccessStatusCode) + throw new XunitException(await createDeck.Content.ReadAsStringAsync()); + + var deck = (await createDeck.Content.ReadFromJsonAsync())!; + + var practice = await http.PostAsJsonAsync( + "/api/game/practice", + new { deckId = deck.Id }); + + if (!practice.IsSuccessStatusCode) + throw new XunitException(await practice.Content.ReadAsStringAsync()); + + var start = (await practice.Content.ReadFromJsonAsync())!; + Assert.Equal(0, start.Seat); + Assert.False(string.IsNullOrWhiteSpace(start.Ticket)); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var client = await TestGameClient.ConnectAsync( + factory, start.MatchId, start.Ticket, "practice-player", 0, cts.Token); + + await client.PlayRandomlyAsync(seed: 7, cts.Token); + + Assert.True(client.View.IsFinished, "Практика с ботом не завершилась."); + Assert.InRange(client.View.Winner, 0, 1); + + var history = await http.GetFromJsonAsync("/api/matches"); + Assert.NotNull(history); + Assert.Empty(history!.Items); + } +}