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.
This commit is contained in:
MpakobecEx 2026-10-21 00:45:15 +05:00
parent 4dd96e6f02
commit 71db856480
5 changed files with 92 additions and 1 deletions

View File

@ -110,7 +110,8 @@
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом. - [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
- [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего. - [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего.
- [x] Перехват: карта 2 ранга из переднего ряда выходит в круг защиты, сила атаки сравнивается с ней; `InterceptOverride` и `InterceptFromBackRow` позволяют выдать способность эффектом. - [x] Перехват: карта 2 ранга из переднего ряда выходит в круг защиты, сила атаки сравнивается с ней; `InterceptOverride` и `InterceptFromBackRow` позволяют выдать способность эффектом.
- [ ] Move, Discard, Shuffle как отдельные интенты. - [x] Перемещение Арьергарда в пределах колонки; если круг занят — карты меняются местами; у центрального заднего ряда пары нет.
- [ ] Discard, Shuffle как отдельные интенты.
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). - [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие. - [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.

View File

@ -7,6 +7,7 @@ public sealed record FinishMulliganCommand : GameCommand;
public sealed record RideCommand(string PrintingId) : GameCommand; public sealed record RideCommand(string PrintingId) : GameCommand;
public sealed record SkipRideCommand : GameCommand; public sealed record SkipRideCommand : GameCommand;
public sealed record CallCommand(string PrintingId, int Slot) : 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 EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand; public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand;
public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : GameCommand; public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : GameCommand;

View File

@ -110,6 +110,7 @@ public sealed class GameEngine
RideCommand c => Ride(c.PrintingId), RideCommand c => Ride(c.PrintingId),
SkipRideCommand => SkipRide(), SkipRideCommand => SkipRide(),
CallCommand c => Call(c.PrintingId, c.Slot), CallCommand c => Call(c.PrintingId, c.Slot),
MoveCommand c => Move(c.FromSlot, c.ToSlot),
EnterBattleCommand => EnterBattle(), EnterBattleCommand => EnterBattle(),
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot), AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId), GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
@ -225,6 +226,35 @@ public sealed class GameEngine
return CommandResult.Ok(); 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();
}
/// <summary>Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет.</summary>
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() private CommandResult EnterBattle()
{ {
if (State.Phase != Phase.Main) if (State.Phase != Phase.Main)

View File

@ -10,6 +10,7 @@ public sealed record MulliganEvent(int Player, int Count) : GameEvent;
public sealed record CardDrawnEvent(int Player) : GameEvent; public sealed record CardDrawnEvent(int Player) : GameEvent;
public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent; public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent;
public sealed record UnitCalledEvent(int Player, int Slot, 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 AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : GameEvent; public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : GameEvent;
public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent; public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent;

View File

@ -414,6 +414,64 @@ public class GameEngineTests
Assert.False(engine.Execute(new InterceptCommand(0)).Accepted); 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] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {