From 99532b6e8f16c28f835147462a8e0e632d438060 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 14:03:14 +0500 Subject: [PATCH] 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. --- IMPLEMENTATION_CHECKLIST.md | 5 +- README.md | 7 +- server/src/Vanguard.Cli/Program.cs | 111 ++++++++++++++++++ .../Diagnostics/GameInvariants.cs | 53 +++++++++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 37 ++++-- .../Vanguard.Rules.Tests/GameEngineTests.cs | 22 ++++ 6 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 server/src/Vanguard.Rules/Diagnostics/GameInvariants.cs diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 6f580d4..0d08c94 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -136,13 +136,14 @@ - [x] Журнал событий + seed; сериализуемый реплей. — `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий. - [~] Защита от бесконечных циклов и дедлоков. - — 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет. + — 50 партий случайного бота в тестах и 1000 через `stress`; лимит 5000 шагов на партию в стресс-тесте. Глобального лимита шагов внутри движка нет. - [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6. - [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга. -**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот на 50 партиях, реплей с хэшем событий. Всего 116 тестов. +**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот (1000 партий через `stress`), реплей с хэшем событий. Всего 117 тестов. **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. + — выполнено: `dotnet run --project server/src/Vanguard.Cli -- stress 1000` → 1000/1000 без ошибок; реплей проверяется тестом. --- diff --git a/README.md b/README.md index 346f06f..2522413 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ dotnet run --project server/src/Vanguard.Cli -- validate # сводка по кланам, триггерам и редкостям dotnet run --project server/src/Vanguard.Cli -- stats +# 1000 партий случайного бота с проверкой инвариантов +dotnet run --project server/src/Vanguard.Cli -- stress 1000 + # тесты dotnet test server/Vanguard.slnx ``` @@ -37,9 +40,7 @@ dotnet test server/Vanguard.slnx ``` Карт: 94 Принтов: 124 - [Warning] card.no-ru-name: Без русского названия: 94 карт (...). - -OK: ошибок 0, предупреждений 1 +OK: ошибок 0, предупреждений 0 ``` ## Структура diff --git a/server/src/Vanguard.Cli/Program.cs b/server/src/Vanguard.Cli/Program.cs index 9b1bc56..cf0b686 100644 --- a/server/src/Vanguard.Cli/Program.cs +++ b/server/src/Vanguard.Cli/Program.cs @@ -2,6 +2,9 @@ using Vanguard.Content.CardText; using Vanguard.Content.Translations; 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 root = FindRepoRoot() ?? Directory.GetCurrentDirectory(); @@ -11,6 +14,7 @@ return command switch "validate" => RunValidate(PickPath(1) ?? Path.Combine(root, "content", "sets")), "stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")), "translations" => RunTranslations(root), + "stress" => RunStress(root, args), _ => Usage(), }; @@ -50,6 +54,112 @@ static int RunValidate(string setsDirectory) 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? 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 BuildBotDeck(CardCatalog catalog) +{ + var deck = new List(); + var used = new HashSet(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) { var catalog = CatalogLoader.LoadDirectory(setsDirectory); @@ -154,6 +264,7 @@ static int Usage() stats [путь] сводка по кланам, триггерам и редкостям translations build [путь] собрать файл переводов translations check [путь] проверить переводы + stress [партий] прогнать случайного бота и проверить инварианты """); return 2; } diff --git a/server/src/Vanguard.Rules/Diagnostics/GameInvariants.cs b/server/src/Vanguard.Rules/Diagnostics/GameInvariants.cs new file mode 100644 index 0000000..677fc96 --- /dev/null +++ b/server/src/Vanguard.Rules/Diagnostics/GameInvariants.cs @@ -0,0 +1,53 @@ +using Vanguard.Rules.Engine; + +namespace Vanguard.Rules.Diagnostics; + +/// Проверки, которые должны выполняться после любой команды. +public static class GameInvariants +{ + public const int DeckSize = 50; + + public static IReadOnlyList Check(GameState state) + { + var issues = new List(); + 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)) + { + 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; + } +} diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index e9d8c3e..bf7aa42 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -234,16 +234,20 @@ public sealed class GameEngine TriggerAll(AbilityTrigger.OnPhaseStart); } - private void OfferAbility(TriggeredAbility ability) + private enum OfferResult + { + Skipped, + Paused, + Resolved, + } + + private OfferResult OfferAbility(TriggeredAbility ability) { var definition = ability.Definition; // если стоимость оплатить нельзя, способность не предлагается if (definition.CanPay is not null && !definition.CanPay(ability.Context)) - { - ContinueOrder(); - return; - } + return OfferResult.Skipped; _events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger)); @@ -252,10 +256,11 @@ public sealed class GameEngine State.PendingAbility = ability; State.Step = Step.Ability; State.ActivePlayer = ability.Owner; - return; + return OfferResult.Paused; } ResolveAbility(ability, accepted: true); + return OfferResult.Resolved; } private void ResolveAbility(TriggeredAbility ability, bool accepted) @@ -303,7 +308,10 @@ public sealed class GameEngine var next = order[0]; State.PendingOrder = null; State.ActivePlayer = next.Owner; - OfferAbility(next); + + if (OfferAbility(next) == OfferResult.Skipped) + ContinueOrder(); + return; } @@ -464,7 +472,9 @@ public sealed class GameEngine if (order.Count == 0) State.PendingOrder = null; - OfferAbility(chosen); + if (OfferAbility(chosen) == OfferResult.Skipped) + ContinueOrder(); + return CommandResult.Ok(); } @@ -1960,6 +1970,17 @@ public sealed class GameEngine 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(); } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 83307de..8ea17dc 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -54,6 +54,8 @@ public class GameEngineTests private const string Joka = "TD02/010EN"; private const string Tejas = "BT01/023EN"; private const string Yaksha = "TD02/003EN"; + private const string Hagakure = "BT01/070EN"; + private const string Chigasumi = "BT01/068EN"; [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -1658,6 +1660,26 @@ public class GameEngineTests 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] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {