Align battle rules with official Vanguard flow
CI / test (push) Successful in 2m18s Details

This commit is contained in:
MpakobecEx 2026-10-22 03:04:04 +05:00
parent 1e0b36bbb8
commit 7035c11873
22 changed files with 359 additions and 100 deletions

View File

@ -72,6 +72,7 @@ namespace Vanguard.UI
private int _pendingBoosterSlot = -1; private int _pendingBoosterSlot = -1;
private int? _triggerPowerSlot; private int? _triggerPowerSlot;
private readonly HashSet<int> _mulliganSelected = new HashSet<int>(); private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private bool _mulliganUsed;
private int _abilityDamageIndex = -1; private int _abilityDamageIndex = -1;
private readonly Dictionary<string, RectTransform> _cardPanels = private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal); new Dictionary<string, RectTransform>(StringComparer.Ordinal);
@ -208,6 +209,7 @@ namespace Vanguard.UI
_pendingIntents.Clear(); _pendingIntents.Clear();
_highlightKeys.Clear(); _highlightKeys.Clear();
_mulliganSelected.Clear(); _mulliganSelected.Clear();
_mulliganUsed = false;
_abilityDamageIndex = -1; _abilityDamageIndex = -1;
_pendingBoosterSlot = -1; _pendingBoosterSlot = -1;
_triggerPowerSlot = null; _triggerPowerSlot = null;
@ -1229,16 +1231,16 @@ namespace Vanguard.UI
var abilityTargeting = IsAbilityDamageTargeting(); var abilityTargeting = IsAbilityDamageTargeting();
var displayIndex = 0; 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) foreach (var card in player.FaceUpDamage)
{ {
AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false); AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting);
displayIndex++; displayIndex++;
} }
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
{
AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting: false);
}
} }
private void AddDamageCard( private void AddDamageCard(
@ -1310,7 +1312,7 @@ namespace Vanguard.UI
return false; return false;
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); 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 false;
return (_view.LegalIntents ?? new List<IntentPayload>()) return (_view.LegalIntents ?? new List<IntentPayload>())
@ -1660,7 +1662,8 @@ namespace Vanguard.UI
_view != null _view != null
&& !_view.IsFinished && !_view.IsFinished
&& _view.Phase == GamePhase.Mulligan && _view.Phase == GamePhase.Mulligan
&& _view.ActivePlayer == _view.You; && _view.ActivePlayer == _view.You
&& !_mulliganUsed;
private int HandIndexOf(int instanceId) private int HandIndexOf(int instanceId)
{ {
@ -1697,6 +1700,7 @@ namespace Vanguard.UI
var indexes = _mulliganSelected.OrderBy(index => index).ToList(); var indexes = _mulliganSelected.OrderBy(index => index).ToList();
_mulliganSelected.Clear(); _mulliganSelected.Clear();
_mulliganUsed = true;
OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
} }

View File

@ -1010,7 +1010,7 @@ namespace Vanguard.UI
private void AddCard(CollectionItemDto item) private void AddCard(CollectionItemDto item)
{ {
if (!_catalog.TryGetValue(item.PrintingId, out _)) if (!_catalog.TryGetValue(item.PrintingId, out var itemMeta))
return; return;
if (_cards.Count >= 50) if (_cards.Count >= 50)
@ -1025,6 +1025,12 @@ namespace Vanguard.UI
return; return;
} }
if (IsTrigger(itemMeta.Trigger) && TriggerCount() >= 16)
{
SetEditorStatus("В колоде уже 16 триггеров.", true);
return;
}
if (_cards.Count(card => card == item.PrintingId) >= item.Count) if (_cards.Count(card => card == item.PrintingId) >= item.Count)
{ {
SetEditorStatus("В коллекции больше нет свободных копий.", true); SetEditorStatus("В коллекции больше нет свободных копий.", true);
@ -1091,8 +1097,9 @@ namespace Vanguard.UI
} }
_stats.text = "Всего: " + _cards.Count + "/50 G0 " + grades[0] _stats.text = "Всего: " + _cards.Count + "/50 G0 " + grades[0]
+ " · G1 " + grades[1] + " · G2 " + grades[2] + " · G3 " + grades[3]; + " · G1 " + grades[1] + " · G2 " + grades[2] + " · G3 " + grades[3]
_deckCount.text = "Колода: " + _cards.Count + "/50"; + " · T " + TriggerCount() + "/16";
_deckCount.text = "Колода: " + _cards.Count + "/50 · триггеры: " + TriggerCount() + "/16";
} }
private void OpenFilters() private void OpenFilters()
@ -1150,6 +1157,12 @@ namespace Vanguard.UI
if (_busy) if (_busy)
return; return;
if (_cards.Count != 50 || TriggerCount() != 16)
{
SetEditorStatus("Нужно ровно 50 карт и 16 триггеров.", true);
return;
}
_busy = true; _busy = true;
SetEditorStatus("Сохраняем…", false); SetEditorStatus("Сохраняем…", false);
try try
@ -1346,6 +1359,14 @@ namespace Vanguard.UI
_cards.Count(printingId => _cards.Count(printingId =>
_catalog.TryGetValue(printingId, out var meta) && meta.NameId == nameId); _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 private static string TriggerShort(string trigger) => trigger switch
{ {
"Critical" => "K", "Critical" => "K",

View File

@ -328,6 +328,9 @@ namespace Vanguard.UI
["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).", ["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).",
["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ", ["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ",
["УРОН · "] = "DAMAGE · ", ["УРОН · "] = "DAMAGE · ",
["Нужно ровно 50 карт и 16 триггеров."] = "The deck needs exactly 50 cards and 16 triggers.",
["В колоде уже 16 триггеров."] = "The deck already has 16 triggers.",
[" · триггеры: "] = " · triggers: ",
}; };
private static readonly KeyValuePair<string, string>[] Phrases = Exact private static readonly KeyValuePair<string, string>[] Phrases = Exact

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **180/180** (135 правил/контрактов + 45 сервер) | | Тесты сервера | **183/183** (138 правил/контрактов + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `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) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94
@ -785,8 +785,8 @@ Battle; Boot и общие сервисы остаются персистент
- имена/тексты карт: `Loc.CardName` / `Loc.CardText` выбирают - имена/тексты карт: `Loc.CardName` / `Loc.CardText` выбирают
`nameRu/nameEn` и `textRu/textEn`; карточные поля помечены literal, `nameRu/nameEn` и `textRu/textEn`; карточные поля помечены literal,
чтобы словарь не портил переводы карт. чтобы словарь не портил переводы карт.
- Проверки: Unity EditMode **3/3** (экраны, импорт арта, перевод статики и - Проверки: Unity EditMode **4/4** (экраны, импорт арта, перевод статики и
динамики, live-смена языка), `SCENE_SMOKE success=True active=Login`. динамики, live-смена языка, рендер доски с уроном), `SCENE_SMOKE success=True active=Login`.
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и - Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
стола — следующий блок; локализация сделана runtime-словарём, без стола — следующий блок; локализация сделана runtime-словарём, без
Unity Localization-таблиц (пакет остаётся подключён). Unity Localization-таблиц (пакет остаётся подключён).
@ -805,13 +805,13 @@ Battle; Boot и общие сервисы остаются персистент
- Heal: сервер больше не забирает последнюю карту автоматически, а создаёт - Heal: сервер больше не забирает последнюю карту автоматически, а создаёт
`ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast `ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast
принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent` принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent`
и использует выбранную закрытую карту (остаток суммы — авто). и использует выбранную открытую карту (остаток суммы — авто).
- Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет - Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет
`MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и `MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и
добирает столько же. После обмена доступно «Закончить маллиган». добирает столько же. После обмена доступно «Закончить маллиган».
- Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`), - Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`),
DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`. DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`.
- Проверки: сервер **135** правил + **45** серверных; клиент EditMode - Проверки: сервер **138** правил + **45** серверных; клиент EditMode
**4/4** (включая рендер доски с уроном и отдыхающей картой); **4/4** (включая рендер доски с уроном и отдыхающей картой);
`SCENE_SMOKE success=True active=Login`; `validate` и `SCENE_SMOKE success=True active=Login`; `validate` и
`translations check` — без ошибок. `translations check` — без ошибок.
@ -822,11 +822,11 @@ Battle; Boot и общие сервисы остаются персистент
- Создан `docs/game-rules.md`: форматы, колода, поле, карта, старт/mulligan, - Создан `docs/game-rules.md`: форматы, колода, поле, карта, старт/mulligan,
победа, фазы, бой, триггеры, стоимости, инварианты и отдельная таблица победа, фазы, бой, триггеры, стоимости, инварианты и отдельная таблица
сверки с текущим кодом. сверки с текущим кодом.
- Главные расхождения для следующего блока (подробно в файле): - Расхождения из первой сверки исправлены:
урон сейчас создаётся закрытым, Counter Blast переворачивает его в урон входит открытым, Counter Blast переворачивает открытое в закрытое;
обратную сторону; heal требует строго большего урона вместо `>=`; heal доступен при `>=`; mulligan — возврат в низ → добор → shuffle, один
mulligan перемешивает до добора, допускает повтор и не переключает раз, оба игрока; в первый ход атака запрещена; первый игрок случайный
второго игрока; нет запрета атаки в первый ход и проверки ровно 16 (для тестов можно зафиксировать); `DeckValidator` требует ровно 16
триггеров; первый игрок всегда 0. триггеров, декбилдер показывает счётчик.
- Classic-режим не включает Stride/Вознесение, Legacy, Gift, Front-триггер - Classic-режим не включает Stride/Вознесение, Legacy, Gift, Front-триггер
и Grade support. и Grade support.

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **135** правил + **45** серверных, CI зелёный. - Тесты: **138** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`, - Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/game-rules.md`, `docs/HANDOFF.md`. `docs/game-rules.md`, `docs/HANDOFF.md`.
@ -67,11 +67,11 @@
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
(+soul), справа колода и ниже дроп; у противника зеркально; между (+soul), справа колода и ниже дроп; у противника зеркально; между
полями — зона защиты со щитом и проверками. полями — зона защиты со щитом и проверками.
- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые — - Зона урона: карты лежат открытыми и на боку; после Counter Blast
артом). Heal предлагает выбрать карту урона; Counter Blast принимает выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
выбранную закрытую карту из зоны урона. Counter Blast — открытую карту до активации способности.
- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет - Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ
выбранные карты; новые карты добираются до пяти, колода перемешивается. колоды, добираются новые, затем shuffle. Клик по руке выбирает карты.
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации». стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют, Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
@ -83,6 +83,7 @@
группировка действий (карты → цели, глобальные кнопки отдельно), группировка действий (карты → цели, глобальные кнопки отдельно),
выбор силы/эффекта триггера, журнал последних событий. Позванные выбор силы/эффекта триггера, журнал последних событий. Позванные
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста. rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
В первый ход атака запрещена; первый игрок определяется сидом.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween, - Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,

View File

@ -325,24 +325,26 @@ Draw, Stand.
- Counter Blast / Soul Blast / Soul Charge / Counter Charge; - Counter Blast / Soul Blast / Soul Charge / Counter Charge;
- проверка переднего ряда для атаки и особая атака по заднему ряду. - проверка переднего ряда для атаки и особая атака по заднему ряду.
### 12.1. Расхождения, которые нужно учесть ### 12.1. Исправленные расхождения
Это не «правила», а результат сверки текста источника с кодом на текущий Сверка правил с кодом была выполнена, расхождения закрыты:
момент. Перед следующим блоком их стоит исправить.
| № | Место | Сейчас | По правилам | | № | Что было | Исправление |
|---|---|---|---| |---|---|---|
| 1 | `GameEngine.PutIntoDamage` | карта урона создаётся `FaceUp = false`; клиент рисует её рубашкой | урон лежит **лицом вверх** | | 1 | урон создавался `FaceUp = false`, клиент рисовал его рубашкой | урон входит **лицом вверх**, закрытым становится только после Counter Blast |
| 2 | `GameEngine.CounterBlast` | переворачивает закрытые карты в открытые; `CanCounterBlast` считает закрытые | Counter Blast переворачивает **открытые в закрытые** | | 2 | Counter Blast переворачивал закрытые карты в открытые | Counter Blast переворачивает **открытые в закрытые**; `CanCounterBlast` считает открытые |
| 3 | `GameEngine.TryBeginHeal` | лечит только если урон **больше** (`<=` — отказ) | достаточно **равного** урона (`>=`) | | 3 | heal срабатывал только при уроне строго больше | heal доступен при уроне **>=** урона соперника |
| 4 | `GameEngine.Mulligan` | shuffle → draw; можно вызывать несколько раз; обрабатывается только активный игрок (0) | возврат в низ → draw → shuffle; один раз; оба игрока | | 4 | mulligan: shuffle → draw, можно было повторять, только игрок 0 | возврат в низ → draw → shuffle; одна попытка на игрока; оба игрока |
| 5 | `GameEngine.Attack` | нет проверки первого хода | в первый ход атака запрещена | | 5 | атака в первый ход не запрещалась | в первый ход атака отклоняется, UI не предлагает атаки |
| 6 | `GameEngine.Start` | первым всегда ходит игрок 0 | первый игрок определяется случайно | | 6 | первым всегда ходил игрок 0 | первый игрок определяется сидом; `MatchSetup.FirstPlayer` может зафиксировать его для тестов |
| 7 | `DeckValidator` | не проверяет ровно 16 триггеров | ровно 16 триггер-карт | | 7 | не проверялось ровно 16 триггеров | `DeckValidator` требует ровно 16; UI декбилдера показывает счётчик |
| 8 | UI зоны урона | закрытые карты показываются рубашкой; после исправления №1—2 открытые должны быть видны | открытые карты видны, на боку | | 8 | зона урона показывала открытые карты как закрытые | открытые карты видны артом, закрытые — рубашкой; всё на боку |
Пункты 1—3 влияют на игровую логику и UI; 4—6 — на корректность матча и Проверки после исправлений: **138** правил + **45** серверных, клиент
колод. Пункт 7 — визуальное следствие 1—2. EditMode **4/4**, `SCENE_SMOKE success=True`, `validate` и
`translations check` — без ошибок.
Осталось вне Classic: см. ниже.
### 12.2. Не входит в Classic ### 12.2. Не входит в Classic

View File

@ -12,11 +12,15 @@ public sealed record DeckViolation(string Code, string Message);
public static class DeckValidator public static class DeckValidator
{ {
public const int RequiredSize = 50; public const int RequiredSize = 50;
public const int RequiredTriggers = 16;
public const int MaxCopiesPerName = 4; public const int MaxCopiesPerName = 4;
public const int MaxSentinel = 4; public const int MaxSentinel = 4;
public const int MaxHeal = 4; public const int MaxHeal = 4;
public static IReadOnlyList<DeckViolation> Validate(CardCatalog catalog, IReadOnlyCollection<string> printingIds) public static IReadOnlyList<DeckViolation> Validate(
CardCatalog catalog,
IReadOnlyCollection<string> printingIds,
bool requireTriggers = true)
{ {
var violations = new List<DeckViolation>(); var violations = new List<DeckViolation>();
@ -66,6 +70,17 @@ public static class DeckValidator
$"Heal-триггеров: {heals}, максимум {MaxHeal}.")); $"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; return violations;
} }

View File

@ -49,10 +49,12 @@ public sealed class GameEngine
IReadOnlyList<string> deck1, IReadOnlyList<string> deck1,
IReadOnlyDictionary<string, CardScript>? scripts = null, IReadOnlyDictionary<string, CardScript>? scripts = null,
string player0Name = "Player 1", 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()) var violations = DeckValidator.Validate(catalog, deck0.ToList(), requireLegalDeckTriggers)
.Concat(DeckValidator.Validate(catalog, deck1.ToList())) .Concat(DeckValidator.Validate(catalog, deck1.ToList(), requireLegalDeckTriggers))
.ToList(); .ToList();
if (violations.Count > 0) if (violations.Count > 0)
@ -60,6 +62,7 @@ public sealed class GameEngine
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
_scripts = scripts ?? CardScripts.Default; _scripts = scripts ?? CardScripts.Default;
_enforceFirstTurnAttackRule = enforceFirstTurnAttackRule;
State = new GameState(catalog, seed, player0Name, player1Name); State = new GameState(catalog, seed, player0Name, player1Name);
FillDeck(0, deck0); FillDeck(0, deck0);
FillDeck(1, deck1); FillDeck(1, deck1);
@ -72,6 +75,7 @@ public sealed class GameEngine
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary> /// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth; private int _resolvingDepth;
private int _counterBlastDisplayIndex = -1; private int _counterBlastDisplayIndex = -1;
private readonly bool _enforceFirstTurnAttackRule;
/// <summary> /// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число; /// Сколько проверок делает атакующая карта. Эффект может изменить число;
@ -678,8 +682,11 @@ public sealed class GameEngine
public int FaceDownDamage(int player) => public int FaceDownDamage(int player) =>
State.Players[player].Damage.Count(c => !c.FaceUp); 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) => public bool CanCounterBlast(int player, int amount) =>
FaceDownDamage(player) >= amount; FaceUpDamage(player) >= amount;
public void CounterBlast(int player, int amount) public void CounterBlast(int player, int amount)
{ {
@ -694,9 +701,9 @@ public sealed class GameEngine
if (_counterBlastDisplayIndex < order.Count) if (_counterBlastDisplayIndex < order.Count)
{ {
var chosen = order[_counterBlastDisplayIndex].Card; var chosen = order[_counterBlastDisplayIndex].Card;
if (!chosen.FaceUp) if (chosen.FaceUp)
{ {
chosen.FaceUp = true; chosen.FaceUp = false;
flipped++; flipped++;
} }
} }
@ -709,10 +716,10 @@ public sealed class GameEngine
if (flipped >= amount) if (flipped >= amount)
break; break;
if (card.FaceUp) if (!card.FaceUp)
continue; continue;
card.FaceUp = true; card.FaceUp = false;
flipped++; flipped++;
} }
} }
@ -1471,6 +1478,7 @@ public sealed class GameEngine
if (State.Step == Step.None) if (State.Step == Step.None)
{ {
if (!_enforceFirstTurnAttackRule || State.TurnNumber != 1)
foreach (var attackerSlot in UnitSlots(player)) foreach (var attackerSlot in UnitSlots(player))
{ {
// Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд. // Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд.
@ -1631,7 +1639,7 @@ public sealed class GameEngine
// ---------- старт партии ---------- // ---------- старт партии ----------
public void Start() public void Start(int? firstPlayer = null)
{ {
ShuffleDeck(0); ShuffleDeck(0);
ShuffleDeck(1); ShuffleDeck(1);
@ -1642,8 +1650,24 @@ public sealed class GameEngine
Draw(0, StartingHand); Draw(0, StartingHand);
Draw(1, 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; 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) private void ShuffleDeck(int player)
@ -1733,12 +1757,18 @@ public sealed class GameEngine
return CommandResult.Reject("Маллиган доступен только в начале партии."); return CommandResult.Reject("Маллиган доступен только в начале партии.");
var player = State.ActivePlayer; var player = State.ActivePlayer;
if (State.MulliganUsed[player])
return CommandResult.Reject("Вторая попытка уже использована.");
var hand = State.Players[player].Hand; var hand = State.Players[player].Hand;
var indexes = command.HandIndexes.Distinct().OrderByDescending(i => i).ToList(); var indexes = command.HandIndexes.Distinct().OrderByDescending(i => i).ToList();
if (indexes.Any(i => i < 0 || i >= hand.Count)) if (indexes.Any(i => i < 0 || i >= hand.Count))
return CommandResult.Reject("Некорректный индекс карты в руке."); return CommandResult.Reject("Некорректный индекс карты в руке.");
State.MulliganUsed[player] = true;
// Возврат в низ колоды, добор сверху, затем перемешивание.
foreach (var index in indexes) foreach (var index in indexes)
{ {
var card = hand[index]; var card = hand[index];
@ -1746,8 +1776,8 @@ public sealed class GameEngine
State.Players[player].Deck.Add(card); State.Players[player].Deck.Add(card);
} }
ShuffleDeck(player);
Draw(player, indexes.Count); Draw(player, indexes.Count);
ShuffleDeck(player);
_events.Add(new MulliganEvent(player, indexes.Count)); _events.Add(new MulliganEvent(player, indexes.Count));
return CommandResult.Ok(); return CommandResult.Ok();
@ -1758,7 +1788,20 @@ public sealed class GameEngine
if (State.Phase != Phase.Mulligan) if (State.Phase != Phase.Mulligan)
return CommandResult.Reject("Маллиган уже завершён."); 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(); StartTurn();
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -1887,6 +1930,9 @@ public sealed class GameEngine
if (State.Phase != Phase.Battle) if (State.Phase != Phase.Battle)
return CommandResult.Reject("Атака доступна только в Боевой фазе."); return CommandResult.Reject("Атака доступна только в Боевой фазе.");
if (_enforceFirstTurnAttackRule && State.TurnNumber == 1)
return CommandResult.Reject("В первый ход атаковать нельзя.");
if (State.Battle is not null) if (State.Battle is not null)
return CommandResult.Reject("Идёт бой: сначала завершите защиту."); return CommandResult.Reject("Идёт бой: сначала завершите защиту.");
@ -2403,7 +2449,7 @@ public sealed class GameEngine
{ {
var p = State.Players[player]; var p = State.Players[player];
var opponent = State.Players[1 - 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; return false;
AskDamageChoice( AskDamageChoice(
@ -2429,7 +2475,7 @@ public sealed class GameEngine
private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) => private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) =>
State.Players[player].Damage State.Players[player].Damage
.Select((card, index) => (Card: card, Index: index)) .Select((card, index) => (Card: card, Index: index))
.OrderBy(item => item.Card.FaceUp) .OrderByDescending(item => item.Card.FaceUp)
.ThenBy(item => item.Index) .ThenBy(item => item.Index)
.ToList(); .ToList();
@ -2672,7 +2718,7 @@ public sealed class GameEngine
private void PutIntoDamage(int player, CardInstance card) private void PutIntoDamage(int player, CardInstance card)
{ {
card.FaceUp = false; card.FaceUp = true;
var p = State.Players[player]; var p = State.Players[player];
p.Damage.Add(card); p.Damage.Add(card);
_events.Add(new DamageTakenEvent(player, 1, p.Damage.Count)); _events.Add(new DamageTakenEvent(player, 1, p.Damage.Count));

View File

@ -43,6 +43,14 @@ public sealed class GameState
public PlayerState[] Players { get; } = new PlayerState[2]; public PlayerState[] Players { get; } = new PlayerState[2];
public int ActivePlayer { get; internal set; } public int ActivePlayer { get; internal set; }
public int FirstPlayer { get; internal set; }
/// <summary>Игрок уже использовал вторую попытку.</summary>
public bool[] MulliganUsed { get; } = new bool[2];
/// <summary>Игрок закончил этап второй попытки.</summary>
public bool[] MulliganDone { get; } = new bool[2];
public Phase Phase { get; internal set; } = Phase.Mulligan; public Phase Phase { get; internal set; } = Phase.Mulligan;
public Step Step { get; internal set; } = Step.None; public Step Step { get; internal set; } = Step.None;
public int TurnNumber { get; internal set; } public int TurnNumber { get; internal set; }

View File

@ -8,7 +8,13 @@ public static class ReplayRunner
{ {
public static GameEngine Replay(CardCatalog catalog, MatchRecord record) 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(); engine.Start();
foreach (var encoded in record.Commands) foreach (var encoded in record.Commands)

View File

@ -23,7 +23,8 @@ public sealed record MatchSetup(
GameMode Mode, GameMode Mode,
MatchSeatSetup Seat0, MatchSeatSetup Seat0,
MatchSeatSetup Seat1, MatchSeatSetup Seat1,
ulong? Seed = null) ulong? Seed = null,
int? FirstPlayer = null)
{ {
public static MatchSetup FromHeader(MatchHeader header) => new( public static MatchSetup FromHeader(MatchHeader header) => new(
header.MatchId, header.MatchId,

View File

@ -107,7 +107,7 @@ public sealed class MatchGrain : IMatchGrain
player0Name: setup.Seat0.PlayerId, player0Name: setup.Seat0.PlayerId,
player1Name: setup.Seat1.PlayerId); player1Name: setup.Seat1.PlayerId);
_engine.Start(); _engine.Start(setup.FirstPlayer);
RefreshSummary(); RefreshSummary();
} }

View File

@ -67,6 +67,9 @@ public sealed class MatchSetupData
[Id(4)] [Id(4)]
public ulong? Seed { get; set; } public ulong? Seed { get; set; }
[Id(5)]
public int? FirstPlayer { get; set; }
public static MatchSetupData From(MatchSetup setup) => new() public static MatchSetupData From(MatchSetup setup) => new()
{ {
MatchId = setup.MatchId, MatchId = setup.MatchId,
@ -84,6 +87,7 @@ public sealed class MatchSetupData
Mmr = setup.Seat1.Mmr, Mmr = setup.Seat1.Mmr,
}, },
Seed = setup.Seed, Seed = setup.Seed,
FirstPlayer = setup.FirstPlayer,
}; };
public MatchSetup ToSetup() => new( public MatchSetup ToSetup() => new(
@ -91,7 +95,8 @@ public sealed class MatchSetupData
Mode, Mode,
new MatchSeatSetup(Seat0.PlayerId, Seat0.Deck, Seat0.Mmr), new MatchSeatSetup(Seat0.PlayerId, Seat0.Deck, Seat0.Mmr),
new MatchSeatSetup(Seat1.PlayerId, Seat1.Deck, Seat1.Mmr), new MatchSeatSetup(Seat1.PlayerId, Seat1.Deck, Seat1.Mmr),
Seed); Seed,
FirstPlayer);
} }
[GenerateSerializer] [GenerateSerializer]

View File

@ -131,8 +131,7 @@ public sealed class MatchmakingService
var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList(); var violations = DeckValidator.Validate(_catalog, deck.ToList()).ToList();
// колода без grade 0 без триггера не может выставить стартовый Авангард // колода без grade 0 без триггера не может выставить стартовый Авангард
if (violations.Count == 0 if (!deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate))
&& !deck.Any(printingId => _catalog.CardOf(printingId).IsStartingVanguardCandidate))
{ {
violations.Add(new DeckViolation( violations.Add(new DeckViolation(
"deck.starting-vanguard", "deck.starting-vanguard",

View File

@ -68,6 +68,20 @@ public class DeckValidatorTests
Assert.Contains(violations, v => v.Code == "deck.heal"); 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] [Fact]
public void Unknown_Printing_Is_Reported() public void Unknown_Printing_Is_Reported()
{ {
@ -96,6 +110,42 @@ public class DeckValidatorTests
usedNames.Add(_catalog.Printing(printingId).NameId); 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 foreach (var group in _catalog.Printings
.Where(p => !p.IsParallel) .Where(p => !p.IsParallel)
.GroupBy(p => p.NameId) .GroupBy(p => p.NameId)
@ -108,7 +158,7 @@ public class DeckValidatorTests
continue; continue;
var card = _catalog.Card(group.Key); var card = _catalog.Card(group.Key);
if (card.IsSentinel || card.Trigger == TriggerType.Heal) if (card.IsSentinel || card.Trigger != TriggerType.None)
continue; continue;
var printingId = group.First().PrintingId; var printingId = group.First().PrintingId;

View File

@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Rules.Engine; using Vanguard.Rules.Engine;
using Vanguard.Rules.Engine.Abilities; using Vanguard.Rules.Engine.Abilities;
using Vanguard.Rules.Replay; using Vanguard.Rules.Replay;
@ -62,7 +63,7 @@ public class GameEngineTests
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
{ {
var engine = NewEngine(); var engine = NewEngine();
engine.Start(); engine.Start(firstPlayer: 0);
var player = engine.State.Players[0]; var player = engine.State.Players[0];
Assert.NotNull(player.Vanguard); Assert.NotNull(player.Vanguard);
@ -73,6 +74,45 @@ public class GameEngineTests
Assert.Equal(Phase.Mulligan, engine.State.Phase); 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] [Fact]
public void Ride_Rejects_Grade_Jump() public void Ride_Rejects_Grade_Jump()
{ {
@ -103,7 +143,7 @@ public class GameEngineTests
public void Mulligan_Replaces_Selected_Hand_Cards_And_Keeps_Five() public void Mulligan_Replaces_Selected_Hand_Cards_And_Keeps_Five()
{ {
var engine = NewEngine(); var engine = NewEngine();
engine.Start(); engine.Start(firstPlayer: 0);
Assert.Equal(Phase.Mulligan, engine.State.Phase); Assert.Equal(Phase.Mulligan, engine.State.Phase);
Assert.Equal(5, engine.State.Players[0].Hand.Count); 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.True(engine.Execute(new ActivateAbilityCommand(conroe.Id, null, 1)).Accepted);
Assert.False(first.FaceUp); Assert.True(first.FaceUp);
Assert.True(second.FaceUp); Assert.False(second.FaceUp);
} }
[Fact] [Fact]
@ -157,6 +197,7 @@ public class GameEngineTests
Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Single(engine.State.Players[1].Damage); Assert.Single(engine.State.Players[1].Damage);
Assert.True(engine.State.Players[1].Damage[0].FaceUp);
Assert.Equal(Step.None, engine.State.Step); Assert.Equal(Step.None, engine.State.Step);
} }
@ -263,7 +304,7 @@ public class GameEngineTests
var engine = ReadyToAttack(); var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin); StackDeck(engine.State.Players[0], Gallatin, Gallatin);
GiveDamage(engine, 1, 2); GiveDamage(engine, 1, 2);
GiveDamage(engine, 0, 1); GiveDamage(engine, 0, 2);
engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine)); engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine));
var chosen = engine.State.Players[1].Damage[0]; var chosen = engine.State.Players[1].Damage[0];
@ -747,7 +788,7 @@ public class GameEngineTests
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Null(opponent.RearGuards[0]); 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] [Fact]
@ -897,7 +938,7 @@ public class GameEngineTests
Assert.Null(engine.State.Players[0].RearGuards[0]); Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Conroe); 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) if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
@ -951,7 +992,7 @@ public class GameEngineTests
var alfred = engine.State.Players[0].Vanguard!; var alfred = engine.State.Players[0].Vanguard!;
Assert.True(engine.Execute(new ActivateAbilityCommand(alfred.Id)).Accepted); 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) if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
@ -1024,7 +1065,7 @@ public class GameEngineTests
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); 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.Equal(handBefore + 1, engine.State.Players[0].Hand.Count);
Assert.Single(engine.State.Players[1].Damage); 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.Execute(new ChooseCommand(0)).Accepted);
Assert.True(engine.State.HasRestriction(target, RestrictionType.CannotStand)); 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] [Fact]
@ -1468,7 +1509,7 @@ public class GameEngineTests
Assert.Same(frank, engine.State.Players[0].Vanguard); Assert.Same(frank, engine.State.Players[0].Vanguard);
Assert.DoesNotContain(engine.State.Players[0].Drop, c => c.Id == frank.Id); 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] [Fact]
@ -1507,7 +1548,7 @@ public class GameEngineTests
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Shieldon); 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] [Fact]
@ -1577,7 +1618,7 @@ public class GameEngineTests
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(handBefore - 1, p1.Hand.Count); 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] [Fact]
@ -1918,7 +1959,8 @@ public class GameEngineTests
private List<GameEvent> RunScriptedMatch(ulong seed) private List<GameEvent> RunScriptedMatch(ulong seed)
{ {
var engine = NewEngine(seed); var engine = NewEngine(seed);
engine.Start(); engine.Start(firstPlayer: 0);
engine.Execute(new FinishMulliganCommand());
engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand());
Give(engine, 0, Marron); Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron)); engine.Execute(new RideCommand(Marron));
@ -1930,12 +1972,19 @@ public class GameEngineTests
// ---------- helpers ---------- // ---------- helpers ----------
private GameEngine NewEngine(ulong seed = 7) => private GameEngine NewEngine(ulong seed = 7) =>
new(_catalog, seed, BuildDeck(), BuildDeck()); new(
_catalog,
seed,
BuildDeck(),
BuildDeck(),
enforceFirstTurnAttackRule: false,
requireLegalDeckTriggers: false);
private GameEngine StartedEngine() private GameEngine StartedEngine()
{ {
var engine = NewEngine(); var engine = NewEngine();
engine.Start(); engine.Start(firstPlayer: 0);
engine.Execute(new FinishMulliganCommand());
engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand());
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах
@ -2119,7 +2168,7 @@ public class GameEngineTests
Assert.NotEmpty(p.Deck); Assert.NotEmpty(p.Deck);
var card = p.Deck[0]; var card = p.Deck[0];
p.Deck.RemoveAt(0); p.Deck.RemoveAt(0);
card.FaceUp = false; card.FaceUp = true;
p.Damage.Add(card); p.Damage.Add(card);
} }
} }

View File

@ -1,4 +1,5 @@
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Rules.Engine; using Vanguard.Rules.Engine;
using Vanguard.Rules.Replay; using Vanguard.Rules.Replay;
using Vanguard.Shared.Projection; using Vanguard.Shared.Projection;
@ -380,7 +381,8 @@ public class ProtocolTests
private static GameEngine StartedEngine() private static GameEngine StartedEngine()
{ {
var engine = NewEngine(); var engine = NewEngine();
engine.Start(); engine.Start(firstPlayer: 0);
engine.Execute(new FinishMulliganCommand());
engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand());
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал
@ -391,7 +393,14 @@ public class ProtocolTests
return engine; 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<string> BuildDeck() private static List<string> BuildDeck()
{ {

View File

@ -72,7 +72,8 @@ public class GameServerE2ETests
await using var second = await TestGameClient.ConnectAsync( await using var second = await TestGameClient.ConnectAsync(
factory, secondStatus.MatchId!, secondStatus.Ticket!, "p2", 0, cts.Token); 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); Assert.True(result.Accepted, result.Reason);
var lastSeq = result.EventSeq; var lastSeq = result.EventSeq;

View File

@ -334,7 +334,8 @@ public class MatchGrainTests
"match-1", "match-1",
GameMode.ClassicRanked, GameMode.ClassicRanked,
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000),
new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000),
FirstPlayer: 0);
return new MatchGrain( return new MatchGrain(
setup, setup,

View File

@ -196,7 +196,7 @@ public class MatchPersistenceTests
GameMode.ClassicRanked, GameMode.ClassicRanked,
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000),
new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000), new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000),
seed); seed ?? 2);
private static MatchGrain NewGrain(MatchSetup setup, TimeProvider time, IMatchStore store) => new( private static MatchGrain NewGrain(MatchSetup setup, TimeProvider time, IMatchStore store) => new(
setup, setup,

View File

@ -33,7 +33,8 @@ public class OrleansMatchTests
"orleans-match", "orleans-match",
GameMode.ClassicRanked, GameMode.ClassicRanked,
new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), 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)); await actor.InitializeAsync(MatchSetupData.From(setup));

View File

@ -1,5 +1,6 @@
using Vanguard.Content; using Vanguard.Content;
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Configuration; using Vanguard.Server.Configuration;
namespace Vanguard.Server.Tests; namespace Vanguard.Server.Tests;
@ -25,23 +26,59 @@ internal static class ServerTestData
var deck = new List<string>(); var deck = new List<string>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal); var counts = new Dictionary<string, int>(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) .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) if (deck.Count >= 50)
break; break;
var card = Catalog.CardOf(printing.PrintingId); var card = Catalog.CardOf(group.First().PrintingId);
if (card.IsSentinel || card.Trigger == TriggerType.Heal) if (card.IsSentinel || card.Trigger != TriggerType.None)
continue; continue;
counts.TryGetValue(card.NameId, out var used); Add(group.First().PrintingId, 4);
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;
} }
Assert.Equal(50, deck.Count); Assert.Equal(50, deck.Count);