diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index 33c427e..c8d801e 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -130,12 +130,16 @@
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
-- [ ] Список легальных интентов на каждый момент (для UI и античита).
+- [x] Список легальных интентов на каждый момент (для UI и античита).
+ — `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан.
-- [ ] Защита от бесконечных циклов и дедлоков.
+- [~] Защита от бесконечных циклов и дедлоков.
+ — 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
+- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
+- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
-**Сделано:** каркас движка (`Engine/`), 8 тестов на старт, райд, призыв, атаку, защиту, урон и детерминизм. Всего 30 тестов.
+**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 58 карт со скриптами, `LegalCommands`, случайный бот на 50 партиях. Всего 112 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index 4989a54..64c50cf 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -223,7 +223,10 @@ public sealed class GameEngine
// если стоимость оплатить нельзя, способность не предлагается
if (definition.CanPay is not null && !definition.CanPay(ability.Context))
+ {
+ ContinueOrder();
return;
+ }
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger));
@@ -965,7 +968,20 @@ public sealed class GameEngine
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
- AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => CallFromDeck(card, player, slot));
+
+ // призывать некуда — карта возвращается в руку, а не теряется
+ if (!HasEmptyRearGuard(player))
+ {
+ State.Players[player].Hand.Add(card);
+ return;
+ }
+
+ State.InTransit.Add((player, card));
+ AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot =>
+ {
+ State.InTransit.Remove((player, card));
+ CallFromDeck(card, player, slot);
+ });
});
}
@@ -1066,7 +1082,10 @@ public sealed class GameEngine
{
var p = State.Players[player];
if (p.RearGuards[slot] is not null)
+ {
+ p.Hand.Add(card);
return;
+ }
card.Rested = true;
p.RearGuards[slot] = card;
@@ -1205,6 +1224,275 @@ public sealed class GameEngine
return true;
}
+ // ---------- легальные интенты ----------
+
+ ///
+ /// Все команды, которые сейчас имеют смысл. Нужны ботам, тестам и будущему UI.
+ /// Список может быть неполным там, где команда требует дополнительного выбора
+ /// (например, сброс карты как стоимость активируемой способности).
+ ///
+ public IReadOnlyList LegalCommands()
+ {
+ var commands = new List();
+ if (State.IsFinished)
+ return commands;
+
+ if (State.PendingChoice is { } choice)
+ {
+ for (var i = 0; i < choice.Options.Count; i++)
+ commands.Add(new ChooseCommand(i));
+
+ return commands;
+ }
+
+ if (State.PendingAbility is not null)
+ {
+ commands.Add(new ResolveAbilityCommand(true));
+ commands.Add(new ResolveAbilityCommand(false));
+ return commands;
+ }
+
+ if (State.PendingOrder is { Count: > 0 } order)
+ {
+ for (var i = 0; i < order.Count; i++)
+ {
+ if (order[i].Owner == State.ActivePlayer)
+ commands.Add(new SelectTriggerCommand(i));
+ }
+
+ return commands;
+ }
+
+ if (State.PendingTrigger is { } pending)
+ {
+ var hasRearGuard = State.Players[pending.Player].RearGuards.Any(u => u is not null);
+
+ foreach (var powerSlot in UnitSlots(pending.Player))
+ {
+ if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
+ {
+ foreach (var effectSlot in UnitSlots(pending.Player))
+ {
+ if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
+ continue;
+
+ commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
+ }
+ }
+ else
+ {
+ commands.Add(new ResolveTriggerCommand(powerSlot, -1));
+ }
+ }
+
+ return commands;
+ }
+
+ var player = State.ActivePlayer;
+ var p = State.Players[player];
+
+ switch (State.Phase)
+ {
+ case Phase.Mulligan:
+ commands.Add(new FinishMulliganCommand());
+ break;
+
+ case Phase.Ride:
+ foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
+ {
+ if (State.Catalog.CardOf(printingId).Grade == VanguardGrade(player) + 1)
+ commands.Add(new RideCommand(printingId));
+ }
+
+ commands.Add(new SkipRideCommand());
+ break;
+
+ case Phase.Main:
+ AddMainCommands(commands, player);
+ break;
+
+ case Phase.Battle:
+ AddBattleCommands(commands, player);
+ break;
+ }
+
+ return commands;
+ }
+
+ private void AddMainCommands(List commands, int player)
+ {
+ var p = State.Players[player];
+ var maxGrade = VanguardGrade(player);
+ var emptySlots = Enumerable.Range(0, PlayerState.RearGuardSlots)
+ .Where(s => p.RearGuards[s] is null)
+ .ToList();
+
+ foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
+ {
+ if (State.Catalog.CardOf(printingId).Grade > maxGrade)
+ continue;
+
+ foreach (var slot in emptySlots)
+ commands.Add(new CallCommand(printingId, slot));
+ }
+
+ for (var from = 0; from < PlayerState.RearGuardSlots; from++)
+ {
+ if (p.RearGuards[from] is null)
+ continue;
+
+ for (var to = 0; to < PlayerState.RearGuardSlots; to++)
+ {
+ if (to != from && SameColumn(from, to))
+ commands.Add(new MoveCommand(from, to));
+ }
+ }
+
+ foreach (var unit in AllUnits(player))
+ {
+ if (CanActivateFrom(unit, player, fromHand: false, fromDrop: false))
+ commands.Add(new ActivateAbilityCommand(unit.Id));
+ }
+
+ foreach (var card in p.Hand)
+ {
+ if (CanActivateFrom(card, player, fromHand: true, fromDrop: false))
+ commands.Add(new ActivateAbilityCommand(card.Id));
+ }
+
+ foreach (var card in p.Drop)
+ {
+ if (CanActivateFrom(card, player, fromHand: false, fromDrop: true))
+ commands.Add(new ActivateAbilityCommand(card.Id));
+ }
+
+ commands.Add(new EnterBattleCommand());
+ commands.Add(new EndTurnCommand());
+ }
+
+ private void AddBattleCommands(List commands, int player)
+ {
+ var p = State.Players[player];
+
+ if (State.Step == Step.None)
+ {
+ foreach (var attackerSlot in UnitSlots(player))
+ {
+ var attacker = State.GetUnit(player, attackerSlot);
+ if (attacker is null || attacker.Rested)
+ continue;
+
+ if (State.IsRestraint(attacker)
+ || State.HasRestriction(attacker, RestrictionType.CannotAttack))
+ continue;
+
+ var targets = new List { -1 };
+ targets.AddRange(UnitSlots(1 - player).Where(s => s >= 0));
+
+ foreach (var target in targets)
+ commands.Add(new AttackCommand(attackerSlot, target));
+
+ if (attackerSlot is 3 or 4)
+ {
+ var boosterSlot = attackerSlot == 3 ? 0 : 2;
+ var booster = State.GetUnit(player, boosterSlot);
+ if (booster is not null && !booster.Rested && State.CanBoost(booster))
+ {
+ foreach (var target in targets)
+ commands.Add(new AttackCommand(attackerSlot, target, boosterSlot));
+ }
+ }
+ }
+
+ commands.Add(new EndTurnCommand());
+ return;
+ }
+
+ if (State.Step != Step.Guard || State.Battle is null)
+ return;
+
+ foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
+ {
+ var def = State.Catalog.CardOf(printingId);
+ if (def.IsSentinel)
+ {
+ commands.Add(new GuardCommand(printingId));
+
+ foreach (var discardId in p.Hand
+ .Where(c => c.PrintingId != printingId
+ && State.Catalog.CardOf(c.PrintingId).Clan == def.Clan)
+ .Select(c => c.PrintingId)
+ .Distinct())
+ {
+ commands.Add(new GuardCommand(printingId, discardId));
+ }
+ }
+ else if (def.Shield > 0)
+ {
+ commands.Add(new GuardCommand(printingId));
+ }
+ }
+
+ foreach (var slot in Enumerable.Range(0, PlayerState.RearGuardSlots))
+ {
+ if (State.Battle.Interceptor is not null)
+ break;
+
+ if (slot == State.Battle.TargetSlot)
+ continue;
+
+ var unit = p.RearGuards[slot];
+ if (unit is null || unit.Rested)
+ continue;
+
+ if (slot is not (3 or 4) && !unit.InterceptFromBackRow)
+ continue;
+
+ if (!State.CanIntercept(unit))
+ continue;
+
+ commands.Add(new InterceptCommand(slot));
+ }
+
+ commands.Add(new PassGuardCommand());
+ }
+
+ private bool CanActivateFrom(CardInstance card, int player, bool fromHand, bool fromDrop)
+ {
+ var nameId = State.Catalog.CardOf(card.PrintingId).NameId;
+ if (!_scripts.TryGetValue(nameId, out var script))
+ return false;
+
+ var context = new AbilityContext
+ {
+ Engine = this,
+ Source = card,
+ Owner = player,
+ Phase = State.Phase,
+ };
+
+ var isOnRearGuard = !fromHand && !fromDrop
+ && Array.IndexOf(State.Players[player].RearGuards, card) >= 0;
+
+ return script.Activated.Any(a =>
+ (fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
+ && !a.RequiresDiscard
+ && (!a.RestsSelf || !card.Rested)
+ && (!a.RetiresSelf || isOnRearGuard)
+ && a.CanActivate(context));
+ }
+
+ private IEnumerable UnitSlots(int player)
+ {
+ yield return -1;
+
+ for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
+ {
+ if (State.Players[player].RearGuards[slot] is not null)
+ yield return slot;
+ }
+ }
+
// ---------- старт партии ----------
public void Start()
@@ -1879,7 +2167,11 @@ public sealed class GameEngine
return CommandResult.Reject("Нет цели для второго эффекта.");
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
- return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
+ {
+ // если Арьергардов нет, поднимать нечего — второй эффект не применяется
+ if (State.Players[player].RearGuards.Any(u => u is not null))
+ return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
+ }
}
if (pending.GrantsPower)
@@ -1900,7 +2192,8 @@ public sealed class GameEngine
critical: 1);
break;
case TriggerType.Stand:
- State.GetUnit(player, command.EffectTarget)!.Rested = false;
+ if (command.EffectTarget >= 0)
+ State.GetUnit(player, command.EffectTarget)!.Rested = false;
break;
case TriggerType.Draw:
Draw(player, 1);
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index 94ab8e5..6809890 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -70,6 +70,12 @@ public sealed class GameState
///
public List? PendingQueue { get; internal set; }
+ ///
+ /// Карты, которые уже покинули колоду, но ещё не положены в новую зону
+ /// (например, ожидают выбора круга для призыва).
+ ///
+ public List<(int Player, CardInstance Card)> InTransit { get; } = new();
+
public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; }
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 6cef7e4..39ed9c9 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -1589,6 +1589,79 @@ public class GameEngineTests
Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
}
+ [Fact]
+ public void Random_Bot_Games_Keep_Invariants_And_Terminate()
+ {
+ for (var seed = 1; seed <= 50; seed++)
+ {
+ var engine = NewEngine((ulong)seed);
+ engine.Start();
+ var random = new Random(seed);
+ GameCommand? lastCommand = null;
+
+ for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
+ {
+ var legal = engine.LegalCommands();
+ Assert.True(
+ legal.Count > 0,
+ $"{seed}/{step}: нет команд; фаза {engine.State.Phase}, шаг {engine.State.Step}, "
+ + $"ожидание триггера {engine.State.PendingTrigger is not null}, "
+ + $"способности {engine.State.PendingAbility is not null}, "
+ + $"выбора {engine.State.PendingChoice is not null}");
+
+ var command = legal[random.Next(legal.Count)];
+ lastCommand = command;
+ var result = engine.Execute(command);
+ Assert.True(result.Accepted, $"{command} -> {result.Reason}");
+
+ AssertInvariants(engine.State, $"{seed}/{step}: {lastCommand}");
+ }
+
+ Assert.True(engine.State.IsFinished, $"Партия {seed} не завершилась за 3000 шагов");
+ }
+ }
+
+ private static void AssertInvariants(GameState state, string context)
+ {
+ var ids = new HashSet();
+
+ for (var player = 0; player < 2; player++)
+ {
+ var p = state.Players[player];
+ var zones = new List>
+ {
+ p.Deck,
+ p.Hand,
+ p.Soul,
+ p.Damage,
+ p.Drop,
+ p.Trigger,
+ p.Guardian,
+ p.Vanguard is null ? Enumerable.Empty() : new[] { p.Vanguard },
+ p.RearGuards.OfType(),
+ state.InTransit.Where(t => t.Player == player).Select(t => t.Card),
+ };
+
+ var total = 0;
+ foreach (var card in zones.SelectMany(zone => zone))
+ {
+ Assert.True(ids.Add(card.Id), $"Повтор instanceId {card.Id}");
+ total++;
+ }
+
+ Assert.True(
+ total == 50,
+ $"Игрок {player}: {total} карт (deck {p.Deck.Count}, hand {p.Hand.Count}, "
+ + $"soul {p.Soul.Count}, damage {p.Damage.Count}, drop {p.Drop.Count}, "
+ + $"trigger {p.Trigger.Count}, guardian {p.Guardian.Count}, "
+ + $"vg {(p.Vanguard is null ? 0 : 1)}, rg {p.RearGuards.Count(u => u is not null)}) [{context}]");
+ Assert.True(p.Damage.Count <= GameEngine.MaxDamage);
+ }
+
+ if (state.IsFinished)
+ Assert.NotNull(state.Winner);
+ }
+
[Fact]
public void Same_Seed_And_Commands_Produce_Same_Events()
{