Enforce front-row-only attacks and label support slots
CI / test (push) Successful in 2m34s Details

This commit is contained in:
MpakobecEx 2026-10-21 23:45:09 +05:00
parent cd2d40e4d4
commit 17c589ab1e
5 changed files with 69 additions and 8 deletions

View File

@ -640,7 +640,7 @@ namespace Vanguard.UI
AddCardBox( AddCardBox(
grid, grid,
card, card,
slot == -1 ? "V" : "R" + (slot + 1), slot == -1 ? "V" : slot <= 2 ? "SUP" + (slot + 1) : "R" + (slot + 1),
compact: false, compact: false,
player.Index, player.Index,
slot); slot);

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **176/176** (131 правила/контракты + 45 сервер) | | Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -72,7 +72,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
Ожидаемо: Ожидаемо:
``` ```
Пройден! : не пройдено 0, пройдено 131 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 132 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94
@ -305,7 +305,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
Orleans сериализуется (тестовый `TestCluster`); полный матч двух Orleans сериализуется (тестовый `TestCluster`); полный матч двух
клиентов через HTTP/WebSocket при `Orleans:Enabled=true`. клиентов через HTTP/WebSocket при `Orleans:Enabled=true`.
Проверено на `ssh home`: 131 + 45 теста, включая реальные PostgreSQL и Redis; Проверено на `ssh home`: 132 + 45 теста, включая реальные PostgreSQL и Redis;
`docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, `docker compose up` поднимает стенд, матч создаётся через HTTP-очередь,
а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`). а после `docker compose restart server` восстанавливается (`Recovery: 1 of 1`).
@ -320,6 +320,10 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
C 5 / R 10 / RR 25 / RRR 100 пыли, крафт 20 / 40 / 100 / 400, SP нельзя. C 5 / R 10 / RR 25 / RRR 100 пыли, крафт 20 / 40 / 100 / 400, SP нельзя.
- **Колоды**: CRUD, валидация 50 карт / ≤4 по `nameId` / Sentinel / Heal / - **Колоды**: CRUD, валидация 50 карт / ≤4 по `nameId` / Sentinel / Heal /
стартовый Авангард, проверка владения картами, лимит 30, шаринг по коду. стартовый Авангард, проверка владения картами, лимит 30, шаринг по коду.
- **Правило атаки**: атакуют только Вэнгард и Арьергард переднего ряда
(слоты 3/4); задний ряд (0–2) — только поддержка/boost. Задний ряд
также нельзя атаковать без `snipeBackRow`. `LegalCommands` не предлагает
атаки из заднего ряда; добавлен регрессионный тест.
- **Магазин**: бустер 100 Gold или 1 токен; вторая триал-дека 500 Gold; - **Магазин**: бустер 100 Gold или 1 токен; вторая триал-дека 500 Gold;
состав бустера 4 C + 1 R+ (R 70 / RR 22 / RRR 7 / SP 1) из состав бустера 4 C + 1 R+ (R 70 / RR 22 / RRR 7 / SP 1) из
`content/pack_odds.yaml`; duplicate protection по `printingId` с порогом 4. `content/pack_odds.yaml`; duplicate protection по `printingId` с порогом 4.
@ -391,7 +395,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`.
/Storage/ EF-хранилище, восстановление матчей /Storage/ EF-хранилище, восстановление матчей
/Profiles/ интерфейс MMR /Profiles/ интерфейс MMR
/Dockerfile /Dockerfile
/server/tests/Vanguard.Rules.Tests 131 тест /server/tests/Vanguard.Rules.Tests 132 теста
/server/tests/Vanguard.Server.Tests 45 тестов, включая E2E и внешние хранилища /server/tests/Vanguard.Server.Tests 45 тестов, включая E2E и внешние хранилища
/content/sets карты /content/sets карты
/content/trial-decks.yaml две триал-деки /content/trial-decks.yaml две триал-деки
@ -729,7 +733,7 @@ Battle; Boot и общие сервисы остаются персистент
нельзя). нельзя).
- `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`, - `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`,
`critical`, `type`, `textEn/textRu` и флаги способностей. `critical`, `type`, `textEn/textRu` и флаги способностей.
- Проверки: серверные 131 + 45 на `ssh home`; Unity EditMode 2/2, - Проверки: серверные 132 + 45 на `ssh home`; Unity EditMode 2/2,
`SCENE_SMOKE success=True active=Login`; live `GET /api/cards` — 124 `SCENE_SMOKE success=True active=Login`; live `GET /api/cards` — 124
принта; live UI-проба: загрузка 50 карт, создание колоды, принта; live UI-проба: загрузка 50 карт, создание колоды,
добавление/удаление, ПКМ-оверлей, текст способности и разбор карты; добавление/удаление, ПКМ-оверлей, текст способности и разбор карты;

View File

@ -18,7 +18,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **131** правил + **45** серверных, CI зелёный. - Тесты: **132** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`, - Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/HANDOFF.md`. `docs/HANDOFF.md`.

View File

@ -1289,6 +1289,15 @@ public sealed class GameEngine
/// </summary> /// </summary>
public IReadOnlyList<GameCommand> LegalCommands() public IReadOnlyList<GameCommand> LegalCommands()
{ {
// Защита от состояния «бой уже начался, но очередь эффектов оставила
// continuation невыполненным». Иначе клиент получает EndTurn,
// который движок затем отклоняет.
if (State.Step == Step.None && State.Battle is not null
&& State.Continuation is not null)
{
ContinueOrder();
}
var commands = new List<GameCommand>(); var commands = new List<GameCommand>();
if (State.IsFinished) if (State.IsFinished)
return commands; return commands;
@ -1425,6 +1434,10 @@ public sealed class GameEngine
{ {
foreach (var attackerSlot in UnitSlots(player)) foreach (var attackerSlot in UnitSlots(player))
{ {
// Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд.
if (attackerSlot is 0 or 1 or 2)
continue;
var attacker = State.GetUnit(player, attackerSlot); var attacker = State.GetUnit(player, attackerSlot);
if (attacker is null || attacker.Rested) if (attacker is null || attacker.Rested)
continue; continue;
@ -1857,6 +1870,11 @@ public sealed class GameEngine
if (State.HasRestriction(attacker, RestrictionType.CannotAttack)) if (State.HasRestriction(attacker, RestrictionType.CannotAttack))
return CommandResult.Reject("Юнит не может атаковать."); return CommandResult.Reject("Юнит не может атаковать.");
// Атаковать могут только Вэнгард и Арьергард переднего ряда.
// Задний ряд — исключительно поддержка (boost) и эффекты.
if (attackerSlot is 0 or 1 or 2)
return CommandResult.Reject("Атаковать могут только юниты переднего ряда.");
var target = State.GetUnit(1 - State.ActivePlayer, targetSlot); var target = State.GetUnit(1 - State.ActivePlayer, targetSlot);
if (target is null) if (target is null)
return CommandResult.Reject("Нет цели."); return CommandResult.Reject("Нет цели.");

View File

@ -1707,6 +1707,7 @@ public class GameEngineTests
engine.Start(); engine.Start();
var random = new Random(seed); var random = new Random(seed);
GameCommand? lastCommand = null; GameCommand? lastCommand = null;
var history = new List<GameCommand>();
for (var step = 0; step < 3000 && !engine.State.IsFinished; step++) for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
{ {
@ -1719,9 +1720,20 @@ public class GameEngineTests
+ $"выбора {engine.State.PendingChoice is not null}"); + $"выбора {engine.State.PendingChoice is not null}");
var command = legal[random.Next(legal.Count)]; var command = legal[random.Next(legal.Count)];
history.Add(command);
if (history.Count > 6)
history.RemoveAt(0);
lastCommand = command; lastCommand = command;
var result = engine.Execute(command); var result = engine.Execute(command);
Assert.True(result.Accepted, $"{command} -> {result.Reason}"); Assert.True(result.Accepted, $"{seed}/{step}: {command} -> {result.Reason}; "
+ $"phase {engine.State.Phase}, step {engine.State.Step}, "
+ $"active {engine.State.ActivePlayer}, battle {engine.State.Battle is not null}, "
+ $"pending trigger {engine.State.PendingTrigger is not null}, "
+ $"ability {engine.State.PendingAbility is not null}, "
+ $"choice {engine.State.PendingChoice is not null}, "
+ $"queue {engine.State.PendingQueue?.Count ?? 0}, "
+ $"continuation {engine.State.Continuation is not null}; "
+ $"history {string.Join(" -> ", history)}");
AssertInvariants(engine.State, $"{seed}/{step}: {lastCommand}"); AssertInvariants(engine.State, $"{seed}/{step}: {lastCommand}");
} }
@ -1730,6 +1742,33 @@ public class GameEngineTests
} }
} }
[Fact]
public void Back_Row_RearGuard_Cannot_Attack()
{
var engine = InMainPhase();
Give(engine, 0, Marron);
Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted);
Give(engine, 0, Gallatin);
Assert.True(engine.Execute(new CallCommand(Gallatin, 3)).Accepted);
engine.State.Players[0].RearGuards[0]!.Rested = false;
engine.State.Players[0].RearGuards[3]!.Rested = false;
Assert.True(engine.Execute(new EnterBattleCommand()).Accepted);
var back = engine.Execute(new AttackCommand(0, -1));
Assert.False(back.Accepted);
Assert.Contains("переднего ряда", back.Reason);
var legal = engine.LegalCommands();
Assert.DoesNotContain(
legal,
command => command is AttackCommand attack && attack.AttackerSlot == 0);
Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
}
[Fact] [Fact]
public void Finish_Awards_Winner_And_Rejects_Later_Commands() public void Finish_Awards_Winner_And_Rejects_Later_Commands()
{ {