Allow calls to occupied circles and show hand ability overlay
CI / test (push) Has been cancelled Details

This commit is contained in:
MpakobecEx 2026-10-23 20:58:48 +05:00
parent d9bcb18bfd
commit df0b548e9d
6 changed files with 95 additions and 39 deletions

View File

@ -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<IntentPayload>();
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent)

View File

@ -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 ошибок |

View File

@ -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 — доступные и выбранные карты в зоне урона. Подтверждение
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик

View File

@ -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);

View File

@ -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);
/// <summary>
/// House rule: a call to an occupied rear-guard circle replaces the
/// previous unit, which is retired to the drop zone.
/// </summary>
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));
}
/// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
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<int> 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;

View File

@ -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]