diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..59ebaad --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index e7cb71f..a6c0870 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -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`. diff --git a/README.md b/README.md index 6a413c7..72bc6e4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ | Сервер: матч по сети, матчмейкинг, реконнект | готово | | PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | | Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово | -| Orleans, CI и миграции | не начато | +| CI (Forgejo Actions) | готово | +| Orleans и миграции | не начато | | Клиент и лаунчер | не начато | ## Требования diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 64622e2..f26ff6d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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` — только видимые зрителю события; рука противника и закрытый урон скрыты. diff --git a/docs/protocol.md b/docs/protocol.md index face674..ffc9634 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 КиБ и мусорный кадр отклоняются без падения сервера. diff --git a/docs/server-api.md b/docs/server-api.md index 6d83810..0888bea 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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 остаётся прежним. Реплей: diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 7172274..48c5786 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -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 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 UnitSlots(int player) diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs index 2dbe679..d53589f 100644 --- a/server/src/Vanguard.Server/Matches/IMatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -43,6 +43,7 @@ public enum MatchFinishReason Concede = 1, Timeout = 2, Absence = 3, + Leave = 4, } /// Результат для записи в профили и историю. MMR меняется только в ранкеде. @@ -87,6 +88,9 @@ public interface IMatchGrain /// Отключить место. Отсчёт отсутствия начинается здесь. Task DetachAsync(int seat, IMatchConnection connection); + /// Явный выход из матча: сразу поражение вышедшего. + Task LeaveAsync(int seat); + /// Обработать интент и отправить результат и события в соединения. Task SubmitAsync(int seat, ClientIntent intent); diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs index 74c5176..65607d4 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -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)) diff --git a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs index 557acf9..19a6d16 100644 --- a/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs +++ b/server/src/Vanguard.Server/Realtime/GameWebSocketEndpoint.cs @@ -172,6 +172,7 @@ public static class GameWebSocketEndpoint break; case ClientLeave: + await match.LeaveAsync(seat); return; default: diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 5b3b065..b00d1e7 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -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); diff --git a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs index 45c8bc6..7cd70b5 100644 --- a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs +++ b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs @@ -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(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 NewFactory() => new(); private static async Task EnqueueAsync( diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs index 7bc34eb..694381d 100644 --- a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -286,6 +286,45 @@ public class MatchGrainTests Assert.Single(replacement.All()); } + [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().SelectMany(e => e.Events), + e => e.Event is GameFinishedView { Winner: 0 }); + Assert.Contains(first.All().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, diff --git a/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs b/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs index 3bed96b..9657f01 100644 --- a/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs +++ b/server/tests/Vanguard.Server.Tests/MetaGameE2ETests.cs @@ -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() + .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(aliceHttp, "/api/meta/onboarding", new { deckId = "td01" }); + await PostAsync(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(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( + 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 factory, AuthResponse auth) diff --git a/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs b/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs index 9dac306..1ecb54d 100644 --- a/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MetaProfileServiceTests.cs @@ -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] diff --git a/server/tests/Vanguard.Server.Tests/TestGameClient.cs b/server/tests/Vanguard.Server.Tests/TestGameClient.cs index 6ab6ea7..afeb977 100644 --- a/server/tests/Vanguard.Server.Tests/TestGameClient.cs +++ b/server/tests/Vanguard.Server.Tests/TestGameClient.cs @@ -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 SubmitAsync( string intentId, IntentPayload intent,