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] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
||||||
триал-дека; анимация открытия — дальше.
|
триал-дека; анимация открытия — дальше.
|
||||||
- [x] История матчей и реплей: список результатов, события и кадры.
|
- [x] История матчей и реплей: список результатов, события и кадры.
|
||||||
|
- [x] Тренировка с ботом: серверный casual-матч, случайный легальный бот,
|
||||||
|
без истории/MMR/наград.
|
||||||
- [ ] Локализация RU/EN, переключение на лету (пакет подключён).
|
- [ ] Локализация RU/EN, переключение на лету (пакет подключён).
|
||||||
- [ ] Настройки: язык, звук, FPS, мьют.
|
- [ ] Настройки: язык, звук, FPS, мьют.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 бой |
|
||||||
| Лаунчер | не начато |
|
| Лаунчер | не начато |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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: доска, рука, часы, кнопки всех легальных интентов,
|
||||||
|
|
|
||||||
|
|
@ -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` проходит
|
||||||
|
полный матч против бота.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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`; в контейнере
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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<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>();
|
||||||
|
|
|
||||||
|
|
@ -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