diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 0e24fea..f28e147 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -640,7 +640,7 @@ namespace Vanguard.UI AddCardBox( grid, card, - slot == -1 ? "V" : "R" + (slot + 1), + slot == -1 ? "V" : slot <= 2 ? "SUP" + (slot + 1) : "R" + (slot + 1), compact: false, player.Index, slot); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 0099fff..43a6979 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **176/176** (131 правила/контракты + 45 сервер) | +| Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `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) OK: ошибок 0, предупреждений 0 Переведено: 94/94 @@ -305,7 +305,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. Orleans сериализуется (тестовый `TestCluster`); полный матч двух клиентов через HTTP/WebSocket при `Orleans:Enabled=true`. -Проверено на `ssh home`: 131 + 45 теста, включая реальные PostgreSQL и Redis; +Проверено на `ssh home`: 132 + 45 теста, включая реальные PostgreSQL и Redis; `docker compose up` поднимает стенд, матч создаётся через HTTP-очередь, а после `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 нельзя. - **Колоды**: CRUD, валидация 50 карт / ≤4 по `nameId` / Sentinel / Heal / стартовый Авангард, проверка владения картами, лимит 30, шаринг по коду. +- **Правило атаки**: атакуют только Вэнгард и Арьергард переднего ряда + (слоты 3/4); задний ряд (0–2) — только поддержка/boost. Задний ряд + также нельзя атаковать без `snipeBackRow`. `LegalCommands` не предлагает + атаки из заднего ряда; добавлен регрессионный тест. - **Магазин**: бустер 100 Gold или 1 токен; вторая триал-дека 500 Gold; состав бустера 4 C + 1 R+ (R 70 / RR 22 / RRR 7 / SP 1) из `content/pack_odds.yaml`; duplicate protection по `printingId` с порогом 4. @@ -391,7 +395,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. /Storage/ EF-хранилище, восстановление матчей /Profiles/ интерфейс MMR /Dockerfile -/server/tests/Vanguard.Rules.Tests 131 тест +/server/tests/Vanguard.Rules.Tests 132 теста /server/tests/Vanguard.Server.Tests 45 тестов, включая E2E и внешние хранилища /content/sets карты /content/trial-decks.yaml две триал-деки @@ -729,7 +733,7 @@ Battle; Boot и общие сервисы остаются персистент нельзя). - `MetaService.CraftAsync`; `GET /api/cards` отдаёт `power`, `shield`, `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 принта; live UI-проба: загрузка 50 карт, создание колоды, добавление/удаление, ПКМ-оверлей, текст способности и разбор карты; diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index ff884f3..0e9319d 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -18,7 +18,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **131** правил + **45** серверных, CI зелёный. +- Тесты: **132** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Документация: `docs/server-api.md`, `docs/protocol.md`, `docs/HANDOFF.md`. diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 48c5786..eec3bb8 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -1289,6 +1289,15 @@ public sealed class GameEngine /// public IReadOnlyList LegalCommands() { + // Защита от состояния «бой уже начался, но очередь эффектов оставила + // continuation невыполненным». Иначе клиент получает EndTurn, + // который движок затем отклоняет. + if (State.Step == Step.None && State.Battle is not null + && State.Continuation is not null) + { + ContinueOrder(); + } + var commands = new List(); if (State.IsFinished) return commands; @@ -1425,6 +1434,10 @@ public sealed class GameEngine { foreach (var attackerSlot in UnitSlots(player)) { + // Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд. + if (attackerSlot is 0 or 1 or 2) + continue; + var attacker = State.GetUnit(player, attackerSlot); if (attacker is null || attacker.Rested) continue; @@ -1857,6 +1870,11 @@ public sealed class GameEngine if (State.HasRestriction(attacker, RestrictionType.CannotAttack)) return CommandResult.Reject("Юнит не может атаковать."); + // Атаковать могут только Вэнгард и Арьергард переднего ряда. + // Задний ряд — исключительно поддержка (boost) и эффекты. + if (attackerSlot is 0 or 1 or 2) + return CommandResult.Reject("Атаковать могут только юниты переднего ряда."); + var target = State.GetUnit(1 - State.ActivePlayer, targetSlot); if (target is null) return CommandResult.Reject("Нет цели."); diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index b00d1e7..3d47ca1 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1707,6 +1707,7 @@ public class GameEngineTests engine.Start(); var random = new Random(seed); GameCommand? lastCommand = null; + var history = new List(); for (var step = 0; step < 3000 && !engine.State.IsFinished; step++) { @@ -1719,9 +1720,20 @@ public class GameEngineTests + $"выбора {engine.State.PendingChoice is not null}"); var command = legal[random.Next(legal.Count)]; + history.Add(command); + if (history.Count > 6) + history.RemoveAt(0); lastCommand = 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}"); } @@ -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] public void Finish_Awards_Winner_And_Rejects_Later_Commands() {