From d917448a5f9d82e659fd6a542169b8fbf0e056c4 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 21:53:23 +0500 Subject: [PATCH] Restore drag actions and hand action menu --- .../Board3D/Logic/BoardPlacementRules.cs | 5 +- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 108 +++++++++--------- .../Tests/Editor/PlaymatLayoutTests.cs | 2 +- docs/HANDOFF.md | 14 ++- docs/SESSION-STATE.md | 15 ++- docs/game-rules.md | 7 +- .../src/Vanguard.Rules/Engine/GameEngine.cs | 7 ++ .../Vanguard.Rules.Tests/GameEngineTests.cs | 13 +++ 8 files changed, 100 insertions(+), 71 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs index 2da1d3d..b71ea1b 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs @@ -12,8 +12,9 @@ namespace Vanguard.Board3D public static bool CanCall(int slot, bool occupied) => slot >= 0 - && slot < PlaymatLayout.RearGuardCount - && !occupied; + && slot < PlaymatLayout.RearGuardCount; + // Occupied circles are valid call targets: the previous unit is + // moved to the drop zone. public static bool CanGuard(BoardZone zone) => zone == BoardZone.Guardian; diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index bd19b46..25fb39f 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -1724,6 +1724,12 @@ namespace Vanguard.UI state.DropTargets.Add(new BoardSlotKey( 1 - _view.You, attack.TargetSlot)); + if (attack.BoosterSlot >= 0) + { + state.DropTargets.Add(new BoardSlotKey( + _view.You, + attack.AttackerSlot)); + } break; case GuardIntent _: case InterceptIntent _: @@ -1917,6 +1923,12 @@ namespace Vanguard.UI break; } + if (!targetSelection) + { + OpenCardMenu(card); + break; + } + OnBoardTargetClicked(new BoardTarget( card.Player, -2, card.PrintingId, card.InstanceId, true)); break; @@ -2191,17 +2203,6 @@ namespace Vanguard.UI } } - private bool HasActivatedAbility(string printingId) - { - var meta = ResolveCatalogItem(printingId); - if (meta == null) - return false; - - var text = ((meta.TextEn ?? string.Empty) + " " - + (meta.TextRu ?? string.Empty)).ToLowerInvariant(); - return text.Contains("[act]") || text.Contains("{act}"); - } - private bool RequiresCounterBlast(ActivateAbilityIntent intent) { if (intent == null || _view == null) @@ -2339,7 +2340,7 @@ namespace Vanguard.UI card.Zone == BoardZone.Vanguard ? -1 : card.Slot, card.PrintingId, card.InstanceId, - false); + card.Zone == BoardZone.Hand); var legal = _view.LegalIntents ?? new List(); var sources = legal @@ -2371,9 +2372,26 @@ namespace Vanguard.UI })); } + var calls = sources.OfType().ToList(); + if (calls.Count > 0) + { + actions.Add(new CardActionMenuItem("Призвать", () => + { + BeginTargetSelection(calls, "Выберите круг для призыва."); + })); + } + + var rides = sources.OfType().ToList(); + if (rides.Count > 0) + { + var ride = rides[0]; + actions.Add(new CardActionMenuItem( + "Сменить: " + CardName(ride.PrintingId), + () => OnIntentClicked(ride))); + } + var abilities = sources.OfType().ToList(); - if (abilities.Count == 0 - && card.Zone is BoardZone.Vanguard or BoardZone.RearGuard) + if (abilities.Count == 0) { // Fallback: never hide an activated ability because source // target resolution failed for an unusual card placement. @@ -2391,7 +2409,8 @@ namespace Vanguard.UI foreach (var intent in sources) { - if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent) + if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent + or CallIntent or RideIntent) continue; var captured = intent; @@ -3256,22 +3275,6 @@ namespace Vanguard.UI return; } - foreach (var ride in (view.LegalIntents ?? new List()) - .OfType() - .GroupBy(item => item.PrintingId) - .Select(group => group.First())) - { - var captured = ride; - var rideButton = UiKit.CreateButton( - _phasePanel, - "Phase_Ride_" + captured.PrintingId, - "Сменить: " + CardName(captured.PrintingId), - () => OnIntentClicked(captured), - 14); - rideButton.interactable = !_inputLock.IsLocked; - UiKit.SetPreferredHeight(rideButton, 40f); - } - foreach (var intent in intents) { var captured = intent; @@ -3595,25 +3598,6 @@ namespace Vanguard.UI } } - if (target.IsHand - && target.Player == _view.You - && _pendingIntents.Count == 0 - && HasActivatedAbility(target.PrintingId)) - { - var handIndex = HandIndexOf(target.InstanceId); - var hand = _view.Players - .FirstOrDefault(player => player.Index == _view.You) - ?.Hand; - if (hand != null && handIndex >= 0 && handIndex < hand.Count) - { - OpenCardDetails(BoardCardRef.Hand( - _view.You, - handIndex, - hand[handIndex])); - return; - } - } - var legal = _view.LegalIntents ?? new List(); if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent) @@ -3869,6 +3853,21 @@ namespace Vanguard.UI return true; case ActivateAbilityIntent activate: source = InstanceTarget(_view.You, activate.InstanceId); + if (source.InstanceId == 0) + { + var hand = _view.Players + .FirstOrDefault(player => player.Index == _view.You) + ?.Hand; + var handCard = hand?.FirstOrDefault( + card => card.InstanceId == activate.InstanceId); + if (handCard != null) + { + source = new BoardTarget( + _view.You, -2, handCard.PrintingId, + handCard.InstanceId, true); + } + } + return source.InstanceId != 0; default: return false; @@ -3973,7 +3972,6 @@ namespace Vanguard.UI var legal = view.LegalIntents ?? new List(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var abilities = playable.OfType().ToList(); - var rides = playable.OfType().ToList(); var board = playable .Where(intent => IsBoardIntent(intent) && intent is not ActivateAbilityIntent @@ -4086,9 +4084,6 @@ namespace Vanguard.UI if (abilities.Count > 0 && _pendingAbilityCost == null) AddAbilityButton(abilities); - if (rides.Count > 0) - AddRideButtons(rides); - foreach (var intent in global) AddIntentButton(intent); @@ -4254,7 +4249,10 @@ namespace Vanguard.UI { return IsHand && other.IsHand && Player == other.Player - && string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal); + && string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal) + && (InstanceId == 0 + || other.InstanceId == 0 + || InstanceId == other.InstanceId); } return Player == other.Player && Slot == other.Slot; diff --git a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs index e6d4c15..5da43eb 100644 --- a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs @@ -181,7 +181,7 @@ namespace Vanguard.Tests { Assert.IsTrue(BoardPlacementRules.CanCall(0, occupied: false)); Assert.IsTrue(BoardPlacementRules.CanCall(4, occupied: false)); - Assert.IsFalse(BoardPlacementRules.CanCall(0, occupied: true)); + Assert.IsTrue(BoardPlacementRules.CanCall(0, occupied: true)); Assert.IsFalse(BoardPlacementRules.CanCall(5, occupied: false)); Assert.IsTrue(BoardPlacementRules.CanRide(BoardZone.Vanguard, -1)); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8897872..67f184f 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -21,9 +21,13 @@ Урон больше не подсвечивается вне режима оплаты. Правый клик отменяет выбор. - Призыв в занятый RC заменяет юнита: прежний уходит в Дроп; правило распространено на призыв из руки, души и колоды. Сервер задеплоен. -- Клик по карте в руке с `[ACT]`/`{act}` открывает оверлей карты. -- Тесты: сервер **190/190**, клиент **38/38** EditMode; `SCENE_SMOKE` и - `PLAYMAT_SMOKE` успешны. +- Клик по карте в руке открывает меню действий с валидными пунктами + (Призвать / Сменить / Активировать способность / Подробнее); способность + в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен + убраны из панелей, смена/призыв — драгом. +- Защита из руки ограничена рангом авангарда. +- Тесты: сервер **146/146** правил + **45/45** серверных, клиент + **38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны. --- @@ -60,7 +64,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **190/190** (145 правил/контрактов + 45 сервер) | +| Тесты сервера | **191/191** (146 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -93,7 +97,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 145 (Vanguard.Rules.Tests) +Пройден! : не пройдено 0, пройдено 146 (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 b0f1406..6236498 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **145** правил + **45** серверных, CI зелёный. +- Тесты: **146** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. @@ -87,21 +87,26 @@ `P/C`. - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом ходу переход в Battle не предлагается; атака по-прежнему запрещена. - Кнопки «Сменить: карта» есть и в панели действий, и в панели «Действия». -- Призыв: разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило + Смена — драг карты из руки на VC. Списки всех возможных смен в панелях + убраны; действия доступны из меню выбранной карты в руке. +- Призыв: клик по карте в руке открывает меню действий (Призвать / Сменить / + Активировать способность / Подробнее), drag в RC/VC выполняет призыв/смену. + Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило действует для обычного призыва, призыва из души/колоды и выбора круга способностями. - Способности: кнопка «Активировать способность» одна на карту; при нескольких вариантах открывается overlay выбора способности. Карта для сброса выбирается **кликом по карте в руке**, а не пунктом меню. Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в - руке с активируемой способностью открывает оверлей карты, не призывая её. + руке с активируемой способностью открывает меню действий; пункт активации + показывается, если фаза и условия позволяют способность. - Оплата способности: выбранные карты цены подсвечиваются в руке, а для Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик отменяет выбор. - Защита: в панели «Действия» одна кнопка — «Не защищаться» без выбора и - «Защищаться» с выбранными картами. Карты защиты и перехвата выбираются + «Защищаться» с выбранными картами. Защитник из руки должен быть не выше + ранга своего авангарда. Карты защиты и перехвата выбираются кликом/драгом по объектам; допустимые подсвечиваются, выбранные — зелёным. Щит выбранных карт сразу учитывается в счётчике; после подтверждения карты уходят в зону защиты, шаг защиты завершается. Дубли карт в Guardian diff --git a/docs/game-rules.md b/docs/game-rules.md index b90581f..6d805cf 100644 --- a/docs/game-rules.md +++ b/docs/game-rules.md @@ -185,9 +185,10 @@ G-эра, в Classic не используется: выбор воина нас уходит в rest. Цель — воин соперника в переднем ряду (или авангард). 2. **Boost.** Стоящий воин 0–1 ранга позади атакующего может уйти в rest; его сила добавляется атакующему до конца битвы. -3. **Guard.** Защищающийся может призвать защитников из руки. Щит - защитников/перехватчиков добавляется к силе защищающегося до конца битвы. - У воинов 3+ ранга щита нет, кроме стражей наследия. +3. **Guard.** Защищающийся может призвать защитников из руки. Ранг защитника + не выше ранга своего авангарда. Щит защитников/перехватчиков добавляется к + силе защищающегося до конца битвы. У воинов 3+ ранга щита нет, кроме + стражей наследия. 4. **Intercept.** Воин 2 ранга из переднего ряда арьергарда может выйти на перехват; его щит учитывается, после боя он уходит в дроп. 5. **Drive Check.** Только если атакующий — авангард. Верхняя карта колоды diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 38beb85..a80573f 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -1537,9 +1537,13 @@ public sealed class GameEngine if (State.Step != Step.Guard || State.Battle is null) return; + var defenderGrade = VanguardGrade(1 - State.Battle.AttackerPlayer); foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct()) { var def = State.Catalog.CardOf(printingId); + if (def.Grade > defenderGrade) + continue; + if (def.IsSentinel) { commands.Add(new GuardCommand(printingId)); @@ -2101,6 +2105,9 @@ public sealed class GameEngine var card = defender.Hand[index]; var def = State.Catalog.CardOf(card.PrintingId); + if (def.Grade > VanguardGrade(defenderIndex)) + return CommandResult.Reject("Ранг карты защиты выше ранга авангарда."); + if (!def.IsSentinel && discardPrintingId is not null) return CommandResult.Reject("Эта карта не требует оплаты стоимости."); diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 37c5d00..0c70072 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -535,10 +535,22 @@ public class GameEngineTests Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); } + [Fact] + public void Guard_Rejects_Unit_With_Grade_Above_Vanguard() + { + var engine = ReadyToAttack(); + SetVanguard(engine, 1, Marron); + Give(engine, 1, Gallatin); + + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.False(engine.Execute(new GuardCommand(Gallatin)).Accepted); + } + [Fact] public void Perfect_Guard_Nullifies_Attack_When_Cost_Paid() { var engine = ReadyToAttack(); + SetVanguard(engine, 1, Marron); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Give(engine, 1, Iseult); Give(engine, 1, Marron); @@ -556,6 +568,7 @@ public class GameEngineTests public void Perfect_Guard_Without_Cost_Adds_No_Shield() { var engine = ReadyToAttack(); + SetVanguard(engine, 1, Marron); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Give(engine, 1, Iseult);