Align battle rules with official Vanguard flow
CI / test (push) Successful in 2m18s
Details
CI / test (push) Successful in 2m18s
Details
This commit is contained in:
parent
1e0b36bbb8
commit
7035c11873
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ public sealed class MatchGrain : IMatchGrain
|
|||
player0Name: setup.Seat0.PlayerId,
|
||||
player1Name: setup.Seat1.PlayerId);
|
||||
|
||||
_engine.Start();
|
||||
_engine.Start(setup.FirstPlayer);
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue