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? _triggerPowerSlot;
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private bool _mulliganUsed;
private int _abilityDamageIndex = -1;
private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(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<IntentPayload>())
@ -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 });
}

View File

@ -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",

View File

@ -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<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 предупреждений |
| `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.

View File

@ -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,

View File

@ -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

View File

@ -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<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>();
@ -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;
}

View File

@ -49,10 +49,12 @@ public sealed class GameEngine
IReadOnlyList<string> deck1,
IReadOnlyDictionary<string, CardScript>? 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
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth;
private int _counterBlastDisplayIndex = -1;
private readonly bool _enforceFirstTurnAttackRule;
/// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
@ -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));

View File

@ -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; }
/// <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 Step Step { get; internal set; } = Step.None;
public int TurnNumber { get; internal set; }

View File

@ -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)

View File

@ -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,

View File

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

View File

@ -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]

View File

@ -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",

View File

@ -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;

View File

@ -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<GameEvent> 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);
}
}

View File

@ -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<string> BuildDeck()
{

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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));

View File

@ -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<string>();
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)
.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);