Add practice matches against server bot
CI / test (push) Successful in 4m54s
Details
CI / test (push) Successful in 4m54s
Details
This commit is contained in:
parent
81af3aeb44
commit
e0deecacd5
|
|
@ -255,6 +255,8 @@
|
|||
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
||||
триал-дека; анимация открытия — дальше.
|
||||
- [x] История матчей и реплей: список результатов, события и кадры.
|
||||
- [x] Тренировка с ботом: серверный casual-матч, случайный легальный бот,
|
||||
без истории/MMR/наград.
|
||||
- [ ] Локализация RU/EN, переключение на лету (пакет подключён).
|
||||
- [ ] Настройки: язык, звук, FPS, мьют.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 бой |
|
||||
| Лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ namespace Vanguard.App
|
|||
Resolve<MetaService>(),
|
||||
Resolve<AuthService>(),
|
||||
() => _ = Flow.LoadLobbyAsync(),
|
||||
deck => _ = Flow.OpenLobbyWithQueueAsync(deck));
|
||||
deck => _ = Flow.OpenLobbyWithQueueAsync(deck),
|
||||
info => _ = Flow.LoadBattleAsync(info));
|
||||
_view.Show();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,11 @@ namespace Vanguard.Meta
|
|||
await _api.GetAsync<QueueStatusResponse>(
|
||||
"/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) =>
|
||||
_api.DeleteAsync("/api/game/queue/" + queueId.ToString("D"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DeckDto> _onPlay;
|
||||
private readonly Action<MatchConnectionInfo> _onPracticeStarted;
|
||||
private readonly RectTransform _root;
|
||||
|
||||
private readonly RectTransform _listView;
|
||||
|
|
@ -78,12 +80,14 @@ namespace Vanguard.UI
|
|||
MetaService meta,
|
||||
AuthService auth,
|
||||
Action onBack,
|
||||
Action<DeckDto> onPlay)
|
||||
Action<DeckDto> onPlay,
|
||||
Action<MatchConnectionInfo> 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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ http://localhost:5080
|
|||
- История матчей: список результатов, награды, MMR; просмотр реплея по
|
||||
кадрам и событиям.
|
||||
- Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой.
|
||||
- Тренировка с ботом: casual-матч против серверного случайного бота,
|
||||
без истории/MMR/наград.
|
||||
- `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events,
|
||||
интенты, ответы на ping, закрытие и переподключение.
|
||||
- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов,
|
||||
|
|
|
|||
|
|
@ -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` проходит
|
||||
полный матч против бота.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`; в контейнере
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<IResult> PollQueue(
|
||||
Guid queueId,
|
||||
private static async Task<IResult> 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<IResult> PollQueue( Guid queueId,
|
||||
string? playerId,
|
||||
ClaimsPrincipal principal,
|
||||
MatchmakingService matchmaking,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -185,6 +185,7 @@ builder.Services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>
|
|||
builder.Services.AddSingleton<MatchGrainRegistry>();
|
||||
builder.Services.AddSingleton<MatchRecoveryService>();
|
||||
builder.Services.AddSingleton<MatchmakingService>();
|
||||
builder.Services.AddSingleton<BotMatchService>();
|
||||
|
||||
builder.Services.AddSingleton<EconomyService>();
|
||||
builder.Services.AddSingleton<CollectionService>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue