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.
- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет.
- [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами.
- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew).
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита).

View File

@ -16,9 +16,32 @@ public static class CardScripts
["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"),
["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"),
["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>
/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 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>
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>
/// Останавливает разрешение и просит выбрать карту из найденных.

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 CardSearchedEvent(int Player, string PrintingId) : 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 AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : 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 Wingal = "BT01/044EN";
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]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -797,6 +799,79 @@ public class GameEngineTests
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]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@ -934,6 +1009,7 @@ public class GameEngineTests
(Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4),
(BlasterBlade, 2), (Wingal, 2),
(Lien, 2),
(Cocoa, 2), (Libra, 2),
};
private List<string> BuildDeck()