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