From 228bc4ce8c669493f55fa80ea9d93f88bf476c7e Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sat, 24 Oct 2026 00:40:22 +0500 Subject: [PATCH] Audit abilities and add Conroe auto call --- AGENTS.md | 32 +++++ docs/HANDOFF.md | 11 +- docs/SESSION-STATE.md | 6 +- docs/ability-implementation.md | 113 ++++++++++++++++++ .../Engine/Abilities/Abilities.cs | 6 + .../Engine/Abilities/CardScripts.cs | 23 ++++ .../Engine/Abilities/CardScriptsBT01.cs | 2 +- .../Engine/Abilities/CardScriptsTD.cs | 4 +- .../src/Vanguard.Rules/Engine/GameEngine.cs | 27 ++++- .../Vanguard.Rules.Tests/GameEngineTests.cs | 33 +++++ 10 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 docs/ability-implementation.md diff --git a/AGENTS.md b/AGENTS.md index 3818eed..d2a1578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,38 @@ - Информационные оверлеи (`ActionPrompt`, подсказки) не должны перехватывать клики и не дублируют выбор кнопками. +## Способности + +- Каждая текстовая способность карты должна иметь ровно одну реализацию: + `CardScript` (`Abilities`/`Continuous`/`Activated`), общий движок + (Sentinel, Heal/Trigger, Tejas) или флаг данных. Текст без реализации — + баг. +- **AUTO**: `AbilityDefinition` с `Trigger`, `Condition`, `Optional`, + `CanPay`/`Pay` и `Resolve`. Проверяй зону/источник через + `IsVanguardOf`/`IsRearGuardOf`/`IsInHand`; `OnPlace` не должен + срабатывать от VC, если текст требует RC. +- **ACT**: `ActivatedAbility`; флаги `RestsSelf`, `RetiresSelf`, + `RequiresDiscard`, `FromHand`, `FromDropZone`. `CanActivate` — только + проверка условий; оплата и эффект — в `Resolve` (или `Pay` для AUTO). +- **CONT**: `ContinuousAbility` для силы/критики; исключения вроде + «нельзя поддерживать» — через `CardScript.CanBeBoosted`, ограничения + передвижения/атаки — через `RestrictionType`. +- **Sentinel**: `IsSentinel` в данных, общий механизм Guard + сброс карты + того же клана; отдельный скрипт не нужен. +- **Trigger/Heal**: данные `trigger`; общий движок проверок и лечения. +- **Ключевые механики**: Tejas — `CanSnipeBackRow`; Restraint — флаг; + Boost/Intercept — `HasBoost`/`HasIntercept` и общий движок. +- **Выбор и цели**: только `AskUnitChoice`, `AskUnitMultiChoice`, + `AskHandCardChoice`, `AskEmptyRearGuardChoice`; клиент не выбирает + карты кнопками вместо объектов. +- **Клиент**: AUTO-подтверждение — оверлей; ACT — меню/оверлей карты; + стоимость способности (сброс/урон/поле) выбирается по объектам и + подтверждается кнопкой панели «Действия». +- После правок способностей проверять весь реестр `CardScripts.Default`, + тесты правил, `validate` и `translations check`. +- Полный аудит 66 карт и правила для новых карт — + `docs/ability-implementation.md`. + ## Технические ограничения - Не запускать `dotnet` и Docker на рабочем ПК; сервер проверять на diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 67f184f..bd9fefe 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,4 +1,4 @@ -# Передача сессии +# Передача сессии Состояние проекта на **23 октября 2026**. Документ нужен, чтобы новая сессия продолжила работу без повторного разбора истории. Краткая оперативная @@ -26,7 +26,10 @@ в руке (Gancelot) доступна как `FromHand`-активация. Списки всех смен убраны из панелей, смена/призыв — драгом. - Защита из руки ограничена рангом авангарда. -- Тесты: сервер **146/146** правил + **45/45** серверных, клиент +- Проведён аудит всех 66 карт с текстом: `docs/ability-implementation.md`. + Исправлены Conroe AUTO, зоны у Maiden of Libra/Starlight/Hungry/Clay/ + Joka/Rakshasa, запрет поддержки Alfred. +- Тесты: сервер **148/148** правил + **45/45** серверных, клиент **38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны. --- @@ -64,7 +67,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **191/191** (146 правил/контрактов + 45 сервер) | +| Тесты сервера | **193/193** (148 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -97,7 +100,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000 Ожидаемо: ``` -Пройден! : не пройдено 0, пройдено 146 (Vanguard.Rules.Tests) +Пройден! : не пройдено 0, пройдено 148 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) OK: ошибок 0, предупреждений 0 Переведено: 94/94 diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 6236498..a1ea373 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,4 +1,4 @@ -# Vanguard ReMaster — состояние сессии +# Vanguard ReMaster — состояние сессии **Дата:** 2026-10-23 @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **146** правил + **45** серверных, CI зелёный. +- Тесты: **148** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. @@ -94,6 +94,8 @@ Призыв разрешён и в занятый RC; прежний юнит уходит в Дроп. Правило действует для обычного призыва, призыва из души/колоды и выбора круга способностями. +- Аудит всех 66 карт со способностями и правила реализации: + `docs/ability-implementation.md`. Conroe получил AUTO-call при ride-over. - Способности: кнопка «Активировать способность» одна на карту; при нескольких вариантах открывается overlay выбора способности. Карта для сброса выбирается **кликом по карте в руке**, а не пунктом меню. diff --git a/docs/ability-implementation.md b/docs/ability-implementation.md new file mode 100644 index 0000000..5c99043 --- /dev/null +++ b/docs/ability-implementation.md @@ -0,0 +1,113 @@ +# Реализация способностей карт + +Актуально на **2026-10-24**. Источник правил — `content/sets/*.yaml`, +`textEn`; источник исполнения — `Vanguard.Rules`. + +## Проверка реестра + +Проверены все **66** карт с текстом способностей в BT01/TD01/TD02. +Для каждой есть одна реализация: + +- `CardScript` — 59 карт в `CardScripts.Default`; +- `Generic: Sentinel` — 3 карты (`flash-shield-iseult`, + `wyvern-guard-barri`, `battle-sister-chocolat`): данные `sentinel` + + общий Guard/сброс; +- `Generic: Heal trigger` — 3 карты (`yggdrasil-maiden-elaine`, + `dragon-monk-genjo`, `ring-girl-clara`): данные `trigger: Heal`; +- `Data: CanSnipeBackRow` — `wyvern-strike-tejas`: флаг данных + общий + движок атаки заднего ряда. + +## Полный список + +| Карта | Клан | Ранг | Реализация | +|---|---|---:|---| +| `king-of-knights-alfred` | RoyalPaladin | 3 | CardScript | +| `blaster-blade` | RoyalPaladin | 2 | CardScript | +| `barcgal` | RoyalPaladin | 0 | CardScript | +| `dragonic-overlord` | Kagero | 3 | CardScript | +| `embodiment-of-victory-aleph` | Kagero | 3 | CardScript | +| `ceo-amaterasu` | OracleThinkTank | 3 | CardScript | +| `battle-sister-cocoa` | OracleThinkTank | 1 | CardScript | +| `asura-kaiser` | NovaGrappler | 3 | CardScript | +| `demon-slaying-knight-lohengrin` | RoyalPaladin | 3 | CardScript | +| `solitary-knight-gancelot` | RoyalPaladin | 3 | CardScript | +| `flash-shield-iseult` | RoyalPaladin | 1 | Generic: Sentinel | +| `future-knight-llew` | RoyalPaladin | 0 | CardScript | +| `vortex-dragon` | Kagero | 3 | CardScript | +| `dragon-knight-aleph` | Kagero | 2 | CardScript | +| `wyvern-guard-barri` | Kagero | 1 | Generic: Sentinel | +| `lizard-soldier-conroe` | Kagero | 0 | CardScript | +| `maiden-of-libra` | OracleThinkTank | 2 | CardScript | +| `battle-sister-mocha` | OracleThinkTank | 2 | CardScript | +| `battle-sister-chocolat` | OracleThinkTank | 1 | Generic: Sentinel | +| `juggernaut-maximum` | SpikeBrothers | 3 | CardScript | +| `wyvern-strike-tejas` | Kagero | 2 | Data: CanSnipeBackRow | +| `oracle-guardian-apollon` | OracleThinkTank | 3 | CardScript | +| `lozenge-magus` | OracleThinkTank | 0 | CardScript | +| `mr-invincible` | NovaGrappler | 3 | CardScript | +| `brutal-jack` | NovaGrappler | 2 | CardScript | +| `queen-of-heart` | NovaGrappler | 1 | CardScript | +| `battleraizer` | NovaGrappler | 0 | CardScript | +| `tyrant-deathrex` | Tachikaze | 3 | CardScript | +| `assault-dragon-blightops` | Tachikaze | 2 | CardScript | +| `stealth-dragon-voidmaster` | Nubatama | 3 | CardScript | +| `demon-eater` | DarkIrregulars | 3 | CardScript | +| `monster-frank` | Granblue | 3 | CardScript | +| `hell-spider` | Megacolony | 3 | CardScript | +| `covenant-knight-randolf` | RoyalPaladin | 2 | CardScript | +| `lake-maiden-lien` | RoyalPaladin | 1 | CardScript | +| `wingal` | RoyalPaladin | 1 | CardScript | +| `yggdrasil-maiden-elaine` | RoyalPaladin | 0 | Generic: Heal trigger | +| `dragon-monk-gojo` | Kagero | 1 | CardScript | +| `wyvern-strike-jarran` | Kagero | 1 | CardScript | +| `dragon-monk-genjo` | Kagero | 0 | Generic: Heal trigger | +| `weather-girl-milk` | OracleThinkTank | 1 | CardScript | +| `hungry-dumpty` | NovaGrappler | 2 | CardScript | +| `screamin-and-dancin-announcer-shout` | NovaGrappler | 1 | CardScript | +| `clay-doll-mechanic` | NovaGrappler | 1 | CardScript | +| `ring-girl-clara` | NovaGrappler | 0 | Generic: Heal trigger | +| `stealth-beast-chigasumi` | Nubatama | 2 | CardScript | +| `stealth-dragon-dreadmaster` | Nubatama | 1 | CardScript | +| `stealth-beast-hagakure` | Nubatama | 0 | CardScript | +| `blue-dust` | DarkIrregulars | 2 | CardScript | +| `nightmare-baby` | DarkIrregulars | 1 | CardScript | +| `rock-the-wall` | DarkIrregulars | 0 | CardScript | +| `highspeed-brakki` | SpikeBrothers | 2 | CardScript | +| `redshoe-milly` | SpikeBrothers | 0 | CardScript | +| `guiding-zombie` | Granblue | 0 | CardScript | +| `karma-queen` | Megacolony | 1 | CardScript | +| `crimson-butterfly-brigitte` | RoyalPaladin | 3 | CardScript | +| `knight-of-conviction-bors` | RoyalPaladin | 3 | CardScript | +| `knight-of-the-harp-tristan` | RoyalPaladin | 2 | CardScript | +| `starlight-unicorn` | RoyalPaladin | 1 | CardScript | +| `knight-of-rose-morgana` | RoyalPaladin | 1 | CardScript | +| `dragon-monk-goku` | Kagero | 3 | CardScript | +| `demonic-dragon-berserker-yaksha` | Kagero | 3 | CardScript | +| `berserk-dragon` | Kagero | 2 | CardScript | +| `flame-of-hope-aermo` | Kagero | 1 | CardScript | +| `demonic-dragon-madonna-joka` | Kagero | 1 | CardScript | +| `demonic-dragon-mage-rakshasa` | Kagero | 0 | CardScript | + +## Исправлено при аудите 2026-10-24 + +- **Conroe**: добавлена недостающая `AUTO`: когда другой Kagero ride-over + эту карту, можно призвать Conroe из души в RC (`OnRideOver`). +- **Maiden of Libra**: добавлена проверка, что авангард — Oracle Think + Tank, как в тексте. +- **Starlight Unicorn**, **Hungry Dumpty**, **Clay Doll Mechanic**: + `OnPlace` теперь требует именно RC, а не срабатывает от VC. +- **Joka**, **Rakshasa**: бонус за retire соперника теперь требует + нахождения на RC, а не просто «не Vanguard» (включая руку). +- **Alfred**: добавлен CONT-запрет поддержки авангарда Alfred. + +## Новые карты + +Для новой карты с текстом: + +1. Найти все `AUTO`/`ACT`/`CONT`/ключевые механики в `textEn`. +2. Реализовать `CardScript` или общий движок; не оставлять текст без + `AbilityDefinition`. +3. Проверить зону источника (`IsVanguardOf`, `IsRearGuardOf`, + `IsInHand`) и опциональность (`Optional`/`CanPay`/`Pay`). +4. Для выбора использовать только `Ask*Choice`, не кнопочные списки. +5. Добавить тест на каждый триггер/активацию и прогнать весь реестр. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs index ef0472a..3b1a1fd 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -87,6 +87,12 @@ public abstract class CardScript public virtual IEnumerable Abilities => Array.Empty(); public virtual IEnumerable Continuous => Array.Empty(); public virtual IEnumerable Activated => Array.Empty(); + + /// + /// CONT-исключения, которые нельзя выразить только бонусом силы, + /// например «ваши юниты не могут поддерживать этот юнит». + /// + public virtual bool CanBeBoosted(AbilityContext context) => true; } /// Сработавшая способность, ожидающая выбора порядка или решения игрока. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 38bfcae..f02f733 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -186,6 +186,7 @@ public sealed class MaidenOfLibraScript : CardScript { Trigger = AbilityTrigger.OnAttackHit, Optional = true, + Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.OracleThinkTank, CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2), Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2), Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 1), @@ -224,6 +225,25 @@ public sealed class ConroeScript : CardScript { public override string NameId => "lizard-soldier-conroe"; + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnRideOver, + Optional = true, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Kagero, + Resolve = ctx => ctx.Engine.AskEmptyRearGuardChoice( + ctx.Owner, + "Выберите круг для призыва Conroe", + slot => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner, slot)), + }; + } + } + public override IEnumerable Activated { get @@ -410,6 +430,9 @@ public sealed class AlfredScript : CardScript { public override string NameId => "king-of-knights-alfred"; + public override bool CanBeBoosted(AbilityContext context) => + !context.Engine.IsVanguardOf(context.Source, context.Owner); + public override IEnumerable Continuous { get diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs index d606126..6c5734a 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs @@ -195,7 +195,7 @@ public sealed class FlipDamageOnPlaceScript : CardScript { Trigger = AbilityTrigger.OnPlace, Condition = ctx => - !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.IsRearGuardOf(ctx.Source, ctx.Owner) && ctx.Engine.VanguardClan(ctx.Owner) == _clan, Resolve = ctx => ctx.Engine.TurnOneDamageFaceUp(ctx.Owner), }; diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs index 53f753f..5745be5 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs @@ -98,7 +98,7 @@ public sealed class StarlightUnicornScript : CardScript yield return new AbilityDefinition { Trigger = AbilityTrigger.OnPlace, - Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Condition = ctx => ctx.Engine.IsRearGuardOf(ctx.Source, ctx.Owner), Resolve = ctx => { var slots = new List(); @@ -261,7 +261,7 @@ public sealed class OpponentRetireBonusScript : CardScript { Trigger = AbilityTrigger.OnOpponentRetire, Condition = ctx => - !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.IsRearGuardOf(ctx.Source, ctx.Owner) && ctx.Phase == Phase.Main, Resolve = ctx => ctx.Engine.AddPower( ctx.Source, diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index a80573f..7e19b65 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -563,6 +563,25 @@ public sealed class GameEngine .Sum(c => c.PowerBonus?.Invoke(context) ?? 0); } + private bool CanBeBoosted(CardInstance unit) + { + var owner = OwnerOf(unit); + if (owner < 0) + return true; + + var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; + if (!_scripts.TryGetValue(nameId, out var script)) + return true; + + return script.CanBeBoosted(new AbilityContext + { + Engine = this, + Source = unit, + Owner = owner, + Phase = State.Phase, + }); + } + public int OwnerOf(CardInstance unit) { for (var i = 0; i < 2; i++) @@ -580,6 +599,9 @@ public sealed class GameEngine public bool IsVanguardOf(CardInstance unit, int player) => ReferenceEquals(State.Players[player].Vanguard, unit); + public bool IsRearGuardOf(CardInstance unit, int player) => + Array.IndexOf(State.Players[player].RearGuards, unit) >= 0; + public int CountRearGuards(int player, Clan clan) => State.Players[player].RearGuards.Count(u => u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); @@ -1519,7 +1541,7 @@ public sealed class GameEngine 4 => 2, _ => -1, }; - if (boosterSlot >= 0) + if (boosterSlot >= 0 && CanBeBoosted(attacker)) { var booster = State.GetUnit(player, boosterSlot); if (booster is not null && !booster.Rested && State.CanBoost(booster)) @@ -2019,6 +2041,9 @@ public sealed class GameEngine if (!State.CanBoost(booster)) return CommandResult.Reject("Эта карта не может поддерживать."); + + if (!CanBeBoosted(attacker)) + return CommandResult.Reject("Этого юнита нельзя поддерживать."); } var attackerPlayer = State.ActivePlayer; diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 0c70072..9788696 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -844,6 +844,22 @@ public class GameEngineTests Assert.DoesNotContain(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal); } + [Fact] + public void Conroe_Offers_Optional_Call_When_Ridden_Over() + { + var engine = StartedEngine(); + SetVanguard(engine, 0, Conroe); + Give(engine, 0, Conroe); + + Assert.True(engine.Execute(new RideCommand(Conroe)).Accepted); + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + Assert.NotNull(engine.State.PendingChoice); + + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + Assert.Equal("BT01/016EN", engine.State.Players[0].RearGuards[0]?.PrintingId); + } + [Fact] public void Barcgal_Decline_Keeps_Card_In_Soul() { @@ -936,6 +952,23 @@ public class GameEngineTests Assert.Equal(0, engine.State.Players[0].RearGuards[3]!.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); } + [Fact] + public void Alfred_On_Vanguard_Cannot_Be_Boosted() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Alfred); + + Give(engine, 0, Marron); + Assert.True(engine.Execute(new CallCommand(Marron, 1)).Accepted); + engine.State.Players[0].RearGuards[1]!.Rested = false; + + SetVanguard(engine, 1, Gallatin); + Assert.True(engine.Execute(new EnterBattleCommand()).Accepted); + + Assert.False(engine.Execute(new AttackCommand(-1, -1, 1)).Accepted); + } + [Fact] public void Alfred_Gets_2000_Per_RoyalPaladin_RearGuard() {