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) private bool RequiresCounterBlast(ActivateAbilityIntent intent)
{ {
if (intent == null || _view == null) 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>(); var legal = _view.LegalIntents ?? new List<IntentPayload>();
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent) 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 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **144** правил + **45** серверных, CI зелёный. - Тесты: **145** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события,
снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`.
@ -88,10 +88,14 @@
- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом
ходу переход в Battle не предлагается; атака по-прежнему запрещена. ходу переход в Battle не предлагается; атака по-прежнему запрещена.
Кнопки «Сменить: карта» есть и в панели действий, и в панели «Действия». Кнопки «Сменить: карта» есть и в панели действий, и в панели «Действия».
- Призыв: разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило
действует для обычного призыва, призыва из души/колоды и выбора круга
способностями.
- Способности: кнопка «Активировать способность» одна на карту; при - Способности: кнопка «Активировать способность» одна на карту; при
нескольких вариантах открывается overlay выбора способности. Карта для нескольких вариантах открывается overlay выбора способности. Карта для
сброса выбирается **кликом по карте в руке**, а не пунктом меню. сброса выбирается **кликом по карте в руке**, а не пунктом меню.
Perfect Guard также просит выбрать карту сброса в руке. Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в
руке с активируемой способностью открывает оверлей карты, не призывая её.
- Оплата способности: выбранные карты цены подсвечиваются в руке, а для - Оплата способности: выбранные карты цены подсвечиваются в руке, а для
Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик

View File

@ -367,8 +367,7 @@ public sealed class BarcgalScript : CardScript
{ {
RestsSelf = true, RestsSelf = true,
CanActivate = ctx => CanActivate = ctx =>
!ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
ctx.Engine.HasEmptyRearGuard(ctx.Owner),
Resolve = ctx => ctx.Engine.SearchDeckToCall( Resolve = ctx => ctx.Engine.SearchDeckToCall(
ctx.Owner, ctx.Owner,
"Найдите Future Knight, Llew или Flogal", "Найдите Future Knight, Llew или Flogal",
@ -432,8 +431,7 @@ public sealed class AlfredScript : CardScript
yield return new ActivatedAbility yield return new ActivatedAbility
{ {
CanActivate = ctx => CanActivate = ctx =>
ctx.Engine.CanCounterBlast(ctx.Owner, 3) && ctx.Engine.CanCounterBlast(ctx.Owner, 3),
ctx.Engine.HasEmptyRearGuard(ctx.Owner),
Resolve = ctx => Resolve = ctx =>
{ {
ctx.Engine.CounterBlast(ctx.Owner, 3); ctx.Engine.CounterBlast(ctx.Owner, 3);

View File

@ -584,6 +584,22 @@ public sealed class GameEngine
State.Players[player].RearGuards.Count(u => State.Players[player].RearGuards.Count(u =>
u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); 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> /// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
public bool CallFromSoul(CardInstance card, int player, int slot = -1) public bool CallFromSoul(CardInstance card, int player, int slot = -1)
{ {
@ -591,10 +607,11 @@ public sealed class GameEngine
if (!p.Soul.Contains(card)) if (!p.Soul.Contains(card))
return false; return false;
if (slot < 0 || slot >= PlayerState.RearGuardSlots || p.RearGuards[slot] is not null) if (slot < 0 || slot >= PlayerState.RearGuardSlots)
return false; return false;
p.Soul.Remove(card); p.Soul.Remove(card);
RemoveRearGuardForCall(player, slot);
card.Rested = false; card.Rested = false;
card.FaceUp = true; card.FaceUp = true;
p.RearGuards[slot] = card; p.RearGuards[slot] = card;
@ -1059,13 +1076,6 @@ public sealed class GameEngine
{ {
_events.Add(new CardRevealedEvent(player, card.PrintingId)); _events.Add(new CardRevealedEvent(player, card.PrintingId));
// призывать некуда — карта возвращается в руку, а не теряется
if (!HasEmptyRearGuard(player))
{
State.Players[player].Hand.Add(card);
return;
}
State.InTransit.Add((player, card)); State.InTransit.Add((player, card));
AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot =>
{ {
@ -1152,12 +1162,9 @@ public sealed class GameEngine
public void AskEmptyRearGuardChoice(int player, string prompt, Action<int> onChosen) public void AskEmptyRearGuardChoice(int player, string prompt, Action<int> onChosen)
{ {
var slots = Enumerable.Range(0, PlayerState.RearGuardSlots) // Kept under the historical name, but occupied circles are valid call
.Where(s => State.Players[player].RearGuards[s] is null) // targets under the replacement house rule.
.ToList(); var slots = Enumerable.Range(0, PlayerState.RearGuardSlots).ToList();
if (slots.Count == 0)
return;
State.PendingChoice = new PendingChoice State.PendingChoice = new PendingChoice
{ {
@ -1173,12 +1180,7 @@ public sealed class GameEngine
public void CallFromDeck(CardInstance card, int player, int slot) public void CallFromDeck(CardInstance card, int player, int slot)
{ {
var p = State.Players[player]; var p = State.Players[player];
if (p.RearGuards[slot] is not null) RemoveRearGuardForCall(player, slot);
{
p.Hand.Add(card);
return;
}
card.Rested = false; card.Rested = false;
card.FaceUp = true; card.FaceUp = true;
p.RearGuards[slot] = card; p.RearGuards[slot] = card;
@ -1440,16 +1442,14 @@ public sealed class GameEngine
{ {
var p = State.Players[player]; var p = State.Players[player];
var maxGrade = VanguardGrade(player); var maxGrade = VanguardGrade(player);
var emptySlots = Enumerable.Range(0, PlayerState.RearGuardSlots) var slots = Enumerable.Range(0, PlayerState.RearGuardSlots).ToList();
.Where(s => p.RearGuards[s] is null)
.ToList();
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct()) foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
{ {
if (State.Catalog.CardOf(printingId).Grade > maxGrade) if (State.Catalog.CardOf(printingId).Grade > maxGrade)
continue; continue;
foreach (var slot in emptySlots) foreach (var slot in slots)
commands.Add(new CallCommand(printingId, slot)); commands.Add(new CallCommand(printingId, slot));
} }
@ -1882,15 +1882,13 @@ public sealed class GameEngine
return CommandResult.Reject("Некорректный круг Арьергарда."); return CommandResult.Reject("Некорректный круг Арьергарда.");
var player = State.Players[State.ActivePlayer]; 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); var index = player.Hand.FindIndex(c => c.PrintingId == printingId);
if (index < 0) if (index < 0)
return CommandResult.Reject("Карты нет в руке."); return CommandResult.Reject("Карты нет в руке.");
var card = player.Hand[index]; var card = player.Hand[index];
player.Hand.RemoveAt(index); player.Hand.RemoveAt(index);
RemoveRearGuardForCall(State.ActivePlayer, slot);
card.Rested = false; card.Rested = false;
card.FaceUp = true; card.FaceUp = true;
player.RearGuards[slot] = card; player.RearGuards[slot] = card;

View File

@ -232,18 +232,40 @@ public class GameEngineTests
} }
[Fact] [Fact]
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy() public void Called_RearGuard_Enters_Standing_And_Replaces_Occupied_Circle()
{ {
var engine = InMainPhase(); var engine = InMainPhase();
Give(engine, 0, Marron); Give(engine, 0, Marron);
Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted); 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); 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] [Fact]