Finish the card set: Tejas, Yaksha, back-row attacks and replay
Normal units can no longer attack the back row; Wyvern Strike, Tejas attacks the back row of its own column. Demonic Dragon Berserker, Yaksha rides itself from hand when an opponent's rear-guard is retired during the main phase. Match replay: CommandCodec, MatchRecord, MatchRecorder, ReplayRunner and an FNV event hash; a JSON round-trip replays a full bot game bit-for-bit. Three tests; 116 total.
This commit is contained in:
parent
4524c0f19f
commit
66d52169fc
|
|
@ -133,13 +133,14 @@
|
||||||
- [x] Список легальных интентов на каждый момент (для UI и античита).
|
- [x] Список легальных интентов на каждый момент (для UI и античита).
|
||||||
— `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
|
— `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
|
||||||
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
|
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
|
||||||
- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан.
|
- [x] Журнал событий + seed; сериализуемый реплей.
|
||||||
|
— `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий.
|
||||||
- [~] Защита от бесконечных циклов и дедлоков.
|
- [~] Защита от бесконечных циклов и дедлоков.
|
||||||
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
|
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
|
||||||
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
|
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
|
||||||
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
|
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
|
||||||
|
|
||||||
**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 58 карт со скриптами, `LegalCommands`, случайный бот на 50 партиях. Всего 112 тестов.
|
**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот на 50 партиях, реплей с хэшем событий. Всего 116 тестов.
|
||||||
|
|
||||||
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
|
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
|
||||||
|
|
||||||
|
|
@ -155,12 +156,12 @@
|
||||||
- [x] RU/EN тексты для всех 94.
|
- [x] RU/EN тексты для всех 94.
|
||||||
- [x] Сила/щит для 14 карт, которых нет в BT01.
|
- [x] Сила/щит для 14 карт, которых нет в BT01.
|
||||||
- [ ] Дашборд покрытия: `scripted / tested / total`.
|
- [ ] Дашборд покрытия: `scripted / tested / total`.
|
||||||
— сейчас заскриптовано **58** карт из 66 с текстом. Остались: Tejas (нужно правило атаки заднего ряда) и Yaksha (триггер из руки). Шесть карт — только напоминания о Sentinel/Heal, им скрипт не нужен.
|
— **66 из 66** карт с текстом закрыты: 59 скриптов + Tejas (флаг атаки заднего ряда) + 6 карт Sentinel/Heal, которые обрабатывает движок. Оставшихся карт нет.
|
||||||
- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa).
|
- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa, Yaksha).
|
||||||
- [x] Способности из руки (Gancelot) и из сброса (Monster Frank).
|
- [x] Способности из руки (Gancelot, Yaksha) и из сброса (Monster Frank).
|
||||||
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
|
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
|
||||||
- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
|
- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
|
||||||
- [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда.
|
- [x] Wyvern Strike, Tejas: атака заднего ряда только из своей колонки; обычные карты атакуют только передний ряд.
|
||||||
|
|
||||||
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.
|
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ cards:
|
||||||
critical: 1
|
critical: 1
|
||||||
trigger: None
|
trigger: None
|
||||||
clan: Kagero
|
clan: Kagero
|
||||||
|
snipeBackRow: true
|
||||||
textEn: "[CONT](VC/RC):If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit."
|
textEn: "[CONT](VC/RC):If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit."
|
||||||
- nameId: embodiment-of-spear-tahr
|
- nameId: embodiment-of-spear-tahr
|
||||||
nameEn: "Embodiment of Spear, Tahr"
|
nameEn: "Embodiment of Spear, Tahr"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public sealed class CardEntry
|
||||||
public Clan Clan { get; set; }
|
public Clan Clan { get; set; }
|
||||||
public bool Sentinel { get; set; }
|
public bool Sentinel { get; set; }
|
||||||
public bool Restraint { get; set; }
|
public bool Restraint { get; set; }
|
||||||
|
public bool SnipeBackRow { get; set; }
|
||||||
|
|
||||||
/// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary>
|
/// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary>
|
||||||
public bool? Boost { get; set; }
|
public bool? Boost { get; set; }
|
||||||
|
|
@ -44,6 +45,7 @@ public sealed class CardEntry
|
||||||
Clan = Clan,
|
Clan = Clan,
|
||||||
IsSentinel = Sentinel,
|
IsSentinel = Sentinel,
|
||||||
IsRestraint = Restraint,
|
IsRestraint = Restraint,
|
||||||
|
CanSnipeBackRow = SnipeBackRow,
|
||||||
BoostOverride = Boost,
|
BoostOverride = Boost,
|
||||||
TextEn = TextEn,
|
TextEn = TextEn,
|
||||||
TextRu = TextRu,
|
TextRu = TextRu,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ public sealed record CardDefinition
|
||||||
/// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary>
|
/// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary>
|
||||||
public bool HasIntercept => InterceptOverride ?? Grade == 2;
|
public bool HasIntercept => InterceptOverride ?? Grade == 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Может атаковать Арьергард в заднем ряду той же колонки (Wyvern Strike, Tejas).
|
||||||
|
/// </summary>
|
||||||
|
public bool CanSnipeBackRow { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Печатное число проверок при атаке из круга Авангарда:
|
/// Печатное число проверок при атаке из круга Авангарда:
|
||||||
/// 0–2 ранг — одна, 3 ранг и выше — двойная.
|
/// 0–2 ранг — одна, 3 ранг и выше — двойная.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ public static class CardScripts
|
||||||
["flame-of-hope-aermo"] = new AermoScript(),
|
["flame-of-hope-aermo"] = new AermoScript(),
|
||||||
["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"),
|
["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"),
|
||||||
["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"),
|
["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"),
|
||||||
|
["demonic-dragon-berserker-yaksha"] = new YakshaScript(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,32 @@ using Vanguard.Rules.Cards;
|
||||||
|
|
||||||
namespace Vanguard.Rules.Engine.Abilities;
|
namespace Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demonic Dragon Berserker, Yaksha: в Основную фазу, когда Арьергард противника
|
||||||
|
/// уходит в сброс, при Авангарде 2 ранга можно показать эту карту и совершить райд.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class YakshaScript : CardScript
|
||||||
|
{
|
||||||
|
public override string NameId => "demonic-dragon-berserker-yaksha";
|
||||||
|
|
||||||
|
public override IEnumerable<AbilityDefinition> Abilities
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
yield return new AbilityDefinition
|
||||||
|
{
|
||||||
|
Trigger = AbilityTrigger.OnOpponentRetire,
|
||||||
|
Optional = true,
|
||||||
|
Condition = ctx =>
|
||||||
|
ctx.Engine.IsInHand(ctx.Owner, ctx.Source) &&
|
||||||
|
ctx.Engine.VanguardGrade(ctx.Owner) == 2 &&
|
||||||
|
ctx.Phase == Phase.Main,
|
||||||
|
Resolve = ctx => ctx.Engine.RideFromHand(ctx.Source, ctx.Owner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает
|
/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает
|
||||||
/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan.
|
/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan.
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,23 @@ public sealed class GameEngine
|
||||||
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// способности из руки (Yaksha): «покажи и соверши райд»
|
||||||
|
foreach (var card in State.Players[owner].Hand)
|
||||||
|
{
|
||||||
|
var context = new AbilityContext
|
||||||
|
{
|
||||||
|
Engine = this,
|
||||||
|
Source = card,
|
||||||
|
Owner = owner,
|
||||||
|
Phase = State.Phase,
|
||||||
|
};
|
||||||
|
|
||||||
|
triggered.AddRange(AbilitiesOf(card)
|
||||||
|
.Where(a => a.Trigger == AbilityTrigger.OnOpponentRetire
|
||||||
|
&& (a.Condition is null || a.Condition(context)))
|
||||||
|
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
||||||
|
}
|
||||||
|
|
||||||
if (triggered.Count == 0)
|
if (triggered.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -1106,6 +1123,18 @@ public sealed class GameEngine
|
||||||
RideUnit(card, player);
|
RideUnit(card, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Райд картой из руки по эффекту (Demonic Dragon Berserker, Yaksha).</summary>
|
||||||
|
public void RideFromHand(CardInstance card, int player)
|
||||||
|
{
|
||||||
|
if (!State.Players[player].Hand.Remove(card))
|
||||||
|
return;
|
||||||
|
|
||||||
|
RideUnit(card, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInHand(int player, CardInstance card) =>
|
||||||
|
State.Players[player].Hand.Contains(card);
|
||||||
|
|
||||||
private void RideUnit(CardInstance card, int player)
|
private void RideUnit(CardInstance card, int player)
|
||||||
{
|
{
|
||||||
var p = State.Players[player];
|
var p = State.Players[player];
|
||||||
|
|
@ -1387,7 +1416,13 @@ public sealed class GameEngine
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var targets = new List<int> { -1 };
|
var targets = new List<int> { -1 };
|
||||||
targets.AddRange(UnitSlots(1 - player).Where(s => s >= 0));
|
var canSnipe = State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow;
|
||||||
|
|
||||||
|
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
|
||||||
|
&& (slot is 3 or 4
|
||||||
|
|| (canSnipe
|
||||||
|
&& attackerSlot is 3 or 4
|
||||||
|
&& SameColumn(attackerSlot, slot)))));
|
||||||
|
|
||||||
foreach (var target in targets)
|
foreach (var target in targets)
|
||||||
commands.Add(new AttackCommand(attackerSlot, target));
|
commands.Add(new AttackCommand(attackerSlot, target));
|
||||||
|
|
@ -1774,6 +1809,17 @@ public sealed class GameEngine
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return CommandResult.Reject("Нет цели.");
|
return CommandResult.Reject("Нет цели.");
|
||||||
|
|
||||||
|
// Задний ряд защищён: атаковать его можно только способностью
|
||||||
|
// и только из переднего ряда своей колонки.
|
||||||
|
if (targetSlot is 0 or 1 or 2)
|
||||||
|
{
|
||||||
|
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
|
||||||
|
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
|
||||||
|
|
||||||
|
if (attackerSlot is not (3 or 4) || !SameColumn(attackerSlot, targetSlot))
|
||||||
|
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
|
||||||
|
}
|
||||||
|
|
||||||
CardInstance? booster = null;
|
CardInstance? booster = null;
|
||||||
if (boosterSlot >= 0)
|
if (boosterSlot >= 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ public sealed class GameState
|
||||||
public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName)
|
public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName)
|
||||||
{
|
{
|
||||||
Catalog = catalog;
|
Catalog = catalog;
|
||||||
|
Seed = seed;
|
||||||
RngState = seed;
|
RngState = seed;
|
||||||
Players[0] = new PlayerState(firstPlayerName);
|
Players[0] = new PlayerState(firstPlayerName);
|
||||||
Players[1] = new PlayerState(secondPlayerName);
|
Players[1] = new PlayerState(secondPlayerName);
|
||||||
|
|
@ -47,6 +48,9 @@ public sealed class GameState
|
||||||
public int TurnNumber { get; internal set; }
|
public int TurnNumber { get; internal set; }
|
||||||
|
|
||||||
public ulong RngState { get; internal set; }
|
public ulong RngState { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Исходное зерно партии: нужно для записи и воспроизведения реплея.</summary>
|
||||||
|
public ulong Seed { get; }
|
||||||
public int NextInstanceId { get; internal set; } = 1;
|
public int NextInstanceId { get; internal set; } = 1;
|
||||||
|
|
||||||
public BattleState? Battle { get; internal set; }
|
public BattleState? Battle { get; internal set; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Replay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Компактное текстовое представление команд. Формат стабильный и годится
|
||||||
|
/// как для реплея, так и для журнала матча в БД.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommandCodec
|
||||||
|
{
|
||||||
|
private const char Separator = '|';
|
||||||
|
|
||||||
|
public static string Encode(GameCommand command) => command switch
|
||||||
|
{
|
||||||
|
MulliganCommand c => $"mulligan{Separator}{string.Join(',', c.HandIndexes)}",
|
||||||
|
FinishMulliganCommand => "finishMulligan",
|
||||||
|
RideCommand c => $"ride{Separator}{c.PrintingId}",
|
||||||
|
SkipRideCommand => "skipRide",
|
||||||
|
CallCommand c => $"call{Separator}{c.PrintingId}{Separator}{c.Slot}",
|
||||||
|
MoveCommand c => $"move{Separator}{c.FromSlot}{Separator}{c.ToSlot}",
|
||||||
|
EnterBattleCommand => "enterBattle",
|
||||||
|
AttackCommand c => $"attack{Separator}{c.AttackerSlot}{Separator}{c.TargetSlot}{Separator}{c.BoosterSlot}",
|
||||||
|
GuardCommand c => $"guard{Separator}{c.PrintingId}{Separator}{c.DiscardPrintingId}",
|
||||||
|
InterceptCommand c => $"intercept{Separator}{c.Slot}",
|
||||||
|
PassGuardCommand => "passGuard",
|
||||||
|
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
|
||||||
|
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
|
||||||
|
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}",
|
||||||
|
ChooseCommand c => $"choose{Separator}{c.Index}",
|
||||||
|
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}",
|
||||||
|
EndTurnCommand => "endTurn",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static GameCommand Decode(string encoded)
|
||||||
|
{
|
||||||
|
var parts = encoded.Split(Separator);
|
||||||
|
var name = parts[0];
|
||||||
|
|
||||||
|
return name switch
|
||||||
|
{
|
||||||
|
"mulligan" => new MulliganCommand(
|
||||||
|
parts[1].Length == 0
|
||||||
|
? Array.Empty<int>()
|
||||||
|
: parts[1].Split(',').Select(ParseInt).ToArray()),
|
||||||
|
"finishMulligan" => new FinishMulliganCommand(),
|
||||||
|
"ride" => new RideCommand(parts[1]),
|
||||||
|
"skipRide" => new SkipRideCommand(),
|
||||||
|
"call" => new CallCommand(parts[1], ParseInt(parts[2])),
|
||||||
|
"move" => new MoveCommand(ParseInt(parts[1]), ParseInt(parts[2])),
|
||||||
|
"enterBattle" => new EnterBattleCommand(),
|
||||||
|
"attack" => new AttackCommand(ParseInt(parts[1]), ParseInt(parts[2]), ParseInt(parts[3])),
|
||||||
|
"guard" => new GuardCommand(parts[1], Nullable(parts[2])),
|
||||||
|
"intercept" => new InterceptCommand(ParseInt(parts[1])),
|
||||||
|
"passGuard" => new PassGuardCommand(),
|
||||||
|
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
|
||||||
|
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
|
||||||
|
"resolveAbility" => new ResolveAbilityCommand(parts[1] == "1"),
|
||||||
|
"choose" => new ChooseCommand(ParseInt(parts[1])),
|
||||||
|
"activate" => new ActivateAbilityCommand(ParseInt(parts[1]), Nullable(parts[2])),
|
||||||
|
"endTurn" => new EndTurnCommand(),
|
||||||
|
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseInt(string value) =>
|
||||||
|
int.Parse(value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static string? Nullable(string value) =>
|
||||||
|
value.Length == 0 ? null : value;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
using Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Replay;
|
||||||
|
|
||||||
|
/// <summary>FNV-1a по тексту событий. Не криптография, а быстрая проверка расхождений.</summary>
|
||||||
|
public static class EventHasher
|
||||||
|
{
|
||||||
|
private const ulong Offset = 14695981039346656037UL;
|
||||||
|
private const ulong Prime = 1099511628211UL;
|
||||||
|
|
||||||
|
public static string Hash(IEnumerable<GameEvent> events)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
var hash = Offset;
|
||||||
|
|
||||||
|
foreach (var gameEvent in events)
|
||||||
|
{
|
||||||
|
foreach (var ch in gameEvent.ToString() ?? string.Empty)
|
||||||
|
{
|
||||||
|
hash ^= ch;
|
||||||
|
hash *= Prime;
|
||||||
|
}
|
||||||
|
|
||||||
|
hash ^= '\n';
|
||||||
|
hash *= Prime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.ToString("x16");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace Vanguard.Rules.Replay;
|
||||||
|
|
||||||
|
/// <summary>Запись матча: зерно, колоды, команды и хэш итоговых событий.</summary>
|
||||||
|
public sealed class MatchRecord
|
||||||
|
{
|
||||||
|
public ulong Seed { get; init; }
|
||||||
|
public List<string> Deck0 { get; init; } = new();
|
||||||
|
public List<string> Deck1 { get; init; } = new();
|
||||||
|
public List<string> Commands { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>Хэш событий всей партии: реплей должен дать точно такой же.</summary>
|
||||||
|
public string EventHash { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
using Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Replay;
|
||||||
|
|
||||||
|
/// <summary>Играет матч и записывает только принятые команды.</summary>
|
||||||
|
public sealed class MatchRecorder
|
||||||
|
{
|
||||||
|
private readonly GameEngine _engine;
|
||||||
|
private readonly List<string> _commands = new();
|
||||||
|
|
||||||
|
public MatchRecorder(GameEngine engine, IReadOnlyList<string> deck0, IReadOnlyList<string> deck1)
|
||||||
|
{
|
||||||
|
_engine = engine;
|
||||||
|
Deck0 = deck0.ToList();
|
||||||
|
Deck1 = deck1.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> Deck0 { get; }
|
||||||
|
public List<string> Deck1 { get; }
|
||||||
|
|
||||||
|
public CommandResult Execute(GameCommand command)
|
||||||
|
{
|
||||||
|
var result = _engine.Execute(command);
|
||||||
|
if (result.Accepted)
|
||||||
|
_commands.Add(CommandCodec.Encode(command));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MatchRecord ToRecord() => new()
|
||||||
|
{
|
||||||
|
Seed = _engine.State.Seed,
|
||||||
|
Deck0 = Deck0,
|
||||||
|
Deck1 = Deck1,
|
||||||
|
Commands = _commands.ToList(),
|
||||||
|
EventHash = EventHasher.Hash(_engine.Events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
using Vanguard.Rules.Cards;
|
||||||
|
using Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Replay;
|
||||||
|
|
||||||
|
/// <summary>Воспроизводит запись матча и проверяет, что события совпали.</summary>
|
||||||
|
public static class ReplayRunner
|
||||||
|
{
|
||||||
|
public static GameEngine Replay(CardCatalog catalog, MatchRecord record)
|
||||||
|
{
|
||||||
|
var engine = new GameEngine(catalog, record.Seed, record.Deck0, record.Deck1);
|
||||||
|
engine.Start();
|
||||||
|
|
||||||
|
foreach (var encoded in record.Commands)
|
||||||
|
{
|
||||||
|
var command = CommandCodec.Decode(encoded);
|
||||||
|
var result = engine.Execute(command);
|
||||||
|
|
||||||
|
if (!result.Accepted)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Реплей разошёлся на команде «{encoded}»: {result.Reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Verify(CardCatalog catalog, MatchRecord record)
|
||||||
|
{
|
||||||
|
var engine = Replay(catalog, record);
|
||||||
|
var actual = EventHasher.Hash(engine.Events);
|
||||||
|
|
||||||
|
if (!string.Equals(actual, record.EventHash, StringComparison.Ordinal))
|
||||||
|
throw new InvalidOperationException($"Хэш событий не совпал: {actual} != {record.EventHash}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Rules.Engine;
|
using Vanguard.Rules.Engine;
|
||||||
using Vanguard.Rules.Engine.Abilities;
|
using Vanguard.Rules.Engine.Abilities;
|
||||||
|
using Vanguard.Rules.Replay;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Vanguard.Rules.Tests;
|
namespace Vanguard.Rules.Tests;
|
||||||
|
|
@ -50,6 +52,8 @@ public class GameEngineTests
|
||||||
private const string Unicorn = "TD01/010EN";
|
private const string Unicorn = "TD01/010EN";
|
||||||
private const string Goku = "TD02/002EN";
|
private const string Goku = "TD02/002EN";
|
||||||
private const string Joka = "TD02/010EN";
|
private const string Joka = "TD02/010EN";
|
||||||
|
private const string Tejas = "BT01/023EN";
|
||||||
|
private const string Yaksha = "TD02/003EN";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
||||||
|
|
@ -353,12 +357,12 @@ public class GameEngineTests
|
||||||
{
|
{
|
||||||
var engine = ReadyToAttackRearGuard();
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.True(engine.Execute(new InterceptCommand(3)).Accepted);
|
Assert.True(engine.Execute(new InterceptCommand(3)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
Assert.NotNull(defender.RearGuards[0]); // цель выжила
|
Assert.NotNull(defender.RearGuards[4]); // цель выжила
|
||||||
Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг
|
Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг
|
||||||
Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin);
|
Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin);
|
||||||
Assert.Contains(engine.Events, e => e is InterceptedEvent);
|
Assert.Contains(engine.Events, e => e is InterceptedEvent);
|
||||||
|
|
@ -370,11 +374,11 @@ public class GameEngineTests
|
||||||
{
|
{
|
||||||
var engine = ReadyToAttackRearGuard();
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
Assert.Null(defender.RearGuards[0]);
|
Assert.Null(defender.RearGuards[4]);
|
||||||
Assert.Contains(defender.Drop, c => c.PrintingId == Marron);
|
Assert.Contains(defender.Drop, c => c.PrintingId == Marron);
|
||||||
Assert.Contains(engine.Events, e => e is UnitRetiredEvent);
|
Assert.Contains(engine.Events, e => e is UnitRetiredEvent);
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +390,7 @@ public class GameEngineTests
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
defender.RearGuards[3] = TakeFromDeck(defender, Marron);
|
defender.RearGuards[3] = TakeFromDeck(defender, Marron);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.False(engine.Execute(new InterceptCommand(3)).Accepted);
|
Assert.False(engine.Execute(new InterceptCommand(3)).Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,7 +401,7 @@ public class GameEngineTests
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
defender.RearGuards[1] = TakeFromDeck(defender, Gallatin);
|
defender.RearGuards[1] = TakeFromDeck(defender, Gallatin);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.False(engine.Execute(new InterceptCommand(1)).Accepted);
|
Assert.False(engine.Execute(new InterceptCommand(1)).Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,7 +415,7 @@ public class GameEngineTests
|
||||||
unit.InterceptFromBackRow = true;
|
unit.InterceptFromBackRow = true;
|
||||||
defender.RearGuards[1] = unit;
|
defender.RearGuards[1] = unit;
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.True(engine.Execute(new InterceptCommand(1)).Accepted);
|
Assert.True(engine.Execute(new InterceptCommand(1)).Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,8 +448,8 @@ public class GameEngineTests
|
||||||
{
|
{
|
||||||
var engine = ReadyToAttackRearGuard();
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.False(engine.Execute(new InterceptCommand(0)).Accepted);
|
Assert.False(engine.Execute(new InterceptCommand(4)).Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -554,7 +558,7 @@ public class GameEngineTests
|
||||||
{
|
{
|
||||||
var engine = ReadyToAttackRearGuard();
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
engine.Execute(new AttackCommand(3, 0));
|
engine.Execute(new AttackCommand(3, 4));
|
||||||
engine.Execute(new PassGuardCommand());
|
engine.Execute(new PassGuardCommand());
|
||||||
|
|
||||||
Assert.Empty(engine.Events.OfType<DriveCheckedEvent>());
|
Assert.Empty(engine.Events.OfType<DriveCheckedEvent>());
|
||||||
|
|
@ -567,7 +571,7 @@ public class GameEngineTests
|
||||||
engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1;
|
engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1;
|
||||||
StackDeck(engine.State.Players[0], Gallatin);
|
StackDeck(engine.State.Players[0], Gallatin);
|
||||||
|
|
||||||
engine.Execute(new AttackCommand(3, 0));
|
engine.Execute(new AttackCommand(3, 4));
|
||||||
engine.Execute(new PassGuardCommand());
|
engine.Execute(new PassGuardCommand());
|
||||||
|
|
||||||
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
|
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
|
||||||
|
|
@ -645,12 +649,12 @@ public class GameEngineTests
|
||||||
var engine = ReadyToAttackRearGuard();
|
var engine = ReadyToAttackRearGuard();
|
||||||
Give(engine, 1, Barcgal);
|
Give(engine, 1, Barcgal);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
|
||||||
Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted);
|
Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
Assert.NotNull(defender.RearGuards[0]);
|
Assert.NotNull(defender.RearGuards[4]);
|
||||||
Assert.Contains(defender.Drop, c => c.PrintingId == Barcgal);
|
Assert.Contains(defender.Drop, c => c.PrintingId == Barcgal);
|
||||||
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
|
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
|
||||||
}
|
}
|
||||||
|
|
@ -1182,10 +1186,10 @@ public class GameEngineTests
|
||||||
"test",
|
"test",
|
||||||
ModifierDuration.UntilEndOfTurn);
|
ModifierDuration.UntilEndOfTurn);
|
||||||
|
|
||||||
engine.Execute(new AttackCommand(3, 0));
|
engine.Execute(new AttackCommand(3, 4));
|
||||||
engine.Execute(new PassGuardCommand());
|
engine.Execute(new PassGuardCommand());
|
||||||
|
|
||||||
Assert.Null(engine.State.Players[1].RearGuards[0]);
|
Assert.Null(engine.State.Players[1].RearGuards[4]);
|
||||||
Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power));
|
Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1275,13 +1279,13 @@ public class GameEngineTests
|
||||||
engine.Execute(new EnterBattleCommand());
|
engine.Execute(new EnterBattleCommand());
|
||||||
|
|
||||||
var opponent = engine.State.Players[1];
|
var opponent = engine.State.Players[1];
|
||||||
opponent.RearGuards[0] = MakeCard(Marron);
|
opponent.RearGuards[4] = MakeCard(Marron);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(-1, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(-1, 4)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
Assert.False(overlord.Rested);
|
Assert.False(overlord.Rested);
|
||||||
Assert.Null(opponent.RearGuards[0]);
|
Assert.Null(opponent.RearGuards[4]);
|
||||||
|
|
||||||
engine.Execute(new EndTurnCommand());
|
engine.Execute(new EndTurnCommand());
|
||||||
Assert.Null(overlord.DriveChecksOverride);
|
Assert.Null(overlord.DriveChecksOverride);
|
||||||
|
|
@ -1581,6 +1585,79 @@ public class GameEngineTests
|
||||||
joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power));
|
joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rear_Guard_In_Back_Row_Cannot_Be_Attacked_Normally()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
|
||||||
|
|
||||||
|
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tejas_Attacks_Back_Row_In_Its_Column()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
|
var tejas = MakeCard(Tejas);
|
||||||
|
engine.State.Players[0].RearGuards[3] = tejas;
|
||||||
|
tejas.Rested = false;
|
||||||
|
|
||||||
|
var defender = engine.State.Players[1];
|
||||||
|
defender.RearGuards[0] = MakeCard(Barcgal);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
||||||
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
|
Assert.Null(defender.RearGuards[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired()
|
||||||
|
{
|
||||||
|
var engine = InMainPhase();
|
||||||
|
|
||||||
|
var oldVanguard = MakeCard(Gallatin);
|
||||||
|
engine.State.Players[0].Vanguard = oldVanguard;
|
||||||
|
|
||||||
|
var yaksha = MakeCard(Yaksha);
|
||||||
|
engine.State.Players[0].Hand.Add(yaksha);
|
||||||
|
|
||||||
|
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
|
||||||
|
|
||||||
|
engine.RetireRearGuard(1, 0);
|
||||||
|
|
||||||
|
Assert.NotNull(engine.State.PendingAbility);
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
|
||||||
|
|
||||||
|
Assert.Same(yaksha, engine.State.Players[0].Vanguard);
|
||||||
|
Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == yaksha.Id);
|
||||||
|
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == oldVanguard.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replay_Restores_The_Match_And_Event_Hash()
|
||||||
|
{
|
||||||
|
var engine = NewEngine(777);
|
||||||
|
var recorder = new MatchRecorder(engine, BuildDeck(), BuildDeck());
|
||||||
|
engine.Start();
|
||||||
|
var random = new Random(777);
|
||||||
|
|
||||||
|
for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
|
||||||
|
{
|
||||||
|
var legal = engine.LegalCommands();
|
||||||
|
recorder.Execute(legal[random.Next(legal.Count)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var record = recorder.ToRecord();
|
||||||
|
var json = JsonSerializer.Serialize(record);
|
||||||
|
var restored = JsonSerializer.Deserialize<MatchRecord>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(restored);
|
||||||
|
ReplayRunner.Verify(_catalog, restored);
|
||||||
|
Assert.Equal(record.EventHash, restored.EventHash);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||||
{
|
{
|
||||||
|
|
@ -1777,8 +1854,8 @@ public class GameEngineTests
|
||||||
engine.State.Players[0].RearGuards[3]!.Rested = false;
|
engine.State.Players[0].RearGuards[3]!.Rested = false;
|
||||||
|
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
defender.RearGuards[0] = TakeFromDeck(defender, Marron);
|
defender.RearGuards[4] = TakeFromDeck(defender, Marron); // цель в переднем ряду
|
||||||
defender.RearGuards[3] = TakeFromDeck(defender, Gallatin);
|
defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); // перехватчик
|
||||||
|
|
||||||
engine.Execute(new EnterBattleCommand());
|
engine.Execute(new EnterBattleCommand());
|
||||||
return engine;
|
return engine;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue