Restore drag actions and hand action menu
CI / test (push) Successful in 2m5s Details

This commit is contained in:
MpakobecEx 2026-10-23 21:53:23 +05:00
parent 14717a3dff
commit d917448a5f
8 changed files with 100 additions and 71 deletions

View File

@ -12,8 +12,9 @@ namespace Vanguard.Board3D
public static bool CanCall(int slot, bool occupied) =>
slot >= 0
&& slot < PlaymatLayout.RearGuardCount
&& !occupied;
&& slot < PlaymatLayout.RearGuardCount;
// Occupied circles are valid call targets: the previous unit is
// moved to the drop zone.
public static bool CanGuard(BoardZone zone) =>
zone == BoardZone.Guardian;

View File

@ -1724,6 +1724,12 @@ namespace Vanguard.UI
state.DropTargets.Add(new BoardSlotKey(
1 - _view.You,
attack.TargetSlot));
if (attack.BoosterSlot >= 0)
{
state.DropTargets.Add(new BoardSlotKey(
_view.You,
attack.AttackerSlot));
}
break;
case GuardIntent _:
case InterceptIntent _:
@ -1917,6 +1923,12 @@ namespace Vanguard.UI
break;
}
if (!targetSelection)
{
OpenCardMenu(card);
break;
}
OnBoardTargetClicked(new BoardTarget(
card.Player, -2, card.PrintingId, card.InstanceId, true));
break;
@ -2191,17 +2203,6 @@ 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)
@ -2339,7 +2340,7 @@ namespace Vanguard.UI
card.Zone == BoardZone.Vanguard ? -1 : card.Slot,
card.PrintingId,
card.InstanceId,
false);
card.Zone == BoardZone.Hand);
var legal = _view.LegalIntents ?? new List<IntentPayload>();
var sources = legal
@ -2371,9 +2372,26 @@ namespace Vanguard.UI
}));
}
var calls = sources.OfType<CallIntent>().ToList();
if (calls.Count > 0)
{
actions.Add(new CardActionMenuItem("Призвать", () =>
{
BeginTargetSelection(calls, "Выберите круг для призыва.");
}));
}
var rides = sources.OfType<RideIntent>().ToList();
if (rides.Count > 0)
{
var ride = rides[0];
actions.Add(new CardActionMenuItem(
"Сменить: " + CardName(ride.PrintingId),
() => OnIntentClicked(ride)));
}
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
if (abilities.Count == 0
&& card.Zone is BoardZone.Vanguard or BoardZone.RearGuard)
if (abilities.Count == 0)
{
// Fallback: never hide an activated ability because source
// target resolution failed for an unusual card placement.
@ -2391,7 +2409,8 @@ namespace Vanguard.UI
foreach (var intent in sources)
{
if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent)
if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent
or CallIntent or RideIntent)
continue;
var captured = intent;
@ -3256,22 +3275,6 @@ namespace Vanguard.UI
return;
}
foreach (var ride in (view.LegalIntents ?? new List<IntentPayload>())
.OfType<RideIntent>()
.GroupBy(item => item.PrintingId)
.Select(group => group.First()))
{
var captured = ride;
var rideButton = UiKit.CreateButton(
_phasePanel,
"Phase_Ride_" + captured.PrintingId,
"Сменить: " + CardName(captured.PrintingId),
() => OnIntentClicked(captured),
14);
rideButton.interactable = !_inputLock.IsLocked;
UiKit.SetPreferredHeight(rideButton, 40f);
}
foreach (var intent in intents)
{
var captured = intent;
@ -3595,25 +3598,6 @@ 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)
@ -3869,6 +3853,21 @@ namespace Vanguard.UI
return true;
case ActivateAbilityIntent activate:
source = InstanceTarget(_view.You, activate.InstanceId);
if (source.InstanceId == 0)
{
var hand = _view.Players
.FirstOrDefault(player => player.Index == _view.You)
?.Hand;
var handCard = hand?.FirstOrDefault(
card => card.InstanceId == activate.InstanceId);
if (handCard != null)
{
source = new BoardTarget(
_view.You, -2, handCard.PrintingId,
handCard.InstanceId, true);
}
}
return source.InstanceId != 0;
default:
return false;
@ -3973,7 +3972,6 @@ namespace Vanguard.UI
var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
var rides = playable.OfType<RideIntent>().ToList();
var board = playable
.Where(intent => IsBoardIntent(intent)
&& intent is not ActivateAbilityIntent
@ -4086,9 +4084,6 @@ namespace Vanguard.UI
if (abilities.Count > 0 && _pendingAbilityCost == null)
AddAbilityButton(abilities);
if (rides.Count > 0)
AddRideButtons(rides);
foreach (var intent in global)
AddIntentButton(intent);
@ -4254,7 +4249,10 @@ namespace Vanguard.UI
{
return IsHand && other.IsHand
&& Player == other.Player
&& string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal);
&& string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal)
&& (InstanceId == 0
|| other.InstanceId == 0
|| InstanceId == other.InstanceId);
}
return Player == other.Player && Slot == other.Slot;

View File

@ -181,7 +181,7 @@ namespace Vanguard.Tests
{
Assert.IsTrue(BoardPlacementRules.CanCall(0, occupied: false));
Assert.IsTrue(BoardPlacementRules.CanCall(4, occupied: false));
Assert.IsFalse(BoardPlacementRules.CanCall(0, occupied: true));
Assert.IsTrue(BoardPlacementRules.CanCall(0, occupied: true));
Assert.IsFalse(BoardPlacementRules.CanCall(5, occupied: false));
Assert.IsTrue(BoardPlacementRules.CanRide(BoardZone.Vanguard, -1));

View File

@ -21,9 +21,13 @@
Урон больше не подсвечивается вне режима оплаты. Правый клик отменяет выбор.
- Призыв в занятый RC заменяет юнита: прежний уходит в Дроп; правило
распространено на призыв из руки, души и колоды. Сервер задеплоен.
- Клик по карте в руке с `[ACT]`/`{act}` открывает оверлей карты.
- Тесты: сервер **190/190**, клиент **38/38** EditMode; `SCENE_SMOKE` и
`PLAYMAT_SMOKE` успешны.
- Клик по карте в руке открывает меню действий с валидными пунктами
(Призвать / Сменить / Активировать способность / Подробнее); способность
в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен
убраны из панелей, смена/призыв — драгом.
- Защита из руки ограничена рангом авангарда.
- Тесты: сервер **146/146** правил + **45/45** серверных, клиент
**38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны.
---
@ -60,7 +64,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты сервера | **190/190** (145 правил/контрактов + 45 сервер) |
| Тесты сервера | **191/191** (146 правил/контрактов + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -93,7 +97,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
Ожидаемо:
```
Пройден! : не пройдено 0, пройдено 145 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 146 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0
Переведено: 94/94

View File

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

View File

@ -185,9 +185,10 @@ G-эра, в Classic не используется: выбор воина нас
уходит в rest. Цель — воин соперника в переднем ряду (или авангард).
2. **Boost.** Стоящий воин 0–1 ранга позади атакующего может уйти в rest;
его сила добавляется атакующему до конца битвы.
3. **Guard.** Защищающийся может призвать защитников из руки. Щит
защитников/перехватчиков добавляется к силе защищающегося до конца битвы.
У воинов 3+ ранга щита нет, кроме стражей наследия.
3. **Guard.** Защищающийся может призвать защитников из руки. Ранг защитника
не выше ранга своего авангарда. Щит защитников/перехватчиков добавляется к
силе защищающегося до конца битвы. У воинов 3+ ранга щита нет, кроме
стражей наследия.
4. **Intercept.** Воин 2 ранга из переднего ряда арьергарда может выйти на
перехват; его щит учитывается, после боя он уходит в дроп.
5. **Drive Check.** Только если атакующий — авангард. Верхняя карта колоды

View File

@ -1537,9 +1537,13 @@ public sealed class GameEngine
if (State.Step != Step.Guard || State.Battle is null)
return;
var defenderGrade = VanguardGrade(1 - State.Battle.AttackerPlayer);
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
{
var def = State.Catalog.CardOf(printingId);
if (def.Grade > defenderGrade)
continue;
if (def.IsSentinel)
{
commands.Add(new GuardCommand(printingId));
@ -2101,6 +2105,9 @@ public sealed class GameEngine
var card = defender.Hand[index];
var def = State.Catalog.CardOf(card.PrintingId);
if (def.Grade > VanguardGrade(defenderIndex))
return CommandResult.Reject("Ранг карты защиты выше ранга авангарда.");
if (!def.IsSentinel && discardPrintingId is not null)
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");

View File

@ -535,10 +535,22 @@ public class GameEngineTests
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
}
[Fact]
public void Guard_Rejects_Unit_With_Grade_Above_Vanguard()
{
var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
Give(engine, 1, Gallatin);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.False(engine.Execute(new GuardCommand(Gallatin)).Accepted);
}
[Fact]
public void Perfect_Guard_Nullifies_Attack_When_Cost_Paid()
{
var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Give(engine, 1, Iseult);
Give(engine, 1, Marron);
@ -556,6 +568,7 @@ public class GameEngineTests
public void Perfect_Guard_Without_Cost_Adds_No_Shield()
{
var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Give(engine, 1, Iseult);