From df0b548e9d73650ec32a3ff839dba1622c88ee2f Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 20:58:48 +0500 Subject: [PATCH] Allow calls to occupied circles and show hand ability overlay --- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 30 +++++++++++ docs/HANDOFF.md | 8 ++- docs/SESSION-STATE.md | 8 ++- .../Engine/Abilities/CardScripts.cs | 6 +-- .../src/Vanguard.Rules/Engine/GameEngine.cs | 52 +++++++++---------- .../Vanguard.Rules.Tests/GameEngineTests.cs | 30 +++++++++-- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 8deea7d..bd19b46 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -2191,6 +2191,17 @@ 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) @@ -3584,6 +3595,25 @@ 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) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cc8de85..102eefb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -19,7 +19,11 @@ кликом по урону. Доступные и выбранные карты цены подсвечиваются; оплата подтверждается кнопкой «Подтвердить оплату» в панели «Действия». Урон больше не подсвечивается вне режима оплаты. Правый клик отменяет выбор. -- Тесты клиента: **38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны. +- Призыв в занятый RC заменяет юнита: прежний уходит в Дроп; правило + распространено на призыв из руки, души и колоды. Сервер задеплоен. +- Клик по карте в руке с `[ACT]`/`{act}` открывает оверлей карты. +- Тесты: сервер **190/190**, клиент **38/38** EditMode; `SCENE_SMOKE` и + `PLAYMAT_SMOKE` успешны. --- @@ -56,7 +60,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **189/189** (144 правил/контрактов + 45 сервер) | +| Тесты сервера | **190/190** (145 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 15d6843..b0f1406 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **144** правил + **45** серверных, CI зелёный. +- Тесты: **145** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. @@ -88,10 +88,14 @@ - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом ходу переход в Battle не предлагается; атака по-прежнему запрещена. Кнопки «Сменить: карта» есть и в панели действий, и в панели «Действия». +- Призыв: разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило + действует для обычного призыва, призыва из души/колоды и выбора круга + способностями. - Способности: кнопка «Активировать способность» одна на карту; при нескольких вариантах открывается overlay выбора способности. Карта для сброса выбирается **кликом по карте в руке**, а не пунктом меню. - Perfect Guard также просит выбрать карту сброса в руке. + Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в + руке с активируемой способностью открывает оверлей карты, не призывая её. - Оплата способности: выбранные карты цены подсвечиваются в руке, а для Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index b3def80..38bfcae 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -367,8 +367,7 @@ public sealed class BarcgalScript : CardScript { RestsSelf = true, CanActivate = ctx => - !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && - ctx.Engine.HasEmptyRearGuard(ctx.Owner), + !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), Resolve = ctx => ctx.Engine.SearchDeckToCall( ctx.Owner, "Найдите Future Knight, Llew или Flogal", @@ -432,8 +431,7 @@ public sealed class AlfredScript : CardScript yield return new ActivatedAbility { CanActivate = ctx => - ctx.Engine.CanCounterBlast(ctx.Owner, 3) && - ctx.Engine.HasEmptyRearGuard(ctx.Owner), + ctx.Engine.CanCounterBlast(ctx.Owner, 3), Resolve = ctx => { ctx.Engine.CounterBlast(ctx.Owner, 3); diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index f086cde..38beb85 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -584,6 +584,22 @@ public sealed class GameEngine State.Players[player].RearGuards.Count(u => u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); + /// + /// House rule: a call to an occupied rear-guard circle replaces the + /// previous unit, which is retired to the drop zone. + /// + private void RemoveRearGuardForCall(int player, int slot) + { + var p = State.Players[player]; + var replaced = p.RearGuards[slot]; + if (replaced is null) + return; + + p.RearGuards[slot] = null; + p.Drop.Add(replaced); + _events.Add(new UnitRetiredEvent(player, slot)); + } + /// Призыв карты из души — используется способностями вроде Barcgal. public bool CallFromSoul(CardInstance card, int player, int slot = -1) { @@ -591,10 +607,11 @@ public sealed class GameEngine if (!p.Soul.Contains(card)) return false; - if (slot < 0 || slot >= PlayerState.RearGuardSlots || p.RearGuards[slot] is not null) + if (slot < 0 || slot >= PlayerState.RearGuardSlots) return false; p.Soul.Remove(card); + RemoveRearGuardForCall(player, slot); card.Rested = false; card.FaceUp = true; p.RearGuards[slot] = card; @@ -1059,13 +1076,6 @@ public sealed class GameEngine { _events.Add(new CardRevealedEvent(player, card.PrintingId)); - // призывать некуда — карта возвращается в руку, а не теряется - if (!HasEmptyRearGuard(player)) - { - State.Players[player].Hand.Add(card); - return; - } - State.InTransit.Add((player, card)); AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => { @@ -1152,12 +1162,9 @@ public sealed class GameEngine public void AskEmptyRearGuardChoice(int player, string prompt, Action onChosen) { - var slots = Enumerable.Range(0, PlayerState.RearGuardSlots) - .Where(s => State.Players[player].RearGuards[s] is null) - .ToList(); - - if (slots.Count == 0) - return; + // Kept under the historical name, but occupied circles are valid call + // targets under the replacement house rule. + var slots = Enumerable.Range(0, PlayerState.RearGuardSlots).ToList(); State.PendingChoice = new PendingChoice { @@ -1173,12 +1180,7 @@ public sealed class GameEngine public void CallFromDeck(CardInstance card, int player, int slot) { var p = State.Players[player]; - if (p.RearGuards[slot] is not null) - { - p.Hand.Add(card); - return; - } - + RemoveRearGuardForCall(player, slot); card.Rested = false; card.FaceUp = true; p.RearGuards[slot] = card; @@ -1440,16 +1442,14 @@ public sealed class GameEngine { var p = State.Players[player]; var maxGrade = VanguardGrade(player); - var emptySlots = Enumerable.Range(0, PlayerState.RearGuardSlots) - .Where(s => p.RearGuards[s] is null) - .ToList(); + var slots = Enumerable.Range(0, PlayerState.RearGuardSlots).ToList(); foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct()) { if (State.Catalog.CardOf(printingId).Grade > maxGrade) continue; - foreach (var slot in emptySlots) + foreach (var slot in slots) commands.Add(new CallCommand(printingId, slot)); } @@ -1882,15 +1882,13 @@ public sealed class GameEngine return CommandResult.Reject("Некорректный круг Арьергарда."); var player = State.Players[State.ActivePlayer]; - if (player.RearGuards[slot] is not null) - return CommandResult.Reject("Круг уже занят."); - var index = player.Hand.FindIndex(c => c.PrintingId == printingId); if (index < 0) return CommandResult.Reject("Карты нет в руке."); var card = player.Hand[index]; player.Hand.RemoveAt(index); + RemoveRearGuardForCall(State.ActivePlayer, slot); card.Rested = false; card.FaceUp = true; player.RearGuards[slot] = card; diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index b95efe6..37c5d00 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -232,18 +232,40 @@ public class GameEngineTests } [Fact] - public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy() + public void Called_RearGuard_Enters_Standing_And_Replaces_Occupied_Circle() { var engine = InMainPhase(); Give(engine, 0, Marron); Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted); - Assert.False(engine.State.Players[0].RearGuards[0]!.Rested); + var first = engine.State.Players[0].RearGuards[0]!; + Assert.False(first.Rested); Give(engine, 0, Marron); - var second = engine.Execute(new CallCommand(Marron, 0)); + var second = engine.State.Players[0].Hand + .First(card => card.PrintingId == Marron); + Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted); - Assert.False(second.Accepted); + Assert.NotSame(first, engine.State.Players[0].RearGuards[0]); + Assert.Same(second, engine.State.Players[0].RearGuards[0]); + Assert.Contains(first, engine.State.Players[0].Drop); + } + + [Fact] + public void Call_To_Occupied_Circle_Puts_Previous_Unit_Into_Drop() + { + var engine = InMainPhase(); + var first = MakeCard(Marron); + engine.State.Players[0].RearGuards[0] = first; + engine.State.Players[0].Hand.Add(MakeCard(Gallatin)); + + var second = engine.State.Players[0].Hand + .First(card => card.PrintingId == Gallatin); + + Assert.True(engine.Execute(new CallCommand(Gallatin, 0)).Accepted); + + Assert.Same(second, engine.State.Players[0].RearGuards[0]); + Assert.Contains(first, engine.State.Players[0].Drop); } [Fact]