Legal intents and a 50-game bot invariant test

GameEngine.LegalCommands returns every meaningful command for the current phase, pending trigger, ability, order or choice. A random bot plays 50 deterministic games and checks that each player always has 50 cards across zones, instance ids stay unique and damage never exceeds six. Fixes found by the bot: search-to-call no longer loses a card when there is no empty circle (InTransit), a stand trigger with no rear-guards can resolve, unpayable optional abilities no longer stall the order, and legal intents skip impossible retire-self and repeated intercept commands.
This commit is contained in:
MpakobecEx 2026-10-21 13:56:24 +05:00
parent d30be8cec2
commit 4524c0f19f
4 changed files with 382 additions and 6 deletions

View File

@ -130,12 +130,16 @@
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). - [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита). - [x] Список легальных интентов на каждый момент (для UI и античита).
— `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`. - [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан. - [~] Журнал событий + seed; сериализуемый реплей ещё не сделан.
- [ ] Защита от бесконечных циклов и дедлоков. - [~] Защита от бесконечных циклов и дедлоков.
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
**Сделано:** каркас движка (`Engine/`), 8 тестов на старт, райд, призыв, атаку, защиту, урон и детерминизм. Всего 30 тестов. **Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 58 карт со скриптами, `LegalCommands`, случайный бот на 50 партиях. Всего 112 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.

View File

@ -223,7 +223,10 @@ public sealed class GameEngine
// если стоимость оплатить нельзя, способность не предлагается // если стоимость оплатить нельзя, способность не предлагается
if (definition.CanPay is not null && !definition.CanPay(ability.Context)) if (definition.CanPay is not null && !definition.CanPay(ability.Context))
{
ContinueOrder();
return; return;
}
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger)); _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 => SearchDeck(player, prompt, predicate, card =>
{ {
_events.Add(new CardRevealedEvent(player, card.PrintingId)); _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]; var p = State.Players[player];
if (p.RearGuards[slot] is not null) if (p.RearGuards[slot] is not null)
{
p.Hand.Add(card);
return; return;
}
card.Rested = true; card.Rested = true;
p.RearGuards[slot] = card; p.RearGuards[slot] = card;
@ -1205,6 +1224,275 @@ public sealed class GameEngine
return true; return true;
} }
// ---------- легальные интенты ----------
/// <summary>
/// Все команды, которые сейчас имеют смысл. Нужны ботам, тестам и будущему UI.
/// Список может быть неполным там, где команда требует дополнительного выбора
/// (например, сброс карты как стоимость активируемой способности).
/// </summary>
public IReadOnlyList<GameCommand> LegalCommands()
{
var commands = new List<GameCommand>();
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<GameCommand> 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<GameCommand> 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<int> { -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<int> 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() public void Start()
@ -1879,7 +2167,11 @@ public sealed class GameEngine
return CommandResult.Reject("Нет цели для второго эффекта."); return CommandResult.Reject("Нет цели для второго эффекта.");
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0) 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) if (pending.GrantsPower)
@ -1900,7 +2192,8 @@ public sealed class GameEngine
critical: 1); critical: 1);
break; break;
case TriggerType.Stand: case TriggerType.Stand:
State.GetUnit(player, command.EffectTarget)!.Rested = false; if (command.EffectTarget >= 0)
State.GetUnit(player, command.EffectTarget)!.Rested = false;
break; break;
case TriggerType.Draw: case TriggerType.Draw:
Draw(player, 1); Draw(player, 1);

View File

@ -70,6 +70,12 @@ public sealed class GameState
/// </summary> /// </summary>
public List<TriggeredAbility>? PendingQueue { get; internal set; } public List<TriggeredAbility>? PendingQueue { get; internal set; }
/// <summary>
/// Карты, которые уже покинули колоду, но ещё не положены в новую зону
/// (например, ожидают выбора круга для призыва).
/// </summary>
public List<(int Player, CardInstance Card)> InTransit { get; } = new();
public bool IsFinished { get; internal set; } public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; } public int? Winner { get; internal set; }

View File

@ -1589,6 +1589,79 @@ public class GameEngineTests
Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); 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<int>();
for (var player = 0; player < 2; player++)
{
var p = state.Players[player];
var zones = new List<IEnumerable<CardInstance>>
{
p.Deck,
p.Hand,
p.Soul,
p.Damage,
p.Drop,
p.Trigger,
p.Guardian,
p.Vanguard is null ? Enumerable.Empty<CardInstance>() : new[] { p.Vanguard },
p.RearGuards.OfType<CardInstance>(),
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] [Fact]
public void Same_Seed_And_Commands_Produce_Same_Events() public void Same_Seed_And_Commands_Produce_Same_Events()
{ {