Fix draw and heal trigger targets, Tejas sniping, and single moves
CI / test (push) Successful in 1m50s Details

This commit is contained in:
MpakobecEx 2026-10-24 01:36:14 +05:00
parent e2d35017ae
commit 0d5f7b933d
8 changed files with 97 additions and 27 deletions

View File

@ -255,3 +255,16 @@ Smoke: `Vanguard.Editor.SceneSmoke.Run` и `RunPlaymat`. Windows build:
`Vanguard.Editor.StandaloneBuild.BuildWindowsCli`.
После блока: тесты, `validate`, `translations check`, коммит и push.
## 8. Триггеры и особые случаи
- После выбора цели силы у **Draw** дополнительный эффект (draw 1)
разрешается сразу: второй цели на поле нет.
- **Heal** после цели силы просит карту только в **своей** зоне урона и
только если серверные условия выполнены (damage ≥ opponent и есть карты).
Нет карт/условий — дополнительный эффект не активируется.
- Tejas бьёт задний ряд своей колонки и с RC, и с VC (центральный задний
ряд); проверять `snipeBackRow` + `SameColumn`.
- Если у RC доступен только один круг перемещения в колонке, клиент
выполняет перемещение сразу, без выбора цели.
- Подсветка урона при выборе карты ограничена зоной урона локального игрока.

View File

@ -228,7 +228,9 @@ namespace Vanguard.Board3D
public HashSet<int> SelectedHandInstanceIds { get; } = new HashSet<int>();
public bool MulliganSelecting { get; set; }
public bool DamageChoice { get; set; }
public int DamageChoicePlayer { get; set; } = -1;
public bool AbilityDamageTargeting { get; set; }
public int AbilityDamagePlayer { get; set; } = -1;
public int AbilityDamageIndex { get; set; } = -1;
public bool MatchFinished { get; set; }
}

View File

@ -755,7 +755,7 @@ namespace Vanguard.Board3D
{
var isUp = i < faceUp.Count;
var card = isUp ? faceUp[i] : null;
var highlight = DamageHighlight(state, i);
var highlight = DamageHighlight(state, player.Index, i);
specs.Add(new CardSpec
{
Key = "D:" + player.Index + ":" + i,
@ -802,16 +802,20 @@ namespace Vanguard.Board3D
}
}
private Color? DamageHighlight(BoardRenderState state, int index)
private Color? DamageHighlight(
BoardRenderState state,
int player,
int index)
{
if (!state.DamageChoice && !state.AbilityDamageTargeting)
var damageChoice = state.DamageChoice
&& player == state.DamageChoicePlayer;
var abilityCost = state.AbilityDamageTargeting
&& player == state.AbilityDamagePlayer;
if (!damageChoice && !abilityCost)
return null;
if (state.AbilityDamageTargeting
&& state.AbilityDamageIndex == index)
{
if (abilityCost && state.AbilityDamageIndex == index)
return _config.selectedColor;
}
return _config.highlightColor;
}

View File

@ -1681,10 +1681,10 @@ namespace Vanguard.UI
var state = new BoardRenderState
{
MulliganSelecting = IsMulliganSelecting(),
// Damage cards are highlighted only while an ability cost is
// being paid, not for ordinary damage choices.
DamageChoice = false,
DamageChoice = IsDamageChoice(),
DamageChoicePlayer = _view?.You ?? -1,
AbilityDamageTargeting = IsAbilityDamageTargeting(),
AbilityDamagePlayer = _view?.You ?? -1,
AbilityDamageIndex = IsAbilityDamageTargeting()
? _abilityDamageIndex
: -1,
@ -1955,7 +1955,7 @@ namespace Vanguard.UI
break;
case BoardZone.Damage:
OnDamageClicked(card.Index);
OnDamageClicked(card.Player, card.Index);
break;
case BoardZone.Soul:
@ -2365,12 +2365,24 @@ namespace Vanguard.UI
var moves = sources.OfType<MoveIntent>().ToList();
if (moves.Count > 0)
{
if (moves.Count == 1)
{
var move = moves[0];
actions.Add(new CardActionMenuItem(
"Переместить",
() => OnIntentClicked(move)));
}
else
{
actions.Add(new CardActionMenuItem("Переместить", () =>
{
BeginTargetSelection(moves, "Выберите круг для перемещения.");
BeginTargetSelection(
moves,
"Выберите круг для перемещения.");
}));
}
}
var calls = sources.OfType<CallIntent>().ToList();
if (calls.Count > 0)
@ -3488,14 +3500,14 @@ namespace Vanguard.UI
private CardCatalogItemDto ResolveCatalogItem(string printingId) =>
_catalog.TryGetValue(printingId, out var item) ? item : null;
private void OnDamageClicked(int displayIndex)
private void OnDamageClicked(int player, int displayIndex)
{
if (_inputLock.IsLocked)
if (_inputLock.IsLocked || _view == null || player != _view.You)
return;
if (IsDamageChoice())
{
if (_view?.Choice?.Options == null)
if (_view.Choice?.Options == null)
return;
for (var i = 0; i < _view.Choice.Options.Count; i++)
@ -3769,6 +3781,18 @@ namespace Vanguard.UI
_triggerPowerSlot = target.Slot;
_pendingIntents.Clear();
_pendingIntents.AddRange(powerTargets);
// Draw and Heal have no second board target: Draw resolves
// immediately, Heal asks for a damage card on the server only
// when its conditions are met.
if (_view.Trigger.Trigger is TriggerKind.Draw or TriggerKind.Heal)
{
var trigger = powerTargets[0];
ClearSelection(rebuild: false);
OnIntentClicked(trigger);
return true;
}
RebuildHighlights();
RebuildIntents(_view);
SetStatus(

View File

@ -26,10 +26,14 @@
в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен
убраны из панелей, смена/призыв — драгом.
- Защита из руки ограничена рангом авангарда.
- Draw-триггер после выбора цели силы сразу тянет карту; Heal после цели
силы просит карту только в своей зоне урона и только при выполнении условий.
- Tejas может атаковать задний ряд не только с RC, но и с VC (центр).
- Перемещение RC с единственным доступным кругом выполняется без выбора цели.
- Проведён аудит всех 66 карт с текстом: `docs/ability-implementation.md`.
Исправлены Conroe AUTO, зоны у Maiden of Libra/Starlight/Hungry/Clay/
Joka/Rakshasa, запрет поддержки Alfred.
- Тесты: сервер **148/148** правил + **45/45** серверных, клиент
- Тесты: сервер **149/149** правил + **45/45** серверных, клиент
**38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны.
---
@ -67,7 +71,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат |
|---|---|
| Тесты сервера | **193/193** (148 правил/контрактов + 45 сервер) |
| Тесты сервера | **194/194** (149 правил/контрактов + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -100,7 +104,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
Ожидаемо:
```
Пройден! : не пройдено 0, пройдено 148 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 149 (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 реестр соединений.
- Тесты: **148** правил + **45** серверных, CI зелёный.
- Тесты: **149** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Диагностика Ride в `MatchGrain`: входящие интенты, результат, события,
снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`.
@ -91,6 +91,8 @@
убраны; действия доступны из меню выбранной карты в руке.
- Призыв: клик по карте в руке открывает меню действий (Призвать / Сменить /
Активировать способность / Подробнее), drag в RC/VC выполняет призыв/смену.
Если у RC доступен единственный круг перемещения, перемещение выполняется
сразу, без выбора цели.
Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило
действует для обычного призыва, призыва из души/колоды и выбора круга
способностями.
@ -102,6 +104,9 @@
Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в
руке с активируемой способностью открывает меню действий; пункт активации
показывается, если фаза и условия позволяют способность.
- Триггеры: Draw после выбора цели силы сразу тянет карту; Heal выбирает
карту только в своей зоне урона и только при выполнении условий, иначе
дополнительный эффект не активируется.
- Оплата способности: выбранные карты цены подсвечиваются в руке, а для
Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик
@ -176,6 +181,8 @@
душа. Guardian-карты повёрнуты на 90° и уложены с перекрытием ~30%,
новые сверху; Trigger-зона зеркальна у противника. 3D-подписи зон скрыты,
включаются флагом `showZoneLabels`.
- Tejas может атаковать задний ряд противника своей колонки как с RC, так и
с VC (центральный задний ряд).
- Drag в бою: задний ряд → свой передний круг/Авангард объявляет поддержку,
после чего атаковать можно только поддерживаемой картой; передний круг →
карта противника объявляет атаку. Авангард поддерживается центральным

View File

@ -1527,9 +1527,7 @@ public sealed class GameEngine
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
&& (slot is 3 or 4
|| (canSnipe
&& attackerSlot is 3 or 4
&& SameColumn(attackerSlot, slot)))));
|| (canSnipe && SameColumn(attackerSlot, slot)))));
foreach (var target in targets)
commands.Add(new AttackCommand(attackerSlot, target));
@ -1951,9 +1949,10 @@ public sealed class GameEngine
return CommandResult.Ok();
}
/// <summary>Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет.</summary>
/// <summary>Колонки: -1↔1 (центр), 0↔3 (левая), 2↔4 (правая).</summary>
private static bool SameColumn(int fromSlot, int toSlot) =>
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2);
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2)
or (-1, 1) or (1, -1);
private CommandResult EnterBattle()
{
@ -2011,7 +2010,7 @@ public sealed class GameEngine
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
if (attackerSlot is not (3 or 4) || !SameColumn(attackerSlot, targetSlot))
if (!SameColumn(attackerSlot, targetSlot))
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
}

View File

@ -1965,6 +1965,23 @@ public class GameEngineTests
Assert.Null(defender.RearGuards[0]);
}
[Fact]
public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
engine.State.Players[0].Vanguard = MakeCard(Tejas);
var defender = engine.State.Players[1];
defender.RearGuards[1] = MakeCard(Marron);
Assert.True(engine.Execute(new EnterBattleCommand()).Accepted);
Assert.True(engine.Execute(new AttackCommand(-1, 1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[1]);
}
[Fact]
public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired()
{