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() {