diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 57bca08..fd0e895 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -72,6 +72,7 @@ namespace Vanguard.UI private int _pendingBoosterSlot = -1; private int? _triggerPowerSlot; private readonly HashSet _mulliganSelected = new HashSet(); + private bool _mulliganUsed; private int _abilityDamageIndex = -1; private readonly Dictionary _cardPanels = new Dictionary(StringComparer.Ordinal); @@ -208,6 +209,7 @@ namespace Vanguard.UI _pendingIntents.Clear(); _highlightKeys.Clear(); _mulliganSelected.Clear(); + _mulliganUsed = false; _abilityDamageIndex = -1; _pendingBoosterSlot = -1; _triggerPowerSlot = null; @@ -1229,16 +1231,16 @@ namespace Vanguard.UI var abilityTargeting = IsAbilityDamageTargeting(); var displayIndex = 0; - for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++) - { - AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting); - } - foreach (var card in player.FaceUpDamage) { - AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false); + AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting); displayIndex++; } + + for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++) + { + AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting: false); + } } private void AddDamageCard( @@ -1310,7 +1312,7 @@ namespace Vanguard.UI return false; var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); - if (self == null || self.FaceDownDamage == 0) + if (self == null || self.FaceUpDamage.Count == 0) return false; return (_view.LegalIntents ?? new List()) @@ -1660,7 +1662,8 @@ namespace Vanguard.UI _view != null && !_view.IsFinished && _view.Phase == GamePhase.Mulligan - && _view.ActivePlayer == _view.You; + && _view.ActivePlayer == _view.You + && !_mulliganUsed; private int HandIndexOf(int instanceId) { @@ -1697,6 +1700,7 @@ namespace Vanguard.UI var indexes = _mulliganSelected.OrderBy(index => index).ToList(); _mulliganSelected.Clear(); + _mulliganUsed = true; OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); } diff --git a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs index 4fcb60d..ba12ac7 100644 --- a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs +++ b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs @@ -1010,7 +1010,7 @@ namespace Vanguard.UI private void AddCard(CollectionItemDto item) { - if (!_catalog.TryGetValue(item.PrintingId, out _)) + if (!_catalog.TryGetValue(item.PrintingId, out var itemMeta)) return; if (_cards.Count >= 50) @@ -1025,6 +1025,12 @@ namespace Vanguard.UI return; } + if (IsTrigger(itemMeta.Trigger) && TriggerCount() >= 16) + { + SetEditorStatus("В колоде уже 16 триггеров.", true); + return; + } + if (_cards.Count(card => card == item.PrintingId) >= item.Count) { SetEditorStatus("В коллекции больше нет свободных копий.", true); @@ -1091,8 +1097,9 @@ namespace Vanguard.UI } _stats.text = "Всего: " + _cards.Count + "/50 G0 " + grades[0] - + " · G1 " + grades[1] + " · G2 " + grades[2] + " · G3 " + grades[3]; - _deckCount.text = "Колода: " + _cards.Count + "/50"; + + " · G1 " + grades[1] + " · G2 " + grades[2] + " · G3 " + grades[3] + + " · T " + TriggerCount() + "/16"; + _deckCount.text = "Колода: " + _cards.Count + "/50 · триггеры: " + TriggerCount() + "/16"; } private void OpenFilters() @@ -1150,6 +1157,12 @@ namespace Vanguard.UI if (_busy) return; + if (_cards.Count != 50 || TriggerCount() != 16) + { + SetEditorStatus("Нужно ровно 50 карт и 16 триггеров.", true); + return; + } + _busy = true; SetEditorStatus("Сохраняем…", false); try @@ -1346,6 +1359,14 @@ namespace Vanguard.UI _cards.Count(printingId => _catalog.TryGetValue(printingId, out var meta) && meta.NameId == nameId); + private int TriggerCount() => + _cards.Count(printingId => + _catalog.TryGetValue(printingId, out var meta) && IsTrigger(meta.Trigger)); + + private static bool IsTrigger(string trigger) => + !string.IsNullOrEmpty(trigger) + && !string.Equals(trigger, "None", StringComparison.Ordinal); + private static string TriggerShort(string trigger) => trigger switch { "Critical" => "K", diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs index eeac4fd..9ba8c7e 100644 --- a/client/Assets/Vanguard/Runtime/UI/Localization.cs +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -328,6 +328,9 @@ namespace Vanguard.UI ["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).", ["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ", ["УРОН · "] = "DAMAGE · ", + ["Нужно ровно 50 карт и 16 триггеров."] = "The deck needs exactly 50 cards and 16 triggers.", + ["В колоде уже 16 триггеров."] = "The deck already has 16 triggers.", + [" · триггеры: "] = " · triggers: ", }; private static readonly KeyValuePair[] Phrases = Exact diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index da23eb0..131d708 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **180/180** (135 правил/контрактов + 45 сервер) | +| Тесты сервера | **183/183** (138 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -72,7 +72,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 135 (Vanguard.Rules.Tests) +Пройден! : не пройдено 0, пройдено 138 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 @@ -785,8 +785,8 @@ Battle; Boot и общие сервисы остаются персистент - имена/тексты карт: `Loc.CardName` / `Loc.CardText` выбирают `nameRu/nameEn` и `textRu/textEn`; карточные поля помечены literal, чтобы словарь не портил переводы карт. -- Проверки: Unity EditMode **3/3** (экраны, импорт арта, перевод статики и - динамики, live-смена языка), `SCENE_SMOKE success=True active=Login`. +- Проверки: Unity EditMode **4/4** (экраны, импорт арта, перевод статики и + динамики, live-смена языка, рендер доски с уроном), `SCENE_SMOKE success=True active=Login`. - Ограничения: звука пока нет; полноценные drive/damage-анимации карт и стола — следующий блок; локализация сделана runtime-словарём, без Unity Localization-таблиц (пакет остаётся подключён). @@ -805,13 +805,13 @@ Battle; Boot и общие сервисы остаются персистент - Heal: сервер больше не забирает последнюю карту автоматически, а создаёт `ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent` - и использует выбранную закрытую карту (остаток суммы — авто). + и использует выбранную открытую карту (остаток суммы — авто). - Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет `MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и добирает столько же. После обмена доступно «Закончить маллиган». - Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`), DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`. -- Проверки: сервер **135** правил + **45** серверных; клиент EditMode +- Проверки: сервер **138** правил + **45** серверных; клиент EditMode **4/4** (включая рендер доски с уроном и отдыхающей картой); `SCENE_SMOKE success=True active=Login`; `validate` и `translations check` — без ошибок. @@ -822,11 +822,11 @@ Battle; Boot и общие сервисы остаются персистент - Создан `docs/game-rules.md`: форматы, колода, поле, карта, старт/mulligan, победа, фазы, бой, триггеры, стоимости, инварианты и отдельная таблица сверки с текущим кодом. -- Главные расхождения для следующего блока (подробно в файле): - урон сейчас создаётся закрытым, Counter Blast переворачивает его в - обратную сторону; heal требует строго большего урона вместо `>=`; - mulligan перемешивает до добора, допускает повтор и не переключает - второго игрока; нет запрета атаки в первый ход и проверки ровно 16 - триггеров; первый игрок всегда 0. +- Расхождения из первой сверки исправлены: + урон входит открытым, Counter Blast переворачивает открытое в закрытое; + heal доступен при `>=`; mulligan — возврат в низ → добор → shuffle, один + раз, оба игрока; в первый ход атака запрещена; первый игрок случайный + (для тестов можно зафиксировать); `DeckValidator` требует ровно 16 + триггеров, декбилдер показывает счётчик. - Classic-режим не включает Stride/Вознесение, Legacy, Gift, Front-триггер и Grade support. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 4d4a2bc..1d663fd 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **135** правил + **45** серверных, CI зелёный. +- Тесты: **138** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Документация: `docs/server-api.md`, `docs/protocol.md`, `docs/game-rules.md`, `docs/HANDOFF.md`. @@ -67,11 +67,11 @@ - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона (+soul), справа колода и ниже дроп; у противника зеркально; между полями — зона защиты со щитом и проверками. -- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые — - артом). Heal предлагает выбрать карту урона; Counter Blast принимает - выбранную закрытую карту из зоны урона. -- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет - выбранные карты; новые карты добираются до пяти, колода перемешивается. +- Зона урона: карты лежат открытыми и на боку; после Counter Blast + выбранная карта переворачивается рубашкой. Heal выбирает карту урона; + Counter Blast — открытую карту до активации способности. +- Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ + колоды, добираются новые, затем shuffle. Клик по руке выбирает карты. - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации». Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют, @@ -83,6 +83,7 @@ группировка действий (карты → цели, глобальные кнопки отдельно), выбор силы/эффекта триггера, журнал последних событий. Позванные rear-guard'ы входят в стоячем положении; rest — только после атаки/буста. + В первый ход атака запрещена; первый игрок определяется сидом. - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). - Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween, diff --git a/docs/game-rules.md b/docs/game-rules.md index 4183314..8f1ceb4 100644 --- a/docs/game-rules.md +++ b/docs/game-rules.md @@ -325,24 +325,26 @@ Draw, Stand. - Counter Blast / Soul Blast / Soul Charge / Counter Charge; - проверка переднего ряда для атаки и особая атака по заднему ряду. -### 12.1. Расхождения, которые нужно учесть +### 12.1. Исправленные расхождения -Это не «правила», а результат сверки текста источника с кодом на текущий -момент. Перед следующим блоком их стоит исправить. +Сверка правил с кодом была выполнена, расхождения закрыты: -| № | Место | Сейчас | По правилам | -|---|---|---|---| -| 1 | `GameEngine.PutIntoDamage` | карта урона создаётся `FaceUp = false`; клиент рисует её рубашкой | урон лежит **лицом вверх** | -| 2 | `GameEngine.CounterBlast` | переворачивает закрытые карты в открытые; `CanCounterBlast` считает закрытые | Counter Blast переворачивает **открытые в закрытые** | -| 3 | `GameEngine.TryBeginHeal` | лечит только если урон **больше** (`<=` — отказ) | достаточно **равного** урона (`>=`) | -| 4 | `GameEngine.Mulligan` | shuffle → draw; можно вызывать несколько раз; обрабатывается только активный игрок (0) | возврат в низ → draw → shuffle; один раз; оба игрока | -| 5 | `GameEngine.Attack` | нет проверки первого хода | в первый ход атака запрещена | -| 6 | `GameEngine.Start` | первым всегда ходит игрок 0 | первый игрок определяется случайно | -| 7 | `DeckValidator` | не проверяет ровно 16 триггеров | ровно 16 триггер-карт | -| 8 | UI зоны урона | закрытые карты показываются рубашкой; после исправления №1—2 открытые должны быть видны | открытые карты видны, на боку | +| № | Что было | Исправление | +|---|---|---| +| 1 | урон создавался `FaceUp = false`, клиент рисовал его рубашкой | урон входит **лицом вверх**, закрытым становится только после Counter Blast | +| 2 | Counter Blast переворачивал закрытые карты в открытые | Counter Blast переворачивает **открытые в закрытые**; `CanCounterBlast` считает открытые | +| 3 | heal срабатывал только при уроне строго больше | heal доступен при уроне **>=** урона соперника | +| 4 | mulligan: shuffle → draw, можно было повторять, только игрок 0 | возврат в низ → draw → shuffle; одна попытка на игрока; оба игрока | +| 5 | атака в первый ход не запрещалась | в первый ход атака отклоняется, UI не предлагает атаки | +| 6 | первым всегда ходил игрок 0 | первый игрок определяется сидом; `MatchSetup.FirstPlayer` может зафиксировать его для тестов | +| 7 | не проверялось ровно 16 триггеров | `DeckValidator` требует ровно 16; UI декбилдера показывает счётчик | +| 8 | зона урона показывала открытые карты как закрытые | открытые карты видны артом, закрытые — рубашкой; всё на боку | -Пункты 1—3 влияют на игровую логику и UI; 4—6 — на корректность матча и -колод. Пункт 7 — визуальное следствие 1—2. +Проверки после исправлений: **138** правил + **45** серверных, клиент +EditMode **4/4**, `SCENE_SMOKE success=True`, `validate` и +`translations check` — без ошибок. + +Осталось вне Classic: см. ниже. ### 12.2. Не входит в Classic diff --git a/server/src/Vanguard.Rules/Decks/DeckValidator.cs b/server/src/Vanguard.Rules/Decks/DeckValidator.cs index 3dde7c9..c53dd8b 100644 --- a/server/src/Vanguard.Rules/Decks/DeckValidator.cs +++ b/server/src/Vanguard.Rules/Decks/DeckValidator.cs @@ -12,11 +12,15 @@ public sealed record DeckViolation(string Code, string Message); public static class DeckValidator { public const int RequiredSize = 50; + public const int RequiredTriggers = 16; public const int MaxCopiesPerName = 4; public const int MaxSentinel = 4; public const int MaxHeal = 4; - public static IReadOnlyList Validate(CardCatalog catalog, IReadOnlyCollection printingIds) + public static IReadOnlyList Validate( + CardCatalog catalog, + IReadOnlyCollection printingIds, + bool requireTriggers = true) { var violations = new List(); @@ -66,6 +70,17 @@ public static class DeckValidator $"Heal-триггеров: {heals}, максимум {MaxHeal}.")); } + if (requireTriggers) + { + var triggers = cards.Count(c => c.Trigger != TriggerType.None); + if (triggers != RequiredTriggers) + { + violations.Add(new DeckViolation( + "deck.triggers", + $"Триггеров: {triggers}, должно быть ровно {RequiredTriggers}.")); + } + } + return violations; } diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 21663c9..768de42 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -49,10 +49,12 @@ public sealed class GameEngine IReadOnlyList deck1, IReadOnlyDictionary? scripts = null, string player0Name = "Player 1", - string player1Name = "Player 2") + string player1Name = "Player 2", + bool enforceFirstTurnAttackRule = true, + bool requireLegalDeckTriggers = true) { - var violations = DeckValidator.Validate(catalog, deck0.ToList()) - .Concat(DeckValidator.Validate(catalog, deck1.ToList())) + var violations = DeckValidator.Validate(catalog, deck0.ToList(), requireLegalDeckTriggers) + .Concat(DeckValidator.Validate(catalog, deck1.ToList(), requireLegalDeckTriggers)) .ToList(); if (violations.Count > 0) @@ -60,6 +62,7 @@ public sealed class GameEngine "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); _scripts = scripts ?? CardScripts.Default; + _enforceFirstTurnAttackRule = enforceFirstTurnAttackRule; State = new GameState(catalog, seed, player0Name, player1Name); FillDeck(0, deck0); FillDeck(1, deck1); @@ -72,6 +75,7 @@ public sealed class GameEngine /// Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь. private int _resolvingDepth; private int _counterBlastDisplayIndex = -1; + private readonly bool _enforceFirstTurnAttackRule; /// /// Сколько проверок делает атакующая карта. Эффект может изменить число; @@ -678,8 +682,11 @@ public sealed class GameEngine public int FaceDownDamage(int player) => State.Players[player].Damage.Count(c => !c.FaceUp); + public int FaceUpDamage(int player) => + State.Players[player].Damage.Count(c => c.FaceUp); + public bool CanCounterBlast(int player, int amount) => - FaceDownDamage(player) >= amount; + FaceUpDamage(player) >= amount; public void CounterBlast(int player, int amount) { @@ -694,9 +701,9 @@ public sealed class GameEngine if (_counterBlastDisplayIndex < order.Count) { var chosen = order[_counterBlastDisplayIndex].Card; - if (!chosen.FaceUp) + if (chosen.FaceUp) { - chosen.FaceUp = true; + chosen.FaceUp = false; flipped++; } } @@ -709,10 +716,10 @@ public sealed class GameEngine if (flipped >= amount) break; - if (card.FaceUp) + if (!card.FaceUp) continue; - card.FaceUp = true; + card.FaceUp = false; flipped++; } } @@ -1471,6 +1478,7 @@ public sealed class GameEngine if (State.Step == Step.None) { + if (!_enforceFirstTurnAttackRule || State.TurnNumber != 1) foreach (var attackerSlot in UnitSlots(player)) { // Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд. @@ -1631,7 +1639,7 @@ public sealed class GameEngine // ---------- старт партии ---------- - public void Start() + public void Start(int? firstPlayer = null) { ShuffleDeck(0); ShuffleDeck(1); @@ -1642,8 +1650,24 @@ public sealed class GameEngine Draw(0, StartingHand); Draw(1, StartingHand); + var first = firstPlayer ?? NextFirstPlayer(); + if (first is not (0 or 1)) + throw new ArgumentOutOfRangeException(nameof(firstPlayer)); + + Array.Clear(State.MulliganUsed); + Array.Clear(State.MulliganDone); + State.FirstPlayer = first; + State.ActivePlayer = first; State.Phase = Phase.Mulligan; - _events.Add(new GameStartedEvent(0)); + _events.Add(new GameStartedEvent(first)); + } + + private int NextFirstPlayer() + { + var rng = State.RngState; + var first = Rng.Next(ref rng, 2); + State.RngState = rng; + return first; } private void ShuffleDeck(int player) @@ -1733,12 +1757,18 @@ public sealed class GameEngine return CommandResult.Reject("Маллиган доступен только в начале партии."); var player = State.ActivePlayer; + if (State.MulliganUsed[player]) + return CommandResult.Reject("Вторая попытка уже использована."); + 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("Некорректный индекс карты в руке."); + State.MulliganUsed[player] = true; + + // Возврат в низ колоды, добор сверху, затем перемешивание. foreach (var index in indexes) { var card = hand[index]; @@ -1746,8 +1776,8 @@ public sealed class GameEngine State.Players[player].Deck.Add(card); } - ShuffleDeck(player); Draw(player, indexes.Count); + ShuffleDeck(player); _events.Add(new MulliganEvent(player, indexes.Count)); return CommandResult.Ok(); @@ -1758,7 +1788,20 @@ public sealed class GameEngine if (State.Phase != Phase.Mulligan) return CommandResult.Reject("Маллиган уже завершён."); - State.ActivePlayer = 0; + var player = State.ActivePlayer; + if (State.MulliganDone[player]) + return CommandResult.Reject("Вторая попытка уже завершена."); + + State.MulliganDone[player] = true; + var other = 1 - player; + + if (!State.MulliganDone[other]) + { + State.ActivePlayer = other; + return CommandResult.Ok(); + } + + State.ActivePlayer = State.FirstPlayer; StartTurn(); return CommandResult.Ok(); } @@ -1887,6 +1930,9 @@ public sealed class GameEngine if (State.Phase != Phase.Battle) return CommandResult.Reject("Атака доступна только в Боевой фазе."); + if (_enforceFirstTurnAttackRule && State.TurnNumber == 1) + return CommandResult.Reject("В первый ход атаковать нельзя."); + if (State.Battle is not null) return CommandResult.Reject("Идёт бой: сначала завершите защиту."); @@ -2403,7 +2449,7 @@ public sealed class GameEngine { var p = State.Players[player]; var opponent = State.Players[1 - player]; - if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0) + if (p.Damage.Count == 0 || p.Damage.Count < opponent.Damage.Count) return false; AskDamageChoice( @@ -2429,7 +2475,7 @@ public sealed class GameEngine private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) => State.Players[player].Damage .Select((card, index) => (Card: card, Index: index)) - .OrderBy(item => item.Card.FaceUp) + .OrderByDescending(item => item.Card.FaceUp) .ThenBy(item => item.Index) .ToList(); @@ -2672,7 +2718,7 @@ public sealed class GameEngine private void PutIntoDamage(int player, CardInstance card) { - card.FaceUp = false; + card.FaceUp = true; var p = State.Players[player]; p.Damage.Add(card); _events.Add(new DamageTakenEvent(player, 1, p.Damage.Count)); diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index ee9bb3f..372bbcb 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -43,6 +43,14 @@ public sealed class GameState public PlayerState[] Players { get; } = new PlayerState[2]; public int ActivePlayer { get; internal set; } + public int FirstPlayer { get; internal set; } + + /// Игрок уже использовал вторую попытку. + public bool[] MulliganUsed { get; } = new bool[2]; + + /// Игрок закончил этап второй попытки. + public bool[] MulliganDone { get; } = new bool[2]; + public Phase Phase { get; internal set; } = Phase.Mulligan; public Step Step { get; internal set; } = Step.None; public int TurnNumber { get; internal set; } diff --git a/server/src/Vanguard.Rules/Replay/ReplayRunner.cs b/server/src/Vanguard.Rules/Replay/ReplayRunner.cs index efed369..fae8f0a 100644 --- a/server/src/Vanguard.Rules/Replay/ReplayRunner.cs +++ b/server/src/Vanguard.Rules/Replay/ReplayRunner.cs @@ -8,7 +8,13 @@ public static class ReplayRunner { public static GameEngine Replay(CardCatalog catalog, MatchRecord record) { - var engine = new GameEngine(catalog, record.Seed, record.Deck0, record.Deck1); + var engine = new GameEngine( + catalog, + record.Seed, + record.Deck0, + record.Deck1, + enforceFirstTurnAttackRule: false, + requireLegalDeckTriggers: false); engine.Start(); foreach (var encoded in record.Commands) diff --git a/server/src/Vanguard.Server/Matches/IMatchGrain.cs b/server/src/Vanguard.Server/Matches/IMatchGrain.cs index b454433..c538dbc 100644 --- a/server/src/Vanguard.Server/Matches/IMatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/IMatchGrain.cs @@ -23,7 +23,8 @@ public sealed record MatchSetup( GameMode Mode, MatchSeatSetup Seat0, MatchSeatSetup Seat1, - ulong? Seed = null) + ulong? Seed = null, + int? FirstPlayer = null) { public static MatchSetup FromHeader(MatchHeader header) => new( header.MatchId, diff --git a/server/src/Vanguard.Server/Matches/MatchGrain.cs b/server/src/Vanguard.Server/Matches/MatchGrain.cs index ed7bff5..dcb6722 100644 --- a/server/src/Vanguard.Server/Matches/MatchGrain.cs +++ b/server/src/Vanguard.Server/Matches/MatchGrain.cs @@ -107,7 +107,7 @@ public sealed class MatchGrain : IMatchGrain player0Name: setup.Seat0.PlayerId, player1Name: setup.Seat1.PlayerId); - _engine.Start(); + _engine.Start(setup.FirstPlayer); RefreshSummary(); } diff --git a/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs b/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs index 9cb24e8..8cbe4f0 100644 --- a/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs +++ b/server/src/Vanguard.Server/Matches/Orleans/IMatchActor.cs @@ -67,6 +67,9 @@ public sealed class MatchSetupData [Id(4)] public ulong? Seed { get; set; } + [Id(5)] + public int? FirstPlayer { get; set; } + public static MatchSetupData From(MatchSetup setup) => new() { MatchId = setup.MatchId, @@ -84,6 +87,7 @@ public sealed class MatchSetupData Mmr = setup.Seat1.Mmr, }, Seed = setup.Seed, + FirstPlayer = setup.FirstPlayer, }; public MatchSetup ToSetup() => new( @@ -91,7 +95,8 @@ public sealed class MatchSetupData Mode, new MatchSeatSetup(Seat0.PlayerId, Seat0.Deck, Seat0.Mmr), new MatchSeatSetup(Seat1.PlayerId, Seat1.Deck, Seat1.Mmr), - Seed); + Seed, + FirstPlayer); } [GenerateSerializer] diff --git a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs index d65edcf..e12b17b 100644 --- a/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs +++ b/server/src/Vanguard.Server/Matchmaking/MatchmakingService.cs @@ -131,8 +131,7 @@ public sealed class MatchmakingService var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList(); // колода без grade 0 без триггера не может выставить стартовый Авангард - if (violations.Count == 0 - && !deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate)) + if (!deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate)) { violations.Add(new DeckViolation( "deck.starting-vanguard", diff --git a/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs b/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs index 8070a97..2a758e1 100644 --- a/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs +++ b/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs @@ -68,6 +68,20 @@ public class DeckValidatorTests Assert.Contains(violations, v => v.Code == "deck.heal"); } + [Fact] + public void Exactly_Sixteen_Triggers_Are_Required() + { + var deck = BuildLegalDeck(); + var triggerIndex = deck.FindIndex(id => + _catalog.CardOf(id).Trigger != TriggerType.None); + Assert.True(triggerIndex >= 0); + + deck[triggerIndex] = "BT01/042EN"; // Marron, no trigger + + var violations = DeckValidator.Validate(_catalog, deck); + Assert.Contains(violations, v => v.Code == "deck.triggers"); + } + [Fact] public void Unknown_Printing_Is_Reported() { @@ -96,6 +110,42 @@ public class DeckValidatorTests usedNames.Add(_catalog.Printing(printingId).NameId); } + var triggers = deck.Count(id => _catalog.CardOf(id).Trigger != TriggerType.None); + var heals = deck.Count(id => _catalog.CardOf(id).Trigger == TriggerType.Heal); + + foreach (var group in _catalog.Printings + .Where(p => !p.IsParallel) + .GroupBy(p => p.NameId) + .OrderBy(g => g.Key, StringComparer.Ordinal)) + { + if (triggers >= DeckValidator.RequiredTriggers || deck.Count >= DeckValidator.RequiredSize) + break; + + if (usedNames.Contains(group.Key)) + continue; + + var card = _catalog.Card(group.Key); + if (card.IsSentinel || card.Trigger == TriggerType.None) + continue; + if (card.Trigger == TriggerType.Heal && heals >= DeckValidator.MaxHeal) + continue; + + var printingId = group.First().PrintingId; + usedNames.Add(group.Key); + + var copies = Math.Min( + DeckValidator.MaxCopiesPerName, + Math.Min(DeckValidator.RequiredTriggers - triggers, + DeckValidator.RequiredSize - deck.Count)); + for (var i = 0; i < copies; i++) + { + deck.Add(printingId); + triggers++; + if (card.Trigger == TriggerType.Heal) + heals++; + } + } + foreach (var group in _catalog.Printings .Where(p => !p.IsParallel) .GroupBy(p => p.NameId) @@ -108,7 +158,7 @@ public class DeckValidatorTests continue; var card = _catalog.Card(group.Key); - if (card.IsSentinel || card.Trigger == TriggerType.Heal) + if (card.IsSentinel || card.Trigger != TriggerType.None) continue; var printingId = group.First().PrintingId; diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index c6dec26..392b2e7 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Vanguard.Rules.Cards; +using Vanguard.Rules.Decks; using Vanguard.Rules.Engine; using Vanguard.Rules.Engine.Abilities; using Vanguard.Rules.Replay; @@ -62,7 +63,7 @@ public class GameEngineTests public void Start_Places_Starting_Vanguard_And_Draws_Five() { var engine = NewEngine(); - engine.Start(); + engine.Start(firstPlayer: 0); var player = engine.State.Players[0]; Assert.NotNull(player.Vanguard); @@ -73,6 +74,45 @@ public class GameEngineTests Assert.Equal(Phase.Mulligan, engine.State.Phase); } + [Fact] + public void First_Turn_Cannot_Attack() + { + var engine = new GameEngine( + _catalog, + 7, + BuildDeck(), + BuildDeck(), + requireLegalDeckTriggers: false); + engine.Start(firstPlayer: 0); + engine.Execute(new FinishMulliganCommand()); + engine.Execute(new FinishMulliganCommand()); + + Give(engine, 0, Marron); + engine.Execute(new RideCommand(Marron)); + engine.Execute(new EnterBattleCommand()); + + Assert.False(engine.Execute(new AttackCommand(-1, -1)).Accepted); + } + + [Fact] + public void Both_Players_Mulligan_Once_Then_First_Player_Starts() + { + var engine = NewEngine(); + engine.Start(firstPlayer: 0); + + Assert.True(engine.Execute(new MulliganCommand(new[] { 0 })).Accepted); + Assert.False(engine.Execute(new MulliganCommand(new[] { 0 })).Accepted); + + Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted); + Assert.Equal(1, engine.State.ActivePlayer); + + Assert.True(engine.Execute(new MulliganCommand(new[] { 0 })).Accepted); + Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted); + + Assert.Equal(0, engine.State.ActivePlayer); + Assert.NotEqual(Phase.Mulligan, engine.State.Phase); + } + [Fact] public void Ride_Rejects_Grade_Jump() { @@ -103,7 +143,7 @@ public class GameEngineTests public void Mulligan_Replaces_Selected_Hand_Cards_And_Keeps_Five() { var engine = NewEngine(); - engine.Start(); + engine.Start(firstPlayer: 0); Assert.Equal(Phase.Mulligan, engine.State.Phase); Assert.Equal(5, engine.State.Players[0].Hand.Count); @@ -128,8 +168,8 @@ public class GameEngineTests Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id, null, 1)).Accepted); - Assert.False(first.FaceUp); - Assert.True(second.FaceUp); + Assert.True(first.FaceUp); + Assert.False(second.FaceUp); } [Fact] @@ -157,6 +197,7 @@ public class GameEngineTests Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Single(engine.State.Players[1].Damage); + Assert.True(engine.State.Players[1].Damage[0].FaceUp); Assert.Equal(Step.None, engine.State.Step); } @@ -263,7 +304,7 @@ public class GameEngineTests var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); GiveDamage(engine, 1, 2); - GiveDamage(engine, 0, 1); + GiveDamage(engine, 0, 2); engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine)); var chosen = engine.State.Players[1].Damage[0]; @@ -747,7 +788,7 @@ public class GameEngineTests Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.Null(opponent.RearGuards[0]); - Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); } [Fact] @@ -897,7 +938,7 @@ public class GameEngineTests Assert.Null(engine.State.Players[0].RearGuards[0]); Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Conroe); - Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); if (engine.State.PendingChoice is not null) Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); @@ -951,7 +992,7 @@ public class GameEngineTests var alfred = engine.State.Players[0].Vanguard!; Assert.True(engine.Execute(new ActivateAbilityCommand(alfred.Id)).Accepted); - Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); if (engine.State.PendingChoice is not null) Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); @@ -1024,7 +1065,7 @@ public class GameEngineTests Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); - Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); Assert.Equal(handBefore + 1, engine.State.Players[0].Hand.Count); Assert.Single(engine.State.Players[1].Damage); } @@ -1308,7 +1349,7 @@ public class GameEngineTests Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.True(engine.State.HasRestriction(target, RestrictionType.CannotStand)); - Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); } [Fact] @@ -1468,7 +1509,7 @@ public class GameEngineTests Assert.Same(frank, engine.State.Players[0].Vanguard); Assert.DoesNotContain(engine.State.Players[0].Drop, c => c.Id == frank.Id); - Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); } [Fact] @@ -1507,7 +1548,7 @@ public class GameEngineTests Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Shieldon); - Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + Assert.Equal(0, engine.State.Players[0].Damage.Count(c => c.FaceUp)); } [Fact] @@ -1577,7 +1618,7 @@ public class GameEngineTests Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.Equal(handBefore - 1, p1.Hand.Count); - Assert.Equal(1, p0.Damage.Count(c => c.FaceUp)); + Assert.Equal(0, p0.Damage.Count(c => c.FaceUp)); } [Fact] @@ -1918,7 +1959,8 @@ public class GameEngineTests private List RunScriptedMatch(ulong seed) { var engine = NewEngine(seed); - engine.Start(); + engine.Start(firstPlayer: 0); + engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand()); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); @@ -1930,12 +1972,19 @@ public class GameEngineTests // ---------- helpers ---------- private GameEngine NewEngine(ulong seed = 7) => - new(_catalog, seed, BuildDeck(), BuildDeck()); + new( + _catalog, + seed, + BuildDeck(), + BuildDeck(), + enforceFirstTurnAttackRule: false, + requireLegalDeckTriggers: false); private GameEngine StartedEngine() { var engine = NewEngine(); - engine.Start(); + engine.Start(firstPlayer: 0); + engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand()); // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах @@ -2119,7 +2168,7 @@ public class GameEngineTests Assert.NotEmpty(p.Deck); var card = p.Deck[0]; p.Deck.RemoveAt(0); - card.FaceUp = false; + card.FaceUp = true; p.Damage.Add(card); } } diff --git a/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs b/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs index f2881f9..bb3dc2b 100644 --- a/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs +++ b/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs @@ -1,4 +1,5 @@ using Vanguard.Rules.Cards; +using Vanguard.Rules.Decks; using Vanguard.Rules.Engine; using Vanguard.Rules.Replay; using Vanguard.Shared.Projection; @@ -380,7 +381,8 @@ public class ProtocolTests private static GameEngine StartedEngine() { var engine = NewEngine(); - engine.Start(); + engine.Start(firstPlayer: 0); + engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand()); // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал @@ -391,7 +393,14 @@ public class ProtocolTests return engine; } - private static GameEngine NewEngine() => new(TestCatalog.Instance, 7, BuildDeck(), BuildDeck()); + private static GameEngine NewEngine() => + new( + TestCatalog.Instance, + 7, + BuildDeck(), + BuildDeck(), + enforceFirstTurnAttackRule: false, + requireLegalDeckTriggers: false); private static List BuildDeck() { diff --git a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs index 7cd70b5..7bde9f7 100644 --- a/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs +++ b/server/tests/Vanguard.Server.Tests/GameServerE2ETests.cs @@ -72,7 +72,8 @@ public class GameServerE2ETests await using var second = await TestGameClient.ConnectAsync( factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); - var result = await first.SubmitAsync("mulligan-1", new FinishMulliganIntent(), cts.Token); + var active = first.View.ActivePlayer == 0 ? first : second; + var result = await active.SubmitAsync("mulligan-1", new FinishMulliganIntent(), cts.Token); Assert.True(result.Accepted, result.Reason); var lastSeq = result.EventSeq; diff --git a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs index 5c0a0e9..d39ecdd 100644 --- a/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs @@ -334,7 +334,8 @@ public class MatchGrainTests "match-1", GameMode.ClassicRanked, new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), - new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); + new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000), + FirstPlayer: 0); return new MatchGrain( setup, diff --git a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs index 75b9380..17ba797 100644 --- a/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs +++ b/server/tests/Vanguard.Server.Tests/MatchPersistenceTests.cs @@ -196,7 +196,7 @@ public class MatchPersistenceTests GameMode.ClassicRanked, new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000), - seed); + seed ?? 2); private static MatchGrain NewGrain(MatchSetup setup, TimeProvider time, IMatchStore store) => new( setup, diff --git a/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs index f031454..be64d07 100644 --- a/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs +++ b/server/tests/Vanguard.Server.Tests/OrleansMatchTests.cs @@ -33,7 +33,8 @@ public class OrleansMatchTests "orleans-match", GameMode.ClassicRanked, new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), - new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); + new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000), + FirstPlayer: 0); await actor.InitializeAsync(MatchSetupData.From(setup)); diff --git a/server/tests/Vanguard.Server.Tests/ServerTestData.cs b/server/tests/Vanguard.Server.Tests/ServerTestData.cs index 570afa3..51bfad4 100644 --- a/server/tests/Vanguard.Server.Tests/ServerTestData.cs +++ b/server/tests/Vanguard.Server.Tests/ServerTestData.cs @@ -1,5 +1,6 @@ using Vanguard.Content; using Vanguard.Rules.Cards; +using Vanguard.Rules.Decks; using Vanguard.Server.Configuration; namespace Vanguard.Server.Tests; @@ -25,23 +26,59 @@ internal static class ServerTestData var deck = new List(); var counts = new Dictionary(StringComparer.Ordinal); - foreach (var printing in Catalog.Printings + void Add(string printingId, int limit) + { + var card = Catalog.CardOf(printingId); + counts.TryGetValue(card.NameId, out var used); + var room = Math.Min(Math.Min(4 - used, limit), 50 - deck.Count); + for (var i = 0; i < room; i++) + deck.Add(printingId); + counts[card.NameId] = used + room; + } + + // Сначала ровно 16 триггеров, Heal — не больше четырёх. + var triggers = 0; + var heals = 0; + foreach (var group in Catalog.Printings .Where(p => !p.IsParallel) - .OrderBy(p => p.PrintingId, StringComparer.Ordinal)) + .GroupBy(p => p.NameId) + .OrderBy(g => g.Key, StringComparer.Ordinal)) + { + if (triggers >= DeckValidator.RequiredTriggers) + break; + + var card = Catalog.CardOf(group.First().PrintingId); + if (card.IsSentinel || card.Trigger == TriggerType.None) + continue; + if (card.Trigger == TriggerType.Heal && heals >= DeckValidator.MaxHeal) + continue; + + var before = deck.Count; + Add(group.First().PrintingId, Math.Min( + DeckValidator.RequiredTriggers - triggers, + card.Trigger == TriggerType.Heal + ? DeckValidator.MaxHeal - heals + : DeckValidator.RequiredTriggers)); + var added = deck.Count - before; + triggers += added; + if (card.Trigger == TriggerType.Heal) + heals += added; + } + + // Остальное — обычные воины. + 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; - var card = Catalog.CardOf(printing.PrintingId); - if (card.IsSentinel || card.Trigger == TriggerType.Heal) + var card = Catalog.CardOf(group.First().PrintingId); + if (card.IsSentinel || card.Trigger != TriggerType.None) continue; - counts.TryGetValue(card.NameId, out var used); - var room = Math.Min(4 - used, 50 - deck.Count); - for (var i = 0; i < room; i++) - deck.Add(printing.PrintingId); - - counts[card.NameId] = used + room; + Add(group.First().PrintingId, 4); } Assert.Equal(50, deck.Count);