Add practice matches against server bot
CI / test (push) Successful in 4m54s Details

This commit is contained in:
MpakobecEx 2026-10-21 19:34:46 +05:00
parent 81af3aeb44
commit e0deecacd5
16 changed files with 452 additions and 15 deletions

View File

@ -255,6 +255,8 @@
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая - [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
триал-дека; анимация открытия — дальше. триал-дека; анимация открытия — дальше.
- [x] История матчей и реплей: список результатов, события и кадры. - [x] История матчей и реплей: список результатов, события и кадры.
- [x] Тренировка с ботом: серверный casual-матч, случайный легальный бот,
без истории/MMR/наград.
- [ ] Локализация RU/EN, переключение на лету (пакет подключён). - [ ] Локализация RU/EN, переключение на лету (пакет подключён).
- [ ] Настройки: язык, звук, FPS, мьют. - [ ] Настройки: язык, звук, FPS, мьют.

View File

@ -21,7 +21,7 @@
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
| EF-миграции | готово (`InitialCreate`) | | EF-миграции | готово (`InitialCreate`) |
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, gray-box бой | | Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, gray-box бой |
| Лаунчер | не начато | | Лаунчер | не начато |
## Требования ## Требования

View File

@ -106,6 +106,20 @@ namespace Vanguard.Api.Contracts
[JsonProperty("expiresAt")] public DateTimeOffset ExpiresAt { get; set; } [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 public sealed class Balances
{ {
[JsonProperty("gold")] public int Gold { get; set; } [JsonProperty("gold")] public int Gold { get; set; }

View File

@ -19,7 +19,8 @@ namespace Vanguard.App
Resolve<MetaService>(), Resolve<MetaService>(),
Resolve<AuthService>(), Resolve<AuthService>(),
() => _ = Flow.LoadLobbyAsync(), () => _ = Flow.LoadLobbyAsync(),
deck => _ = Flow.OpenLobbyWithQueueAsync(deck)); deck => _ = Flow.OpenLobbyWithQueueAsync(deck),
info => _ = Flow.LoadBattleAsync(info));
_view.Show(); _view.Show();
} }

View File

@ -108,6 +108,11 @@ namespace Vanguard.Meta
await _api.GetAsync<QueueStatusResponse>( await _api.GetAsync<QueueStatusResponse>(
"/api/game/queue/" + queueId.ToString("D")); "/api/game/queue/" + queueId.ToString("D"));
public async Task<PracticeStartResponse> StartPracticeAsync(Guid deckId) =>
await _api.PostAsync<PracticeStartResponse>(
"/api/game/practice",
new PracticeRequest { DeckId = deckId });
public Task CancelQueueAsync(Guid queueId) => public Task CancelQueueAsync(Guid queueId) =>
_api.DeleteAsync("/api/game/queue/" + queueId.ToString("D")); _api.DeleteAsync("/api/game/queue/" + queueId.ToString("D"));

View File

@ -8,6 +8,7 @@ using UnityEngine.UI;
using Vanguard.Api; using Vanguard.Api;
using Vanguard.Api.Contracts; using Vanguard.Api.Contracts;
using Vanguard.Auth; using Vanguard.Auth;
using Vanguard.Battle;
using Vanguard.Meta; using Vanguard.Meta;
namespace Vanguard.UI namespace Vanguard.UI
@ -22,6 +23,7 @@ namespace Vanguard.UI
private readonly AuthService _auth; private readonly AuthService _auth;
private readonly Action _onBack; private readonly Action _onBack;
private readonly Action<DeckDto> _onPlay; private readonly Action<DeckDto> _onPlay;
private readonly Action<MatchConnectionInfo> _onPracticeStarted;
private readonly RectTransform _root; private readonly RectTransform _root;
private readonly RectTransform _listView; private readonly RectTransform _listView;
@ -78,12 +80,14 @@ namespace Vanguard.UI
MetaService meta, MetaService meta,
AuthService auth, AuthService auth,
Action onBack, Action onBack,
Action<DeckDto> onPlay) Action<DeckDto> onPlay,
Action<MatchConnectionInfo> onPracticeStarted)
{ {
_meta = meta; _meta = meta;
_auth = auth; _auth = auth;
_onBack = onBack; _onBack = onBack;
_onPlay = onPlay; _onPlay = onPlay;
_onPracticeStarted = onPracticeStarted;
_root = UiKit.CreatePanel(parent, "DeckBuilderScreen", new Color(0.018f, 0.03f, 0.06f, 0.97f)); _root = UiKit.CreatePanel(parent, "DeckBuilderScreen", new Color(0.018f, 0.03f, 0.06f, 0.97f));
_listView = BuildListView(); _listView = BuildListView();
@ -226,6 +230,9 @@ namespace Vanguard.UI
var save = UiKit.CreateButton(top, "Save", "Сохранить", SaveDeck, 17); var save = UiKit.CreateButton(top, "Save", "Сохранить", SaveDeck, 17);
SetFixedSize(save, 120f, 44f); SetFixedSize(save, 120f, 44f);
var bot = UiKit.CreateButton(top, "Bot", "С ботом", PlayBot, 17);
SetFixedSize(bot, 100f, 44f);
var duel = UiKit.CreateButton(top, "Duel", "В бой", PlayDeck, 17); var duel = UiKit.CreateButton(top, "Duel", "В бой", PlayDeck, 17);
SetFixedSize(duel, 90f, 44f); SetFixedSize(duel, 90f, 44f);
@ -1158,6 +1165,47 @@ namespace Vanguard.UI
_onPlay(_editing); _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() private void RequestDeleteCurrent()
{ {
if (_editing == null) if (_editing == null)

View File

@ -37,7 +37,7 @@ namespace Vanguard.Tests
var onboarding = new OnboardingView( var onboarding = new OnboardingView(
canvas.transform, auth, meta, () => { }, () => { }); canvas.transform, auth, meta, () => { }, () => { });
var decks = new DeckBuilderView( var decks = new DeckBuilderView(
canvas.transform, meta, auth, () => { }, _ => { }); canvas.transform, meta, auth, () => { }, _ => { }, _ => { });
var queue = new QueueView( var queue = new QueueView(
canvas.transform, meta, auth, () => { }, _ => { }); canvas.transform, meta, auth, () => { }, _ => { });
var store = new StoreView( var store = new StoreView(

View File

@ -58,6 +58,8 @@ http://localhost:5080
- История матчей: список результатов, награды, MMR; просмотр реплея по - История матчей: список результатов, награды, MMR; просмотр реплея по
кадрам и событиям. кадрам и событиям.
- Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой. - Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой.
- Тренировка с ботом: casual-матч против серверного случайного бота,
без истории/MMR/наград.
- `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, - `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events,
интенты, ответы на ping, закрытие и переподключение. интенты, ответы на ping, закрытие и переподключение.
- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов, - Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов,

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **175/175** (131 правила/контракты + 44 сервер) | | Тесты сервера | **176/176** (131 правила/контракты + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -73,7 +73,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
``` ```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 44 (Vanguard.Server.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0 Партий: 1000, завершено: 1000, ошибок: 0
@ -285,7 +285,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
`translations check` на push/PR. Actions включены для репозитория, `translations check` на push/PR. Actions включены для репозитория,
`home-runner` выполняет job (проверено: все шаги зелёные). `setup-dotnet` `home-runner` выполняет job (проверено: все шаги зелёные). `setup-dotnet`
указан явным URL с GitHub: в зеркале `code.forgejo.org` его нет. указан явным URL с GitHub: в зеркале `code.forgejo.org` его нет.
- **Тесты серверного ядра** (35 из 44 в `Vanguard.Server.Tests`): - **Тесты серверного ядра** (36 из 45 в `Vanguard.Server.Tests`):
- тикеты: одноразовость и TTL; - тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром; - heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача, - grain: attach, чужое место, duplicate intent, чужой ход, сдача,
@ -305,7 +305,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
Orleans сериализуется (тестовый `TestCluster`); полный матч двух Orleans сериализуется (тестовый `TestCluster`); полный матч двух
клиентов через HTTP/WebSocket при `Orleans:Enabled=true`. клиентов через HTTP/WebSocket при `Orleans:Enabled=true`.
Проверено на `ssh home`: 131 + 44 теста, включая реальные PostgreSQL и Redis; Проверено на `ssh home`: 131 + 45 теста, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, `docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`). а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
@ -349,9 +349,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
11 стартовых событий и кадр, рука противника пуста. После рестарта 11 стартовых событий и кадр, рука противника пуста. После рестарта
сервера матч восстановлен (`matches: 1`). сервера матч восстановлен (`matches: 1`).
Счётчик `Vanguard.Server.Tests` вырос до 44: добавлены auth, мета-профиль, Счётчик `Vanguard.Server.Tests` вырос до 45: добавлены auth, мета-профиль,
мета-E2E (онбординг → бустер → колода → матч → история → реплей), мета-E2E (онбординг → бустер → колода → матч → история → реплей),
проверки реплея, явного выхода и истории/реплея после выхода. проверки реплея, явного выхода и истории/реплея после выхода,
матч против бота.
--- ---
@ -391,7 +392,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/Profiles/ интерфейс MMR /Profiles/ интерфейс MMR
/Dockerfile /Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест /server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 44 теста, включая E2E и внешние хранилища /server/tests/Vanguard.Server.Tests 45 тестов, включая E2E и внешние хранилища
/content/sets карты /content/sets карты
/content/trial-decks.yaml две триал-деки /content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера /content/pack_odds.yaml веса бустера
@ -728,8 +729,15 @@ Battle; Boot и общие сервисы остаются персистент
нельзя). нельзя).
- `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`, - `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`,
`critical`, `type`, `textEn/textRu` и флаги способностей. `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 `SCENE_SMOKE success=True active=Login`; live `GET /api/cards` — 124
принта; live UI-проба: загрузка 50 карт, создание колоды, принта; live UI-проба: загрузка 50 карт, создание колоды,
добавление/удаление, ПКМ-оверлей, текст способности и разбор карты; добавление/удаление, ПКМ-оверлей, текст способности и разбор карты;
live API-проба крафта: разбор 4 C → 20 пыли → крафт C. live API-проба крафта: разбор 4 C → 20 пыли → крафт C.
- `POST /api/game/practice` и `BotMatchService`: серверный случайный бот
подключается к своему месту через in-process snapshot-соединение,
выбирает легальный интент и играет casual-матч. Практика не пишет
историю, не меняет MMR и не выдаёт награды.
- Клиент: кнопка «С ботом» в редакторе колоды; `MetaService.StartPracticeAsync`,
переход в `Battle`. Серверный E2E-тест `PracticeBotE2ETests` проходит
полный матч против бота.

View File

@ -18,7 +18,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **131** правил + **44** серверных, CI зелёный. - Тесты: **131** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`, - Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/HANDOFF.md`. `docs/HANDOFF.md`.
@ -52,6 +52,9 @@
Изображения карт пока заглушки-плашки по цвету клана. Изображения карт пока заглушки-плашки по цвету клана.
- ПКМ по карте — оверлей с картой, способностями, разбором в пыль и - ПКМ по карте — оверлей с картой, способностями, разбором в пыль и
крафтом; SP/TD не разбираются и не крафтятся. крафтом; SP/TD не разбираются и не крафтятся.
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween. - Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween.
@ -138,6 +141,8 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
4. Локализация RU/EN, настройки, DOTween. 4. Локализация RU/EN, настройки, DOTween.
5. Лаунчер WPF. 5. Лаунчер WPF.
Тренировка с ботом уже есть — следующий блок можно начинать с боевого UI.
## Подводные камни ## Подводные камни
- Не запускать `dotnet`/Docker на рабочем ПК; сервер проверяется на `ssh home`. - Не запускать `dotnet`/Docker на рабочем ПК; сервер проверяется на `ssh home`.

View File

@ -302,9 +302,26 @@ Duplicate protection: ключ — `printingId`, порог 4; замена вн
POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" } POST /api/game/queue [Bearer] { "mode": "classic-ranked", "deckId": "…" }
GET /api/game/queue/{queueId} [Bearer] GET /api/game/queue/{queueId} [Bearer]
DELETE /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/tickets [Bearer] { "matchId": "…" }
``` ```
`POST /api/game/practice` сразу создаёт casual-матч против серверного
случайного бота и возвращает ticket:
```json
{
"matchId": "…",
"seat": 0,
"ticket": "…",
"wsUrl": "ws://…/game",
"opponent": "Bot"
}
```
Бот играет легальными интентами из своего snapshot. Практика не пишет
историю, не меняет MMR и не выдаёт награды.
С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку С JWT игрок определяется по токену, колода — по `deckId` (принадлежит игроку
и валидна на момент постановки). Без JWT работает dev-режим и валидна на момент постановки). Без JWT работает dev-режим
(`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере (`playerId` + готовая колода), если `Game:AllowDevQueue=true`; в контейнере

View File

@ -32,6 +32,15 @@ public sealed record TicketResponse(
string WsUrl, string WsUrl,
DateTimeOffset ExpiresAt); 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( public sealed record ApiError(
string Error, string Error,
string Message, string Message,

View File

@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using Vanguard.Content;
using Vanguard.Server.Auth; using Vanguard.Server.Auth;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
using Vanguard.Server.Matches; using Vanguard.Server.Matches;
@ -20,6 +21,9 @@ public static class ApiEndpoints
app.MapPost("/api/game/queue", Enqueue); app.MapPost("/api/game/queue", Enqueue);
app.MapGet("/api/game/queue/{queueId:guid}", PollQueue); app.MapGet("/api/game/queue/{queueId:guid}", PollQueue);
app.MapDelete("/api/game/queue/{queueId:guid}", CancelQueue); app.MapDelete("/api/game/queue/{queueId:guid}", CancelQueue);
app.MapPost("/api/game/practice", StartPractice)
.RequireAuthorization()
.RequireRateLimiting("mutating");
app.MapPost("/api/game/tickets", IssueTicket); app.MapPost("/api/game/tickets", IssueTicket);
app.MapGet("/health", Health); app.MapGet("/health", Health);
} }
@ -92,8 +96,77 @@ public static class ApiEndpoints
} }
} }
private static async Task<IResult> PollQueue( private static async Task<IResult> StartPractice(
Guid queueId, 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<IResult> PollQueue( Guid queueId,
string? playerId, string? playerId,
ClaimsPrincipal principal, ClaimsPrincipal principal,
MatchmakingService matchmaking, MatchmakingService matchmaking,

View File

@ -0,0 +1,172 @@
using System.Collections.Concurrent;
using Vanguard.Shared.Protocol;
namespace Vanguard.Server.Matches;
/// <summary>
/// Простой серверный бот для тренировочных матчей. Он подключается к
/// своему месту через in-process соединение, получает полный снимок
/// и раз в несколько сотен миллисекунд выбирает случайный легальный интент.
/// Это специально «глупый» бот: цель — дать клиенту живого соперника для
/// проверки UI и сетевого потока, а не сильного AI.
/// </summary>
public sealed class BotMatchService : IDisposable
{
public const string BotPlayerId = "Bot";
private readonly IMatchConnectionRegistry _connections;
private readonly ILogger<BotMatchService> _logger;
private readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.Ordinal);
public BotMatchService(
IMatchConnectionRegistry connections,
ILogger<BotMatchService> 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);
}
}

View File

@ -185,6 +185,7 @@ builder.Services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>
builder.Services.AddSingleton<MatchGrainRegistry>(); builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchRecoveryService>(); builder.Services.AddSingleton<MatchRecoveryService>();
builder.Services.AddSingleton<MatchmakingService>(); builder.Services.AddSingleton<MatchmakingService>();
builder.Services.AddSingleton<BotMatchService>();
builder.Services.AddSingleton<EconomyService>(); builder.Services.AddSingleton<EconomyService>();
builder.Services.AddSingleton<CollectionService>(); builder.Services.AddSingleton<CollectionService>();

View File

@ -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<Program>()
.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<AuthResponse>())!;
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<DeckDto>())!;
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<PracticeStartResponse>())!;
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<MatchHistoryResponse>("/api/matches");
Assert.NotNull(history);
Assert.Empty(history!.Items);
}
}