Count concede, leave and absence as a loss; add CI
This commit is contained in:
parent
4d5d7f0423
commit
ea04839d44
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
||||
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
||||
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
|
||||
| Orleans, CI и миграции | не начато |
|
||||
| CI (Forgejo Actions) | готово |
|
||||
| Orleans и миграции | не начато |
|
||||
| Клиент и лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
|
|
|||
|
|
@ -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` —
|
||||
только видимые зрителю события; рука противника и закрытый урон скрыты.
|
||||
|
|
|
|||
|
|
@ -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 КиБ и мусорный кадр отклоняются без падения сервера.
|
||||
|
|
|
|||
|
|
@ -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 остаётся прежним.
|
||||
|
||||
Реплей:
|
||||
|
|
|
|||
|
|
@ -373,11 +373,26 @@ public sealed class GameEngine
|
|||
Phase = State.Phase,
|
||||
};
|
||||
|
||||
var ability = script.Activated.FirstOrDefault(a =>
|
||||
var candidates = script.Activated
|
||||
.Where(a =>
|
||||
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
|
||||
&& a.CanActivate(context));
|
||||
&& 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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ public static class GameWebSocketEndpoint
|
|||
break;
|
||||
|
||||
case ClientLeave:
|
||||
await match.LeaveAsync(seat);
|
||||
return;
|
||||
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue