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`. `Vanguard.Editor.StandaloneBuild.BuildWindowsCli`.
После блока: тесты, `validate`, `translations check`, коммит и push. После блока: тесты, `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 HashSet<int> SelectedHandInstanceIds { get; } = new HashSet<int>();
public bool MulliganSelecting { get; set; } public bool MulliganSelecting { get; set; }
public bool DamageChoice { get; set; } public bool DamageChoice { get; set; }
public int DamageChoicePlayer { get; set; } = -1;
public bool AbilityDamageTargeting { get; set; } public bool AbilityDamageTargeting { get; set; }
public int AbilityDamagePlayer { get; set; } = -1;
public int AbilityDamageIndex { get; set; } = -1; public int AbilityDamageIndex { get; set; } = -1;
public bool MatchFinished { get; set; } public bool MatchFinished { get; set; }
} }

View File

@ -755,7 +755,7 @@ namespace Vanguard.Board3D
{ {
var isUp = i < faceUp.Count; var isUp = i < faceUp.Count;
var card = isUp ? faceUp[i] : null; var card = isUp ? faceUp[i] : null;
var highlight = DamageHighlight(state, i); var highlight = DamageHighlight(state, player.Index, i);
specs.Add(new CardSpec specs.Add(new CardSpec
{ {
Key = "D:" + player.Index + ":" + i, 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; return null;
if (state.AbilityDamageTargeting if (abilityCost && state.AbilityDamageIndex == index)
&& state.AbilityDamageIndex == index)
{
return _config.selectedColor; return _config.selectedColor;
}
return _config.highlightColor; return _config.highlightColor;
} }

View File

@ -1681,10 +1681,10 @@ namespace Vanguard.UI
var state = new BoardRenderState var state = new BoardRenderState
{ {
MulliganSelecting = IsMulliganSelecting(), MulliganSelecting = IsMulliganSelecting(),
// Damage cards are highlighted only while an ability cost is DamageChoice = IsDamageChoice(),
// being paid, not for ordinary damage choices. DamageChoicePlayer = _view?.You ?? -1,
DamageChoice = false,
AbilityDamageTargeting = IsAbilityDamageTargeting(), AbilityDamageTargeting = IsAbilityDamageTargeting(),
AbilityDamagePlayer = _view?.You ?? -1,
AbilityDamageIndex = IsAbilityDamageTargeting() AbilityDamageIndex = IsAbilityDamageTargeting()
? _abilityDamageIndex ? _abilityDamageIndex
: -1, : -1,
@ -1955,7 +1955,7 @@ namespace Vanguard.UI
break; break;
case BoardZone.Damage: case BoardZone.Damage:
OnDamageClicked(card.Index); OnDamageClicked(card.Player, card.Index);
break; break;
case BoardZone.Soul: case BoardZone.Soul:
@ -2365,12 +2365,24 @@ namespace Vanguard.UI
var moves = sources.OfType<MoveIntent>().ToList(); var moves = sources.OfType<MoveIntent>().ToList();
if (moves.Count > 0) if (moves.Count > 0)
{
if (moves.Count == 1)
{
var move = moves[0];
actions.Add(new CardActionMenuItem(
"Переместить",
() => OnIntentClicked(move)));
}
else
{ {
actions.Add(new CardActionMenuItem("Переместить", () => actions.Add(new CardActionMenuItem("Переместить", () =>
{ {
BeginTargetSelection(moves, "Выберите круг для перемещения."); BeginTargetSelection(
moves,
"Выберите круг для перемещения.");
})); }));
} }
}
var calls = sources.OfType<CallIntent>().ToList(); var calls = sources.OfType<CallIntent>().ToList();
if (calls.Count > 0) if (calls.Count > 0)
@ -3488,14 +3500,14 @@ namespace Vanguard.UI
private CardCatalogItemDto ResolveCatalogItem(string printingId) => private CardCatalogItemDto ResolveCatalogItem(string printingId) =>
_catalog.TryGetValue(printingId, out var item) ? item : null; _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; return;
if (IsDamageChoice()) if (IsDamageChoice())
{ {
if (_view?.Choice?.Options == null) if (_view.Choice?.Options == null)
return; return;
for (var i = 0; i < _view.Choice.Options.Count; i++) for (var i = 0; i < _view.Choice.Options.Count; i++)
@ -3769,6 +3781,18 @@ namespace Vanguard.UI
_triggerPowerSlot = target.Slot; _triggerPowerSlot = target.Slot;
_pendingIntents.Clear(); _pendingIntents.Clear();
_pendingIntents.AddRange(powerTargets); _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(); RebuildHighlights();
RebuildIntents(_view); RebuildIntents(_view);
SetStatus( SetStatus(

View File

@ -26,10 +26,14 @@
в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен
убраны из панелей, смена/призыв — драгом. убраны из панелей, смена/призыв — драгом.
- Защита из руки ограничена рангом авангарда. - Защита из руки ограничена рангом авангарда.
- Draw-триггер после выбора цели силы сразу тянет карту; Heal после цели
силы просит карту только в своей зоне урона и только при выполнении условий.
- Tejas может атаковать задний ряд не только с RC, но и с VC (центр).
- Перемещение RC с единственным доступным кругом выполняется без выбора цели.
- Проведён аудит всех 66 карт с текстом: `docs/ability-implementation.md`. - Проведён аудит всех 66 карт с текстом: `docs/ability-implementation.md`.
Исправлены Conroe AUTO, зоны у Maiden of Libra/Starlight/Hungry/Clay/ Исправлены Conroe AUTO, зоны у Maiden of Libra/Starlight/Hungry/Clay/
Joka/Rakshasa, запрет поддержки Alfred. Joka/Rakshasa, запрет поддержки Alfred.
- Тесты: сервер **148/148** правил + **45/45** серверных, клиент - Тесты: сервер **149/149** правил + **45/45** серверных, клиент
**38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны. **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 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `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) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **148** правил + **45** серверных, CI зелёный. - Тесты: **149** правил + **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`.
@ -91,6 +91,8 @@
убраны; действия доступны из меню выбранной карты в руке. убраны; действия доступны из меню выбранной карты в руке.
- Призыв: клик по карте в руке открывает меню действий (Призвать / Сменить / - Призыв: клик по карте в руке открывает меню действий (Призвать / Сменить /
Активировать способность / Подробнее), drag в RC/VC выполняет призыв/смену. Активировать способность / Подробнее), drag в RC/VC выполняет призыв/смену.
Если у RC доступен единственный круг перемещения, перемещение выполняется
сразу, без выбора цели.
Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило
действует для обычного призыва, призыва из души/колоды и выбора круга действует для обычного призыва, призыва из души/колоды и выбора круга
способностями. способностями.
@ -102,6 +104,9 @@
Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в Perfect Guard также просит выбрать карту сброса в руке. Клик по карте в
руке с активируемой способностью открывает меню действий; пункт активации руке с активируемой способностью открывает меню действий; пункт активации
показывается, если фаза и условия позволяют способность. показывается, если фаза и условия позволяют способность.
- Триггеры: Draw после выбора цели силы сразу тянет карту; Heal выбирает
карту только в своей зоне урона и только при выполнении условий, иначе
дополнительный эффект не активируется.
- Оплата способности: выбранные карты цены подсвечиваются в руке, а для - Оплата способности: выбранные карты цены подсвечиваются в руке, а для
Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик
@ -176,6 +181,8 @@
душа. Guardian-карты повёрнуты на 90° и уложены с перекрытием ~30%, душа. Guardian-карты повёрнуты на 90° и уложены с перекрытием ~30%,
новые сверху; Trigger-зона зеркальна у противника. 3D-подписи зон скрыты, новые сверху; Trigger-зона зеркальна у противника. 3D-подписи зон скрыты,
включаются флагом `showZoneLabels`. включаются флагом `showZoneLabels`.
- Tejas может атаковать задний ряд противника своей колонки как с RC, так и
с VC (центральный задний ряд).
- Drag в бою: задний ряд → свой передний круг/Авангард объявляет поддержку, - Drag в бою: задний ряд → свой передний круг/Авангард объявляет поддержку,
после чего атаковать можно только поддерживаемой картой; передний круг → после чего атаковать можно только поддерживаемой картой; передний круг →
карта противника объявляет атаку. Авангард поддерживается центральным карта противника объявляет атаку. Авангард поддерживается центральным

View File

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

View File

@ -1965,6 +1965,23 @@ public class GameEngineTests
Assert.Null(defender.RearGuards[0]); 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] [Fact]
public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired() public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired()
{ {