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] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
|
||||||
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
|
- [x] `docker-compose.yml`: postgres, redis (AOF), game-server с контентом.
|
||||||
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
|
- [x] `docker compose up` поднимает БД, Redis и API; `/health` отвечает.
|
||||||
- [ ] CI: сборка solution + `dotnet test` на push.
|
- [x] CI: сборка solution + `dotnet test` на push.
|
||||||
|
|
||||||
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
**Готово, когда:** `docker compose up` + `dotnet run` дают живой `/health`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
||||||
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
||||||
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
|
| Мета-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 предупреждений |
|
| `validate` | 0 ошибок, 0 предупреждений |
|
||||||
| `translations check` | 94/94 |
|
| `translations check` | 94/94 |
|
||||||
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
||||||
|
|
@ -71,7 +71,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
|
||||||
|
|
||||||
```
|
```
|
||||||
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
|
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests)
|
||||||
Пройден! : не пройдено 0, пройдено 38 (Vanguard.Server.Tests)
|
Пройден! : не пройдено 0, пройдено 42 (Vanguard.Server.Tests)
|
||||||
OK: ошибок 0, предупреждений 0
|
OK: ошибок 0, предупреждений 0
|
||||||
Переведено: 94/94
|
Переведено: 94/94
|
||||||
Партий: 1000, завершено: 1000, ошибок: 0
|
Партий: 1000, завершено: 1000, ошибок: 0
|
||||||
|
|
@ -237,8 +237,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
|
→ `4408`), реконнект, захват сессии (`4409`), rate limit 16 интентов/с.
|
||||||
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
|
- **Таймеры**: 90 с на решение + банк 300 с, отсутствие 300 с → поражение;
|
||||||
`ServerClock` каждые 5 с.
|
`ServerClock` каждые 5 с.
|
||||||
- **Результат**: сдача, таймер и отсутствие завершают партию через
|
- **Результат**: сдача, явный выход, таймер и отсутствие завершают партию
|
||||||
`GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
|
через `GameEngine.Finish(winner)`; событие `GameFinished` уходит обоим,
|
||||||
результат пишется в профили.
|
результат пишется в профили.
|
||||||
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
|
- **Проекция**: своя рука, свой выбор, свои легальные интенты; у противника
|
||||||
только количества и открытые зоны. `seed` и RNG не покидают сервер.
|
только количества и открытые зоны. `seed` и RNG не покидают сервер.
|
||||||
|
|
@ -256,11 +256,13 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
|
- **Docker**: `Dockerfile` + `docker-compose.yml` поднимают postgres, redis
|
||||||
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
|
(AOF) и сервер; `/health` на `http://localhost:8080` (порт меняется
|
||||||
через `VANGUARD_PORT`, адрес WebSocket — `VANGUARD_WS_URL`).
|
через `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;
|
- тикеты: одноразовость и TTL;
|
||||||
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
- heartbeat: ping после 20 с простоя, закрытие через 20 с, сброс любым кадром;
|
||||||
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
|
- grain: attach, чужое место, duplicate intent, чужой ход, сдача,
|
||||||
команды после финала, банк времени, отсутствие, старт часов,
|
явный выход, команды после финала, банк времени, отсутствие, старт часов,
|
||||||
reconnect с `eventSeq`, захват сессии;
|
reconnect с `eventSeq`, захват сессии;
|
||||||
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
|
- матчмейкинг: подбор, разные очереди, окно, плохая колода,
|
||||||
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
|
отсутствие стартового Авангарда, отмена, замена очереди, истечение, MMR,
|
||||||
|
|
@ -273,7 +275,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
|
- E2E: два клиента играют полный матч по WebSocket и сходятся в победителе;
|
||||||
обрыв и возврат по новому тикету с догоном состояния.
|
обрыв и возврат по новому тикету с догоном состояния.
|
||||||
|
|
||||||
Проверено на `ssh home`: 169/169 тестов, включая реальные PostgreSQL и Redis;
|
Проверено на `ssh home`: 173/173 тестов, включая реальные 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`).
|
||||||
|
|
||||||
|
|
@ -303,8 +305,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
|
|
||||||
- `MetaProfileService` после подтверждённого завершения матча пишет
|
- `MetaProfileService` после подтверждённого завершения матча пишет
|
||||||
историю обоим игрокам, меняет MMR в ранкеде и начисляет Gold/XP.
|
историю обоим игрокам, меняет MMR в ранкеде и начисляет Gold/XP.
|
||||||
Победа 30 Gold / 150 XP, поражение 15 / 75; сдача, таймер и отсутствие
|
Победа 30 Gold / 150 XP, поражение 15 / 75; сдача, явный выход, таймер
|
||||||
дают 0, но остаются в истории.
|
и отсутствие дают 0, но остаются в истории.
|
||||||
- `GET /api/matches`, `GET /api/matches/{id}` — история игрока.
|
- `GET /api/matches`, `GET /api/matches/{id}` — история игрока.
|
||||||
- `GET /api/replays/{id}` восстанавливает матч из seed, колод и журнала
|
- `GET /api/replays/{id}` восстанавливает матч из seed, колод и журнала
|
||||||
принятых команд и отдаёт:
|
принятых команд и отдаёт:
|
||||||
|
|
@ -317,9 +319,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
11 стартовых событий и кадр, рука противника пуста. После рестарта
|
11 стартовых событий и кадр, рука противника пуста. После рестарта
|
||||||
сервера матч восстановлен (`matches: 1`).
|
сервера матч восстановлен (`matches: 1`).
|
||||||
|
|
||||||
Счётчик `Vanguard.Server.Tests` вырос до 38: добавлены auth, мета-профиль,
|
Счётчик `Vanguard.Server.Tests` вырос до 42: добавлены auth, мета-профиль,
|
||||||
мета-E2E (онбординг → бустер → колода → матч → история → реплей)
|
мета-E2E (онбординг → бустер → колода → матч → история → реплей),
|
||||||
и проверки реплея.
|
проверки реплея, явного выхода и истории/реплея после выхода.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -359,7 +361,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 38 тестов, включая E2E и внешние хранилища
|
/server/tests/Vanguard.Server.Tests 42 теста, включая E2E и внешние хранилища
|
||||||
/content/sets карты
|
/content/sets карты
|
||||||
/content/trial-decks.yaml две триал-деки
|
/content/trial-decks.yaml две триал-деки
|
||||||
/content/pack_odds.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,
|
- Пыль: разбор C 5 / R 10 / RR 25 / RRR 100, крафт 20 / 40 / 100 / 400,
|
||||||
SP не разбирается и не крафтится.
|
SP не разбирается и не крафтится.
|
||||||
- Матч: победа 30 Gold / 150 XP, поражение 15 / 75; Gold = XP / 5.
|
- Матч: победа 30 Gold / 150 XP, поражение 15 / 75; Gold = XP / 5.
|
||||||
Сдача, таймер и отсутствие наград не дают.
|
Сдача, явный выход, таймер и отсутствие наград не дают.
|
||||||
- История матча пишется обоим игрокам один раз; реплей доступен только
|
- История матча пишется обоим игрокам один раз; реплей доступен только
|
||||||
участникам и собирается под зрителя, без руки противника.
|
участникам и собирается под зрителя, без руки противника.
|
||||||
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
|
- Порядок триггеров выбирает игрок; опциональные способности спрашиваются.
|
||||||
|
|
@ -458,7 +460,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
Критерий готовности серверного блока выполнен: два клиента играют полный
|
Критерий готовности серверного блока выполнен: два клиента играют полный
|
||||||
матч по сети; kill клиента восстанавливается через новый тикет и
|
матч по сети; kill клиента восстанавливается через новый тикет и
|
||||||
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
|
`resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL.
|
||||||
Остались Orleans и инфраструктурные мелочи (CI, миграции).
|
Остались Orleans и миграции вместо `EnsureCreated`.
|
||||||
|
|
||||||
### 7.2. Мета-API
|
### 7.2. Мета-API
|
||||||
|
|
||||||
|
|
@ -515,7 +517,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
|
`GameConnection`, логин, коллекция, декбилдер, магазин, очередь,
|
||||||
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
|
история и реплей. Сервер и мета-API готовы: `docs/protocol.md`,
|
||||||
`docs/server-api.md`, `Vanguard.Shared`.
|
`docs/server-api.md`, `Vanguard.Shared`.
|
||||||
4. Orleans, CI и миграции — отдельные серверные долги, не блокируют клиент.
|
4. Orleans и миграции — отдельные серверные долги, не блокируют клиент.
|
||||||
5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
5. После каждого блока: тесты, `validate`, `translations check`, коммит, push.
|
||||||
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот.
|
||||||
|
|
||||||
|
|
@ -574,9 +576,9 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
|
||||||
Duplicate protection считает `printingId` и порог 4; при насыщении
|
Duplicate protection считает `printingId` и порог 4; при насыщении
|
||||||
редкости выбирается другая, иначе допускается дубль. SP не крафтится
|
редкости выбирается другая, иначе допускается дубль. SP не крафтится
|
||||||
и не разбирается.
|
и не разбирается.
|
||||||
- **Мета: награды.** Gold/XP только за `normal`; сдача, таймер и отсутствие
|
- **Мета: награды.** Gold/XP только за `normal`; сдача, явный выход, таймер
|
||||||
дают 0. История пишется один раз на `(userId, matchId)`, повтор результата
|
и отсутствие дают 0. История пишется один раз на `(userId, matchId)`,
|
||||||
не дублирует валюту.
|
повтор результата не дублирует валюту.
|
||||||
- **Реплей.** Доступен только участнику; собирается из seed, колод и журнала
|
- **Реплей.** Доступен только участнику; собирается из seed, колод и журнала
|
||||||
принятых команд. `frames` — состояние после каждой команды, `events` —
|
принятых команд. `frames` — состояние после каждой команды, `events` —
|
||||||
только видимые зрителю события; рука противника и закрытый урон скрыты.
|
только видимые зрителю события; рука противника и закрытый урон скрыты.
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
| 2 | `ClientResync` | запрос догона или полного снимка |
|
| 2 | `ClientResync` | запрос догона или полного снимка |
|
||||||
| 3 | `ClientPing` | проверка связи и часов |
|
| 3 | `ClientPing` | проверка связи и часов |
|
||||||
| 4 | `ClientPong` | ответ на `ServerPing` |
|
| 4 | `ClientPong` | ответ на `ServerPing` |
|
||||||
| 5 | `ClientLeave` | добровольный выход (матч не бросается) |
|
| 5 | `ClientLeave` | явный выход: поражение и завершение матча |
|
||||||
|
|
||||||
`ClientHello`:
|
`ClientHello`:
|
||||||
|
|
||||||
|
|
@ -380,14 +380,18 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
`GameFinishedView` и рассылает его обоим. Интент проигравшего после этого
|
`GameFinishedView` и рассылает его обоим. Интент проигравшего после этого
|
||||||
отклоняется с `MatchFinished`.
|
отклоняется с `MatchFinished`.
|
||||||
|
|
||||||
### 8.2. Отсутствие
|
### 8.2. Сдача, выход и отсутствие
|
||||||
|
|
||||||
- Пропал транспорт (закрытие, ошибка, heartbeat) → матч продолжается.
|
- **Сдача** (`ConcedeIntent`) — немедленное поражение сдавшегося.
|
||||||
- Игрок отсутствует **5 минут** подряд → авто-поражение.
|
- **Явный выход** (`ClientLeave`) — немедленное поражение вышедшего;
|
||||||
- Таймер хода при этом не останавливается: банк может кончиться раньше.
|
матч завершается, сопернику уходит `GameFinished`.
|
||||||
|
- **Разрыв** (закрытие сокета, ошибка, heartbeat) — матч продолжается:
|
||||||
|
у игрока есть **5 минут** на возврат. Если он не вернулся — поражение.
|
||||||
|
- Таймер хода при разрыве не останавливается: банк может кончиться раньше.
|
||||||
- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
|
- Оба отсутствуют → проигрывает тот, кто первым превысил 5 минут.
|
||||||
- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает
|
- Возврат в матч сбрасывает счётчик отсутствия, но не восстанавливает
|
||||||
потраченное время.
|
потраченное время. После явного выхода возврата в игру нет — только
|
||||||
|
просмотр результата.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -400,8 +404,9 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
- Клиент, у которого 20 секунд нет трафика, отправляет `ClientPing`;
|
- Клиент, у которого 20 секунд нет трафика, отправляет `ClientPing`;
|
||||||
сервер отвечает `ServerPong`. Это же синхронизирует часы.
|
сервер отвечает `ServerPong`. Это же синхронизирует часы.
|
||||||
- Любой кадр считается активностью и сбрасывает оба таймера.
|
- Любой кадр считается активностью и сбрасывает оба таймера.
|
||||||
- Штатное закрытие — `ClientLeave` + WebSocket close `1000`; матч при этом
|
- Штатное закрытие — `ClientLeave` + WebSocket close `1000`. `ClientLeave`
|
||||||
не завершается, а ждёт возвращения по общим правилам.
|
завершает матч поражением вышедшего. Если клиент закрыл сокет без
|
||||||
|
`ClientLeave`, это считается разрывом: матч ждёт возвращения 5 минут.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -565,7 +570,9 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
4. Повторная отправка одного `intentId` не меняет состояние и возвращает
|
4. Повторная отправка одного `intentId` не меняет состояние и возвращает
|
||||||
`duplicate = true`.
|
`duplicate = true`.
|
||||||
5. Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие
|
5. Пропуск heartbeat закрывает соединение за 40 секунд, а отсутствие
|
||||||
5 минут завершает матч поражением отсутствующего.
|
5 минут завершает матч поражением отсутствующего. `ConcedeIntent` и
|
||||||
|
`ClientLeave` дают немедленное поражение; сетевой разрыв оставляет
|
||||||
|
окно на возврат.
|
||||||
6. Истечение 90 секунд и банка корректно завершает партию; `ServerClock`
|
6. Истечение 90 секунд и банка корректно завершает партию; `ServerClock`
|
||||||
приходит не реже раза в 5 секунд.
|
приходит не реже раза в 5 секунд.
|
||||||
7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.
|
7. Кадр больше 256 КиБ и мусорный кадр отклоняются без падения сервера.
|
||||||
|
|
|
||||||
|
|
@ -315,8 +315,9 @@ GET /api/replays/{matchId}?includeFrames=true [Bearer]
|
||||||
- Запись создаётся только после подтверждённого сервером завершения матча.
|
- Запись создаётся только после подтверждённого сервером завершения матча.
|
||||||
- Одна запись на игрока и матч; повтор результата не дублируется.
|
- Одна запись на игрока и матч; повтор результата не дублируется.
|
||||||
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
|
- Награды: **победа 30 Gold / 150 XP, поражение 15 / 75**; `Gold = XP / 5`.
|
||||||
- За сдачу, таймер и отсутствие Gold/XP не начисляются, но матч попадает
|
- За сдачу, явный выход, таймер и отсутствие Gold/XP не начисляются,
|
||||||
в историю.
|
но матч попадает в историю.
|
||||||
|
- `finishReason`: `normal`, `concede`, `leave`, `timeout`, `absence`.
|
||||||
- MMR меняется в ранкеде; casual MMR остаётся прежним.
|
- MMR меняется в ранкеде; casual MMR остаётся прежним.
|
||||||
|
|
||||||
Реплей:
|
Реплей:
|
||||||
|
|
|
||||||
|
|
@ -373,11 +373,26 @@ public sealed class GameEngine
|
||||||
Phase = State.Phase,
|
Phase = State.Phase,
|
||||||
};
|
};
|
||||||
|
|
||||||
var ability = script.Activated.FirstOrDefault(a =>
|
var candidates = script.Activated
|
||||||
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
|
.Where(a =>
|
||||||
&& a.CanActivate(context));
|
(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 (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("Нет доступной способности.");
|
return CommandResult.Reject("Нет доступной способности.");
|
||||||
|
}
|
||||||
|
|
||||||
if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf))
|
if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf))
|
||||||
return CommandResult.Reject("Способность из этой зоны не оплачивается поворотом или уничтожением.");
|
return CommandResult.Reject("Способность из этой зоны не оплачивается поворотом или уничтожением.");
|
||||||
|
|
@ -1390,22 +1405,13 @@ public sealed class GameEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var unit in AllUnits(player))
|
foreach (var unit in AllUnits(player))
|
||||||
{
|
AddActivatedCommands(commands, unit, player, fromHand: false, fromDrop: false);
|
||||||
if (CanActivateFrom(unit, player, fromHand: false, fromDrop: false))
|
|
||||||
commands.Add(new ActivateAbilityCommand(unit.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var card in p.Hand)
|
foreach (var card in p.Hand)
|
||||||
{
|
AddActivatedCommands(commands, card, player, fromHand: true, fromDrop: false);
|
||||||
if (CanActivateFrom(card, player, fromHand: true, fromDrop: false))
|
|
||||||
commands.Add(new ActivateAbilityCommand(card.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var card in p.Drop)
|
foreach (var card in p.Drop)
|
||||||
{
|
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
|
||||||
if (CanActivateFrom(card, player, fromHand: false, fromDrop: true))
|
|
||||||
commands.Add(new ActivateAbilityCommand(card.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
commands.Add(new EnterBattleCommand());
|
commands.Add(new EnterBattleCommand());
|
||||||
commands.Add(new EndTurnCommand());
|
commands.Add(new EndTurnCommand());
|
||||||
|
|
@ -1504,11 +1510,16 @@ public sealed class GameEngine
|
||||||
commands.Add(new PassGuardCommand());
|
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;
|
var nameId = State.Catalog.CardOf(card.PrintingId).NameId;
|
||||||
if (!_scripts.TryGetValue(nameId, out var script))
|
if (!_scripts.TryGetValue(nameId, out var script))
|
||||||
return false;
|
return;
|
||||||
|
|
||||||
var context = new AbilityContext
|
var context = new AbilityContext
|
||||||
{
|
{
|
||||||
|
|
@ -1521,12 +1532,38 @@ public sealed class GameEngine
|
||||||
var isOnRearGuard = !fromHand && !fromDrop
|
var isOnRearGuard = !fromHand && !fromDrop
|
||||||
&& Array.IndexOf(State.Players[player].RearGuards, card) >= 0;
|
&& Array.IndexOf(State.Players[player].RearGuards, card) >= 0;
|
||||||
|
|
||||||
return script.Activated.Any(a =>
|
var available = script.Activated
|
||||||
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
|
.Where(ability =>
|
||||||
&& !a.RequiresDiscard
|
(fromHand ? ability.FromHand : fromDrop ? ability.FromDropZone
|
||||||
&& (!a.RestsSelf || !card.Rested)
|
: !ability.FromHand && !ability.FromDropZone)
|
||||||
&& (!a.RetiresSelf || isOnRearGuard)
|
&& (!(fromHand || fromDrop) || (!ability.RestsSelf && !ability.RetiresSelf))
|
||||||
&& a.CanActivate(context));
|
&& (!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)
|
private IEnumerable<int> UnitSlots(int player)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ public enum MatchFinishReason
|
||||||
Concede = 1,
|
Concede = 1,
|
||||||
Timeout = 2,
|
Timeout = 2,
|
||||||
Absence = 3,
|
Absence = 3,
|
||||||
|
Leave = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Результат для записи в профили и историю. MMR меняется только в ранкеде.</summary>
|
/// <summary>Результат для записи в профили и историю. MMR меняется только в ранкеде.</summary>
|
||||||
|
|
@ -87,6 +88,9 @@ public interface IMatchGrain
|
||||||
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
|
/// <summary>Отключить место. Отсчёт отсутствия начинается здесь.</summary>
|
||||||
Task DetachAsync(int seat, IMatchConnection connection);
|
Task DetachAsync(int seat, IMatchConnection connection);
|
||||||
|
|
||||||
|
/// <summary>Явный выход из матча: сразу поражение вышедшего.</summary>
|
||||||
|
Task LeaveAsync(int seat);
|
||||||
|
|
||||||
/// <summary>Обработать интент и отправить результат и события в соединения.</summary>
|
/// <summary>Обработать интент и отправить результат и события в соединения.</summary>
|
||||||
Task SubmitAsync(int seat, ClientIntent intent);
|
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)
|
public async Task SubmitAsync(int seat, ClientIntent intent)
|
||||||
{
|
{
|
||||||
if (seat is not (0 or 1))
|
if (seat is not (0 or 1))
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ public static class GameWebSocketEndpoint
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ClientLeave:
|
case ClientLeave:
|
||||||
|
await match.LeaveAsync(seat);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -731,6 +731,16 @@ public class GameEngineTests
|
||||||
Give(engine, 0, Marron);
|
Give(engine, 0, Marron);
|
||||||
var handBefore = engine.State.Players[0].Hand.Count;
|
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(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
|
||||||
|
|
||||||
Assert.True(lien.Rested);
|
Assert.True(lien.Rested);
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,45 @@ public class GameServerE2ETests
|
||||||
Assert.False(reconnected.View.IsFinished);
|
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 WebApplicationFactory<Program> NewFactory() => new();
|
||||||
|
|
||||||
private static async Task<QueueAccepted> EnqueueAsync(
|
private static async Task<QueueAccepted> EnqueueAsync(
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,45 @@ public class MatchGrainTests
|
||||||
Assert.Single(replacement.All<ServerHello>());
|
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(
|
private static MatchGrain NewGrain(
|
||||||
FakeTimeProvider time,
|
FakeTimeProvider time,
|
||||||
RecordingResultSink? sink = null,
|
RecordingResultSink? sink = null,
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,79 @@ public class MetaGameE2ETests
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
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(
|
private static HttpClient Authed(
|
||||||
WebApplicationFactory<Program> factory,
|
WebApplicationFactory<Program> factory,
|
||||||
AuthResponse auth)
|
AuthResponse auth)
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,32 @@ public class MetaProfileServiceTests
|
||||||
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
[Fact]
|
[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();
|
var factory = new TestFactory();
|
||||||
await CreateUsersAsync(factory);
|
await CreateUsersAsync(factory);
|
||||||
var service = NewService(factory);
|
var service = NewService(factory);
|
||||||
|
|
||||||
await service.RecordAsync(new MatchResult(
|
await service.RecordAsync(new MatchResult(
|
||||||
"m-forfeit", GameMode.ClassicRanked, "p1", "p2", 0,
|
"m-concede", GameMode.ClassicRanked, "p1", "p2", 0,
|
||||||
MatchFinishReason.Concede));
|
MatchFinishReason.Concede));
|
||||||
|
|
||||||
await using var db = factory.CreateDbContext();
|
await service.RecordAsync(new MatchResult(
|
||||||
var rows = await db.MatchHistory.OrderBy(row => row.UserId).ToListAsync();
|
"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.All(rows, row =>
|
||||||
{
|
{
|
||||||
Assert.Equal(0, row.Gold);
|
Assert.Equal(0, row.Gold);
|
||||||
Assert.Equal(0, row.Xp);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ internal sealed class TestGameClient : IAsyncDisposable
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendLeaveAsync(string reason, CancellationToken cancellationToken) =>
|
||||||
|
await SendAsync(new ClientLeave { Reason = reason }, cancellationToken);
|
||||||
|
|
||||||
public async Task<ServerIntentResult> SubmitAsync(
|
public async Task<ServerIntentResult> SubmitAsync(
|
||||||
string intentId,
|
string intentId,
|
||||||
IntentPayload intent,
|
IntentPayload intent,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue