Top-deck look and search-to-call/ride

LookTopAndReorder asks top or bottom without revealing the deck. SearchDeckToCall and SearchDeckToRide wrap the search primitive; Alfred searches a Royal Paladin grade 2 or lower and calls it, Cocoa looks at the top card. Five tests.
This commit is contained in:
MpakobecEx 2026-10-21 13:20:47 +05:00
parent fa507576d9
commit a5fef89984
5 changed files with 239 additions and 1 deletions

View File

@ -120,6 +120,8 @@
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. - [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет.
- [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами.
- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew).
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита). - [ ] Список легальных интентов на каждый момент (для UI и античита).

View File

@ -16,9 +16,32 @@ public static class CardScripts
["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"), ["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"),
["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"), ["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"),
["lizard-soldier-conroe"] = new ConroeScript(), ["lizard-soldier-conroe"] = new ConroeScript(),
["battle-sister-cocoa"] = new CocoaScript(),
}; };
} }
/// <summary>
/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank,
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ.
/// </summary>
public sealed class CocoaScript : CardScript
{
public override string NameId => "battle-sister-cocoa";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPlace,
Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.OracleThinkTank,
Resolve = ctx => ctx.Engine.LookTopAndReorder(ctx.Owner, "Верхняя карта колоды"),
};
}
}
}
/// <summary> /// <summary>
/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту → /// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту →
/// найти в колоде Kagero 1 ранга или ниже и добавить в руку. /// найти в колоде Kagero 1 ранга или ниже и добавить в руку.
@ -206,4 +229,25 @@ public sealed class AlfredScript : CardScript
}; };
} }
} }
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
CanActivate = ctx =>
ctx.Engine.CanCounterBlast(ctx.Owner, 3) &&
ctx.Engine.HasEmptyRearGuard(ctx.Owner),
Resolve = ctx =>
{
ctx.Engine.CounterBlast(ctx.Owner, 3);
ctx.Engine.SearchDeckToCall(
ctx.Owner,
"Найдите Royal Paladin 2 ранга или ниже",
d => d.Clan == Clan.RoyalPaladin && d.Grade <= 2);
},
};
}
}
} }

View File

@ -516,7 +516,122 @@ public sealed class GameEngine
/// <summary>Поиск с добавлением карты в руку.</summary> /// <summary>Поиск с добавлением карты в руку.</summary>
public void SearchDeckToHand(int player, string prompt, Func<CardDefinition, bool> predicate) => public void SearchDeckToHand(int player, string prompt, Func<CardDefinition, bool> predicate) =>
SearchDeck(player, prompt, predicate, card => State.Players[player].Hand.Add(card)); SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
State.Players[player].Hand.Add(card);
});
/// <summary>Поиск с призывом в выбранный круг Арьергарда.</summary>
public void SearchDeckToCall(int player, string prompt, Func<CardDefinition, bool> predicate)
{
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => CallFromDeck(card, player, slot));
});
}
/// <summary>Поиск с райдом найденной карты (superior ride).</summary>
public void SearchDeckToRide(int player, string prompt, Func<CardDefinition, bool> predicate)
{
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
RideFromDeck(card, player);
});
}
/// <summary>Посмотреть верхнюю карту и оставить её на верху или отправить в низ.</summary>
public void LookTopAndReorder(int player, string prompt)
{
var deck = State.Players[player].Deck;
if (deck.Count == 0)
return;
var top = deck[0];
State.PendingChoice = new PendingChoice
{
Player = player,
Prompt = $"{prompt}: {State.Catalog.CardOf(top.PrintingId).NameEn}",
Options = new List<ChoiceOption>
{
new(0, "Оставить на верху"),
new(1, "Положить в низ"),
},
Resolve = index =>
{
if (index == 1)
{
deck.RemoveAt(0);
deck.Add(top);
}
_events.Add(new CardRevealedEvent(player, top.PrintingId));
},
};
State.Step = Step.Choice;
State.ActivePlayer = player;
}
public bool HasEmptyRearGuard(int player) =>
State.Players[player].RearGuards.Any(u => u is null);
public void AskEmptyRearGuardChoice(int player, string prompt, Action<int> onChosen)
{
var slots = Enumerable.Range(0, PlayerState.RearGuardSlots)
.Where(s => State.Players[player].RearGuards[s] is null)
.ToList();
if (slots.Count == 0)
return;
State.PendingChoice = new PendingChoice
{
Player = player,
Prompt = prompt,
Options = slots.Select(s => new ChoiceOption(s, $"Круг {s + 1}")).ToList(),
Resolve = onChosen,
};
State.Step = Step.Choice;
State.ActivePlayer = player;
}
public void CallFromDeck(CardInstance card, int player, int slot)
{
var p = State.Players[player];
if (p.RearGuards[slot] is not null)
return;
card.Rested = true;
p.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
TriggerFromCard(player, card, AbilityTrigger.OnPlace, null);
}
/// <summary>Райд картой не из руки (superior ride).</summary>
public void RideFromDeck(CardInstance card, int player)
{
var p = State.Players[player];
var riddenOver = p.Vanguard;
if (riddenOver is not null)
{
riddenOver.Rested = false;
p.Soul.Add(riddenOver);
}
card.Rested = false;
p.Vanguard = card;
_events.Add(new UnitRodeEvent(player, card.PrintingId));
TriggerFromCard(player, card, AbilityTrigger.OnPlace, null);
if (State.Step == Step.None)
TriggerFromCard(player, riddenOver, AbilityTrigger.OnRideOver, card);
}
/// <summary> /// <summary>
/// Останавливает разрешение и просит выбрать карту из найденных. /// Останавливает разрешение и просит выбрать карту из найденных.

View File

@ -27,6 +27,7 @@ public sealed record HealedEvent(int Player) : GameEvent;
public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent; public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent;
public sealed record CardSearchedEvent(int Player, string PrintingId) : GameEvent; public sealed record CardSearchedEvent(int Player, string PrintingId) : GameEvent;
public sealed record SearchFailedEvent(int Player, string Query) : GameEvent; public sealed record SearchFailedEvent(int Player, string Query) : GameEvent;
public sealed record CardRevealedEvent(int Player, string PrintingId) : GameEvent;
public sealed record GameFinishedEvent(int Winner) : GameEvent; public sealed record GameFinishedEvent(int Winner) : GameEvent;
public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent; public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent;
public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent; public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent;

View File

@ -22,6 +22,8 @@ public class GameEngineTests
private const string BlasterBlade = "BT01/002EN"; private const string BlasterBlade = "BT01/002EN";
private const string Wingal = "BT01/044EN"; private const string Wingal = "BT01/044EN";
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту
private const string Libra = "BT01/017EN"; // OTT, grade 2
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -797,6 +799,79 @@ public class GameEngineTests
Assert.True(def.Grade <= 1); Assert.True(def.Grade <= 1);
} }
[Fact]
public void Cocoa_Can_Keep_The_Top_Card_On_Top()
{
var engine = InMainPhase();
SetVanguard(engine, 0, Libra);
Give(engine, 0, Cocoa);
engine.Execute(new CallCommand(Cocoa, 0));
var choice = engine.State.PendingChoice;
Assert.NotNull(choice);
Assert.Equal(2, choice!.Options.Count);
var topBefore = engine.State.Players[0].Deck[0].PrintingId;
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(topBefore, engine.State.Players[0].Deck[0].PrintingId);
}
[Fact]
public void Cocoa_Can_Put_The_Top_Card_On_Bottom()
{
var engine = InMainPhase();
SetVanguard(engine, 0, Libra);
Give(engine, 0, Cocoa);
engine.Execute(new CallCommand(Cocoa, 0));
var topBefore = engine.State.Players[0].Deck[0].PrintingId;
Assert.True(engine.Execute(new ChooseCommand(1)).Accepted);
Assert.Equal(topBefore, engine.State.Players[0].Deck[^1].PrintingId);
}
[Fact]
public void Alfred_Searches_And_Calls_A_RoyalPaladin()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Alfred);
GiveDamage(engine, 0, 3);
var alfred = engine.State.Players[0].Vanguard!;
Assert.True(engine.Execute(new ActivateAbilityCommand(alfred.Id)).Accepted);
Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp));
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
var called = engine.State.Players[0].RearGuards.FirstOrDefault(u => u is not null);
Assert.NotNull(called);
var def = _catalog.CardOf(called!.PrintingId);
Assert.Equal(Clan.RoyalPaladin, def.Clan);
Assert.True(def.Grade <= 2);
}
[Fact]
public void Search_Can_Ride_The_Found_Card()
{
var engine = InMainPhase();
engine.SearchDeckToRide(0, "Blaster Blade", d => d.NameId == "blaster-blade");
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -934,6 +1009,7 @@ public class GameEngineTests
(Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4), (Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4),
(BlasterBlade, 2), (Wingal, 2), (BlasterBlade, 2), (Wingal, 2),
(Lien, 2), (Lien, 2),
(Cocoa, 2), (Libra, 2),
}; };
private List<string> BuildDeck() private List<string> BuildDeck()