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