Stress-test 1000 bot games and fix the guard-step deadlock

GameInvariants checks card counts, unique instance ids and damage. The CLI stress command runs random legal bot games and fails on the first violation. It exposed a real bug: an optional OnGuard ability that could not be paid (or was declined) reset the step from Guard to None while the battle was still open. OfferAbility now reports Skipped/Paused/Resolved and callers continue only when needed. 1000/1000 games finish with no errors.
This commit is contained in:
MpakobecEx 2026-10-21 14:03:14 +05:00
parent 66d52169fc
commit 99532b6e8f
6 changed files with 222 additions and 13 deletions

View File

@ -136,13 +136,14 @@
- [x] Журнал событий + seed; сериализуемый реплей. - [x] Журнал событий + seed; сериализуемый реплей.
— `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий. — `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий.
- [~] Защита от бесконечных циклов и дедлоков. - [~] Защита от бесконечных циклов и дедлоков.
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет. — 50 партий случайного бота в тестах и 1000 через `stress`; лимит 5000 шагов на партию в стресс-тесте. Глобального лимита шагов внутри движка нет.
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6. - [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга. - [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот на 50 партиях, реплей с хэшем событий. Всего 116 тестов. **Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот (1000 партий через `stress`), реплей с хэшем событий. Всего 117 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
— выполнено: `dotnet run --project server/src/Vanguard.Cli -- stress 1000` → 1000/1000 без ошибок; реплей проверяется тестом.
--- ---

View File

@ -28,6 +28,9 @@ dotnet run --project server/src/Vanguard.Cli -- validate
# сводка по кланам, триггерам и редкостям # сводка по кланам, триггерам и редкостям
dotnet run --project server/src/Vanguard.Cli -- stats dotnet run --project server/src/Vanguard.Cli -- stats
# 1000 партий случайного бота с проверкой инвариантов
dotnet run --project server/src/Vanguard.Cli -- stress 1000
# тесты # тесты
dotnet test server/Vanguard.slnx dotnet test server/Vanguard.slnx
``` ```
@ -37,9 +40,7 @@ dotnet test server/Vanguard.slnx
``` ```
Карт: 94 Принтов: 124 Карт: 94 Принтов: 124
[Warning] card.no-ru-name: Без русского названия: 94 карт (...). OK: ошибок 0, предупреждений 0
OK: ошибок 0, предупреждений 1
``` ```
## Структура ## Структура

View File

@ -2,6 +2,9 @@
using Vanguard.Content.CardText; using Vanguard.Content.CardText;
using Vanguard.Content.Translations; using Vanguard.Content.Translations;
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Diagnostics;
using Vanguard.Rules.Engine;
using Vanguard.Rules.Replay;
var command = (args.Length > 0 ? args[0] : "validate").ToLowerInvariant(); var command = (args.Length > 0 ? args[0] : "validate").ToLowerInvariant();
var root = FindRepoRoot() ?? Directory.GetCurrentDirectory(); var root = FindRepoRoot() ?? Directory.GetCurrentDirectory();
@ -11,6 +14,7 @@ return command switch
"validate" => RunValidate(PickPath(1) ?? Path.Combine(root, "content", "sets")), "validate" => RunValidate(PickPath(1) ?? Path.Combine(root, "content", "sets")),
"stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")), "stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")),
"translations" => RunTranslations(root), "translations" => RunTranslations(root),
"stress" => RunStress(root, args),
_ => Usage(), _ => Usage(),
}; };
@ -50,6 +54,112 @@ static int RunValidate(string setsDirectory)
return report.IsValid ? 0 : 1; return report.IsValid ? 0 : 1;
} }
static int RunStress(string repoRoot, string[] args)
{
var games = args.Length > 1 && int.TryParse(args[1], out var parsed) ? parsed : 100;
var catalog = CatalogLoader.LoadDirectory(Path.Combine(repoRoot, "content", "sets"));
var finished = 0;
var failures = 0;
var totalSteps = 0;
for (var game = 0; game < games; game++)
{
var seed = (ulong)(game + 1);
var engine = new GameEngine(catalog, seed, BuildBotDeck(catalog), BuildBotDeck(catalog));
engine.Start();
var random = new Random(game + 1);
var steps = 0;
var history = new List<string>();
string? failure = null;
while (!engine.State.IsFinished && steps < 5000)
{
var legal = engine.LegalCommands();
if (legal.Count == 0)
{
failure = "нет легальных команд";
break;
}
var command = legal[random.Next(legal.Count)];
history.Add(CommandCodec.Encode(command));
if (history.Count > 10)
history.RemoveAt(0);
var result = engine.Execute(command);
if (!result.Accepted)
{
failure = $"{command}: {result.Reason} "
+ $"[фаза {engine.State.Phase}, шаг {engine.State.Step}, "
+ $"бой {engine.State.Battle is not null}, "
+ $"триггер {engine.State.PendingTrigger is not null}]"
+ $"\n {string.Join(" -> ", history)}";
break;
}
var issues = GameInvariants.Check(engine.State);
if (issues.Count > 0)
{
failure = string.Join("; ", issues);
break;
}
steps++;
}
totalSteps += steps;
if (failure is null && !engine.State.IsFinished)
failure = $"партия не завершилась за {steps} шагов";
if (failure is null)
{
finished++;
}
else
{
failures++;
Console.Error.WriteLine($" партия {seed}: {failure}");
}
}
Console.WriteLine(
$"Партий: {games}, завершено: {finished}, ошибок: {failures}, "
+ $"шагов в среднем: {totalSteps / Math.Max(1, games)}");
return failures == 0 && finished == games ? 0 : 1;
}
static List<string> BuildBotDeck(CardCatalog catalog)
{
var deck = new List<string>();
var used = new HashSet<string>(StringComparer.Ordinal);
void Add(string nameId, int count)
{
if (deck.Count >= 50 || !used.Add(nameId))
return;
var printing = catalog.Printings.FirstOrDefault(p => p.NameId == nameId && !p.IsParallel);
if (printing is null)
return;
for (var i = 0; i < count && deck.Count < 50; i++)
deck.Add(printing.PrintingId);
}
// сначала стартовые Авангарды, иначе некому начинать партию
foreach (var card in catalog.Cards.Where(c => c.IsStartingVanguardCandidate))
Add(card.NameId, 4);
foreach (var card in catalog.Cards.Where(c => !c.IsSentinel && c.Trigger != TriggerType.Heal))
Add(card.NameId, 4);
return deck;
}
static int RunStats(string setsDirectory) static int RunStats(string setsDirectory)
{ {
var catalog = CatalogLoader.LoadDirectory(setsDirectory); var catalog = CatalogLoader.LoadDirectory(setsDirectory);
@ -154,6 +264,7 @@ static int Usage()
stats [путь] сводка по кланам, триггерам и редкостям stats [путь] сводка по кланам, триггерам и редкостям
translations build [путь] собрать файл переводов translations build [путь] собрать файл переводов
translations check [путь] проверить переводы translations check [путь] проверить переводы
stress [партий] прогнать случайного бота и проверить инварианты
"""); """);
return 2; return 2;
} }

View File

@ -0,0 +1,53 @@
using Vanguard.Rules.Engine;
namespace Vanguard.Rules.Diagnostics;
/// <summary>Проверки, которые должны выполняться после любой команды.</summary>
public static class GameInvariants
{
public const int DeckSize = 50;
public static IReadOnlyList<string> Check(GameState state)
{
var issues = new List<string>();
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))
{
if (!ids.Add(card.Id))
issues.Add($"Игрок {player}: повтор instanceId {card.Id}");
total++;
}
if (total != DeckSize)
issues.Add($"Игрок {player}: {total} карт вместо {DeckSize}");
if (p.Damage.Count > GameEngine.MaxDamage)
issues.Add($"Игрок {player}: урон {p.Damage.Count} больше {GameEngine.MaxDamage}");
}
if (state.IsFinished && state.Winner is null)
issues.Add("Партия завершена без победителя");
return issues;
}
}

View File

@ -234,16 +234,20 @@ public sealed class GameEngine
TriggerAll(AbilityTrigger.OnPhaseStart); TriggerAll(AbilityTrigger.OnPhaseStart);
} }
private void OfferAbility(TriggeredAbility ability) private enum OfferResult
{
Skipped,
Paused,
Resolved,
}
private OfferResult OfferAbility(TriggeredAbility ability)
{ {
var definition = ability.Definition; var definition = ability.Definition;
// если стоимость оплатить нельзя, способность не предлагается // если стоимость оплатить нельзя, способность не предлагается
if (definition.CanPay is not null && !definition.CanPay(ability.Context)) if (definition.CanPay is not null && !definition.CanPay(ability.Context))
{ return OfferResult.Skipped;
ContinueOrder();
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));
@ -252,10 +256,11 @@ public sealed class GameEngine
State.PendingAbility = ability; State.PendingAbility = ability;
State.Step = Step.Ability; State.Step = Step.Ability;
State.ActivePlayer = ability.Owner; State.ActivePlayer = ability.Owner;
return; return OfferResult.Paused;
} }
ResolveAbility(ability, accepted: true); ResolveAbility(ability, accepted: true);
return OfferResult.Resolved;
} }
private void ResolveAbility(TriggeredAbility ability, bool accepted) private void ResolveAbility(TriggeredAbility ability, bool accepted)
@ -303,7 +308,10 @@ public sealed class GameEngine
var next = order[0]; var next = order[0];
State.PendingOrder = null; State.PendingOrder = null;
State.ActivePlayer = next.Owner; State.ActivePlayer = next.Owner;
OfferAbility(next);
if (OfferAbility(next) == OfferResult.Skipped)
ContinueOrder();
return; return;
} }
@ -464,7 +472,9 @@ public sealed class GameEngine
if (order.Count == 0) if (order.Count == 0)
State.PendingOrder = null; State.PendingOrder = null;
OfferAbility(chosen); if (OfferAbility(chosen) == OfferResult.Skipped)
ContinueOrder();
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -1960,6 +1970,17 @@ public sealed class GameEngine
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null); TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
// способность на размещение в (GC) может встать на выбор игрока:
// после её разрешения возвращаемся в шаг защиты
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () =>
{
State.Step = Step.Guard;
State.ActivePlayer = defenderIndex;
};
}
return CommandResult.Ok(); return CommandResult.Ok();
} }

View File

@ -54,6 +54,8 @@ public class GameEngineTests
private const string Joka = "TD02/010EN"; private const string Joka = "TD02/010EN";
private const string Tejas = "BT01/023EN"; private const string Tejas = "BT01/023EN";
private const string Yaksha = "TD02/003EN"; private const string Yaksha = "TD02/003EN";
private const string Hagakure = "BT01/070EN";
private const string Chigasumi = "BT01/068EN";
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -1658,6 +1660,26 @@ public class GameEngineTests
Assert.Equal(record.EventHash, restored.EventHash); Assert.Equal(record.EventHash, restored.EventHash);
} }
[Fact]
public void Guard_Trigger_Keeps_Guard_Step_After_Decline()
{
var engine = ReadyToAttack();
engine.State.Players[1].Vanguard = MakeCard(Chigasumi); // Nubatama
GiveDamage(engine, 1, 1);
engine.State.Players[1].Hand.Add(MakeCard(Hagakure));
engine.State.Players[0].Hand.Add(MakeCard(Gallatin)); // у атакующего больше карт
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Hagakure)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
Assert.Equal(Step.Guard, engine.State.Step);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {