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:
parent
66d52169fc
commit
99532b6e8f
|
|
@ -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 без ошибок; реплей проверяется тестом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue