Fix battle card art, mulligan, damage targeting and rest
CI / test (push) Successful in 1m49s Details

This commit is contained in:
MpakobecEx 2026-10-22 02:17:21 +05:00
parent f5b7f3e2d8
commit c0c00ccc4c
17 changed files with 349 additions and 69 deletions

View File

@ -275,9 +275,13 @@ drive/damage-анимации и звук — следующие. Финальн
- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
колода/дроп справа, зеркально у противника, зона защиты между полями.
- [x] Карты: реальный арт из `imageKey`, рамка клана, рубашка, имя, ранг,
триггер, сила/крит, REST/скрытая карта; процедурный fallback.
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
- [x] Карты: реальный арт на всю карту, рамка клана, рубашка, имя, ранг,
триггер, сила/крит; REST поворачивает карту на бок; процедурный fallback.
- [x] Старт: маллиган с выбором карт в руке, возвратом в колоду,
перемешиванием и добиром до пяти.
- [x] Зона урона: видимые карты на боку; heal и Counter Blast выбирают
карту урона кликом.
- [x] Позванные rear-guard'ы входят стоящими; rest — после атаки/буста.
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
подсветка целей, группировка действий, выбор силы/эффекта триггера
и журнал боя.

View File

@ -21,7 +21,7 @@
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
| EF-миграции | готово (`InitialCreate`) |
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с реальными артами, анимациями и RU/EN |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с артами, маллиганом, зоной урона, анимациями и RU/EN |
| Лаунчер | не начато |
## Требования

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 int _abilityDamageIndex = -1;
private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
@ -207,6 +208,7 @@ namespace Vanguard.UI
_pendingIntents.Clear();
_highlightKeys.Clear();
_mulliganSelected.Clear();
_abilityDamageIndex = -1;
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
_showAllIntents = false;
@ -963,6 +965,15 @@ namespace Vanguard.UI
if (_sending || _connection == null || _cts == null || _view == null)
return;
if (_abilityDamageIndex >= 0)
{
if (intent is ActivateAbilityIntent activate)
activate.DamageIndex = _abilityDamageIndex;
else if (intent is ResolveAbilityIntent resolve)
resolve.DamageIndex = _abilityDamageIndex;
}
_abilityDamageIndex = -1;
_pendingIntents.Clear();
_highlightKeys.Clear();
_pendingBoosterSlot = -1;
@ -1215,16 +1226,17 @@ namespace Vanguard.UI
gridElement.preferredHeight = 176f;
var damageChoice = IsDamageChoice();
var abilityTargeting = IsAbilityDamageTargeting();
var displayIndex = 0;
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
{
AddDamageCard(grid, displayIndex, null, false, damageChoice);
AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting);
}
foreach (var card in player.FaceUpDamage)
{
AddDamageCard(grid, displayIndex, card, true, damageChoice);
AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false);
displayIndex++;
}
}
@ -1234,7 +1246,8 @@ namespace Vanguard.UI
int displayIndex,
CardView card,
bool faceUp,
bool damageChoice)
bool damageChoice,
bool abilityTargeting)
{
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
var rect = panel;
@ -1267,19 +1280,23 @@ namespace Vanguard.UI
image.color = Color.white;
}
var canClick = damageChoice || abilityTargeting;
var selected = abilityTargeting && _abilityDamageIndex == displayIndex;
var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = damageChoice
? new Color(0.98f, 0.82f, 0.42f)
: new Color(0.42f, 0.18f, 0.18f);
outline.effectDistance = damageChoice
? new Vector2(3f, -3f)
outline.effectColor = selected
? new Color(0.45f, 0.95f, 0.62f)
: canClick
? new Color(0.98f, 0.82f, 0.42f)
: new Color(0.42f, 0.18f, 0.18f);
outline.effectDistance = canClick
? new Vector2(selected ? 4f : 3f, selected ? -4f : -3f)
: new Vector2(1f, -1f);
var button = panel.gameObject.AddComponent<Button>();
button.targetGraphic = image;
var capturedIndex = displayIndex;
button.onClick.AddListener(() => OnDamageClicked(capturedIndex));
button.interactable = damageChoice;
button.interactable = canClick;
}
private bool IsDamageChoice() =>
@ -1287,19 +1304,46 @@ namespace Vanguard.UI
&& _view.Choice.Prompt != null
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
private bool IsAbilityDamageTargeting()
{
if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You)
return false;
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
if (self == null || self.FaceDownDamage == 0)
return false;
return (_view.LegalIntents ?? new List<IntentPayload>())
.Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent);
}
private void OnDamageClicked(int displayIndex)
{
if (_view?.Choice?.Options == null || !IsDamageChoice())
if (IsDamageChoice())
{
if (_view?.Choice?.Options == null)
return;
for (var i = 0; i < _view.Choice.Options.Count; i++)
{
if (_view.Choice.Options[i].Slot == displayIndex)
{
OnIntentClicked(new ChooseIntent { Index = i });
return;
}
}
return;
}
if (!IsAbilityDamageTargeting())
return;
for (var i = 0; i < _view.Choice.Options.Count; i++)
{
if (_view.Choice.Options[i].Slot == displayIndex)
{
OnIntentClicked(new ChooseIntent { Index = i });
return;
}
}
_abilityDamageIndex = displayIndex;
SetStatus(
"Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
new Color(0.45f, 0.95f, 0.62f));
Render();
}
private void AddPileColumn(Transform parent, PlayerView player)
@ -2054,6 +2098,19 @@ namespace Vanguard.UI
}
}
if (IsAbilityDamageTargeting())
{
var abilityHint = UiKit.CreateText(
_intents,
"AbilityDamageHint",
"Выберите закрытую карту урона для Counter Blast (необязательно).",
15,
new Color(0.72f, 0.80f, 0.94f),
TextAnchor.UpperLeft);
abilityHint.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(abilityHint, 34f);
}
if (board.Count > 0)
{
var hint = UiKit.CreateText(

View File

@ -322,6 +322,12 @@ namespace Vanguard.UI
["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.",
["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.",
["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.",
["Кликните карты, которые вернутся в колоду."] = "Click the cards you want to return to the deck.",
["К обмену выбрано карт: "] = "Cards selected for mulligan: ",
["Закрытая карта"] = "Face-down card",
["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).",
["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ",
["УРОН · "] = "DAMAGE · ",
};
private static readonly KeyValuePair<string, string>[] Phrases = Exact

View File

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.EventSystems;
@ -8,8 +10,10 @@ using Vanguard.Api.Contracts;
using Vanguard.App;
using Vanguard.Auth;
using Vanguard.Meta;
using Vanguard.Shared.Protocol;
using Vanguard.State;
using Vanguard.UI;
using BattleView = Vanguard.UI.BattleView;
namespace Vanguard.Tests
{
@ -115,6 +119,100 @@ namespace Vanguard.Tests
Object.DestroyImmediate(go);
}
[Test]
public void Battle_Renders_Damage_And_Rested_Cards()
{
var config = new AppConfig();
config.Load();
var tokens = new TokenStore();
var api = new ApiClient(config, tokens);
var player = new PlayerState();
var auth = new AuthService(api, tokens, player);
var meta = new MetaService(api, player);
var canvas = UiKit.CreateCanvas("Test Canvas");
var battle = new BattleView(canvas.transform, meta, () => { });
var screen = Resources.FindObjectsOfTypeAll<GameObject>()
.First(go => go.name == "BattleScreen");
screen.SetActive(true);
CardView Card(string id, int instance, bool rested = false) => new CardView
{
PrintingId = id,
InstanceId = instance,
Rested = rested,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
var self = new PlayerView
{
Index = 0,
Name = "Me",
Vanguard = Card("BT01/001EN", 1),
RearGuards = new CardView?[5],
Hand = new List<CardView> { Card("BT01/042EN", 2) },
Soul = new List<CardView> { Card("BT01/003EN", 3) },
FaceDownDamage = 2,
FaceUpDamage = new List<CardView> { Card("BT01/056EN", 4) },
DeckCount = 40,
Drop = new List<CardView>(),
};
self.RearGuards[3] = Card("BT01/042EN", 5, rested: true);
var opponent = new PlayerView
{
Index = 1,
Name = "Opponent",
Vanguard = Card("BT01/001EN", 6),
RearGuards = new CardView?[5],
Hand = new List<CardView>(),
Soul = new List<CardView>(),
FaceDownDamage = 1,
FaceUpDamage = new List<CardView>(),
DeckCount = 40,
Drop = new List<CardView>(),
};
var match = new MatchView
{
MatchId = "render-test",
You = 0,
ActivePlayer = 0,
Phase = GamePhase.Main,
Players = new List<PlayerView> { self, opponent },
Clock = new ClockView(),
LegalIntents = new List<IntentPayload>(),
};
typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(battle, match);
typeof(BattleView)
.GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(battle, null);
Assert.IsTrue(Exists("DamageZone"));
Assert.IsTrue(Exists("Damage_0"));
Assert.IsTrue(Exists("Damage_1"));
Assert.IsTrue(Exists("Damage_2"));
Assert.IsTrue(Exists("Card_R4"));
var hasSidewaysCard = Resources.FindObjectsOfTypeAll<GameObject>()
.Where(go => go.name == "Card_R4")
.Any(go =>
{
var angle = go.transform.localEulerAngles.z;
return Mathf.Abs(Mathf.DeltaAngle(angle, 90f)) < 1f
|| Mathf.Abs(Mathf.DeltaAngle(angle, -90f)) < 1f;
});
Assert.IsTrue(hasSidewaysCard, "Rested card was not turned sideways");
Cleanup();
}
private static bool Exists(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);

View File

@ -9,8 +9,10 @@
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"overrideReferences": true,
"precompiledReferences": [
"Vanguard.Shared.dll"
],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],

View File

@ -68,8 +68,11 @@ http://localhost:5080
действия, выбор силы/эффекта триггера, журнал событий и подсказки;
event-анимации с очередью и скипом.
- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг,
триггер, сила/крит, REST и рубашка для скрытых карт; call/ride-въезд,
пульс атаки и призрак ухода в дроп.
триггер, сила/крит, REST поворотом на бок и рубашка для скрытых карт;
call/ride-въезд, пульс атаки и призрак ухода в дроп.
- Зона урона: видимые карты на боку; heal выбирает карту урона, Counter
Blast принимает выбранную закрытую карту. Маллиган — выбор карт в руке
с обменом и добиром до пяти.
- Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`;
`imageKey` приходит из `/api/cards`. Скачаны все **124** арта;
импорт — `tools/import_card_art.py`.
@ -134,5 +137,5 @@ Smoke сцен (входит в Play Mode, открывает Boot → Login, п
```
Тесты не ходят в сеть: проверяются composition root, создание Canvas,
экранов, импорт арта и локализация (статические строки, динамика,
live-переключение). Сейчас **3/3**.
экранов, импорт арта, локализация (статические строки, динамика,
live-переключение) и рендер боевой доски с уроном/rest. Сейчас **4/4**.

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) |
| Тесты сервера | **180/180** (135 правил/контрактов + 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, пройдено 132 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 135 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94
@ -790,3 +790,28 @@ Battle; Boot и общие сервисы остаются персистент
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
стола — следующий блок; локализация сделана runtime-словарём, без
Unity Localization-таблиц (пакет остаётся подключён).
## 2026-10-22 — боевые карты, маллиган, урон
- Боевые карты переделаны: арт растянут на всю карту, сверху слот/грейд,
снизу плашка с именем и P/C/триггером; в руке — компактный вариант.
`CardImageService` проверен тестом: `td01_005` реально загружается.
- Rest теперь визуально поворачивает карту на бок; удалён старый текстовый
маркер `REST`. Позванные rear-guard'ы входят стоящими (`Call`,
`CallFromSoul`, `CallFromDeck` исправлены на `Rested = false`).
- Зона урона: отдельная сетка видимых карт на боку, до 6 штук; закрытые
показываются рубашкой, открытые — артом. При выборе карты урона (heal или
Counter Blast) карты подсвечиваются и кликаются.
- Heal: сервер больше не забирает последнюю карту автоматически, а создаёт
`ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast
принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent`
и использует выбранную закрытую карту (остаток суммы — авто).
- Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет
`MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и
добирает столько же. После обмена доступно «Закончить маллиган».
- Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`),
DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`.
- Проверки: сервер **135** правил + **45** серверных; клиент EditMode
**4/4** (включая рендер доски с уроном и отдыхающей картой);
`SCENE_SMOKE success=True active=Login`; `validate` и
`translations check` — без ошибок.

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений.
- Тесты: **132** правил + **45** серверных, CI зелёный.
- Тесты: **135** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/HANDOFF.md`.
@ -61,11 +61,17 @@
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград.
- Боевой экран: реальные арты карт, рамка по клану, имя, ранг, триггер,
сила/крит, REST и рубашка; процедурные плашки остаются fallback.
- Боевой экран: реальный арт на всю карту, рамка по клану, имя, ранг,
триггер, сила/крит и рубашка; fallback — процедурная плашка.
Rest поворачивает карту на бок.
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
(+soul), справа колода и ниже дроп; у противника зеркально; между
полями — зона защиты со щитом и проверками.
- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые —
артом). Heal предлагает выбрать карту урона; Counter Blast принимает
выбранную закрытую карту из зоны урона.
- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет
выбранные карты; новые карты добираются до пяти, колода перемешивается.
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
@ -75,7 +81,8 @@
`tools/import_card_art.py`.
- Взаимодействие в бою: клик по картам/зонам, подсветка целей,
группировка действий (карты → цели, глобальные кнопки отдельно),
выбор силы/эффекта триггера, журнал последних событий.
выбор силы/эффекта триггера, журнал последних событий. Позванные
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
@ -111,9 +118,9 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.Run
```
Клиентские проверки: **3/3** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк и live-переключение языка),
`SCENE_SMOKE success=True`.
Клиентские проверки: **4/4** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер
доски с уроном и отдыхающей картой), `SCENE_SMOKE success=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:

View File

@ -167,13 +167,15 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
| 10 | `PassGuardIntent` | — |
| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` |
| 12 | `SelectTriggerIntent` | `index` |
| 13 | `ResolveAbilityIntent` | `accept` |
| 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
| 14 | `ChooseIntent` | `index` |
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?` |
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` |
| 16 | `EndTurnIntent` | — |
| 17 | `ConcedeIntent` | — |
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
`damageIndex` — индекс карты урона в порядке отображения (сначала закрытые,
затем открытые); `-1` — авто.
`GuardIntent.discardPrintingId` заполняется только для идеальной защиты:
без него Sentinel не отменяет атаку; с ним указывается карта того же клана.

View File

@ -15,9 +15,12 @@ public sealed record InterceptCommand(int Slot) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record SelectTriggerCommand(int Index) : GameCommand;
public sealed record ResolveAbilityCommand(bool Accept) : GameCommand;
public sealed record ResolveAbilityCommand(bool Accept, int DamageIndex = -1) : GameCommand;
public sealed record ChooseCommand(int Index) : GameCommand;
public sealed record ActivateAbilityCommand(int InstanceId, string? DiscardPrintingId = null) : GameCommand;
public sealed record ActivateAbilityCommand(
int InstanceId,
string? DiscardPrintingId = null,
int DamageIndex = -1) : GameCommand;
public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason)

View File

@ -71,6 +71,7 @@ public sealed class GameEngine
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth;
private int _counterBlastDisplayIndex = -1;
/// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
@ -331,7 +332,7 @@ public sealed class GameEngine
}
}
private CommandResult Activate(int instanceId, string? discardPrintingId)
private CommandResult Activate(int instanceId, string? discardPrintingId, int damageIndex)
{
if (State.Phase != Phase.Main)
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
@ -433,7 +434,16 @@ public sealed class GameEngine
_events.Add(new UnitRetiredEvent(player, slot));
}
ability.Resolve(context);
_counterBlastDisplayIndex = damageIndex;
try
{
ability.Resolve(context);
}
finally
{
_counterBlastDisplayIndex = -1;
}
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
return CommandResult.Ok();
@ -495,14 +505,23 @@ public sealed class GameEngine
return CommandResult.Ok();
}
private CommandResult ResolvePendingAbility(bool accept)
private CommandResult ResolvePendingAbility(bool accept, int damageIndex)
{
var ability = State.PendingAbility;
if (ability is null || State.Step != Step.Ability)
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
State.PendingAbility = null;
ResolveAbility(ability, accept);
_counterBlastDisplayIndex = damageIndex;
try
{
ResolveAbility(ability, accept);
}
finally
{
_counterBlastDisplayIndex = -1;
}
return CommandResult.Ok();
}
@ -664,10 +683,30 @@ public sealed class GameEngine
public void CounterBlast(int player, int amount)
{
var damage = State.Players[player].Damage;
var flipped = 0;
foreach (var card in State.Players[player].Damage)
// Клиент передаёт индекс в том же порядке, в котором рисует урон:
// сначала закрытые, затем открытые карты.
if (_counterBlastDisplayIndex >= 0)
{
if (flipped == amount)
var order = DamageDisplayOrder(player);
if (_counterBlastDisplayIndex < order.Count)
{
var chosen = order[_counterBlastDisplayIndex].Card;
if (!chosen.FaceUp)
{
chosen.FaceUp = true;
flipped++;
}
}
_counterBlastDisplayIndex = -1;
}
foreach (var card in damage)
{
if (flipped >= amount)
break;
if (card.FaceUp)
@ -1680,9 +1719,9 @@ public sealed class GameEngine
PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c),
SelectTriggerCommand c => SelectTrigger(c.Index),
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
ChooseCommand c => Choose(c.Index),
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
};
@ -2387,23 +2426,22 @@ public sealed class GameEngine
return true;
}
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)
.ThenBy(item => item.Index)
.ToList();
private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
{
var damage = State.Players[player].Damage;
// Закрытый урон показываем первым, затем открытый: так клиент
// отрисовывает зону урона и может сопоставить клик с вариантом.
var order = damage
.Select((card, index) => (card, index))
.OrderBy(item => item.card.FaceUp)
.ThenBy(item => item.index)
.ToList();
var order = DamageDisplayOrder(player);
var options = order
.Select((item, displayIndex) => new ChoiceOption(
displayIndex,
item.card.FaceUp
? State.Catalog.CardOf(item.card.PrintingId).NameEn
item.Card.FaceUp
? State.Catalog.CardOf(item.Card.PrintingId).NameEn
: "Закрытая карта"))
.ToList();
@ -2413,7 +2451,7 @@ public sealed class GameEngine
Prompt = prompt,
Options = options,
Kind = ChoiceTargetKind.Damage,
Resolve = displayIndex => onChosen(order[displayIndex].index),
Resolve = displayIndex => onChosen(order[displayIndex].Index),
};
State.Step = Step.Choice;
State.ActivePlayer = player;

View File

@ -26,9 +26,9 @@ public static class CommandCodec
PassGuardCommand => "passGuard",
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}",
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}",
ChooseCommand c => $"choose{Separator}{c.Index}",
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}",
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}",
EndTurnCommand => "endTurn",
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
};
@ -56,9 +56,14 @@ public static class CommandCodec
"passGuard" => new PassGuardCommand(),
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
"resolveAbility" => new ResolveAbilityCommand(parts[1] == "1"),
"resolveAbility" => new ResolveAbilityCommand(
parts[1] == "1",
parts.Length > 2 ? ParseInt(parts[2]) : -1),
"choose" => new ChooseCommand(ParseInt(parts[1])),
"activate" => new ActivateAbilityCommand(ParseInt(parts[1]), Nullable(parts[2])),
"activate" => new ActivateAbilityCommand(
ParseInt(parts[1]),
Nullable(parts[2]),
parts.Length > 3 ? ParseInt(parts[3]) : -1),
"endTurn" => new EndTurnCommand(),
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
};

View File

@ -37,12 +37,17 @@ public static class IntentMapping
EffectTarget = c.EffectTarget,
},
SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index },
ResolveAbilityCommand c => new ResolveAbilityIntent { Accept = c.Accept },
ResolveAbilityCommand c => new ResolveAbilityIntent
{
Accept = c.Accept,
DamageIndex = c.DamageIndex,
},
ChooseCommand c => new ChooseIntent { Index = c.Index },
ActivateAbilityCommand c => new ActivateAbilityIntent
{
InstanceId = c.InstanceId,
DiscardPrintingId = c.DiscardPrintingId,
DamageIndex = c.DamageIndex,
},
EndTurnCommand => new EndTurnIntent(),
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
@ -66,9 +71,9 @@ public static class IntentMapping
PassGuardIntent => new PassGuardCommand(),
ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget),
SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept),
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex),
ChooseIntent c => new ChooseCommand(c.Index),
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId),
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
EndTurnIntent => new EndTurnCommand(),
ConcedeIntent => null,
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),

View File

@ -119,6 +119,9 @@ public sealed class SelectTriggerIntent : IntentPayload
public sealed class ResolveAbilityIntent : IntentPayload
{
[Key(0)] public bool Accept { get; set; }
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
[Key(1)] public int DamageIndex { get; set; } = -1;
}
[MessagePackObject]
@ -132,6 +135,9 @@ public sealed class ActivateAbilityIntent : IntentPayload
{
[Key(0)] public int InstanceId { get; set; }
[Key(1)] public string? DiscardPrintingId { get; set; }
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
[Key(2)] public int DamageIndex { get; set; } = -1;
}
[MessagePackObject]

View File

@ -115,6 +115,23 @@ public class GameEngineTests
Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 });
}
[Fact]
public void CounterBlast_Uses_Selected_Damage_Card()
{
var engine = InMainPhase();
var conroe = MakeCard(Conroe);
engine.State.Players[0].RearGuards[0] = conroe;
GiveDamage(engine, 0, 2);
var first = engine.State.Players[0].Damage[0];
var second = engine.State.Players[0].Damage[1];
Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id, null, 1)).Accepted);
Assert.False(first.FaceUp);
Assert.True(second.FaceUp);
}
[Fact]
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
{
@ -205,6 +222,7 @@ public class GameEngineTests
Assert.NotNull(engine.State.PendingTrigger);
var rearGuard = engine.State.Players[0].RearGuards[0]!;
rearGuard.Rested = true;
Assert.True(rearGuard.Rested);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
@ -246,7 +264,7 @@ public class GameEngineTests
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
GiveDamage(engine, 1, 2);
GiveDamage(engine, 0, 1);
StackDeck(engine.State.Players[1], Elaine);
engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine));
var chosen = engine.State.Players[1].Damage[0];
@ -814,6 +832,7 @@ public class GameEngineTests
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
Give(engine, 0, Marron);
lien.Rested = true;
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
}