Allow calls to occupied circles and show hand ability overlay
CI / test (push) Has been cancelled
Details
CI / test (push) Has been cancelled
Details
This commit is contained in:
parent
d9bcb18bfd
commit
df0b548e9d
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ошибок |
|
||||
|
|
|
|||
|
|
@ -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 — доступные и выбранные карты в зоне урона. Подтверждение
|
||||
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue