diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index b57ce68..5576251 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -100,26 +100,29 @@ Пишется **как чистая библиотека**: без Orleans, без БД, без сети. Вход — состояние + интент, выход — новое состояние + события. -- [ ] `GameState`: игроки, зоны, фазы, счётчики, `rngState`, `sequence`. -- [ ] Зоны: Vanguard, Rear-Guard (3 колонки × 2 ряда), Guardian, Soul, Damage, Drop, Trigger, Deck, Hand. -- [ ] Юнит: `instanceId`, `printingId`, `standState`, модификаторы, временные эффекты. -- [ ] Damage: список карт с `faceUp`, скрыт от обоих игроков. -- [ ] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End. -- [ ] Старт: выбор grade 0 → выставление → маллиган. -- [ ] Действия: Ride, Call, Move, Attack, Intercept, Guard, Boost, Drive, Damage, Stand/Rest, Retire, Draw, Discard, Shuffle. -- [ ] `driveCheckCount` как модифицируемое свойство. -- [ ] Результат боя: `Hit` / `Guarded` / `Missed` как событие. +- [x] `GameState`: игроки, зоны, фазы, счётчики, `rngState`. +- [x] Зоны: Vanguard, Rear-Guard (3 колонки × 2 ряда), Guardian, Soul, Damage, Drop, Trigger, Deck, Hand. +- [x] Юнит: `instanceId`, `printingId`, состояние Stand/Rest. +- [x] Damage: список карт с `faceUp`, скрыт от обоих игроков. +- [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End. +- [x] Старт: выбор grade 0 → выставление → маллиган. +- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw. +- [ ] Move, Intercept, Boost, Discard, Shuffle как отдельные интенты. +- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). +- [x] Результат боя: `Hit` / `Guarded` как событие. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». - [ ] Слои модификаторов силы/крита с длительностью. -- [ ] Валидация интентов: `Rejected(reason)` без изменения состояния. +- [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [ ] Список легальных интентов на каждый момент (для UI и античита). -- [ ] Детерминированный RNG: отдельные потоки для шаффла, drive, damage, эффектов. -- [ ] Журнал интентов + seed → реплей. +- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`. +- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан. - [ ] Защита от бесконечных циклов и дедлоков. +**Сделано:** каркас движка (`Engine/`), 8 тестов на старт, райд, призыв, атаку, защиту, урон и детерминизм. Всего 30 тестов. + **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. --- diff --git a/README.md b/README.md index 1da1598..346f06f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ | Документация и решения | готово | | Каталог карт (94 / 124 принта) | готово | | Контент-пайплайн и валидатор | готово | -| Игровое ядро (правила) | в работе | +| Игровое ядро (правила) | каркас: фазы, райд, призыв, бой, драйв, урон | | Сервер, клиент, лаунчер | не начато | ## Требования diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs new file mode 100644 index 0000000..728877a --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs @@ -0,0 +1,20 @@ +namespace Vanguard.Rules.Engine; + +public abstract record GameCommand; + +public sealed record MulliganCommand(IReadOnlyList 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); +} diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs new file mode 100644 index 0000000..72145d5 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -0,0 +1,591 @@ +using Vanguard.Rules.Cards; +using Vanguard.Rules.Decks; + +namespace Vanguard.Rules.Engine; + +/// +/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события. +/// +/// Упрощения первого среза (карты со способностями подключаются следующим шагом): +/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает; +/// * Boost не реализован; +/// * выбор цели триггера автоматический: усиление получает атакующая карта, +/// Stand возвращает первый повёрнутый Арьергард. +/// +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 _events = new(); + + public GameEngine(CardCatalog catalog, ulong seed, IReadOnlyList deck0, IReadOnlyList 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 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 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 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)); + } +} diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs new file mode 100644 index 0000000..994f121 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -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; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs new file mode 100644 index 0000000..e2df5d2 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -0,0 +1,55 @@ +namespace Vanguard.Rules.Engine; + +public enum Phase +{ + Mulligan, + Stand, + Draw, + Ride, + Main, + Battle, + End, +} + +public enum Step +{ + None, + Guard, +} + +/// Экземпляр карты в партии. Один принт может встречаться много раз. +public sealed class CardInstance +{ + public required int Id { get; init; } + public required string PrintingId { get; init; } + + /// Повёрнута ли карта (Rest). Актуально только для юнитов на поле. + public bool Rested { get; set; } + + /// Открыта ли карта. Для урона — лицом вверх после Counter Blast. + public bool FaceUp { get; set; } +} + +public sealed class PlayerState +{ + public PlayerState(string name) => Name = name; + + public string Name { get; } + + public List Deck { get; } = new(); + public List Hand { get; } = new(); + public CardInstance? Vanguard { get; set; } + + /// Пять кругов Арьергарда: 0–2 задний ряд, 3–4 передний (3 — левый, 4 — правый). + public CardInstance?[] RearGuards { get; } = new CardInstance?[RearGuardSlots]; + + public const int RearGuardSlots = 5; + + public List Guardian { get; } = new(); + public List Soul { get; } = new(); + public List Damage { get; } = new(); + public List Drop { get; } = new(); + public List Trigger { get; } = new(); + + public bool IsDefeated { get; set; } +} diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs new file mode 100644 index 0000000..01eb855 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -0,0 +1,56 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Engine; + +public sealed class BattleState +{ + public required int AttackerPlayer { get; init; } + + /// Индекс атакующего: -1 — Авангард, 0..4 — круг Арьергарда. + public required int AttackerSlot { get; init; } + + /// Индекс цели: -1 — Авангард противника. + public required int TargetSlot { get; init; } + + public int PowerBonus { get; set; } + public int CriticalBonus { get; set; } + public List 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]; +} diff --git a/server/src/Vanguard.Rules/Engine/Rng.cs b/server/src/Vanguard.Rules/Engine/Rng.cs new file mode 100644 index 0000000..f0920b7 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Rng.cs @@ -0,0 +1,34 @@ +namespace Vanguard.Rules.Engine; + +/// +/// Детерминированный генератор. Состояние хранится в GameState, +/// поэтому реплей воспроизводится из seed и списка команд. +/// +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(ref ulong state, IList 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]); + } + } +} diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs new file mode 100644 index 0000000..1c0649d --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -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 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 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(); + 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(); + 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; + } +}