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:
parent
fa507576d9
commit
a5fef89984
|
|
@ -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 и античита).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/// Останавливает разрешение и просит выбрать карту из найденных.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue