Count concede, leave and absence as a loss; add CI

This commit is contained in:
MpakobecEx 2026-10-21 15:39:05 +05:00
parent 4d5d7f0423
commit ea04839d44
16 changed files with 341 additions and 61 deletions

34
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,34 @@
name: CI
"on":
push:
branches:
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore server/Vanguard.slnx
- name: Test
run: dotnet test server/Vanguard.slnx --nologo --no-restore
- name: Validate content
run: dotnet run --project server/src/Vanguard.Cli -- validate
- name: Check translations
run: dotnet run --project server/src/Vanguard.Cli -- translations check

View File

@ -72,7 +72,7 @@
- [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
- [ ] CI: сборка solution + `dotnet test` на push.
- [x] CI: сборка solution + `dotnet test` на push.
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.

View File

@ -16,7 +16,8 @@
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
| Orleans, CI и миграции | не начато |
| CI (Forgejo Actions) | готово |
| Orleans и миграции | не начато |
| Клиент и лаунчер | не начато |
## Требования

View File

@ -38,7 +38,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты | **169/169** (131 правила/контракты + 38 сервер) |
| Тесты | **173/173** (131 правила/контракты + 42 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -71,7 +71,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 38 (Vanguard.Server.Tests)
Пройден! : не пройдено 0, пройдено 42 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
Партий: 1000, завершено: 1000, ошибок: 0
@ -237,8 +237,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
`ServerClock` каждые 5 с.
- **Результат**: сдача, таймер и отсутствие завершают партию через
`GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
- **Результат**: сдача, явный выход, таймер и отсутствие завершают партию
через `GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
результат пишется в профили.
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
только количества и открытые зоны. `seed` и RNG не покидают сервер.
@ -256,11 +256,13 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
- **Тесты серверного ядра** (33 из 38 в `Vanguard.Server.Tests`):
- **CI**: `.gitea/workflows/ci.yml` — restore, тесты, `validate` и
`translations check` на push/PR через Forgejo Actions.
- **Тесты серверного ядра** (33 из 42 в `Vanguard.Server.Tests`):
- тикеты: одноразовость и TTL;
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
команды после финала, банк времени, отсутствие, старт часов,
явный выход, команды после финала, банк времени, отсутствие, старт часов,
reconnect с `eventSeq`, захват сессии;
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
@ -273,7 +275,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
обрыв и возврат по новому тикету с догоном состояния.
Проверено на `ssh home`: 169/169 тестов, включая реальные PostgreSQL и Redis;
Проверено на `ssh home`: 173/173 тестов, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
@ -303,8 +305,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- `MetaProfileService` после подтверждённого завершения матча пишет
историю обоим игрокам, меняет MMR в ранкеде и начисляет Gold/XP.
Победа 30 Gold / 150 XP, поражение 15 / 75; сдача, таймер и отсутствие
дают 0, но остаются в истории.
Победа 30 Gold / 150 XP, поражение 15 / 75; сдача, явный выход, таймер
и отсутствие дают 0, но остаются в истории.
- `GET /api/matches`, `GET /api/matches/{id}` — история игрока.
- `GET /api/replays/{id}` восстанавливает матч из seed, колод и журнала
принятых команд и отдаёт:
@ -317,9 +319,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
11 стартовых событий и кадр, рука противника пуста. После рестарта
сервера матч восстановлен (`matches: 1`).
Счётчик `Vanguard.Server.Tests` вырос до 38: добавлены auth, мета-профиль,
мета-E2E (онбординг → бустер → колода → матч → история → реплей)
и проверки реплея.
Счётчик `Vanguard.Server.Tests` вырос до 42: добавлены auth, мета-профиль,
мета-E2E (онбординг → бустер → колода → матч → история → реплей),
проверки реплея, явного выхода и истории/реплея после выхода.
---
@ -359,7 +361,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/Profiles/ интерфейс MMR
/Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест
/server/tests/Vanguard.Server.Tests 38 тестов, включая E2E и внешние хранилища
/server/tests/Vanguard.Server.Tests 42 теста, включая E2E и внешние хранилища
/content/sets карты
/content/trial-decks.yaml две триал-деки
/content/pack_odds.yaml веса бустера
@ -408,7 +410,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт 20 / 40 / 100 / 400,
SP не разбирается и не крафтится.
- Матч: победа 30 Gold / 150 XP, поражение 15 / 75; Gold = XP / 5.
Сдача, таймер и отсутствие наград не дают.
Сдача, явный выход, таймер и отсутствие наград не дают.
- История матча пишется обоим игрокам один раз; реплей доступен только
участникам и собирается под зрителя, без руки противника.
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
@ -458,7 +460,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
Критерий готовности серверного блока выполнен: два клиента играют полный
матч по сети; kill клиента восстанавливается через новый тикет и
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
Остались Orleans и инфраструктурные мелочи (CI, миграции).
Остались Orleans и миграции вместо `EnsureCreated`.
### 7.2. Мета-API
@ -515,7 +517,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
`docs/server-api.md`, `Vanguard.Shared`.
4. Orleans, CI и миграции — отдельные серверные долги, не блокируют клиент.
4. Orleans и миграции — отдельные серверные долги, не блокируют клиент.
5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
@ -574,9 +576,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
Duplicate protection считает `printingId` и порог 4; при насыщении
редкости выбирается другая, иначе допускается дубль. SP не крафтится
и не разбирается.
- **Мета: награды.** Gold/XP только за `normal`; сдача, таймер и отсутствие
дают 0. История пишется один раз на `(userId, matchId)`, повтор результата
не дублирует валюту.
- **Мета: награды.** Gold/XP только за `normal`; сдача, явный выход, таймер
и отсутствие дают 0. История пишется один раз на `(userId, matchId)`,
повтор результата не дублирует валюту.
- **Реплей.** Доступен только участнику; собирается из seed, колод и журнала
принятых команд. `frames` — состояние после каждой команды, `events` —
только видимые зрителю события; рука противника и закрытый урон скрыты.

View File

@ -85,7 +85,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
| 2 | `ClientResync` | запрос догона или полного снимка |
| 3 | `ClientPing` | проверка связи и часов |
| 4 | `ClientPong` | ответ на `ServerPing` |
| 5 | `ClientLeave` | добровольный выход (матч не бросается) |
| 5 | `ClientLeave` | явный выход: поражение и завершение матча |
`ClientHello`:
@ -380,14 +380,18 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
`GameFinishedView` и рассылает его обоим. Интент проигравшего после этого
отклоняется с `MatchFinished`.
### 8.2. Отсутствие
### 8.2. Сдача, выход и отсутствие
- Пропал транспорт (закрытие, ошибка, heartbeat) → матч продолжается.
- Игрок отсутствует **5 минут** подряд → авто-поражение.
- Таймер хода при этом не останавливается: банк может кончиться раньше.
- **Сдача** (`ConcedeIntent`) — немедленное поражение сдавшегося.
- **Явный выход** (`ClientLeave`) — немедленное поражение вышедшего;
матч завершается, сопернику уходит `GameFinished`.
- **Разрыв** (закрытие сокета, ошибка, heartbeat) — матч продолжается:
у игрока есть **5 минут** на возврат. Если он не вернулся — поражение.
- Таймер хода при разрыве не останавливается: банк может кончиться раньше.
- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает
потраченное время.
потраченное время. После явного выхода возврата в игру нет — только
просмотр результата.
---
@ -400,8 +404,9 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
- Клиент, у которого 20 секунд нет трафика, отправляет `ClientPing`;
сервер отвечает `ServerPong`. Это же синхронизирует часы.
- Любой кадр считается активностью и сбрасывает оба таймера.
- Штатное закрытие — `ClientLeave` + WebSocket close `1000`; матч при этом
не завершается, а ждёт возвращения по общим правилам.
- Штатное закрытие — `ClientLeave` + WebSocket close `1000`. `ClientLeave`
завершает матч поражением вышедшего. Если клиент закрыл сокет без
`ClientLeave`, это считается разрывом: матч ждёт возвращения 5 минут.
---
@ -565,7 +570,9 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
4. Повторная отправка одного `intentId` не меняет состояние и возвращает
`duplicate = true`.
5. Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие
5 минут завершает матч поражением отсутствующего.
5 минут завершает матч поражением отсутствующего. `ConcedeIntent` и
`ClientLeave` дают немедленное поражение; сетевой разрыв оставляет
окно на возврат.
6. Истечение 90 секунд и банка корректно завершает партию; `ServerClock`
приходит не реже раза в 5 секунд.
7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.

View File

@ -315,8 +315,9 @@ GET /api/replays/{matchId}?includeFrames=true [Bearer]
- Запись создаётся только после подтверждённого сервером завершения матча.
- Одна запись на игрока и матч; повтор результата не дублируется.
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает
в историю.
- За сдачу, явный выход, таймер и отсутствие Gold/XP не начисляются,
но матч попадает в историю.
- `finishReason`: `normal`, `concede`, `leave`, `timeout`, `absence`.
- MMR меняется в ранкеде; casual MMR остаётся прежним.
Реплей:

View File

@ -373,11 +373,26 @@ public sealed class GameEngine
Phase = State.Phase,
};
var ability = script.Activated.FirstOrDefault(a =>
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
&& a.CanActivate(context));
var candidates = script.Activated
.Where(a =>
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
&& a.CanActivate(context))
.ToList();
var ability = discardPrintingId is null
? candidates.FirstOrDefault(a => !a.RequiresDiscard)
: candidates.FirstOrDefault(a => a.RequiresDiscard);
if (ability is null)
{
if (discardPrintingId is null && candidates.Any(a => a.RequiresDiscard))
return CommandResult.Reject("Нужно выбрать карту для сброса.");
if (discardPrintingId is not null && candidates.Count > 0)
return CommandResult.Reject("Эта способность не требует сброса.");
return CommandResult.Reject("Нет доступной способности.");
}
if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf))
return CommandResult.Reject("Способность из этой зоны не оплачивается поворотом или уничтожением.");
@ -1390,22 +1405,13 @@ public sealed class GameEngine
}
foreach (var unit in AllUnits(player))
{
if (CanActivateFrom(unit, player, fromHand: false, fromDrop: false))
commands.Add(new ActivateAbilityCommand(unit.Id));
}
AddActivatedCommands(commands, unit, player, fromHand: false, fromDrop: false);
foreach (var card in p.Hand)
{
if (CanActivateFrom(card, player, fromHand: true, fromDrop: false))
commands.Add(new ActivateAbilityCommand(card.Id));
}
AddActivatedCommands(commands, card, player, fromHand: true, fromDrop: false);
foreach (var card in p.Drop)
{
if (CanActivateFrom(card, player, fromHand: false, fromDrop: true))
commands.Add(new ActivateAbilityCommand(card.Id));
}
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
commands.Add(new EnterBattleCommand());
commands.Add(new EndTurnCommand());
@ -1504,11 +1510,16 @@ public sealed class GameEngine
commands.Add(new PassGuardCommand());
}
private bool CanActivateFrom(CardInstance card, int player, bool fromHand, bool fromDrop)
private void AddActivatedCommands(
List<GameCommand> commands,
CardInstance card,
int player,
bool fromHand,
bool fromDrop)
{
var nameId = State.Catalog.CardOf(card.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
return false;
return;
var context = new AbilityContext
{
@ -1521,12 +1532,38 @@ public sealed class GameEngine
var isOnRearGuard = !fromHand && !fromDrop
&& Array.IndexOf(State.Players[player].RearGuards, card) >= 0;
return script.Activated.Any(a =>
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
&& !a.RequiresDiscard
&& (!a.RestsSelf || !card.Rested)
&& (!a.RetiresSelf || isOnRearGuard)
&& a.CanActivate(context));
var available = script.Activated
.Where(ability =>
(fromHand ? ability.FromHand : fromDrop ? ability.FromDropZone
: !ability.FromHand && !ability.FromDropZone)
&& (!(fromHand || fromDrop) || (!ability.RestsSelf && !ability.RetiresSelf))
&& (!ability.RestsSelf || !card.Rested)
&& (!ability.RetiresSelf || isOnRearGuard)
&& ability.CanActivate(context))
.ToList();
if (available.Count == 0)
return;
if (available.Any(ability => !ability.RequiresDiscard))
commands.Add(new ActivateAbilityCommand(card.Id));
if (!available.Any(ability => ability.RequiresDiscard))
return;
var discardPrintings = State.Players[player].Hand
.Select(handCard => handCard.PrintingId)
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var printingId in discardPrintings)
{
// карту из руки нельзя предлагать самой себе как стоимость
if (fromHand && printingId == card.PrintingId)
continue;
commands.Add(new ActivateAbilityCommand(card.Id, printingId));
}
}
private IEnumerable<int> UnitSlots(int player)

View File

@ -43,6 +43,7 @@ public enum MatchFinishReason
Concede = 1,
Timeout = 2,
Absence = 3,
Leave = 4,
}
/// <summary>Результат для записи в профили и историю. MMR меняется только в ранкеде.</summary>
@ -87,6 +88,9 @@ public interface IMatchGrain
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
Task DetachAsync(int seat, IMatchConnection connection);
/// <summary>Явный выход из матча: сразу поражение вышедшего.</summary>
Task LeaveAsync(int seat);
/// <summary>Обработать интент и отправить результат и события в соединения.</summary>
Task SubmitAsync(int seat, ClientIntent intent);

View File

@ -225,6 +225,29 @@ public sealed class MatchGrain : IMatchGrain
}
}
public async Task LeaveAsync(int seat)
{
if (seat is not (0 or 1))
return;
await _gate.WaitAsync();
try
{
if (_engine.State.IsFinished)
return;
_logger.LogInformation(
"Match {MatchId}: seat {Seat} left the match, opponent wins.",
_matchId, seat);
await FinishFromServerAsync(1 - seat, _time.GetUtcNow(), MatchFinishReason.Leave);
}
finally
{
_gate.Release();
}
}
public async Task SubmitAsync(int seat, ClientIntent intent)
{
if (seat is not (0 or 1))

View File

@ -172,6 +172,7 @@ public static class GameWebSocketEndpoint
break;
case ClientLeave:
await match.LeaveAsync(seat);
return;
default:

View File

@ -731,6 +731,16 @@ public class GameEngineTests
Give(engine, 0, Marron);
var handBefore = engine.State.Players[0].Hand.Count;
var legal = engine.LegalCommands();
Assert.Contains(legal, command =>
command is ActivateAbilityCommand activate
&& activate.InstanceId == lien.Id
&& activate.DiscardPrintingId == Marron);
Assert.DoesNotContain(legal, command =>
command is ActivateAbilityCommand activate
&& activate.InstanceId == lien.Id
&& activate.DiscardPrintingId is null);
Assert.True(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
Assert.True(lien.Rested);

View File

@ -100,6 +100,45 @@ public class GameServerE2ETests
Assert.False(reconnected.View.IsFinished);
}
[Fact]
public async Task Explicit_Leave_Ends_Match_As_Loss()
{
using var factory = NewFactory();
using var http = factory.CreateClient();
var deck = ServerTestData.BuildDeck();
var firstQueue = await EnqueueAsync(http, "p1", deck);
var secondQueue = await EnqueueAsync(http, "p2", deck);
var firstStatus = await WaitForMatchAsync(http, firstQueue.QueueId, "p1");
var secondStatus = await WaitForMatchAsync(http, secondQueue.QueueId, "p2");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var first = await TestGameClient.ConnectAsync(
factory, firstStatus.MatchId!, firstStatus.Ticket!, "p1", 0, cts.Token);
await using var second = await TestGameClient.ConnectAsync(
factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token);
await second.SendLeaveAsync("exit", cts.Token);
var sawFinish = false;
for (var attempt = 0; attempt < 20 && !sawFinish; attempt++)
{
var events = await first.ReadUntilAsync<ServerEvents>(cts.Token);
sawFinish = events.Events.Any(item => item.Event is GameFinishedView { Winner: 0 });
}
Assert.True(sawFinish, "Соперник не получил GameFinished после выхода.");
await first.RequestSnapshotAsync(cts.Token);
Assert.True(first.View.IsFinished);
Assert.Equal(0, first.View.Winner);
}
private static WebApplicationFactory<Program> NewFactory() => new();
private static async Task<QueueAccepted> EnqueueAsync(

View File

@ -286,6 +286,45 @@ public class MatchGrainTests
Assert.Single(replacement.All<ServerHello>());
}
[Fact]
public async Task Leave_Ends_Match_As_Loss()
{
var sink = new RecordingResultSink();
var grain = NewGrain(new FakeTimeProvider(Start), sink);
var first = await Attach(grain, 0, "p1");
var second = await Attach(grain, 1, "p2");
await grain.LeaveAsync(1);
Assert.True(grain.Summary.IsFinished);
Assert.Equal(0, grain.Summary.Winner);
var result = Assert.Single(sink.Results);
Assert.Equal(MatchFinishReason.Leave, result.FinishReason);
Assert.Equal("p1", result.WinnerPlayerId);
Assert.Contains(second.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 });
Assert.Contains(first.All<ServerEvents>().SelectMany(e => e.Events),
e => e.Event is GameFinishedView { Winner: 0 });
}
[Fact]
public async Task Leave_After_Finish_Is_NoOp()
{
var grain = NewGrain(new FakeTimeProvider(Start));
var first = await Attach(grain, 0, "p1");
await Attach(grain, 1, "p2");
await grain.LeaveAsync(0);
var seq = grain.Summary.EventSeq;
await grain.LeaveAsync(1);
Assert.Equal(seq, grain.Summary.EventSeq);
Assert.Equal(1, grain.Summary.Winner);
}
private static MatchGrain NewGrain(
FakeTimeProvider time,
RecordingResultSink? sink = null,

View File

@ -169,6 +169,79 @@ public class MetaGameE2ETests
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task Leave_Counts_As_Loss_In_History_And_Replay()
{
using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder => builder.UseSetting("Storage:Provider", "Memory"));
using var anonymous = factory.CreateClient();
var alice = await RegisterAsync(anonymous, "leave-a");
var bob = await RegisterAsync(anonymous, "leave-b");
using var aliceHttp = Authed(factory, alice);
using var bobHttp = Authed(factory, bob);
var trialDeck = ServerTestData.TrialDecks.Decks.Single(deck => deck.Id == "td01");
var cards = trialDeck.ToDeck().ToList();
await PostAsync<OnboardingResponse>(aliceHttp, "/api/meta/onboarding", new { deckId = "td01" });
await PostAsync<OnboardingResponse>(bobHttp, "/api/meta/onboarding", new { deckId = "td01" });
var aliceDeck = await CreateDeckAsync(aliceHttp, "Leave A", cards);
var bobDeck = await CreateDeckAsync(bobHttp, "Leave B", cards);
var aliceQueue = await QueueAsync(aliceHttp, aliceDeck.Id);
var bobQueue = await QueueAsync(bobHttp, bobDeck.Id);
var aliceStatus = await WaitForMatchAsync(aliceHttp, aliceQueue.QueueId);
var bobStatus = await WaitForMatchAsync(bobHttp, bobQueue.QueueId);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await using var aliceClient = await TestGameClient.ConnectAsync(
factory, aliceStatus.MatchId!, aliceStatus.Ticket!, alice.UserName, 0, cts.Token);
await using var bobClient = await TestGameClient.ConnectAsync(
factory, bobStatus.MatchId!, bobStatus.Ticket!, bob.UserName, 0, cts.Token);
await bobClient.SendLeaveAsync("exit", cts.Token);
var sawFinish = false;
for (var attempt = 0; attempt < 20 && !sawFinish; attempt++)
{
var events = await aliceClient.ReadUntilAsync<ServerEvents>(cts.Token);
sawFinish = events.Events.Any(item => item.Event is GameFinishedView { Winner: 0 });
}
Assert.True(sawFinish, "Соперник не получил GameFinished после выхода.");
var aliceHistory = await WaitForHistoryAsync(aliceHttp);
var bobHistory = await WaitForHistoryAsync(bobHttp);
var aliceRow = Assert.Single(aliceHistory.Items);
var bobRow = Assert.Single(bobHistory.Items);
Assert.Equal("win", aliceRow.Result);
Assert.Equal("loss", bobRow.Result);
Assert.Equal("leave", aliceRow.FinishReason);
Assert.Equal("leave", bobRow.FinishReason);
Assert.Equal(0, aliceRow.Gold);
Assert.Equal(0, aliceRow.Xp);
Assert.Equal(0, bobRow.Gold);
Assert.Equal(0, bobRow.Xp);
var replay = await GetAsync<ReplayResponse>(
aliceHttp, $"/api/replays/{aliceStatus.MatchId}");
Assert.Equal("leave", replay.FinishReason);
Assert.Equal(0, replay.Winner);
Assert.True(replay.Frames[^1].View.IsFinished);
Assert.Contains(replay.Events, item => item.Kind == "GameFinished");
}
private static HttpClient Authed(
WebApplicationFactory<Program> factory,
AuthResponse auth)

View File

@ -12,26 +12,32 @@ public class MetaProfileServiceTests
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Forfeit_Records_History_Without_Gold_Or_Xp()
public async Task Forfeit_And_Leave_Record_History_Without_Gold_Or_Xp()
{
var factory = new TestFactory();
await CreateUsersAsync(factory);
var service = NewService(factory);
await service.RecordAsync(new MatchResult(
"m-forfeit", GameMode.ClassicRanked, "p1", "p2", 0,
"m-concede", GameMode.ClassicRanked, "p1", "p2", 0,
MatchFinishReason.Concede));
await using var db = factory.CreateDbContext();
var rows = await db.MatchHistory.OrderBy(row => row.UserId).ToListAsync();
await service.RecordAsync(new MatchResult(
"m-leave", GameMode.ClassicRanked, "p1", "p2", 0,
MatchFinishReason.Leave));
Assert.Equal(2, rows.Count);
await using var db = factory.CreateDbContext();
var rows = await db.MatchHistory.OrderBy(row => row.MatchId).ToListAsync();
Assert.Equal(4, rows.Count);
Assert.All(rows, row =>
{
Assert.Equal(0, row.Gold);
Assert.Equal(0, row.Xp);
Assert.Equal("concede", row.FinishReason);
});
Assert.Contains(rows, row => row.FinishReason == "concede");
Assert.Contains(rows, row => row.FinishReason == "leave");
}
[Fact]

View File

@ -88,6 +88,9 @@ internal sealed class TestGameClient : IAsyncDisposable
}, cancellationToken);
}
public async Task SendLeaveAsync(string reason, CancellationToken cancellationToken) =>
await SendAsync(new ClientLeave { Reason = reason }, cancellationToken);
public async Task<ServerIntentResult> SubmitAsync(
string intentId,
IntentPayload intent,