Fix first-turn battle, same-grade ride, ability and guard choices, center camera
CI / test (push) Successful in 1m53s Details

This commit is contained in:
MpakobecEx 2026-10-23 16:11:55 +05:00
parent dfd766eaad
commit 30aed3b25d
11 changed files with 317 additions and 26 deletions

View File

@ -54,12 +54,12 @@ namespace Vanguard.App
camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f; camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f;
camera.transform.position = theme switch camera.transform.position = theme switch
{ {
SceneTheme.Battle => new Vector3(1.4f, 5.8f, -8f), SceneTheme.Battle => new Vector3(0f, 4.2f, -8f),
SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f), SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f),
_ => new Vector3(0f, 3.2f, -10f), _ => new Vector3(0f, 3.2f, -10f),
}; };
camera.transform.rotation = theme == SceneTheme.Battle camera.transform.rotation = theme == SceneTheme.Battle
? Quaternion.Euler(35.9f, 0f, 0f) ? Quaternion.Euler(27.7f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f); : Quaternion.Euler(8f, 0f, 0f);
} }

View File

@ -56,8 +56,8 @@ MonoBehaviour:
damageX: 3 damageX: 3
pileX: 2.95 pileX: 2.95
showZoneLabels: 0 showZoneLabels: 0
cameraPosition: {x: 1.4, y: 5.8, z: -8} cameraPosition: {x: 0, y: 4.2, z: -8}
cameraTarget: {x: 1.4, y: 0, z: 0} cameraTarget: {x: 0, y: 0, z: 0}
cameraFov: 46 cameraFov: 46
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1} tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1} matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}

View File

@ -87,8 +87,8 @@ namespace Vanguard.Board3D
[Header("Камера")] [Header("Камера")]
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")] [Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f); public Vector3 cameraPosition = new Vector3(0f, 4.2f, -8f);
public Vector3 cameraTarget = new Vector3(1.4f, 0f, 0f); public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
public float cameraFov = 46f; public float cameraFov = 46f;
[Header("Палитра")] [Header("Палитра")]

View File

@ -1086,8 +1086,7 @@ namespace Vanguard.UI
} }
private void OpenCardMenu(BoardCardRef card) private void OpenCardMenu(BoardCardRef card)
{ { if (_view == null || _board3D == null)
if (_view == null || _board3D == null)
return; return;
var target = new BoardTarget( var target = new BoardTarget(
@ -1127,9 +1126,17 @@ namespace Vanguard.UI
})); }));
} }
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
if (abilities.Count > 0)
{
actions.Add(new CardActionMenuItem(
"Активировать способность",
() => OpenAbilitySelector(abilities)));
}
foreach (var intent in sources) foreach (var intent in sources)
{ {
if (intent is AttackIntent or MoveIntent) if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent)
continue; continue;
var captured = intent; var captured = intent;
@ -1225,6 +1232,159 @@ namespace Vanguard.UI
OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false)); OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false));
} }
private void AddAbilityButton(IReadOnlyList<ActivateAbilityIntent> abilities)
{
if (abilities == null || abilities.Count == 0)
return;
var button = UiKit.CreateButton(
_intents,
"ActivateAbility",
"Активировать способность",
() => OpenAbilitySelector(abilities),
16);
button.interactable = !_inputLock.IsLocked;
UiKit.SetPreferredHeight(button, 46f);
}
private void OpenAbilitySelector(IReadOnlyList<ActivateAbilityIntent> abilities)
{
var groups = abilities
.GroupBy(ability => ability.InstanceId)
.ToList();
if (groups.Count == 0)
return;
if (groups.Count == 1)
{
ShowAbilityVariantChoice(groups[0].ToList());
return;
}
var items = groups
.Select(group => new CardActionMenuItem(
"Активировать: " + CardNameForInstance(group.Key),
() => ShowAbilityVariantChoice(group.ToList())))
.ToList();
ShowChoiceMenu("Способности", items);
}
private void ShowAbilityVariantChoice(
IReadOnlyList<ActivateAbilityIntent> variants)
{
var distinct = variants
.GroupBy(variant => variant.DiscardPrintingId ?? string.Empty)
.Select(group => group.First())
.ToList();
if (distinct.Count == 0)
return;
if (distinct.Count == 1)
{
OnIntentClicked(distinct[0]);
return;
}
var items = distinct
.Select(variant => new CardActionMenuItem(
string.IsNullOrEmpty(variant.DiscardPrintingId)
? "Без сброса"
: "Сбросить: " + CardName(variant.DiscardPrintingId),
() => OnIntentClicked(variant)))
.ToList();
ShowChoiceMenu("Сброс для способности", items);
}
private void ShowGuardDiscardChoice(
IReadOnlyList<GuardIntent> guards,
Action onClosed = null)
{
var distinct = guards
.GroupBy(guard => guard.DiscardPrintingId ?? string.Empty)
.Select(group => group.First())
.ToList();
if (distinct.Count == 0)
return;
if (distinct.Count > 1)
{
distinct = distinct
.Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId))
.ToList();
}
if (distinct.Count == 1)
{
OnIntentClicked(distinct[0]);
return;
}
var items = distinct
.Select(guard => new CardActionMenuItem(
string.IsNullOrEmpty(guard.DiscardPrintingId)
? "Guard без сброса"
: "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId),
() => OnIntentClicked(guard)))
.ToList();
ShowChoiceMenu("Guard", items, onClosed);
}
private void ShowChoiceMenu(
string title,
IReadOnlyList<CardActionMenuItem> items,
Action onClosed = null)
{
if (_cardMenu == null || items == null || items.Count == 0)
return;
_inputLock.Acquire("card-menu");
_cardMenu.Show(
new Vector2(Screen.width * 0.5f, Screen.height * 0.5f),
title,
items,
() =>
{
_inputLock.Release("card-menu");
onClosed?.Invoke();
});
}
private string CardNameForInstance(int instanceId)
{
if (_view == null)
return "карта #" + instanceId;
foreach (var player in _view.Players)
{
var cards = new List<CardView>();
if (player.Vanguard != null)
cards.Add(player.Vanguard);
if (player.RearGuards != null)
{
foreach (var card in player.RearGuards)
{
if (card != null)
cards.Add(card);
}
}
if (player.Hand != null)
cards.AddRange(player.Hand);
if (player.Drop != null)
cards.AddRange(player.Drop);
if (player.Soul != null)
cards.AddRange(player.Soul);
if (player.Guardian != null)
cards.AddRange(player.Guardian);
var found = cards.FirstOrDefault(card => card.InstanceId == instanceId);
if (found != null)
return CardName(found.PrintingId);
}
return "карта #" + instanceId;
}
private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target) private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target)
{ {
if (_view == null || _sending || _inputLock.IsLocked) if (_view == null || _sending || _inputLock.IsLocked)
@ -1237,8 +1397,16 @@ namespace Vanguard.UI
{ {
if (target.Zone == BoardZone.Guardian) if (target.Zone == BoardZone.Guardian)
{ {
intent = legal.OfType<GuardIntent>() var guards = legal.OfType<GuardIntent>()
.FirstOrDefault(item => item.PrintingId == source.PrintingId); .Where(item => item.PrintingId == source.PrintingId)
.ToList();
if (guards.Count > 1)
{
ShowGuardDiscardChoice(guards, Render);
return;
}
intent = guards.FirstOrDefault();
} }
else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard) else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard)
{ {
@ -1354,6 +1522,7 @@ namespace Vanguard.UI
var intents = (view.LegalIntents ?? new List<IntentPayload>()) var intents = (view.LegalIntents ?? new List<IntentPayload>())
.Where(IsPhaseIntent) .Where(IsPhaseIntent)
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1))
.ToList(); .ToList();
if (intents.Count == 0) if (intents.Count == 0)
{ {
@ -1696,6 +1865,13 @@ namespace Vanguard.UI
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target)) .Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
.ToList(); .ToList();
var guards = sources.OfType<GuardIntent>().ToList();
if (guards.Count > 0 && guards.Count == sources.Count)
{
ShowGuardDiscardChoice(guards);
return;
}
if (sources.Count == 0) if (sources.Count == 0)
{ {
SetStatus("Для этой карты сейчас нет доступного действия.", Muted); SetStatus("Для этой карты сейчас нет доступного действия.", Muted);
@ -1713,6 +1889,28 @@ namespace Vanguard.UI
.Where(intent => TryGetTarget(intent, out _)) .Where(intent => TryGetTarget(intent, out _))
.ToList(); .ToList();
if (withTargets.Count == 0 && sources.Count > 1)
{
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
var other = sources
.Where(intent => intent is not ActivateAbilityIntent)
.ToList();
var items = other
.Select(intent => new CardActionMenuItem(
Label(intent),
() => OnIntentClicked(intent)))
.ToList();
if (abilities.Count > 0)
{
items.Add(new CardActionMenuItem(
"Активировать способность",
() => OpenAbilitySelector(abilities)));
}
ShowChoiceMenu("Действия", items);
return;
}
if (withTargets.Count == 0) if (withTargets.Count == 0)
{ {
ClearSelection(rebuild: false); ClearSelection(rebuild: false);
@ -1956,9 +2154,14 @@ namespace Vanguard.UI
var legal = view.LegalIntents ?? new List<IntentPayload>(); var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var board = playable.Where(IsBoardIntent).ToList(); var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
var board = playable
.Where(intent => IsBoardIntent(intent) && intent is not ActivateAbilityIntent)
.ToList();
var global = playable var global = playable
.Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent)) .Where(intent => !IsBoardIntent(intent)
&& !IsPhaseIntent(intent)
&& intent is not ActivateAbilityIntent)
.ToList(); .ToList();
if (_pendingIntents.Count > 0) if (_pendingIntents.Count > 0)
@ -2065,6 +2268,9 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(toggle, 38f); UiKit.SetPreferredHeight(toggle, 38f);
} }
if (abilities.Count > 0)
AddAbilityButton(abilities);
foreach (var intent in global) foreach (var intent in global)
AddIntentButton(intent); AddIntentButton(intent);

View File

@ -63,6 +63,14 @@ namespace Vanguard.UI
["Атака "] = "Attack ", ["Атака "] = "Attack ",
["Атака"] = "Attack", ["Атака"] = "Attack",
["Атака с поддержкой: "] = "Boosted attack: ", ["Атака с поддержкой: "] = "Boosted attack: ",
["Активировать способность"] = "Activate ability",
["Активировать: "] = "Activate: ",
["Сброс для способности"] = "Discard for ability",
["Сбросить: "] = "Discard: ",
["Без сброса"] = "No discard",
["Guard без сброса"] = "Guard without discard",
["Perfect Guard: сбросить "] = "Perfect Guard: discard ",
["Действия"] = "Actions",
["Переместить"] = "Move", ["Переместить"] = "Move",
["Подробнее"] = "Details", ["Подробнее"] = "Details",
["Нет доступных действий"] = "No available actions", ["Нет доступных действий"] = "No available actions",

View File

@ -7,6 +7,17 @@ namespace Vanguard.Tests
{ {
public sealed class PlaymatLayoutTests public sealed class PlaymatLayoutTests
{ {
[Test]
public void Camera_Is_Centered_And_Only_Pitches_Down()
{
var config = PlaymatLayoutConfig.CreateDefault();
Assert.AreEqual(0f, config.cameraPosition.x, 0.0001f);
Assert.AreEqual(0f, config.cameraTarget.x, 0.0001f);
Assert.AreEqual(config.cameraPosition.x, config.cameraTarget.x, 0.0001f);
Assert.Greater(config.cameraPosition.y, config.cameraTarget.y);
Assert.AreEqual(0f, config.cameraTarget.z, 0.0001f);
}
[Test] [Test]
public void Default_Playmat_Has_Six_Circles_Per_Side() public void Default_Playmat_Has_Six_Circles_Per_Side()
{ {

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **184/184** (139 правил/контрактов + 45 сервер) | | Тесты сервера | **186/186** (141 правил/контрактов + 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 ошибок |
@ -858,8 +858,9 @@ Battle; Boot и общие сервисы остаются персистент
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики, - `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
индикатором точки броска. Ошибка интента возвращает карту на место. индикатором точки броска. Ошибка интента возвращает карту на место.
- Камера фиксирована над полем: позиция `(1.4, 5.8, -8)`, цель - Камера фиксирована по центру поля: позиция `(0, 4.2, -8)`, цель
`(1.4, 0, 0)`, FOV 46; игрок её не двигает. Рука не лежит на столе, `(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения, игрок её
не двигает. Рука не лежит на столе,
а закреплена в экране снизу по центру (`handViewportX/Y`, а закреплена в экране снизу по центру (`handViewportX/Y`,
`handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы `handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы
не перекрывать задний ряд RC. Драг из руки уходит на стол. не перекрывать задний ряд RC. Драг из руки уходит на стол.
@ -958,7 +959,7 @@ Battle; Boot и общие сервисы остаются персистент
- Добавлен `Battle_Layout_Fits_Reference_Resolution`: проверяет блоки зон, - Добавлен `Battle_Layout_Fits_Reference_Resolution`: проверяет блоки зон,
панель фаз и боевую плашку на 1920×1080 и отсутствие пересечений; при панель фаз и боевую плашку на 1920×1080 и отсутствие пересечений; при
другом разрешении CanvasScaler масштабирует reference-раскладку. другом разрешении CanvasScaler масштабирует reference-раскладку.
- Проверки: клиент EditMode **28/28**, `SCENE_SMOKE success=True`, - Проверки: клиент EditMode **29/29**, `SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
- Дальше: только 3D-модели/наполнение сцен, анимации retire/ghost и - Дальше: только 3D-модели/наполнение сцен, анимации retire/ghost и
декбилдер; prefab-оверлеи и раскладка закрыты. декбилдер; prefab-оверлеи и раскладка закрыты.
@ -978,3 +979,21 @@ Battle; Boot и общие сервисы остаются персистент
не показателен: runtime-материалы требуют графического устройства. не показателен: runtime-материалы требуют графического устройства.
- Сервер для standalone по умолчанию — `http://192.168.31.158:18080`; - Сервер для standalone по умолчанию — `http://192.168.31.158:18080`;
его нужно поднять/держать доступным. его нужно поднять/держать доступным.
## 2026-10-23 — Исправления боя и камера
- Первый ход: `EnterBattleCommand` больше не попадает в легальные интенты,
пока действует правило первого хода; кнопка «В бой» не появляется.
Атака по-прежнему запрещена.
- Смена: в фазе Ride предлагается и карта того же ранга, и следующего
(раньше legal-команды содержали только `+1`, хотя движок разрешал same
grade).
- Способности: `ActivateAbilityIntent` группируются по карте; в UI одна
кнопка «Активировать способность». Если целей/вариантов несколько —
отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок).
- Guard: варианты Perfect Guard/сброса показываются отдельным overlay;
случайный первый интент больше не выбирается.
- Камера боя: `(0, 4.2, -8)` → цель `(0, 0, 0)`, только наклон по X;
поле по центру, без бокового смещения.
- Проверки: сервер **141** правил + **45** серверных, `validate` и
`translations check` OK; клиент EditMode **29/29**.

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **139** правил + **45** серверных, CI зелёный. - Тесты: **141** правил + **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`.
@ -68,10 +68,10 @@
Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`), Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`),
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не
закрывать задний ряд RC. Камера фиксирована над полем под небольшим закрывать задний ряд RC. Камера фиксирована по центру поля под небольшим
углом и не двигается игроком: `(1.4, 5.8, -8)`, цель `(1.4, 0, 0)`, углом к горизонту и не двигается игроком: `(0, 4.2, -8)`, цель
FOV 46. Драг-н-дроп на плоскость стола с подсветкой целей и плавным `(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения. Драг-н-дроп
снапом. на плоскость стола с подсветкой целей и плавным снапом.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у - Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
противника зеркально; между полями общий круг защиты; колода, дроп, противника зеркально; между полями общий круг защиты; колода, дроп,
триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если
@ -79,6 +79,12 @@
лицом вверх; урон — один вертикальный ряд, карта входит открытой. лицом вверх; урон — один вертикальный ряд, карта входит открытой.
Над колодой — зона проверки триггера. Под картами V/R — динамические Над колодой — зона проверки триггера. Под картами V/R — динамические
`P/C`. `P/C`.
- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом
ходу переход в Battle не предлагается; атака по-прежнему запрещена.
- Способности: кнопка «Активировать способность» одна на карту; при
нескольких вариантах открывается overlay выбора, затем — выбор карты
для сброса. Perfect Guard также спрашивает карту для сброса отдельным
overlay, а не выбирает случайную.
- Статистика карт — отдельное UI-наложение: своя панель снизу слева, - Статистика карт — отдельное UI-наложение: своя панель снизу слева,
противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер` противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер`
кликабельны и открывают общий горизонтальный `CardListView`; из списка кликабельны и открывают общий горизонтальный `CardListView`; из списка
@ -175,7 +181,7 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **28/28** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **29/29** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила 3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,

View File

@ -96,7 +96,8 @@
1. Каждый игрок выбирает карту 0 ранга и кладёт её **закрытой** в круг 1. Каждый игрок выбирает карту 0 ранга и кладёт её **закрытой** в круг
авангарда — это стартовый авангард. авангарда — это стартовый авангард.
2. Случайно определяется первый игрок. **В первый ход атаковать нельзя.** 2. Случайно определяется первый игрок. **В первый ход атаковать нельзя**;
переход в Battle на первом ходу также недоступен.
3. Оба игрока перемешивают колоду и берут по **5 карт**. 3. Оба игрока перемешивают колоду и берут по **5 карт**.
4. **Вторая попытка (mulligan):** игрок может вернуть **любое количество** 4. **Вторая попытка (mulligan):** игрок может вернуть **любое количество**
карт из руки в низ колоды, взять столько же карт сверху, после чего колода карт из руки в низ колоды, взять столько же карт сверху, после чего колода

View File

@ -1412,9 +1412,11 @@ public sealed class GameEngine
break; break;
case Phase.Ride: case Phase.Ride:
var rideGrade = VanguardGrade(player);
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct()) foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
{ {
if (State.Catalog.CardOf(printingId).Grade == VanguardGrade(player) + 1) var grade = State.Catalog.CardOf(printingId).Grade;
if (grade == rideGrade || grade == rideGrade + 1)
commands.Add(new RideCommand(printingId)); commands.Add(new RideCommand(printingId));
} }
@ -1471,7 +1473,8 @@ public sealed class GameEngine
foreach (var card in p.Drop) foreach (var card in p.Drop)
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true); AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
commands.Add(new EnterBattleCommand()); if (!_enforceFirstTurnAttackRule || State.TurnNumber != 1)
commands.Add(new EnterBattleCommand());
commands.Add(new EndTurnCommand()); commands.Add(new EndTurnCommand());
} }

View File

@ -94,6 +94,43 @@ public class GameEngineTests
Assert.False(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.False(engine.Execute(new AttackCommand(-1, -1)).Accepted);
} }
[Fact]
public void First_Turn_Does_Not_Offer_Battle_Phase()
{
var engine = new GameEngine(
_catalog,
7,
BuildDeck(),
BuildDeck(),
requireLegalDeckTriggers: false);
engine.Start(firstPlayer: 0);
engine.Execute(new FinishMulliganCommand());
engine.Execute(new FinishMulliganCommand());
Assert.DoesNotContain(
engine.LegalCommands(),
command => command is EnterBattleCommand);
engine.Execute(new SkipRideCommand());
Assert.DoesNotContain(
engine.LegalCommands(),
command => command is EnterBattleCommand);
}
[Fact]
public void Ride_Can_Use_Same_Grade()
{
var engine = StartedEngine();
Give(engine, 0, Conroe);
Assert.Contains(
engine.LegalCommands(),
command => command is RideCommand ride && ride.PrintingId == Conroe);
Assert.True(engine.Execute(new RideCommand(Conroe)).Accepted);
}
[Fact] [Fact]
public void Both_Players_Mulligan_Once_Then_First_Player_Starts() public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
{ {