From 0d5f7b933db1642422b96d5e07f483e247126585 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sat, 24 Oct 2026 01:36:14 +0500 Subject: [PATCH] Fix draw and heal trigger targets, Tejas sniping, and single moves --- .opencode/skills/vanguard-remaster/SKILL.md | 13 ++++++ .../Runtime/Board3D/Data/BoardTypes.cs | 2 + .../Runtime/Board3D/View/GameBoard3D.cs | 18 +++++--- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 44 ++++++++++++++----- docs/HANDOFF.md | 10 +++-- docs/SESSION-STATE.md | 9 +++- .../src/Vanguard.Rules/Engine/GameEngine.cs | 11 +++-- .../Vanguard.Rules.Tests/GameEngineTests.cs | 17 +++++++ 8 files changed, 97 insertions(+), 27 deletions(-) diff --git a/.opencode/skills/vanguard-remaster/SKILL.md b/.opencode/skills/vanguard-remaster/SKILL.md index 54a93c4..af13514 100644 --- a/.opencode/skills/vanguard-remaster/SKILL.md +++ b/.opencode/skills/vanguard-remaster/SKILL.md @@ -255,3 +255,16 @@ Smoke: `Vanguard.Editor.SceneSmoke.Run` и `RunPlaymat`. Windows build: `Vanguard.Editor.StandaloneBuild.BuildWindowsCli`. После блока: тесты, `validate`, `translations check`, коммит и push. + +## 8. Триггеры и особые случаи + +- После выбора цели силы у **Draw** дополнительный эффект (draw 1) + разрешается сразу: второй цели на поле нет. +- **Heal** после цели силы просит карту только в **своей** зоне урона и + только если серверные условия выполнены (damage ≥ opponent и есть карты). + Нет карт/условий — дополнительный эффект не активируется. +- Tejas бьёт задний ряд своей колонки и с RC, и с VC (центральный задний + ряд); проверять `snipeBackRow` + `SameColumn`. +- Если у RC доступен только один круг перемещения в колонке, клиент + выполняет перемещение сразу, без выбора цели. +- Подсветка урона при выборе карты ограничена зоной урона локального игрока. diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs index d2b422c..3ae312e 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs @@ -228,7 +228,9 @@ namespace Vanguard.Board3D public HashSet SelectedHandInstanceIds { get; } = new HashSet(); public bool MulliganSelecting { get; set; } public bool DamageChoice { get; set; } + public int DamageChoicePlayer { get; set; } = -1; public bool AbilityDamageTargeting { get; set; } + public int AbilityDamagePlayer { get; set; } = -1; public int AbilityDamageIndex { get; set; } = -1; public bool MatchFinished { get; set; } } diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index 4e34fc3..a4e19fd 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -755,7 +755,7 @@ namespace Vanguard.Board3D { var isUp = i < faceUp.Count; var card = isUp ? faceUp[i] : null; - var highlight = DamageHighlight(state, i); + var highlight = DamageHighlight(state, player.Index, i); specs.Add(new CardSpec { Key = "D:" + player.Index + ":" + i, @@ -802,16 +802,20 @@ namespace Vanguard.Board3D } } - private Color? DamageHighlight(BoardRenderState state, int index) + private Color? DamageHighlight( + BoardRenderState state, + int player, + int index) { - if (!state.DamageChoice && !state.AbilityDamageTargeting) + var damageChoice = state.DamageChoice + && player == state.DamageChoicePlayer; + var abilityCost = state.AbilityDamageTargeting + && player == state.AbilityDamagePlayer; + if (!damageChoice && !abilityCost) return null; - if (state.AbilityDamageTargeting - && state.AbilityDamageIndex == index) - { + if (abilityCost && state.AbilityDamageIndex == index) return _config.selectedColor; - } return _config.highlightColor; } diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 25fb39f..a5bc668 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -1681,10 +1681,10 @@ namespace Vanguard.UI var state = new BoardRenderState { MulliganSelecting = IsMulliganSelecting(), - // Damage cards are highlighted only while an ability cost is - // being paid, not for ordinary damage choices. - DamageChoice = false, + DamageChoice = IsDamageChoice(), + DamageChoicePlayer = _view?.You ?? -1, AbilityDamageTargeting = IsAbilityDamageTargeting(), + AbilityDamagePlayer = _view?.You ?? -1, AbilityDamageIndex = IsAbilityDamageTargeting() ? _abilityDamageIndex : -1, @@ -1955,7 +1955,7 @@ namespace Vanguard.UI break; case BoardZone.Damage: - OnDamageClicked(card.Index); + OnDamageClicked(card.Player, card.Index); break; case BoardZone.Soul: @@ -2366,10 +2366,22 @@ namespace Vanguard.UI var moves = sources.OfType().ToList(); if (moves.Count > 0) { - actions.Add(new CardActionMenuItem("Переместить", () => + if (moves.Count == 1) { - BeginTargetSelection(moves, "Выберите круг для перемещения."); - })); + var move = moves[0]; + actions.Add(new CardActionMenuItem( + "Переместить", + () => OnIntentClicked(move))); + } + else + { + actions.Add(new CardActionMenuItem("Переместить", () => + { + BeginTargetSelection( + moves, + "Выберите круг для перемещения."); + })); + } } var calls = sources.OfType().ToList(); @@ -3488,14 +3500,14 @@ namespace Vanguard.UI private CardCatalogItemDto ResolveCatalogItem(string printingId) => _catalog.TryGetValue(printingId, out var item) ? item : null; - private void OnDamageClicked(int displayIndex) + private void OnDamageClicked(int player, int displayIndex) { - if (_inputLock.IsLocked) + if (_inputLock.IsLocked || _view == null || player != _view.You) return; if (IsDamageChoice()) { - if (_view?.Choice?.Options == null) + if (_view.Choice?.Options == null) return; for (var i = 0; i < _view.Choice.Options.Count; i++) @@ -3769,6 +3781,18 @@ namespace Vanguard.UI _triggerPowerSlot = target.Slot; _pendingIntents.Clear(); _pendingIntents.AddRange(powerTargets); + + // Draw and Heal have no second board target: Draw resolves + // immediately, Heal asks for a damage card on the server only + // when its conditions are met. + if (_view.Trigger.Trigger is TriggerKind.Draw or TriggerKind.Heal) + { + var trigger = powerTargets[0]; + ClearSelection(rebuild: false); + OnIntentClicked(trigger); + return true; + } + RebuildHighlights(); RebuildIntents(_view); SetStatus( diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bd9fefe..19f74f2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -26,10 +26,14 @@ в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен убраны из панелей, смена/призыв — драгом. - Защита из руки ограничена рангом авангарда. +- Draw-триггер после выбора цели силы сразу тянет карту; Heal после цели + силы просит карту только в своей зоне урона и только при выполнении условий. +- Tejas может атаковать задний ряд не только с RC, но и с VC (центр). +- Перемещение RC с единственным доступным кругом выполняется без выбора цели. - Проведён аудит всех 66 карт с текстом: `docs/ability-implementation.md`. Исправлены Conroe AUTO, зоны у Maiden of Libra/Starlight/Hungry/Clay/ Joka/Rakshasa, запрет поддержки Alfred. -- Тесты: сервер **148/148** правил + **45/45** серверных, клиент +- Тесты: сервер **149/149** правил + **45/45** серверных, клиент **38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны. --- @@ -67,7 +71,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **193/193** (148 правил/контрактов + 45 сервер) | +| Тесты сервера | **194/194** (149 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -100,7 +104,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 148 (Vanguard.Rules.Tests) +Пройден! : не пройдено 0, пройдено 149 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a1ea373..cd69c85 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **148** правил + **45** серверных, CI зелёный. +- Тесты: **149** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. @@ -91,6 +91,8 @@ убраны; действия доступны из меню выбранной карты в руке. - Призыв: клик по карте в руке открывает меню действий (Призвать / Сменить / Активировать способность / Подробнее), drag в RC/VC выполняет призыв/смену. + Если у RC доступен единственный круг перемещения, перемещение выполняется + сразу, без выбора цели. Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило действует для обычного призыва, призыва из души/колоды и выбора круга способностями. @@ -102,6 +104,9 @@ Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в руке с активируемой способностью открывает меню действий; пункт активации показывается, если фаза и условия позволяют способность. +- Триггеры: Draw после выбора цели силы сразу тянет карту; Heal выбирает + карту только в своей зоне урона и только при выполнении условий, иначе + дополнительный эффект не активируется. - Оплата способности: выбранные карты цены подсвечиваются в руке, а для Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик @@ -176,6 +181,8 @@ душа. Guardian-карты повёрнуты на 90° и уложены с перекрытием ~30%, новые сверху; Trigger-зона зеркальна у противника. 3D-подписи зон скрыты, включаются флагом `showZoneLabels`. +- Tejas может атаковать задний ряд противника своей колонки как с RC, так и + с VC (центральный задний ряд). - Drag в бою: задний ряд → свой передний круг/Авангард объявляет поддержку, после чего атаковать можно только поддерживаемой картой; передний круг → карта противника объявляет атаку. Авангард поддерживается центральным diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 7e19b65..3f23651 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -1527,9 +1527,7 @@ public sealed class GameEngine targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0 && (slot is 3 or 4 - || (canSnipe - && attackerSlot is 3 or 4 - && SameColumn(attackerSlot, slot))))); + || (canSnipe && SameColumn(attackerSlot, slot))))); foreach (var target in targets) commands.Add(new AttackCommand(attackerSlot, target)); @@ -1951,9 +1949,10 @@ public sealed class GameEngine return CommandResult.Ok(); } - /// Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет. + /// Колонки: -1↔1 (центр), 0↔3 (левая), 2↔4 (правая). private static bool SameColumn(int fromSlot, int toSlot) => - (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2); + (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2) + or (-1, 1) or (1, -1); private CommandResult EnterBattle() { @@ -2011,7 +2010,7 @@ public sealed class GameEngine if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow) return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности."); - if (attackerSlot is not (3 or 4) || !SameColumn(attackerSlot, targetSlot)) + if (!SameColumn(attackerSlot, targetSlot)) return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки."); } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 9788696..d6ddfe4 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1965,6 +1965,23 @@ public class GameEngineTests Assert.Null(defender.RearGuards[0]); } + [Fact] + public void Tejas_On_Vanguard_Attacks_Back_Row_Center() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + engine.State.Players[0].Vanguard = MakeCard(Tejas); + + var defender = engine.State.Players[1]; + defender.RearGuards[1] = MakeCard(Marron); + + Assert.True(engine.Execute(new EnterBattleCommand()).Accepted); + Assert.True(engine.Execute(new AttackCommand(-1, 1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.Null(defender.RearGuards[1]); + } + [Fact] public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired() {