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; сериализуемый реплей.
|
||||
— `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 без ошибок; реплей проверяется тестом.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
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)
|
||||
{
|
||||
var catalog = CatalogLoader.LoadDirectory(setsDirectory);
|
||||
|
|
@ -154,6 +264,7 @@ static int Usage()
|
|||
stats [путь] сводка по кланам, триггерам и редкостям
|
||||
translations build [путь] собрать файл переводов
|
||||
translations check [путь] проверить переводы
|
||||
stress [партий] прогнать случайного бота и проверить инварианты
|
||||
""");
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue