From 71db8564802b3af91ac7b8246a9a143fac2c5435 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 00:45:15 +0500 Subject: [PATCH] Move: rear-guard moves within its column and swaps with an occupied circle MoveCommand(from, to) in the Main phase. Only 0-3 (left) and 2-4 (right) are valid pairs; the center back row has no partner. Rested state stays with the card. Five tests. --- IMPLEMENTATION_CHECKLIST.md | 3 +- .../src/Vanguard.Rules/Engine/GameCommands.cs | 1 + .../src/Vanguard.Rules/Engine/GameEngine.cs | 30 ++++++++++ .../src/Vanguard.Rules/Engine/GameEvents.cs | 1 + .../Vanguard.Rules.Tests/GameEngineTests.cs | 58 +++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index c054ea2..b9dda45 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -110,7 +110,8 @@ - [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом. - [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего. - [x] Перехват: карта 2 ранга из переднего ряда выходит в круг защиты, сила атаки сравнивается с ней; `InterceptOverride` и `InterceptFromBackRow` позволяют выдать способность эффектом. -- [ ] Move, Discard, Shuffle как отдельные интенты. +- [x] Перемещение Арьергарда в пределах колонки; если круг занят — карты меняются местами; у центрального заднего ряда пары нет. +- [ ] Discard, Shuffle как отдельные интенты. - [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). - [x] Результат боя: `Hit` / `Guarded` как событие. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs index 825cf46..1ca2153 100644 --- a/server/src/Vanguard.Rules/Engine/GameCommands.cs +++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs @@ -7,6 +7,7 @@ public sealed record FinishMulliganCommand : GameCommand; public sealed record RideCommand(string PrintingId) : GameCommand; public sealed record SkipRideCommand : GameCommand; public sealed record CallCommand(string PrintingId, int Slot) : GameCommand; +public sealed record MoveCommand(int FromSlot, int ToSlot) : GameCommand; public sealed record EnterBattleCommand : GameCommand; public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand; public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : GameCommand; diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 0a1b9f7..ced8234 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -110,6 +110,7 @@ public sealed class GameEngine RideCommand c => Ride(c.PrintingId), SkipRideCommand => SkipRide(), CallCommand c => Call(c.PrintingId, c.Slot), + MoveCommand c => Move(c.FromSlot, c.ToSlot), EnterBattleCommand => EnterBattle(), AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot), GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId), @@ -225,6 +226,35 @@ public sealed class GameEngine return CommandResult.Ok(); } + private CommandResult Move(int fromSlot, int toSlot) + { + if (State.Phase != Phase.Main) + return CommandResult.Reject("Перемещение доступно только в Основной фазе."); + + if (fromSlot is < 0 or >= PlayerState.RearGuardSlots || toSlot is < 0 or >= PlayerState.RearGuardSlots) + return CommandResult.Reject("Некорректный круг Арьергарда."); + + var player = State.Players[State.ActivePlayer]; + var unit = player.RearGuards[fromSlot]; + if (unit is null) + return CommandResult.Reject("В исходном кругу нет карты."); + + if (!SameColumn(fromSlot, toSlot)) + return CommandResult.Reject("Двигать можно только в пределах своей колонки."); + + // целевой круг может быть занят — тогда карты меняются местами + var other = player.RearGuards[toSlot]; + player.RearGuards[toSlot] = unit; + player.RearGuards[fromSlot] = other; + + _events.Add(new UnitMovedEvent(State.ActivePlayer, fromSlot, toSlot)); + return CommandResult.Ok(); + } + + /// Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет. + private static bool SameColumn(int fromSlot, int toSlot) => + (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2); + private CommandResult EnterBattle() { if (State.Phase != Phase.Main) diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 593e6d4..77ebf33 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -10,6 +10,7 @@ public sealed record MulliganEvent(int Player, int Count) : GameEvent; public sealed record CardDrawnEvent(int Player) : GameEvent; public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent; public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent; +public sealed record UnitMovedEvent(int Player, int FromSlot, int ToSlot) : GameEvent; public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent; public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : GameEvent; public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent; diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 6b3d496..b688776 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -414,6 +414,64 @@ public class GameEngineTests Assert.False(engine.Execute(new InterceptCommand(0)).Accepted); } + [Fact] + public void Move_To_Empty_Circle_In_Same_Column() + { + var engine = InMainPhase(); + Give(engine, 0, Marron); + engine.Execute(new CallCommand(Marron, 0)); + + Assert.True(engine.Execute(new MoveCommand(0, 3)).Accepted); + + Assert.Null(engine.State.Players[0].RearGuards[0]); + Assert.NotNull(engine.State.Players[0].RearGuards[3]); + Assert.Contains(engine.Events, e => e is UnitMovedEvent { FromSlot: 0, ToSlot: 3 }); + } + + [Fact] + public void Move_Swaps_Occupied_Circles() + { + var engine = InMainPhase(); + Give(engine, 0, Marron); + engine.Execute(new CallCommand(Marron, 0)); + Give(engine, 0, Gallatin); + engine.Execute(new CallCommand(Gallatin, 3)); + + Assert.True(engine.Execute(new MoveCommand(0, 3)).Accepted); + + Assert.Equal(Marron, engine.State.Players[0].RearGuards[3]!.PrintingId); + Assert.Equal(Gallatin, engine.State.Players[0].RearGuards[0]!.PrintingId); + } + + [Fact] + public void Move_Rejects_Other_Column() + { + var engine = InMainPhase(); + Give(engine, 0, Marron); + engine.Execute(new CallCommand(Marron, 0)); + + Assert.False(engine.Execute(new MoveCommand(0, 4)).Accepted); + } + + [Fact] + public void Center_Back_Row_Has_No_Pair() + { + var engine = InMainPhase(); + Give(engine, 0, Marron); + engine.Execute(new CallCommand(Marron, 1)); + + Assert.False(engine.Execute(new MoveCommand(1, 3)).Accepted); + Assert.False(engine.Execute(new MoveCommand(1, 0)).Accepted); + } + + [Fact] + public void Move_Is_Available_Only_In_Main_Phase() + { + var engine = StartedEngine(); // фаза Смены + + Assert.False(engine.Execute(new MoveCommand(0, 3)).Accepted); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {