Game engine skeleton: phases, ride, call, battle, drive and damage

Pure rules library with no Orleans or database. Command in, events out. Deterministic RNG stored in GameState. Covers setup with starting vanguard and mulligan, six phases, ride, call, attack, guard, drive check, damage check with triggers, retirement and win at six damage. 8 engine tests, 30 total. Sentinel, boost and card abilities are the next step.
This commit is contained in:
MpakobecEx 2026-10-21 00:20:10 +05:00
parent 7687d2f0b6
commit 1b317b6532
9 changed files with 1039 additions and 13 deletions

View File

@ -100,26 +100,29 @@
Пишется **как чистая библиотека**: без Orleans, без БД, без сети. Вход — состояние + интент, выход — новое состояние + события. Пишется **как чистая библиотека**: без Orleans, без БД, без сети. Вход — состояние + интент, выход — новое состояние + события.
- [ ] `GameState`: игроки, зоны, фазы, счётчики, `rngState`, `sequence`. - [x] `GameState`: игроки, зоны, фазы, счётчики, `rngState`.
- [ ] Зоны: Vanguard, Rear-Guard (3 колонки × 2 ряда), Guardian, Soul, Damage, Drop, Trigger, Deck, Hand. - [x] Зоны: Vanguard, Rear-Guard (3 колонки × 2 ряда), Guardian, Soul, Damage, Drop, Trigger, Deck, Hand.
- [ ] Юнит: `instanceId`, `printingId`, `standState`, модификаторы, временные эффекты. - [x] Юнит: `instanceId`, `printingId`, состояние Stand/Rest.
- [ ] Damage: список карт с `faceUp`, скрыт от обоих игроков. - [x] Damage: список карт с `faceUp`, скрыт от обоих игроков.
- [ ] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End. - [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End.
- [ ] Старт: выбор grade 0 → выставление → маллиган. - [x] Старт: выбор grade 0 → выставление → маллиган.
- [ ] Действия: Ride, Call, Move, Attack, Intercept, Guard, Boost, Drive, Damage, Stand/Rest, Retire, Draw, Discard, Shuffle. - [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
- [ ] `driveCheckCount` как модифицируемое свойство. - [ ] Move, Intercept, Boost, Discard, Shuffle как отдельные интенты.
- [ ] Результат боя: `Hit` / `Guarded` / `Missed` как событие. - [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. - [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
- [ ] Слои модификаторов силы/крита с длительностью. - [ ] Слои модификаторов силы/крита с длительностью.
- [ ] Валидация интентов: `Rejected(reason)` без изменения состояния. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита). - [ ] Список легальных интентов на каждый момент (для UI и античита).
- [ ] Детерминированный RNG: отдельные потоки для шаффла, drive, damage, эффектов. - [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
- [ ] Журнал интентов + seed → реплей. - [~] Журнал событий + seed; сериализуемый реплей ещё не сделан.
- [ ] Защита от бесконечных циклов и дедлоков. - [ ] Защита от бесконечных циклов и дедлоков.
**Сделано:** каркас движка (`Engine/`), 8 тестов на старт, райд, призыв, атаку, защиту, урон и детерминизм. Всего 30 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
--- ---

View File

@ -11,7 +11,7 @@
| Документация и решения | готово | | Документация и решения | готово |
| Каталог карт (94 / 124 принта) | готово | | Каталог карт (94 / 124 принта) | готово |
| Контент-пайплайн и валидатор | готово | | Контент-пайплайн и валидатор | готово |
| Игровое ядро (правила) | в работе | | Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон |
| Сервер, клиент, лаунчер | не начато | | Сервер, клиент, лаунчер | не начато |
## Требования ## Требования

View File

@ -0,0 +1,20 @@
namespace Vanguard.Rules.Engine;
public abstract record GameCommand;
public sealed record MulliganCommand(IReadOnlyList<int> HandIndexes) : GameCommand;
public sealed record FinishMulliganCommand : GameCommand;
public sealed record RideCommand(string PrintingId) : GameCommand;
public sealed record SkipRideCommand : GameCommand;
public sealed record CallCommand(string PrintingId, int Slot) : GameCommand;
public sealed record EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot) : GameCommand;
public sealed record GuardCommand(string PrintingId) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason)
{
public static CommandResult Ok() => new(true, null);
public static CommandResult Reject(string reason) => new(false, reason);
}

View File

@ -0,0 +1,591 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
namespace Vanguard.Rules.Engine;
/// <summary>
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
/// * Boost не реализован;
/// * выбор цели триггера автоматический: усиление получает атакующая карта,
/// Stand возвращает первый повёрнутый Арьергард.
/// </summary>
public sealed class GameEngine
{
public const int StartingHand = 5;
public const int MaxDamage = 6;
public const int DriveChecks = 2;
public const int TriggerPowerBonus = 5000;
private readonly List<GameEvent> _events = new();
public GameEngine(CardCatalog catalog, ulong seed, IReadOnlyList<string> deck0, IReadOnlyList<string> deck1)
{
var violations = DeckValidator.Validate(catalog, deck0.ToList())
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
.ToList();
if (violations.Count > 0)
throw new InvalidOperationException(
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
State = new GameState(catalog, seed, "Player 1", "Player 2");
FillDeck(0, deck0);
FillDeck(1, deck1);
}
public GameState State { get; }
public IReadOnlyList<GameEvent> Events => _events;
// ---------- старт партии ----------
public void Start()
{
ShuffleDeck(0);
ShuffleDeck(1);
PlaceStartingVanguard(0);
PlaceStartingVanguard(1);
Draw(0, StartingHand);
Draw(1, StartingHand);
State.Phase = Phase.Mulligan;
_events.Add(new GameStartedEvent(0));
}
private void ShuffleDeck(int player)
{
var state = State.RngState;
Rng.Shuffle(ref state, State.Players[player].Deck);
State.RngState = state;
}
private void FillDeck(int player, IReadOnlyList<string> printingIds)
{
foreach (var printingId in printingIds)
{
State.Players[player].Deck.Add(new CardInstance
{
Id = State.NextInstanceId++,
PrintingId = printingId,
});
}
}
private void PlaceStartingVanguard(int player)
{
var p = State.Players[player];
var index = p.Deck.FindIndex(c =>
{
var def = State.Catalog.CardOf(c.PrintingId);
return def.IsStartingVanguardCandidate;
});
if (index < 0)
throw new InvalidOperationException("В колоде нет карты для стартового Авангарда (grade 0 без триггера).");
var card = p.Deck[index];
p.Deck.RemoveAt(index);
p.Vanguard = card;
}
// ---------- команды ----------
public CommandResult Execute(GameCommand command) => command switch
{
MulliganCommand c => Mulligan(c),
FinishMulliganCommand => FinishMulligan(),
RideCommand c => Ride(c.PrintingId),
SkipRideCommand => SkipRide(),
CallCommand c => Call(c.PrintingId, c.Slot),
EnterBattleCommand => EnterBattle(),
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot),
GuardCommand c => Guard(c.PrintingId),
PassGuardCommand => PassGuard(),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
};
private CommandResult Mulligan(MulliganCommand command)
{
if (State.Phase != Phase.Mulligan)
return CommandResult.Reject("Маллиган доступен только в начале партии.");
var player = State.ActivePlayer;
var hand = State.Players[player].Hand;
var indexes = command.HandIndexes.Distinct().OrderByDescending(i => i).ToList();
if (indexes.Any(i => i < 0 || i >= hand.Count))
return CommandResult.Reject("Некорректный индекс карты в руке.");
foreach (var index in indexes)
{
var card = hand[index];
hand.RemoveAt(index);
State.Players[player].Deck.Add(card);
}
ShuffleDeck(player);
Draw(player, indexes.Count);
_events.Add(new MulliganEvent(player, indexes.Count));
return CommandResult.Ok();
}
private CommandResult FinishMulligan()
{
if (State.Phase != Phase.Mulligan)
return CommandResult.Reject("Маллиган уже завершён.");
State.ActivePlayer = 0;
StartTurn();
return CommandResult.Ok();
}
private CommandResult Ride(string printingId)
{
if (State.Phase != Phase.Ride)
return CommandResult.Reject("Райд доступен только в фазе Смены.");
var player = State.Players[State.ActivePlayer];
var index = player.Hand.FindIndex(c => c.PrintingId == printingId);
if (index < 0)
return CommandResult.Reject("Карты нет в руке.");
var card = player.Hand[index];
var def = State.Catalog.CardOf(card.PrintingId);
var current = player.Vanguard is null ? -1 : State.Catalog.CardOf(player.Vanguard.PrintingId).Grade;
if (def.Grade != current && def.Grade != current + 1)
return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й.");
if (player.Vanguard is not null)
{
player.Vanguard.Rested = false;
player.Soul.Add(player.Vanguard);
}
player.Hand.RemoveAt(index);
card.Rested = false;
player.Vanguard = card;
_events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId));
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
return CommandResult.Ok();
}
private CommandResult SkipRide()
{
if (State.Phase != Phase.Ride)
return CommandResult.Reject("Пропустить смену можно только в фазе Смены.");
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
return CommandResult.Ok();
}
private CommandResult Call(string printingId, int slot)
{
if (State.Phase != Phase.Main)
return CommandResult.Reject("Призыв доступен только в Основной фазе.");
if (slot is < 0 or >= PlayerState.RearGuardSlots)
return CommandResult.Reject("Некорректный круг Арьергарда.");
var player = State.Players[State.ActivePlayer];
if (player.RearGuards[slot] is not null)
return CommandResult.Reject("Круг уже занят.");
var index = player.Hand.FindIndex(c => c.PrintingId == printingId);
if (index < 0)
return CommandResult.Reject("Карты нет в руке.");
var card = player.Hand[index];
player.Hand.RemoveAt(index);
card.Rested = true;
player.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
return CommandResult.Ok();
}
private CommandResult EnterBattle()
{
if (State.Phase != Phase.Main)
return CommandResult.Reject("Перейти в Боевую фазу можно только из Основной.");
State.Phase = Phase.Battle;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Battle));
return CommandResult.Ok();
}
private CommandResult Attack(int attackerSlot, int targetSlot)
{
if (State.Phase != Phase.Battle)
return CommandResult.Reject("Атака доступна только в Боевой фазе.");
if (State.Battle is not null)
return CommandResult.Reject("Идёт бой: сначала завершите защиту.");
if (attackerSlot is < -1 or >= PlayerState.RearGuardSlots)
return CommandResult.Reject("Некорректный атакующий.");
if (targetSlot is < -1 or >= PlayerState.RearGuardSlots)
return CommandResult.Reject("Некорректная цель.");
var attacker = State.GetUnit(State.ActivePlayer, attackerSlot);
if (attacker is null)
return CommandResult.Reject("Нет атакующего юнита.");
if (attacker.Rested)
return CommandResult.Reject("Юнит уже повёрнут.");
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
if (attackerDef.IsRestraint)
return CommandResult.Reject("Юнит со Сдерживанием не может атаковать.");
var target = State.GetUnit(1 - State.ActivePlayer, targetSlot);
if (target is null)
return CommandResult.Reject("Нет цели.");
var attackerPlayer = State.ActivePlayer;
attacker.Rested = true;
State.Battle = new BattleState
{
AttackerPlayer = attackerPlayer,
AttackerSlot = attackerSlot,
TargetSlot = targetSlot,
};
State.Step = Step.Guard;
// на шаге защиты ход ввода переходит обороняющемуся
State.ActivePlayer = 1 - attackerPlayer;
_events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot));
return CommandResult.Ok();
}
private CommandResult Guard(string printingId)
{
var battle = State.Battle;
if (State.Step != Step.Guard || battle is null)
return CommandResult.Reject("Сейчас нельзя защищаться.");
var defenderIndex = 1 - battle.AttackerPlayer;
if (State.ActivePlayer != defenderIndex)
return CommandResult.Reject("Защищается только обороняющийся.");
var defender = State.Players[defenderIndex];
var index = defender.Hand.FindIndex(c => c.PrintingId == printingId);
if (index < 0)
return CommandResult.Reject("Карты нет в руке.");
var card = defender.Hand[index];
var def = State.Catalog.CardOf(card.PrintingId);
var shield = def.IsSentinel ? 0 : def.Shield;
defender.Hand.RemoveAt(index);
defender.Guardian.Add(card);
battle.GuardCards.Add(card);
battle.GuardShield += shield;
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
return CommandResult.Ok();
}
private CommandResult PassGuard()
{
var battle = State.Battle;
if (State.Step != Step.Guard || battle is null)
return CommandResult.Reject("Сейчас нет боя.");
if (State.ActivePlayer != 1 - battle.AttackerPlayer)
return CommandResult.Reject("Защищается только обороняющийся.");
ResolveBattle(battle);
return CommandResult.Ok();
}
private CommandResult EndTurn()
{
if (State.Phase is not (Phase.Main or Phase.Battle))
return CommandResult.Reject("Завершить ход можно из Основной или Боевой фазы.");
if (State.Battle is not null)
return CommandResult.Reject("Сначала завершите текущий бой.");
State.Phase = Phase.End;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End));
if (State.IsFinished)
return CommandResult.Ok();
State.ActivePlayer = 1 - State.ActivePlayer;
StartTurn();
return CommandResult.Ok();
}
// ---------- ход ----------
private void StartTurn()
{
State.TurnNumber++;
foreach (var unit in AllUnits(State.ActivePlayer))
unit.Rested = false;
State.Phase = Phase.Stand;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Stand));
State.Phase = Phase.Draw;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Draw));
Draw(State.ActivePlayer, 1);
if (State.IsFinished)
return;
State.Phase = Phase.Ride;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Ride));
}
private IEnumerable<CardInstance> AllUnits(int player)
{
var p = State.Players[player];
if (p.Vanguard is not null)
yield return p.Vanguard;
foreach (var unit in p.RearGuards)
{
if (unit is not null)
yield return unit;
}
}
// ---------- бой ----------
private void ResolveBattle(BattleState battle)
{
var defenderIndex = 1 - battle.AttackerPlayer;
var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot);
var target = State.GetUnit(defenderIndex, battle.TargetSlot);
if (attacker is null || target is null)
{
ClearBattle(battle);
return;
}
if (battle.AttackerSlot < 0)
DriveCheck(battle);
if (State.IsFinished)
{
ClearBattle(battle);
return;
}
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var targetDef = State.Catalog.CardOf(target.PrintingId);
var attackPower = attackerDef.Power + battle.PowerBonus;
var defensePower = targetDef.Power + battle.GuardShield;
var hit = attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
if (hit)
{
if (battle.TargetSlot < 0)
{
var damage = attackerDef.Critical + battle.CriticalBonus;
DealDamage(defenderIndex, damage);
}
else
{
State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null;
State.Players[defenderIndex].Drop.Add(target);
_events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot));
}
}
ClearBattle(battle);
}
private void ClearBattle(BattleState battle)
{
var defender = State.Players[1 - battle.AttackerPlayer];
foreach (var guard in battle.GuardCards)
{
defender.Guardian.Remove(guard);
defender.Drop.Add(guard);
}
State.Battle = null;
State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer;
}
private void DriveCheck(BattleState battle)
{
var player = battle.AttackerPlayer;
for (var i = 0; i < DriveChecks; i++)
{
if (State.IsFinished)
return;
var card = TakeTop(player);
if (card is null)
return;
var def = State.Catalog.CardOf(card.PrintingId);
if (def.Trigger == TriggerType.None)
{
State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player));
continue;
}
State.Players[player].Trigger.Add(card);
battle.PowerBonus += TriggerPowerBonus;
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
switch (def.Trigger)
{
case TriggerType.Critical:
battle.CriticalBonus += 1;
break;
case TriggerType.Draw:
Draw(player, 1);
break;
case TriggerType.Stand:
StandFirstRested(player);
break;
case TriggerType.Heal:
Heal(player);
break;
}
}
}
private void DealDamage(int player, int amount)
{
for (var i = 0; i < amount; i++)
{
if (State.IsFinished)
return;
var card = TakeTop(player);
if (card is null)
return;
var def = State.Catalog.CardOf(card.PrintingId);
if (def.Trigger != TriggerType.None)
{
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
ApplyDamageTrigger(player, def.Trigger);
}
card.FaceUp = false;
State.Players[player].Damage.Add(card);
_events.Add(new DamageTakenEvent(player, 1, State.Players[player].Damage.Count));
if (State.Players[player].Damage.Count >= MaxDamage)
{
Defeat(player);
return;
}
}
}
private void ApplyDamageTrigger(int player, TriggerType trigger)
{
switch (trigger)
{
case TriggerType.Draw:
Draw(player, 1);
break;
case TriggerType.Stand:
StandFirstRested(player);
break;
case TriggerType.Heal:
Heal(player);
break;
}
}
private void StandFirstRested(int player)
{
var p = State.Players[player];
for (var i = 0; i < p.RearGuards.Length; i++)
{
if (p.RearGuards[i] is { Rested: true } unit)
{
unit.Rested = false;
return;
}
}
}
private void Heal(int player)
{
var p = State.Players[player];
var opponent = State.Players[1 - player];
if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0)
return;
var card = p.Damage[^1];
p.Damage.RemoveAt(p.Damage.Count - 1);
p.Drop.Add(card);
_events.Add(new HealedEvent(player));
}
// ---------- служебное ----------
private CardInstance? TakeTop(int player)
{
var deck = State.Players[player].Deck;
if (deck.Count == 0)
{
Defeat(player);
return null;
}
var card = deck[0];
deck.RemoveAt(0);
return card;
}
private void Draw(int player, int count)
{
for (var i = 0; i < count; i++)
{
if (State.IsFinished)
return;
var card = TakeTop(player);
if (card is null)
return;
State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player));
}
}
private void Defeat(int player)
{
if (State.IsFinished)
return;
State.Players[player].IsDefeated = true;
State.IsFinished = true;
State.Winner = 1 - player;
_events.Add(new GameFinishedEvent(State.Winner.Value));
}
}

View File

@ -0,0 +1,20 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine;
public abstract record GameEvent;
public sealed record GameStartedEvent(int FirstPlayer) : GameEvent;
public sealed record PhaseChangedEvent(int Player, Phase Phase) : GameEvent;
public sealed record MulliganEvent(int Player, int Count) : GameEvent;
public sealed record CardDrawnEvent(int Player) : GameEvent;
public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent;
public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent;
public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent;
public sealed record TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
public sealed record BattleResolvedEvent(bool Hit) : GameEvent;
public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameEvent;
public sealed record HealedEvent(int Player) : GameEvent;
public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent;
public sealed record GameFinishedEvent(int Winner) : GameEvent;

View File

@ -0,0 +1,55 @@
namespace Vanguard.Rules.Engine;
public enum Phase
{
Mulligan,
Stand,
Draw,
Ride,
Main,
Battle,
End,
}
public enum Step
{
None,
Guard,
}
/// <summary>Экземпляр карты в партии. Один принт может встречаться много раз.</summary>
public sealed class CardInstance
{
public required int Id { get; init; }
public required string PrintingId { get; init; }
/// <summary>Повёрнута ли карта (Rest). Актуально только для юнитов на поле.</summary>
public bool Rested { get; set; }
/// <summary>Открыта ли карта. Для урона — лицом вверх после Counter Blast.</summary>
public bool FaceUp { get; set; }
}
public sealed class PlayerState
{
public PlayerState(string name) => Name = name;
public string Name { get; }
public List<CardInstance> Deck { get; } = new();
public List<CardInstance> Hand { get; } = new();
public CardInstance? Vanguard { get; set; }
/// <summary>Пять кругов Арьергарда: 0–2 задний ряд, 3–4 передний (3 — левый, 4 — правый).</summary>
public CardInstance?[] RearGuards { get; } = new CardInstance?[RearGuardSlots];
public const int RearGuardSlots = 5;
public List<CardInstance> Guardian { get; } = new();
public List<CardInstance> Soul { get; } = new();
public List<CardInstance> Damage { get; } = new();
public List<CardInstance> Drop { get; } = new();
public List<CardInstance> Trigger { get; } = new();
public bool IsDefeated { get; set; }
}

View File

@ -0,0 +1,56 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine;
public sealed class BattleState
{
public required int AttackerPlayer { get; init; }
/// <summary>Индекс атакующего: -1 — Авангард, 0..4 — круг Арьергарда.</summary>
public required int AttackerSlot { get; init; }
/// <summary>Индекс цели: -1 — Авангард противника.</summary>
public required int TargetSlot { get; init; }
public int PowerBonus { get; set; }
public int CriticalBonus { get; set; }
public List<CardInstance> GuardCards { get; } = new();
public int GuardShield { get; set; }
}
public sealed class GameState
{
public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName)
{
Catalog = catalog;
RngState = seed;
Players[0] = new PlayerState(firstPlayerName);
Players[1] = new PlayerState(secondPlayerName);
}
public CardCatalog Catalog { get; }
public PlayerState[] Players { get; } = new PlayerState[2];
public int ActivePlayer { get; internal set; }
public Phase Phase { get; internal set; } = Phase.Mulligan;
public Step Step { get; internal set; } = Step.None;
public int TurnNumber { get; internal set; }
public ulong RngState { get; internal set; }
public int NextInstanceId { get; internal set; } = 1;
public BattleState? Battle { get; internal set; }
public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; }
public PlayerState Active => Players[ActivePlayer];
public PlayerState Opponent => Players[1 - ActivePlayer];
public CardDefinition Definition(CardInstance card) => Catalog.CardOf(card.PrintingId);
public CardInstance? GetUnit(int player, int slot) =>
slot < 0
? Players[player].Vanguard
: Players[player].RearGuards[slot];
}

View File

@ -0,0 +1,34 @@
namespace Vanguard.Rules.Engine;
/// <summary>
/// Детерминированный генератор. Состояние хранится в GameState,
/// поэтому реплей воспроизводится из seed и списка команд.
/// </summary>
public static class Rng
{
public static ulong NextUInt64(ref ulong state)
{
state += 0x9E3779B97F4A7C15UL;
var z = state;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
public static int Next(ref ulong state, int exclusiveMax)
{
if (exclusiveMax <= 0)
throw new ArgumentOutOfRangeException(nameof(exclusiveMax));
return (int)(NextUInt64(ref state) % (ulong)exclusiveMax);
}
public static void Shuffle<T>(ref ulong state, IList<T> items)
{
for (var i = items.Count - 1; i > 0; i--)
{
var j = Next(ref state, i + 1);
(items[i], items[j]) = (items[j], items[i]);
}
}
}

View File

@ -0,0 +1,247 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Xunit;
namespace Vanguard.Rules.Tests;
public class GameEngineTests
{
private readonly CardCatalog _catalog = TestCatalog.Instance;
private const string Barcgal = "BT01/003EN"; // grade 0, без триггера — стартовый Авангард
private const string Marron = "BT01/042EN"; // grade 1, 8000
private const string Gallatin = "BT01/021EN"; // grade 2, 10000
private const string Alfred = "BT01/001EN"; // grade 3
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
{
var engine = NewEngine();
engine.Start();
var player = engine.State.Players[0];
Assert.NotNull(player.Vanguard);
Assert.Equal(0, _catalog.CardOf(player.Vanguard!.PrintingId).Grade);
Assert.Equal(TriggerType.None, _catalog.CardOf(player.Vanguard.PrintingId).Trigger);
Assert.Equal(GameEngine.StartingHand, player.Hand.Count);
Assert.Equal(44, player.Deck.Count);
Assert.Equal(Phase.Mulligan, engine.State.Phase);
}
[Fact]
public void Ride_Rejects_Grade_Jump()
{
var engine = StartedEngine();
Give(engine, 0, Alfred);
var result = engine.Execute(new RideCommand(Alfred));
Assert.False(result.Accepted);
Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId);
}
[Fact]
public void Ride_Moves_Previous_Vanguard_To_Soul()
{
var engine = StartedEngine();
Give(engine, 0, Marron);
var result = engine.Execute(new RideCommand(Marron));
Assert.True(result.Accepted);
Assert.Equal(Marron, engine.State.Players[0].Vanguard!.PrintingId);
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
Assert.Equal(Phase.Main, engine.State.Phase);
}
[Fact]
public void Called_RearGuard_Enters_Rested_And_Slot_Is_Busy()
{
var engine = InMainPhase();
Give(engine, 0, Marron);
Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted);
Assert.True(engine.State.Players[0].RearGuards[0]!.Rested);
Give(engine, 0, Marron);
var second = engine.Execute(new CallCommand(Marron, 0));
Assert.False(second.Accepted);
}
[Fact]
public void Attack_Without_Guard_Deals_Damage()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Single(engine.State.Players[1].Damage);
Assert.Equal(Step.None, engine.State.Step);
}
[Fact]
public void Guard_Prevents_Damage()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Give(engine, 1, Barcgal);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Empty(engine.State.Players[1].Damage);
Assert.Single(engine.State.Players[1].Drop);
}
[Fact]
public void Sixth_Damage_Ends_The_Game()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
var defender = engine.State.Players[1];
for (var i = 0; i < GameEngine.MaxDamage - 1; i++)
defender.Damage.Add(TakeAnyFromDeck(defender));
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.True(engine.State.IsFinished);
Assert.Equal(0, engine.State.Winner);
Assert.Contains(engine.Events, e => e is GameFinishedEvent);
}
[Fact]
public void Same_Seed_And_Commands_Produce_Same_Events()
{
var first = RunScriptedMatch(seed: 12345);
var second = RunScriptedMatch(seed: 12345);
Assert.Equal(first, second);
}
private List<GameEvent> RunScriptedMatch(ulong seed)
{
var engine = NewEngine(seed);
engine.Start();
engine.Execute(new FinishMulliganCommand());
Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron));
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
return engine.Events.ToList();
}
// ---------- helpers ----------
private GameEngine NewEngine(ulong seed = 7) =>
new(_catalog, seed, BuildDeck(), BuildDeck());
private GameEngine StartedEngine()
{
var engine = NewEngine();
engine.Start();
engine.Execute(new FinishMulliganCommand());
return engine;
}
private GameEngine InMainPhase()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
return engine;
}
private GameEngine ReadyToAttack()
{
var engine = StartedEngine();
Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron));
engine.Execute(new EnterBattleCommand());
return engine;
}
private List<string> BuildDeck()
{
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred };
var names = mustInclude
.Select(_catalog.CardOf)
.Select(c => c.NameId)
.ToHashSet(StringComparer.Ordinal);
var deck = new List<string>();
foreach (var printing in mustInclude)
deck.AddRange(Enumerable.Repeat(printing, 4));
foreach (var group in _catalog.Printings
.Where(p => !p.IsParallel)
.GroupBy(p => p.NameId)
.OrderBy(g => g.Key, StringComparer.Ordinal))
{
if (deck.Count >= 50)
break;
if (names.Contains(group.Key))
continue;
var card = _catalog.Card(group.Key);
if (card.IsSentinel || card.Trigger == TriggerType.Heal)
continue;
var printingId = group.First().PrintingId;
for (var i = 0; i < 4 && deck.Count < 50; i++)
deck.Add(printingId);
}
return deck;
}
private static void Give(GameEngine engine, int player, string printingId)
{
var p = engine.State.Players[player];
var index = p.Deck.FindIndex(c => c.PrintingId == printingId);
Assert.True(index >= 0, $"В колоде нет {printingId}");
var card = p.Deck[index];
p.Deck.RemoveAt(index);
p.Hand.Add(card);
}
private static void StackDeck(PlayerState player, params string[] printingIds)
{
var cards = new List<CardInstance>();
foreach (var printingId in printingIds)
{
var index = player.Deck.FindIndex(c => c.PrintingId == printingId);
Assert.True(index >= 0, $"В колоде нет {printingId}");
cards.Add(player.Deck[index]);
player.Deck.RemoveAt(index);
}
for (var i = cards.Count - 1; i >= 0; i--)
player.Deck.Insert(0, cards[i]);
}
private static CardInstance TakeAnyFromDeck(PlayerState player)
{
Assert.NotEmpty(player.Deck);
var card = player.Deck[0];
player.Deck.RemoveAt(0);
return card;
}
private static CardInstance TakeFromDeck(PlayerState player, string printingId)
{
var index = player.Deck.FindIndex(c => c.PrintingId == printingId);
Assert.True(index >= 0, $"В колоде нет {printingId}");
var card = player.Deck[index];
player.Deck.RemoveAt(index);
return card;
}
}