From bdb0bb11ef9cffb0206f100f804997ae77997e5b Mon Sep 17 00:00:00 2001 From: Dragos Ionita Date: Thu, 3 Sep 2026 01:46:23 +0300 Subject: [PATCH] Last 5 Vortex Evolution cards + small refactorings due to implementation of Vortex Evolution feature --- sim/game/cards/dm06/armored_dragon.go | 17 +- sim/game/cards/dm12/naga.go | 32 +++ sim/game/cards/dm12/pegasus.go | 62 ++++ sim/game/cards/dm12/phoenix.go | 79 +++++ sim/game/cards/dm12/starnoid.go | 32 +++ sim/game/cards/repository.go | 5 + sim/game/cnd/conditions.go | 1 + sim/game/family/families.go | 8 + sim/game/fx/evolution.go | 59 +++- sim/game/fx/quality_of_life.go | 13 + sim/game/fx/shield_break_replacement.go | 34 +++ sim/game/fx/vortex_evolution.go | 184 ++++++++++++ .../cards/aura_pegasus_avatar_of_life_test.go | 213 ++++++++++++++ .../cards/bolmeteus_steel_dragon_test.go | 76 +++++ .../cards/cruel_naga_avatar_of_fate_test.go | 185 ++++++++++++ .../death_phoenix_avatar_of_doom_test.go | 194 +++++++++++++ sim/tests/cards/gigios_hammer_test.go | 8 +- .../soul_phoenix_avatar_of_unity_test.go | 177 ++++++++++++ .../wise_starnoid_avatar_of_hope_test.go | 272 ++++++++++++++++++ sim/tests/fx/evolution_event_test.go | 32 +++ sim/tests/fx/vortex_evolution_test.go | 85 ++++++ 21 files changed, 1739 insertions(+), 29 deletions(-) create mode 100644 sim/game/cards/dm12/naga.go create mode 100644 sim/game/cards/dm12/pegasus.go create mode 100644 sim/game/cards/dm12/phoenix.go create mode 100644 sim/game/cards/dm12/starnoid.go create mode 100644 sim/game/fx/shield_break_replacement.go create mode 100644 sim/game/fx/vortex_evolution.go create mode 100644 sim/tests/cards/aura_pegasus_avatar_of_life_test.go create mode 100644 sim/tests/cards/bolmeteus_steel_dragon_test.go create mode 100644 sim/tests/cards/cruel_naga_avatar_of_fate_test.go create mode 100644 sim/tests/cards/death_phoenix_avatar_of_doom_test.go create mode 100644 sim/tests/cards/soul_phoenix_avatar_of_unity_test.go create mode 100644 sim/tests/cards/wise_starnoid_avatar_of_hope_test.go create mode 100644 sim/tests/fx/vortex_evolution_test.go diff --git a/sim/game/cards/dm06/armored_dragon.go b/sim/game/cards/dm06/armored_dragon.go index 04646d2a..0bc024ab 100644 --- a/sim/game/cards/dm06/armored_dragon.go +++ b/sim/game/cards/dm06/armored_dragon.go @@ -37,20 +37,5 @@ func BolmeteusSteelDragon(c *match.Card) { c.ManaCost = 7 c.ManaRequirement = []string{civ.Fire} - c.Use(fx.Creature, fx.Doublebreaker, fx.When(fx.BreakShield, func(card *match.Card, ctx *match.Context) { - - event, _ := ctx.Event.(*match.BreakShieldEvent) - if event.Source != card { - return - } - - ctx.InterruptFlow() - for _, shield := range event.Cards { - moved, err := ctx.Match.Opponent(card.Player).MoveCard(shield.ID, match.SHIELDZONE, match.GRAVEYARD, card.ID) - if err == nil { - ctx.Match.ReportActionInChat(ctx.Match.Opponent(card.Player), fmt.Sprintf("%s was moved to %s's graveyard instead of hand by %s", moved.Name, ctx.Match.Opponent(card.Player).Username(), card.Name)) - } - } - - })) + c.Use(fx.Creature, fx.Doublebreaker, fx.When(fx.BreakShield, fx.ShieldsToGraveyardInsteadOfHand)) } diff --git a/sim/game/cards/dm12/naga.go b/sim/game/cards/dm12/naga.go new file mode 100644 index 00000000..3d7f3bfc --- /dev/null +++ b/sim/game/cards/dm12/naga.go @@ -0,0 +1,32 @@ +package dm12 + +import ( + "duel-masters/game/civ" + "duel-masters/game/family" + "duel-masters/game/fx" + "duel-masters/game/match" +) + +// CruelNagaAvatarOfFate ... +func CruelNagaAvatarOfFate(c *match.Card) { + + c.Name = "Cruel Naga, Avatar of Fate" + c.Power = 9000 + c.Civs = []string{civ.Water, civ.Darkness} + c.Family = []string{family.Naga} + c.ManaCost = 6 + c.ManaRequirement = []string{civ.Water, civ.Darkness} + + c.Use( + fx.Creature, + fx.PutIntoManaZoneTapped, + fx.Doublebreaker, + fx.CantBeBlocked, + fx.VortexEvolution( + family.Merfolk, func(x *match.Card) bool { return x.HasFamily(family.Merfolk) }, + family.Chimera, func(x *match.Card) bool { return x.HasFamily(family.Chimera) }, + ), + fx.When(fx.LeftBattlezone, fx.DestroyAllCreatures(match.DestroyedByMiscAbility)), + ) + +} diff --git a/sim/game/cards/dm12/pegasus.go b/sim/game/cards/dm12/pegasus.go new file mode 100644 index 00000000..86edcb40 --- /dev/null +++ b/sim/game/cards/dm12/pegasus.go @@ -0,0 +1,62 @@ +package dm12 + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/fx" + "duel-masters/game/match" + "fmt" +) + +// AuraPegasusAvatarOfLife ... +func AuraPegasusAvatarOfLife(c *match.Card) { + + c.Name = "Aura Pegasus, Avatar of Life" + c.Power = 12000 + c.Civs = []string{civ.Light, civ.Nature} + c.Family = []string{family.Pegasus} + c.ManaCost = 6 + c.ManaRequirement = []string{civ.Light, civ.Nature} + + c.Use( + fx.Creature, + fx.PutIntoManaZoneTapped, + fx.Triplebreaker, + fx.VortexEvolution( + family.HornedBeast, func(x *match.Card) bool { return x.HasFamily(family.HornedBeast) }, + family.AngelCommand, func(x *match.Card) bool { return x.HasFamily(family.AngelCommand) }, + ), + fx.When(fx.Attacking, auraPegasusAvatarOfLifeRevealTopCard), + fx.When(fx.LeftBattlezone, auraPegasusAvatarOfLifeRevealTopCard), + ) + +} + +// auraPegasusAvatarOfLifeRevealTopCard implements "reveal the top card of +// your deck. If it's a non-evolution creature, put it into the battle zone. +// Otherwise, put it into your hand." cnd.Evolution is rebuilt for every +// creature at every untap step regardless of zone, so it is already correct +// for a card that has never left the deck. +func auraPegasusAvatarOfLifeRevealTopCard(card *match.Card, ctx *match.Context) { + + revealed := card.Player.PeekDeck(1) + + if len(revealed) < 1 { + return + } + + top := revealed[0] + + if top.HasCondition(cnd.Creature) && !top.HasCondition(cnd.Evolution) { + fx.ForcePutCreatureIntoBZ(ctx, top, match.DECK, card) + return + } + + moved, err := card.Player.MoveCard(top.ID, match.DECK, match.HAND, card.ID) + + if err == nil { + ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s put %s into their hand from the top of their deck by %s's effect", card.Player.Username(), moved.Name, card.Name)) + } + +} diff --git a/sim/game/cards/dm12/phoenix.go b/sim/game/cards/dm12/phoenix.go new file mode 100644 index 00000000..4b914844 --- /dev/null +++ b/sim/game/cards/dm12/phoenix.go @@ -0,0 +1,79 @@ +package dm12 + +import ( + "duel-masters/game/civ" + "duel-masters/game/family" + "duel-masters/game/fx" + "duel-masters/game/match" + "fmt" +) + +// DeathPhoenixAvatarOfDoom ... +func DeathPhoenixAvatarOfDoom(c *match.Card) { + + c.Name = "Death Phoenix, Avatar of Doom" + c.Power = 9000 + c.Civs = []string{civ.Darkness, civ.Fire} + c.Family = []string{family.Phoenix} + c.ManaCost = 4 + c.ManaRequirement = []string{civ.Darkness, civ.Fire} + + c.Use( + fx.Creature, + fx.PutIntoManaZoneTapped, + fx.Doublebreaker, + fx.VortexEvolution( + family.ZombieDragon, func(x *match.Card) bool { return x.HasFamily(family.ZombieDragon) }, + family.FireBird, func(x *match.Card) bool { return x.HasFamily(family.FireBird) }, + ), + fx.When(fx.BreakShield, fx.ShieldsToGraveyardInsteadOfHand), + fx.When(fx.LeftBattlezone, fx.OpponentDiscardsHand), + ) + +} + +// soulPhoenixAvatarOfUnitySeparateBases implements "When this card would +// leave the battle zone, only the top card leaves the battle zone instead +// (separate the other cards into 2 creatures)." +// +// Registered before VortexEvolution in SoulPhoenixAvatarOfUnity's c.Use, so +// this runs first for the same CardMoved event: by clearing the attachments +// itself, the shared cascade that VortexEvolution runs afterward (which would +// otherwise carry both bases along to wherever this card is headed) finds +// nothing left to move. +func soulPhoenixAvatarOfUnitySeparateBases(card *match.Card, ctx *match.Context) { + + for _, base := range card.Attachments() { + moved, err := card.Player.MoveCard(base.ID, match.HIDDENZONE, match.BATTLEZONE, card.ID) + + if err == nil && moved.Zone == match.BATTLEZONE { + ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s separated from %s and returned to the battle zone", moved.Name, card.Name)) + } + } + + card.ClearAttachments() + +} + +// SoulPhoenixAvatarOfUnity ... +func SoulPhoenixAvatarOfUnity(c *match.Card) { + + c.Name = "Soul Phoenix, Avatar of Unity" + c.Power = 13000 + c.Civs = []string{civ.Fire, civ.Nature} + c.Family = []string{family.Phoenix} + c.ManaCost = 4 + c.ManaRequirement = []string{civ.Fire, civ.Nature} + + c.Use( + fx.Creature, + fx.PutIntoManaZoneTapped, + fx.Triplebreaker, + fx.When(fx.LeftBattlezone, soulPhoenixAvatarOfUnitySeparateBases), + fx.VortexEvolution( + family.FireBird, func(x *match.Card) bool { return x.HasFamily(family.FireBird) }, + family.EarthDragon, func(x *match.Card) bool { return x.HasFamily(family.EarthDragon) }, + ), + ) + +} diff --git a/sim/game/cards/dm12/starnoid.go b/sim/game/cards/dm12/starnoid.go new file mode 100644 index 00000000..fba53e0b --- /dev/null +++ b/sim/game/cards/dm12/starnoid.go @@ -0,0 +1,32 @@ +package dm12 + +import ( + "duel-masters/game/civ" + "duel-masters/game/family" + "duel-masters/game/fx" + "duel-masters/game/match" +) + +// WiseStarnoidAvatarOfHope ... +func WiseStarnoidAvatarOfHope(c *match.Card) { + + c.Name = "Wise Starnoid, Avatar of Hope" + c.Power = 9000 + c.Civs = []string{civ.Light, civ.Water} + c.Family = []string{family.Starnoid} + c.ManaCost = 5 + c.ManaRequirement = []string{civ.Light, civ.Water} + + c.Use( + fx.Creature, + fx.PutIntoManaZoneTapped, + fx.Doublebreaker, + fx.VortexEvolution( + family.LightBringer, func(x *match.Card) bool { return x.HasFamily(family.LightBringer) }, + family.CyberLord, func(x *match.Card) bool { return x.HasFamily(family.CyberLord) }, + ), + fx.When(fx.Attacking, fx.TopCardToShield), + fx.When(fx.LeftBattlezone, fx.TopCardToShield), + ) + +} diff --git a/sim/game/cards/repository.go b/sim/game/cards/repository.go index 6bc1da1c..4b6e3d5e 100644 --- a/sim/game/cards/repository.go +++ b/sim/game/cards/repository.go @@ -958,6 +958,11 @@ var DM12 = map[string]match.CardConstructor{ "05c5496d-e5fa-4691-8542-2d6c6919f402": dm12.UlarusPunishmentElemental, "76310adb-f7c2-4545-8b71-2332b36fbb83": dm12.Gigavrand, "0fe53ab5-bef9-4bbd-bb05-c94ccb9b1342": dm12.CosmicDarts, + "ba894f7e-b7d7-409e-8393-cab4285a879c": dm12.WiseStarnoidAvatarOfHope, + "23475e43-4cbd-4054-a90d-fde230a28db3": dm12.CruelNagaAvatarOfFate, + "dcc5ee70-bc60-420d-92e5-ed8bd7ff949f": dm12.DeathPhoenixAvatarOfDoom, + "5ffd2e9f-98bc-4e36-8454-8f822740f8eb": dm12.AuraPegasusAvatarOfLife, + "cfa7c730-2818-4f78-aa4b-1395060cd687": dm12.SoulPhoenixAvatarOfUnity, } // Promo is a map with all the card id's in the game and corresponding CardConstructor for promotional exclusive cards diff --git a/sim/game/cnd/conditions.go b/sim/game/cnd/conditions.go index e2c948e5..dc50a38d 100644 --- a/sim/game/cnd/conditions.go +++ b/sim/game/cnd/conditions.go @@ -21,6 +21,7 @@ const ( ReducedCost = "reduced_cost" IncreasedCost = "increased_cost" Evolution = "evolution" + PendingEvolutionTapState = "pending_evolution_tap_state" Survivor = "survivor" SpeedAttacker = "speed_attacker" EvolveIntoAnyFamily = "evolve_into_any_family" diff --git a/sim/game/family/families.go b/sim/game/family/families.go index 58a6679f..03e60118 100644 --- a/sim/game/family/families.go +++ b/sim/game/family/families.go @@ -45,8 +45,11 @@ const ( MeltWarrior = "Melt Warrior" Merfolk = "Merfolk" MysteryTotem = "Mystery Totem" + Naga = "Naga" PandorasBox = "Pandora's Box" ParasiteWorm = "Parasite Worm" + Pegasus = "Pegasus" + Phoenix = "Phoenix" RainbowPhantom = "Rainbow Phantom" RockBeast = "Rock Beast" SeaHacker = "Sea Hacker" @@ -54,6 +57,7 @@ const ( Soltrooper = "Soltrooper" SpiritQuartz = "Spirit Quartz" StarlightTree = "Starlight Tree" + Starnoid = "Starnoid" Survivor = "Survivor" TreeFolk = "Tree Folk" VolcanoDragon = "Volcano Dragon" @@ -107,8 +111,11 @@ var Families = []string{ MeltWarrior, Merfolk, MysteryTotem, + Naga, PandorasBox, ParasiteWorm, + Pegasus, + Phoenix, RainbowPhantom, RockBeast, SeaHacker, @@ -116,6 +123,7 @@ var Families = []string{ Soltrooper, SpiritQuartz, StarlightTree, + Starnoid, Survivor, TreeFolk, VolcanoDragon, diff --git a/sim/game/fx/evolution.go b/sim/game/fx/evolution.go index 01305856..0b6dbe18 100644 --- a/sim/game/fx/evolution.go +++ b/sim/game/fx/evolution.go @@ -116,7 +116,7 @@ func handleEvolutionEvents(card *match.Card, ctx *match.Context, evolvableCreatu creature := creatures[0] card.ClearAttachments() - card.Tapped = creature.Tapped + stagePendingEvolutionTapState(card, creature.Tapped) card.Player.MoveCard(creature.ID, match.BATTLEZONE, match.HIDDENZONE, card.ID) card.Attach(creature) @@ -135,20 +135,59 @@ func handleEvolutionEvents(card *match.Card, ctx *match.Context, evolvableCreatu })) } - // Card moved - if event, ok := ctx.Event.(*match.CardMoved); ok { + handleEvolutionCardMoved(card, ctx) - if event.CardID != card.ID || event.To == match.BATTLEZONE || event.To == match.HIDDENZONE { - return - } +} + +// stagePendingEvolutionTapState records the tap state an evolution creature +// (regular or vortex) should have once it lands in the battle zone. +// +// It cannot just be assigned to card.Tapped directly: CardPlayedEvent, where +// the base is chosen, fires before the evolution creature itself is moved +// from hand to the battle zone, and every successful Player.MoveCard +// unconditionally resets Tapped to false on arrival - wiping out an +// assignment made here. Staging it as a condition lets +// handleEvolutionCardMoved re-apply it once that reset has already happened. +func stagePendingEvolutionTapState(card *match.Card, tapped bool) { + card.AddUniqueSourceCondition(cnd.PendingEvolutionTapState, tapped, card.ID) +} + +// handleEvolutionCardMoved keeps an evolution creature (regular or vortex) in +// sync with its own CardMoved events: +// - arriving in the battle zone applies the tap state stagePendingEvolutionTapState +// recorded during CardPlayedEvent, undoing the Player.MoveCard reset; +// - leaving the battle zone for anywhere but the hidden zone takes every +// attached base along with it, however many there are. Neither regular +// nor vortex evolution cares how many cards are attached, only that the +// whole pile relocates together. +func handleEvolutionCardMoved(card *match.Card, ctx *match.Context) { + + event, ok := ctx.Event.(*match.CardMoved) - for _, creature := range card.Attachments() { - card.Player.MoveCard(creature.ID, match.HIDDENZONE, event.To, card.ID) - ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s was sent to the %s together with %s", creature.Name, event.To, card.Name)) + if !ok || event.CardID != card.ID { + return + } + + if event.To == match.BATTLEZONE { + if condition, err := card.GetCondition(cnd.PendingEvolutionTapState); err == nil { + if tapped, ok := condition.Val.(bool); ok { + card.Tapped = tapped + } + card.RemoveSpecificConditionBySource(cnd.PendingEvolutionTapState, card.ID) } - card.ClearAttachments() + return + } + if event.To == match.HIDDENZONE { + return } + for _, creature := range card.Attachments() { + card.Player.MoveCard(creature.ID, match.HIDDENZONE, event.To, card.ID) + ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s was sent to the %s together with %s", creature.Name, event.To, card.Name)) + } + + card.ClearAttachments() + } diff --git a/sim/game/fx/quality_of_life.go b/sim/game/fx/quality_of_life.go index 7a7de86d..ea712347 100644 --- a/sim/game/fx/quality_of_life.go +++ b/sim/game/fx/quality_of_life.go @@ -647,6 +647,19 @@ func InTheBattlezone(card *match.Card, ctx *match.Context) bool { return false } +// LeftBattlezone returns true if the card itself just left the battle zone +// for anywhere other than the hidden zone. Moving under a new evolution is +// not "leaving the battle zone" - the pile is still considered in play. +func LeftBattlezone(card *match.Card, ctx *match.Context) bool { + event, ok := ctx.Event.(*match.CardMoved) + + return ok && + event.CardID == card.ID && + event.From == match.BATTLEZONE && + event.To != match.BATTLEZONE && + event.To != match.HIDDENZONE +} + // SpellCast returns true if the spell was cast func SpellCast(card *match.Card, ctx *match.Context) bool { diff --git a/sim/game/fx/shield_break_replacement.go b/sim/game/fx/shield_break_replacement.go new file mode 100644 index 00000000..616382ee --- /dev/null +++ b/sim/game/fx/shield_break_replacement.go @@ -0,0 +1,34 @@ +package fx + +import ( + "duel-masters/game/match" + "fmt" +) + +// ShieldsToGraveyardInsteadOfHand implements "Whenever this creature would +// break a shield, your opponent puts that shield into his graveyard instead." +// +// Interrupting BreakShieldEvent stops the default move to hand entirely, +// which also means the shield never reaches hand and so never offers its +// shield trigger, matching the printed replacement. +// +// Register with fx.When(fx.BreakShield, fx.ShieldsToGraveyardInsteadOfHand), +// which already guards the event type and this card's battle zone presence. +func ShieldsToGraveyardInsteadOfHand(card *match.Card, ctx *match.Context) { + + event, ok := ctx.Event.(*match.BreakShieldEvent) + + if !ok || event.Source != card { + return + } + + ctx.InterruptFlow() + + for _, shield := range event.Cards { + moved, err := ctx.Match.Opponent(card.Player).MoveCard(shield.ID, match.SHIELDZONE, match.GRAVEYARD, card.ID) + if err == nil { + ctx.Match.ReportActionInChat(ctx.Match.Opponent(card.Player), fmt.Sprintf("%s was moved to %s's graveyard instead of hand by %s", moved.Name, ctx.Match.Opponent(card.Player).Username(), card.Name)) + } + } + +} diff --git a/sim/game/fx/vortex_evolution.go b/sim/game/fx/vortex_evolution.go new file mode 100644 index 00000000..d2df9c49 --- /dev/null +++ b/sim/game/fx/vortex_evolution.go @@ -0,0 +1,184 @@ +package fx + +import ( + "duel-masters/game/cnd" + "duel-masters/game/match" + "fmt" +) + +// VortexEvolution implements "Vortex Evolution — Put on one of your and +// one of your ." +// +// Unlike a plain evolution, which needs a single matching creature already in +// the battle zone, a vortex evolution needs two distinct creatures: one +// matching aFilter and a different one matching bFilter (the same creature +// can never fill both roles, even if its own characteristics satisfy both +// filters). aDescription/bDescription only name the two requirements in +// prompts, e.g. "Light Bringer" and "Cyber Lord". +// +// The player chooses which of the two goes on top of the other, and the +// vortex evolution creature is placed on top of that stack: it takes the tap +// state of whichever creature it lands on directly, exactly like a regular +// evolution. +// +// It does carry cnd.Evolution, like a plain evolution creature, purely so +// generic "is this an evolution creature" checks (for example, a card +// revealed off the top of a deck) recognize it - matching either requirement +// name is a much looser test than the two distinct creatures actually needed, +// so fx.CanBeSummoned may offer a vortex evolution creature it cannot +// actually complete; the guards below still make an illegal summon +// impossible either way. +func VortexEvolution(aDescription string, aFilter func(*match.Card) bool, bDescription string, bFilter func(*match.Card) bool) match.HandlerFunc { + return func(card *match.Card, ctx *match.Context) { + if _, ok := ctx.Event.(*match.UntapStep); ok { + card.AddCondition(cnd.Evolution, []string{aDescription, bDescription}, card.ID) + } + + handleVortexEvolutionEvents(card, ctx, aDescription, aFilter, bDescription, bFilter) + } +} + +// vortexPartnerExists returns true if the player has a creature matching +// filter in the battle zone other than the one named by excludingID. +func vortexPartnerExists(player *match.Player, excludingID string, filter func(*match.Card) bool) bool { + for _, c := range FindFilter(player, match.BATTLEZONE, filter) { + if c.ID != excludingID { + return true + } + } + + return false +} + +// canVortexEvolve returns true if there exist two distinct creatures in the +// battle zone, one matching aFilter and a different one matching bFilter. +func canVortexEvolve(player *match.Player, aFilter func(*match.Card) bool, bFilter func(*match.Card) bool) bool { + for _, a := range FindFilter(player, match.BATTLEZONE, aFilter) { + if vortexPartnerExists(player, a.ID, bFilter) { + return true + } + } + + return false +} + +func handleVortexEvolutionEvents(card *match.Card, ctx *match.Context, aDescription string, aFilter func(*match.Card) bool, bDescription string, bFilter func(*match.Card) bool) { + + if event, ok := ctx.Event.(*match.PlayCardEvent); ok { + + if event.CardID != card.ID { + return + } + + if !canVortexEvolve(card.Player, aFilter, bFilter) { + ctx.InterruptFlow() + ctx.Match.WarnPlayer(card.Player, fmt.Sprintf("There are no cards for %s to evolve from in your battle zone", card.Name)) + return + } + + ctx.ScheduleAfter(func() { + card.RemoveCondition(cnd.SummoningSickness) + }) + + } + + if event, ok := ctx.Event.(*match.CardPlayedEvent); ok { + + if event.CardID != card.ID { + return + } + + // Only offered when picking it still leaves a distinct match for the + // other requirement, so this selection can never strand the next one + // even when a single creature happens to satisfy both filters. + aCandidates := SelectFilter( + card.Player, + ctx.Match, + card.Player, + match.BATTLEZONE, + fmt.Sprintf("Choose one of your %s for %s to evolve from", aDescription, card.Name), + 1, + 1, + false, + func(x *match.Card) bool { return aFilter(x) && vortexPartnerExists(card.Player, x.ID, bFilter) }, + false, + ) + + if len(aCandidates) < 1 { + ctx.InterruptFlow() + ctx.Match.WarnPlayer(card.Player, fmt.Sprintf("There are no cards for %s to evolve from in your battle zone", card.Name)) + return + } + + baseA := aCandidates[0] + + bCandidates := SelectFilter( + card.Player, + ctx.Match, + card.Player, + match.BATTLEZONE, + fmt.Sprintf("Choose one of your %s for %s to evolve from", bDescription, card.Name), + 1, + 1, + false, + func(x *match.Card) bool { return x.ID != baseA.ID && bFilter(x) }, + false, + ) + + if len(bCandidates) < 1 { + ctx.InterruptFlow() + ctx.Match.WarnPlayer(card.Player, fmt.Sprintf("There are no cards for %s to evolve from in your battle zone", card.Name)) + return + } + + baseB := bCandidates[0] + + orderedIDs := OrderCards( + card.Player, + ctx.Match, + []*match.Card{baseA, baseB}, + fmt.Sprintf("Choose which creature to stack on top before %s is put on top of both", card.Name), + ) + + top, err := card.Player.GetCard(orderedIDs[0], match.BATTLEZONE) + if err != nil { + return + } + + bottom, err := card.Player.GetCard(orderedIDs[1], match.BATTLEZONE) + if err != nil { + return + } + + card.ClearAttachments() + stagePendingEvolutionTapState(card, top.Tapped) + + card.Player.MoveCard(top.ID, match.BATTLEZONE, match.HIDDENZONE, card.ID) + card.Player.MoveCard(bottom.ID, match.BATTLEZONE, match.HIDDENZONE, card.ID) + card.Attach(top, bottom) + + // Announced once the pile is whole, once per base, so anything + // generically watching for "a creature evolved" sees both - matching + // how a plain evolution announces its single base. + matchPlayerID := byte(2) + if card.Player == ctx.Match.Player1.Player { + matchPlayerID = 1 + } + + ctx.Match.HandleFx(match.NewContext(ctx.Match, &match.EvolutionEvent{ + CardID: card.ID, + BaseID: top.ID, + MatchPlayerID: matchPlayerID, + })) + + ctx.Match.HandleFx(match.NewContext(ctx.Match, &match.EvolutionEvent{ + CardID: card.ID, + BaseID: bottom.ID, + MatchPlayerID: matchPlayerID, + })) + + } + + handleEvolutionCardMoved(card, ctx) + +} diff --git a/sim/tests/cards/aura_pegasus_avatar_of_life_test.go b/sim/tests/cards/aura_pegasus_avatar_of_life_test.go new file mode 100644 index 00000000..6fd9b708 --- /dev/null +++ b/sim/tests/cards/aura_pegasus_avatar_of_life_test.go @@ -0,0 +1,213 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + auraPegasusUID = "5ffd2e9f-98bc-4e36-8454-8f822740f8eb" + auraPegasusHornedBeastUID = "18e0e199-7827-4a4c-a37d-3acfa4e500d6" // Roaring Great-Horn (Horned Beast) + auraPegasusAngelCommandUID = "5d3d7052-e5fa-4502-8d31-c72673232317" // Hanusa, Radiance Elemental (Angel Command) + auraPegasusNonEvolutionUID = "af3bc221-1cc2-4f58-83ea-2673ac2c66c5" // Immortal Baron, Vorg (plain creature) + auraPegasusEvolutionUID = "24353d06-89ef-4867-9513-485750d01e10" // Armored Cannon Balbaro (evolves from Human) + auraPegasusSetupSrc = "aura_pegasus_avatar_of_life_test_setup" +) + +// summonAuraPegasus pays for Aura Pegasus with copies of itself and plays it, +// then answers the stacking-order prompt that appears once its controller has +// exactly one Horned Beast and one Angel Command: with no real choice for +// either requirement, both selections resolve on their own. +func summonAuraPegasus(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, topID string, bottomID string) *match.Card { + t.Helper() + + pegasus, err := player.Player.SpawnCard(auraPegasusUID, match.HAND) + require.NoError(t, err) + + for range pegasus.ManaCost { + _, err := player.Player.SpawnCard(auraPegasusUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, pegasus.ID)) + + action, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, action.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return pegasus +} + +// seedTopOfDeck moves card, already in hand, to the front of player's deck, +// so a reveal-the-top-card effect is deterministic. +// +// It must already be in hand rather than spawned fresh here: cnd.Creature and +// cnd.Evolution are only rebuilt for a card that is actually present - +// whatever its zone - when an untap step runs, so a card conjured straight +// into the deck after the untap step this test already passed through would +// carry neither, unlike every card that has been part of the deck since the +// match began. +func seedTopOfDeck(t *testing.T, player *match.PlayerReference, card *match.Card) *match.Card { + t.Helper() + + top, err := player.Player.MoveCardToFront(card.ID, match.HAND, match.DECK, auraPegasusSetupSrc) + require.NoError(t, err) + + deck, err := player.Player.Container(match.DECK) + require.NoError(t, err) + require.Equal(t, top.ID, deck[0].ID, "the seeded card must be on top") + + return top +} + +func TestAuraPegasusAvatarOfLife(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + pegasus := putCardInBattlezone(t, scn, player.Player, auraPegasusUID, auraPegasusSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, pegasus, "Aura Pegasus, Avatar of Life", 12000, 6, []string{civ.Light, civ.Nature}) + assert.True(t, pegasus.HasFamily(family.Pegasus)) + assert.True(t, pegasus.IsMulticolored()) + assert.True(t, pegasus.HasCondition(cnd.TripleBreaker)) + }) + + t.Run("it enters the mana zone tapped", func(t *testing.T) { + _, player, _ := setupDuel(t) + + card, err := player.Player.SpawnCard(auraPegasusUID, match.HAND) + require.NoError(t, err) + + moved, err := player.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, auraPegasusSetupSrc) + require.NoError(t, err) + + assert.True(t, moved.Tapped) + }) + + t.Run("cannot be summoned without a Horned Beast and an Angel Command in the battle zone", func(t *testing.T) { + cases := []struct { + name string + hornedBeast bool + angelCommand bool + }{ + {"neither", false, false}, + {"only a Horned Beast", true, false}, + {"only an Angel Command", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scn, player, _ := setupDuel(t) + + if c.hornedBeast { + putCardInBattlezone(t, scn, player.Player, auraPegasusHornedBeastUID, auraPegasusSetupSrc) + } + if c.angelCommand { + putCardInBattlezone(t, scn, player.Player, auraPegasusAngelCommandUID, auraPegasusSetupSrc) + } + + pegasus, err := player.Player.SpawnCard(auraPegasusUID, match.HAND) + require.NoError(t, err) + for range pegasus.ManaCost { + _, err := player.Player.SpawnCard(auraPegasusUID, match.MANAZONE) + require.NoError(t, err) + } + + // Rejected before mana is even asked for, exactly like a + // regular evolution with no legal base: no prompt is ever + // opened for the caller to answer. + require.Error(t, scn.ActionPlayCard(player, pegasus.ID), "there is nothing for it to evolve from") + assert.Equal(t, match.HAND, pegasus.Zone, "it stays in hand") + }) + } + }) + + t.Run("vortex evolution consumes one of each and stacks on top of the chosen base", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + hornedBeast := putCardInBattlezone(t, scn, player.Player, auraPegasusHornedBeastUID, auraPegasusSetupSrc) + angelCommand := putCardInBattlezone(t, scn, player.Player, auraPegasusAngelCommandUID, auraPegasusSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + // Tapped, and chosen as the top of the stack: Aura Pegasus should take + // this tap state, matching a regular evolution landing on a tapped + // creature. + angelCommand.Tapped = true + + pegasus := summonAuraPegasus(t, scn, player, angelCommand.ID, hornedBeast.ID) + + require.Equal(t, match.BATTLEZONE, pegasus.Zone) + assert.Equal(t, 12000, pegasus.Power) + assert.True(t, pegasus.Tapped, "it took the tap state of the base it was put on top of") + + assert.Equal(t, match.HIDDENZONE, hornedBeast.Zone) + assert.Equal(t, match.HIDDENZONE, angelCommand.Zone) + + attached := pegasus.Attachments() + require.Len(t, attached, 2) + assert.Equal(t, angelCommand.ID, attached[0].ID, "the chosen top base is attached first") + assert.Equal(t, hornedBeast.ID, attached[1].ID) + + assert.False(t, pegasus.HasCondition(cnd.SummoningSickness), "vortex evolution has no summoning sickness, like a regular evolution") + }) + + t.Run("attacking reveals the top card: a non-evolution creature goes to the battle zone", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + pegasus := putCardInBattlezone(t, scn, player.Player, auraPegasusUID, auraPegasusSetupSrc) + seed := spawnForLater(t, player, auraPegasusNonEvolutionUID) + passTurnToSelf(t, scn, player, opponent) + top := seedTopOfDeck(t, player, seed) + + action, err := scn.ActionAttackPlayer(player, pegasus.ID) + require.NoError(t, err) + require.NoError(t, scn.ResolveAttack(player, action.Cards[0].CardID, action.Cards[1].CardID, action.Cards[2].CardID)) + settleTurn(t, scn) + + assert.Equal(t, match.BATTLEZONE, top.Zone) + }) + + t.Run("attacking reveals the top card: an evolution creature goes to hand instead", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + pegasus := putCardInBattlezone(t, scn, player.Player, auraPegasusUID, auraPegasusSetupSrc) + seed := spawnForLater(t, player, auraPegasusEvolutionUID) + passTurnToSelf(t, scn, player, opponent) + require.True(t, seed.HasCondition(cnd.Evolution)) + top := seedTopOfDeck(t, player, seed) + + action, err := scn.ActionAttackPlayer(player, pegasus.ID) + require.NoError(t, err) + require.NoError(t, scn.ResolveAttack(player, action.Cards[0].CardID, action.Cards[1].CardID, action.Cards[2].CardID)) + settleTurn(t, scn) + + assert.Equal(t, match.HAND, top.Zone, "an evolution creature is not a legal target for this effect") + }) + + t.Run("leaving the battle zone also reveals the top card", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + hornedBeast := putCardInBattlezone(t, scn, player.Player, auraPegasusHornedBeastUID, auraPegasusSetupSrc) + angelCommand := putCardInBattlezone(t, scn, player.Player, auraPegasusAngelCommandUID, auraPegasusSetupSrc) + seed := spawnForLater(t, player, auraPegasusNonEvolutionUID) + passTurnToSelf(t, scn, player, opponent) + + pegasus := summonAuraPegasus(t, scn, player, hornedBeast.ID, angelCommand.ID) + top := seedTopOfDeck(t, player, seed) + + _, err := player.Player.MoveCard(pegasus.ID, match.BATTLEZONE, match.GRAVEYARD, auraPegasusSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.GRAVEYARD, pegasus.Zone) + assert.Equal(t, match.GRAVEYARD, hornedBeast.Zone, "its base leaves the hidden zone together with it") + assert.Equal(t, match.GRAVEYARD, angelCommand.Zone, "so does the other base") + assert.Equal(t, match.BATTLEZONE, top.Zone, "leaving the battle zone also reveals the top card") + }) +} diff --git a/sim/tests/cards/bolmeteus_steel_dragon_test.go b/sim/tests/cards/bolmeteus_steel_dragon_test.go new file mode 100644 index 00000000..7e0660f7 --- /dev/null +++ b/sim/tests/cards/bolmeteus_steel_dragon_test.go @@ -0,0 +1,76 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + bolmeteusSteelDragonUID = "4717faef-1065-4153-a509-854c22637e27" + bolmeteusSteelDragonSetupSrc = "bolmeteus_steel_dragon_test_setup" +) + +func TestBolmeteusSteelDragon(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + dragon := putCardInBattlezone(t, scn, player.Player, bolmeteusSteelDragonUID, bolmeteusSteelDragonSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, dragon, "Bolmeteus Steel Dragon", 7000, 7, []string{civ.Fire}) + assert.True(t, dragon.HasFamily(family.ArmoredDragon)) + assert.True(t, dragon.HasCondition(cnd.DoubleBreaker)) + }) + + t.Run("shields it would break go to the opponent's graveyard instead of hand", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + dragon := putCardInBattlezone(t, scn, player.Player, bolmeteusSteelDragonUID, bolmeteusSteelDragonSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + handBefore, err := opponent.Player.Container(match.HAND) + require.NoError(t, err) + handCount := len(handBefore) + + action, err := scn.ActionAttackPlayer(player, dragon.ID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(action.Cards), 2) + targeted := []string{action.Cards[0].CardID, action.Cards[1].CardID} + + require.NoError(t, scn.ResolveAttack(player, targeted...)) + settleTurn(t, scn) + + for _, id := range targeted { + card, err := opponent.Player.GetCard(id, match.GRAVEYARD) + require.NoError(t, err, "the shield landed in the graveyard, not the hand") + assert.Equal(t, match.GRAVEYARD, card.Zone) + } + + hand, err := opponent.Player.Container(match.HAND) + require.NoError(t, err) + assert.Len(t, hand, handCount, "the broken shields never reached hand") + }) + + t.Run("only applies to shields it breaks itself", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + dragon := putCardInBattlezone(t, scn, player.Player, bolmeteusSteelDragonUID, bolmeteusSteelDragonSetupSrc) + dragon.Tapped = true // present, but not the attacker + attacker := putCardInBattlezone(t, scn, player.Player, cruelNagaFillerUID, bolmeteusSteelDragonSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + shields, err := opponent.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + target := shields[0] + + _, err = scn.ActionAttackPlayer(player, attacker.ID) + require.NoError(t, err) + require.NoError(t, scn.ResolveAttack(player, target.ID)) + settleTurn(t, scn) + + assert.Equal(t, match.HAND, target.Zone, "an unrelated attacker's broken shield still goes to hand") + }) +} diff --git a/sim/tests/cards/cruel_naga_avatar_of_fate_test.go b/sim/tests/cards/cruel_naga_avatar_of_fate_test.go new file mode 100644 index 00000000..e3920333 --- /dev/null +++ b/sim/tests/cards/cruel_naga_avatar_of_fate_test.go @@ -0,0 +1,185 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + cruelNagaUID = "23475e43-4cbd-4054-a90d-fde230a28db3" + cruelNagaMerfolkUID = "acc8adb5-63c9-4438-976c-dcdf8fe1dad8" // Tide Patroller (Merfolk) + cruelNagaChimeraUID = "5d73062e-acff-47e6-b49a-c0bb1a1762b5" // Gigagiele (Chimera) + cruelNagaBlockerUID = "c7fec5e8-4e56-451b-a7b6-ad08680703a4" // La Byle, Seeker of the Winds (Blocker) + cruelNagaFillerUID = "af3bc221-1cc2-4f58-83ea-2673ac2c66c5" // Immortal Baron, Vorg + cruelNagaSetupSrc = "cruel_naga_avatar_of_fate_test_setup" +) + +// summonCruelNaga pays for Cruel Naga with copies of itself and plays it, then +// answers the stacking-order prompt that appears once its controller has +// exactly one Merfolk and one Chimera: with no real choice for either +// requirement, both selections resolve on their own. +func summonCruelNaga(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, topID string, bottomID string) *match.Card { + t.Helper() + + naga, err := player.Player.SpawnCard(cruelNagaUID, match.HAND) + require.NoError(t, err) + + for range naga.ManaCost { + _, err := player.Player.SpawnCard(cruelNagaUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, naga.ID)) + + action, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, action.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return naga +} + +func TestCruelNagaAvatarOfFate(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + naga := putCardInBattlezone(t, scn, player.Player, cruelNagaUID, cruelNagaSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, naga, "Cruel Naga, Avatar of Fate", 9000, 6, []string{civ.Water, civ.Darkness}) + assert.True(t, naga.HasFamily(family.Naga)) + assert.True(t, naga.IsMulticolored()) + assert.True(t, naga.HasCondition(cnd.DoubleBreaker)) + assert.True(t, naga.HasCondition(cnd.CantBeBlocked)) + }) + + t.Run("it enters the mana zone tapped", func(t *testing.T) { + _, player, _ := setupDuel(t) + + card, err := player.Player.SpawnCard(cruelNagaUID, match.HAND) + require.NoError(t, err) + + moved, err := player.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, cruelNagaSetupSrc) + require.NoError(t, err) + + assert.True(t, moved.Tapped) + }) + + t.Run("cannot be summoned without a Merfolk and a Chimera in the battle zone", func(t *testing.T) { + cases := []struct { + name string + merfolk bool + chimera bool + }{ + {"neither", false, false}, + {"only a Merfolk", true, false}, + {"only a Chimera", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scn, player, _ := setupDuel(t) + + if c.merfolk { + putCardInBattlezone(t, scn, player.Player, cruelNagaMerfolkUID, cruelNagaSetupSrc) + } + if c.chimera { + putCardInBattlezone(t, scn, player.Player, cruelNagaChimeraUID, cruelNagaSetupSrc) + } + + naga, err := player.Player.SpawnCard(cruelNagaUID, match.HAND) + require.NoError(t, err) + for range naga.ManaCost { + _, err := player.Player.SpawnCard(cruelNagaUID, match.MANAZONE) + require.NoError(t, err) + } + + // Rejected before mana is even asked for, exactly like a + // regular evolution with no legal base: no prompt is ever + // opened for the caller to answer. + require.Error(t, scn.ActionPlayCard(player, naga.ID), "there is nothing for it to evolve from") + assert.Equal(t, match.HAND, naga.Zone, "it stays in hand") + }) + } + }) + + t.Run("vortex evolution consumes one of each and stacks on top of the chosen base", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + merfolk := putCardInBattlezone(t, scn, player.Player, cruelNagaMerfolkUID, cruelNagaSetupSrc) + chimera := putCardInBattlezone(t, scn, player.Player, cruelNagaChimeraUID, cruelNagaSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + // Tapped, and chosen as the top of the stack: Cruel Naga should take + // this tap state, matching a regular evolution landing on a tapped + // creature. + chimera.Tapped = true + + naga := summonCruelNaga(t, scn, player, chimera.ID, merfolk.ID) + + require.Equal(t, match.BATTLEZONE, naga.Zone) + assert.Equal(t, 9000, naga.Power) + assert.True(t, naga.Tapped, "it took the tap state of the base it was put on top of") + + assert.Equal(t, match.HIDDENZONE, merfolk.Zone) + assert.Equal(t, match.HIDDENZONE, chimera.Zone) + + attached := naga.Attachments() + require.Len(t, attached, 2) + assert.Equal(t, chimera.ID, attached[0].ID, "the chosen top base is attached first") + assert.Equal(t, merfolk.ID, attached[1].ID) + + assert.False(t, naga.HasCondition(cnd.SummoningSickness), "vortex evolution has no summoning sickness, like a regular evolution") + }) + + t.Run("it cannot be blocked", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + naga := putCardInBattlezone(t, scn, player.Player, cruelNagaUID, cruelNagaSetupSrc) + blocker := putCardInBattlezone(t, scn, opponent.Player, cruelNagaBlockerUID, cruelNagaSetupSrc) + passTurnToSelf(t, scn, player, opponent) + require.True(t, blocker.HasCondition(cnd.Blocker)) + + shields, err := opponent.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + shieldCount := len(shields) + + action, err := scn.ActionAttackPlayer(player, naga.ID) + require.NoError(t, err) + require.NoError(t, scn.ResolveAttack(player, action.Cards[0].CardID, action.Cards[1].CardID)) + settleTurn(t, scn) + + assert.False(t, blocker.Tapped, "it was never offered as a blocker") + + remaining, err := opponent.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + assert.Len(t, remaining, shieldCount-2, "the attack went through unblocked") + }) + + t.Run("leaving the battle zone destroys every creature in the battle zone", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + merfolk := putCardInBattlezone(t, scn, player.Player, cruelNagaMerfolkUID, cruelNagaSetupSrc) + chimera := putCardInBattlezone(t, scn, player.Player, cruelNagaChimeraUID, cruelNagaSetupSrc) + ally := putCardInBattlezone(t, scn, player.Player, cruelNagaFillerUID, cruelNagaSetupSrc) + theirs := putCardInBattlezone(t, scn, opponent.Player, cruelNagaFillerUID, cruelNagaSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + naga := summonCruelNaga(t, scn, player, merfolk.ID, chimera.ID) + + _, err := player.Player.MoveCard(naga.ID, match.BATTLEZONE, match.GRAVEYARD, cruelNagaSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.GRAVEYARD, naga.Zone) + assert.Equal(t, match.GRAVEYARD, merfolk.Zone, "its base leaves the hidden zone together with it") + assert.Equal(t, match.GRAVEYARD, chimera.Zone, "so does the other base") + assert.Equal(t, match.GRAVEYARD, ally.Zone, "destroy all creatures catches its controller's own") + assert.Equal(t, match.GRAVEYARD, theirs.Zone, "and the opponent's") + }) +} diff --git a/sim/tests/cards/death_phoenix_avatar_of_doom_test.go b/sim/tests/cards/death_phoenix_avatar_of_doom_test.go new file mode 100644 index 00000000..6f28f00e --- /dev/null +++ b/sim/tests/cards/death_phoenix_avatar_of_doom_test.go @@ -0,0 +1,194 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + deathPhoenixUID = "dcc5ee70-bc60-420d-92e5-ed8bd7ff949f" + deathPhoenixZombieDragonUID = "d2f91d1a-5e8d-43ce-8512-ec6be9c3f424" // Necrodragon Giland (Zombie Dragon) + deathPhoenixFireBirdUID = "479b477f-f535-4564-ac0c-5f0aeaafe914" // Baby Zoppe (Fire Bird) + deathPhoenixFillerUID = "af3bc221-1cc2-4f58-83ea-2673ac2c66c5" // Immortal Baron, Vorg + deathPhoenixSetupSrc = "death_phoenix_avatar_of_doom_test_setup" +) + +// summonDeathPhoenix pays for Death Phoenix with copies of itself and plays +// it, then answers the stacking-order prompt that appears once its controller +// has exactly one Zombie Dragon and one Fire Bird: with no real choice for +// either requirement, both selections resolve on their own. +func summonDeathPhoenix(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, topID string, bottomID string) *match.Card { + t.Helper() + + phoenix, err := player.Player.SpawnCard(deathPhoenixUID, match.HAND) + require.NoError(t, err) + + for range phoenix.ManaCost { + _, err := player.Player.SpawnCard(deathPhoenixUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, phoenix.ID)) + + action, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, action.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return phoenix +} + +func TestDeathPhoenixAvatarOfDoom(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + phoenix := putCardInBattlezone(t, scn, player.Player, deathPhoenixUID, deathPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, phoenix, "Death Phoenix, Avatar of Doom", 9000, 4, []string{civ.Darkness, civ.Fire}) + assert.True(t, phoenix.HasFamily(family.Phoenix)) + assert.True(t, phoenix.IsMulticolored()) + assert.True(t, phoenix.HasCondition(cnd.DoubleBreaker)) + }) + + t.Run("it enters the mana zone tapped", func(t *testing.T) { + _, player, _ := setupDuel(t) + + card, err := player.Player.SpawnCard(deathPhoenixUID, match.HAND) + require.NoError(t, err) + + moved, err := player.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, deathPhoenixSetupSrc) + require.NoError(t, err) + + assert.True(t, moved.Tapped) + }) + + t.Run("cannot be summoned without a Zombie Dragon and a Fire Bird in the battle zone", func(t *testing.T) { + cases := []struct { + name string + zombieDragon bool + fireBird bool + }{ + {"neither", false, false}, + {"only a Zombie Dragon", true, false}, + {"only a Fire Bird", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scn, player, _ := setupDuel(t) + + if c.zombieDragon { + putCardInBattlezone(t, scn, player.Player, deathPhoenixZombieDragonUID, deathPhoenixSetupSrc) + } + if c.fireBird { + putCardInBattlezone(t, scn, player.Player, deathPhoenixFireBirdUID, deathPhoenixSetupSrc) + } + + phoenix, err := player.Player.SpawnCard(deathPhoenixUID, match.HAND) + require.NoError(t, err) + for range phoenix.ManaCost { + _, err := player.Player.SpawnCard(deathPhoenixUID, match.MANAZONE) + require.NoError(t, err) + } + + // Rejected before mana is even asked for, exactly like a + // regular evolution with no legal base: no prompt is ever + // opened for the caller to answer. + require.Error(t, scn.ActionPlayCard(player, phoenix.ID), "there is nothing for it to evolve from") + assert.Equal(t, match.HAND, phoenix.Zone, "it stays in hand") + }) + } + }) + + t.Run("vortex evolution consumes one of each and stacks on top of the chosen base", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + zombieDragon := putCardInBattlezone(t, scn, player.Player, deathPhoenixZombieDragonUID, deathPhoenixSetupSrc) + fireBird := putCardInBattlezone(t, scn, player.Player, deathPhoenixFireBirdUID, deathPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + // Tapped, and chosen as the top of the stack: Death Phoenix should + // take this tap state, matching a regular evolution landing on a + // tapped creature. + fireBird.Tapped = true + + phoenix := summonDeathPhoenix(t, scn, player, fireBird.ID, zombieDragon.ID) + + require.Equal(t, match.BATTLEZONE, phoenix.Zone) + assert.Equal(t, 9000, phoenix.Power) + assert.True(t, phoenix.Tapped, "it took the tap state of the base it was put on top of") + + assert.Equal(t, match.HIDDENZONE, zombieDragon.Zone) + assert.Equal(t, match.HIDDENZONE, fireBird.Zone) + + attached := phoenix.Attachments() + require.Len(t, attached, 2) + assert.Equal(t, fireBird.ID, attached[0].ID, "the chosen top base is attached first") + assert.Equal(t, zombieDragon.ID, attached[1].ID) + + assert.False(t, phoenix.HasCondition(cnd.SummoningSickness), "vortex evolution has no summoning sickness, like a regular evolution") + }) + + t.Run("shields it would break go to the opponent's graveyard instead of hand", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + phoenix := putCardInBattlezone(t, scn, player.Player, deathPhoenixUID, deathPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + handBefore, err := opponent.Player.Container(match.HAND) + require.NoError(t, err) + handCount := len(handBefore) + + action, err := scn.ActionAttackPlayer(player, phoenix.ID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(action.Cards), 2) + targeted := []string{action.Cards[0].CardID, action.Cards[1].CardID} + + require.NoError(t, scn.ResolveAttack(player, targeted...)) + settleTurn(t, scn) + + for _, id := range targeted { + card, err := opponent.Player.GetCard(id, match.GRAVEYARD) + require.NoError(t, err, "the shield landed in the graveyard, not the hand") + assert.Equal(t, match.GRAVEYARD, card.Zone) + } + + hand, err := opponent.Player.Container(match.HAND) + require.NoError(t, err) + assert.Len(t, hand, handCount, "the broken shields never reached hand") + }) + + t.Run("leaving the battle zone makes the opponent discard their hand", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + zombieDragon := putCardInBattlezone(t, scn, player.Player, deathPhoenixZombieDragonUID, deathPhoenixSetupSrc) + fireBird := putCardInBattlezone(t, scn, player.Player, deathPhoenixFireBirdUID, deathPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + phoenix := summonDeathPhoenix(t, scn, player, zombieDragon.ID, fireBird.ID) + + emptyHand(t, opponent, deathPhoenixSetupSrc) + _, err := opponent.Player.SpawnCard(deathPhoenixFillerUID, match.HAND) + require.NoError(t, err) + _, err = opponent.Player.SpawnCard(deathPhoenixFillerUID, match.HAND) + require.NoError(t, err) + + _, err = player.Player.MoveCard(phoenix.ID, match.BATTLEZONE, match.GRAVEYARD, deathPhoenixSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + hand, err := opponent.Player.Container(match.HAND) + require.NoError(t, err) + assert.Empty(t, hand, "the opponent's whole hand was discarded") + + assert.Equal(t, match.GRAVEYARD, phoenix.Zone) + assert.Equal(t, match.GRAVEYARD, zombieDragon.Zone, "its base leaves the hidden zone together with it") + assert.Equal(t, match.GRAVEYARD, fireBird.Zone, "so does the other base") + }) +} diff --git a/sim/tests/cards/gigios_hammer_test.go b/sim/tests/cards/gigios_hammer_test.go index 2ca9de5a..1a4cb75c 100644 --- a/sim/tests/cards/gigios_hammer_test.go +++ b/sim/tests/cards/gigios_hammer_test.go @@ -5,6 +5,7 @@ import ( "duel-masters/game/cnd" "duel-masters/game/family" "duel-masters/game/match" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -18,9 +19,10 @@ const ( gigiosHammerSetupSrc = "gigios_hammer_test_setup" ) -// The full family list is offered in this order by fx.ChooseAFamily; Spirit -// Quartz is Deklowaz's race. -const gigiosHammerSpiritQuartzChoice = 51 +// fx.ChooseAFamily offers family.GetAllFamilies() in order; Spirit Quartz is +// Deklowaz's race. Computed rather than hardcoded so a new family added +// anywhere in that list cannot silently shift this index out from under the test. +var gigiosHammerSpiritQuartzChoice = slices.Index(family.GetAllFamilies(), family.SpiritQuartz) func TestGigiosHammer(t *testing.T) { t.Run("printed characteristics", func(t *testing.T) { diff --git a/sim/tests/cards/soul_phoenix_avatar_of_unity_test.go b/sim/tests/cards/soul_phoenix_avatar_of_unity_test.go new file mode 100644 index 00000000..23d9a52f --- /dev/null +++ b/sim/tests/cards/soul_phoenix_avatar_of_unity_test.go @@ -0,0 +1,177 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + soulPhoenixUID = "cfa7c730-2818-4f78-aa4b-1395060cd687" + soulPhoenixFireBirdUID = "479b477f-f535-4564-ac0c-5f0aeaafe914" // Baby Zoppe (Fire Bird) + soulPhoenixEarthDragonUID = "ea9dbf9c-d049-4213-a658-e47ab25867e6" // Terradragon Regarion (Earth Dragon) + soulPhoenixSetupSrc = "soul_phoenix_avatar_of_unity_test_setup" +) + +// summonSoulPhoenix pays for Soul Phoenix with copies of itself and plays it, +// then answers the stacking-order prompt that appears once its controller has +// exactly one Fire Bird and one Earth Dragon: with no real choice for either +// requirement, both selections resolve on their own. +func summonSoulPhoenix(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, topID string, bottomID string) *match.Card { + t.Helper() + + phoenix, err := player.Player.SpawnCard(soulPhoenixUID, match.HAND) + require.NoError(t, err) + + for range phoenix.ManaCost { + _, err := player.Player.SpawnCard(soulPhoenixUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, phoenix.ID)) + + action, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, action.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return phoenix +} + +func TestSoulPhoenixAvatarOfUnity(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + phoenix := putCardInBattlezone(t, scn, player.Player, soulPhoenixUID, soulPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, phoenix, "Soul Phoenix, Avatar of Unity", 13000, 4, []string{civ.Fire, civ.Nature}) + assert.True(t, phoenix.HasFamily(family.Phoenix)) + assert.True(t, phoenix.IsMulticolored()) + assert.True(t, phoenix.HasCondition(cnd.TripleBreaker)) + }) + + t.Run("it enters the mana zone tapped", func(t *testing.T) { + _, player, _ := setupDuel(t) + + card, err := player.Player.SpawnCard(soulPhoenixUID, match.HAND) + require.NoError(t, err) + + moved, err := player.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, soulPhoenixSetupSrc) + require.NoError(t, err) + + assert.True(t, moved.Tapped) + }) + + t.Run("cannot be summoned without a Fire Bird and an Earth Dragon in the battle zone", func(t *testing.T) { + cases := []struct { + name string + fireBird bool + earthDragon bool + }{ + {"neither", false, false}, + {"only a Fire Bird", true, false}, + {"only an Earth Dragon", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scn, player, _ := setupDuel(t) + + if c.fireBird { + putCardInBattlezone(t, scn, player.Player, soulPhoenixFireBirdUID, soulPhoenixSetupSrc) + } + if c.earthDragon { + putCardInBattlezone(t, scn, player.Player, soulPhoenixEarthDragonUID, soulPhoenixSetupSrc) + } + + phoenix, err := player.Player.SpawnCard(soulPhoenixUID, match.HAND) + require.NoError(t, err) + for range phoenix.ManaCost { + _, err := player.Player.SpawnCard(soulPhoenixUID, match.MANAZONE) + require.NoError(t, err) + } + + // Rejected before mana is even asked for, exactly like a + // regular evolution with no legal base: no prompt is ever + // opened for the caller to answer. + require.Error(t, scn.ActionPlayCard(player, phoenix.ID), "there is nothing for it to evolve from") + assert.Equal(t, match.HAND, phoenix.Zone, "it stays in hand") + }) + } + }) + + t.Run("vortex evolution consumes one of each and stacks on top of the chosen base", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + fireBird := putCardInBattlezone(t, scn, player.Player, soulPhoenixFireBirdUID, soulPhoenixSetupSrc) + earthDragon := putCardInBattlezone(t, scn, player.Player, soulPhoenixEarthDragonUID, soulPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + // Tapped, and chosen as the top of the stack: Soul Phoenix should + // take this tap state, matching a regular evolution landing on a + // tapped creature. + earthDragon.Tapped = true + + phoenix := summonSoulPhoenix(t, scn, player, earthDragon.ID, fireBird.ID) + + require.Equal(t, match.BATTLEZONE, phoenix.Zone) + assert.Equal(t, 13000, phoenix.Power) + assert.True(t, phoenix.Tapped, "it took the tap state of the base it was put on top of") + + assert.Equal(t, match.HIDDENZONE, fireBird.Zone) + assert.Equal(t, match.HIDDENZONE, earthDragon.Zone) + + attached := phoenix.Attachments() + require.Len(t, attached, 2) + assert.Equal(t, earthDragon.ID, attached[0].ID, "the chosen top base is attached first") + assert.Equal(t, fireBird.ID, attached[1].ID) + + assert.False(t, phoenix.HasCondition(cnd.SummoningSickness), "vortex evolution has no summoning sickness, like a regular evolution") + }) + + t.Run("leaving the battle zone separates its bases back into the battle zone instead of taking them with it", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + fireBird := putCardInBattlezone(t, scn, player.Player, soulPhoenixFireBirdUID, soulPhoenixSetupSrc) + earthDragon := putCardInBattlezone(t, scn, player.Player, soulPhoenixEarthDragonUID, soulPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + phoenix := summonSoulPhoenix(t, scn, player, fireBird.ID, earthDragon.ID) + + _, err := player.Player.MoveCard(phoenix.ID, match.BATTLEZONE, match.GRAVEYARD, soulPhoenixSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.GRAVEYARD, phoenix.Zone, "only the top card actually leaves") + assert.Equal(t, match.BATTLEZONE, fireBird.Zone, "its bases separate into the battle zone instead of following it") + assert.Equal(t, match.BATTLEZONE, earthDragon.Zone) + + assert.False(t, fireBird.HasCondition(cnd.SummoningSickness), "a separated base is not newly summoned") + assert.False(t, earthDragon.HasCondition(cnd.SummoningSickness)) + + assert.Empty(t, phoenix.Attachments(), "nothing is left attached once they separate") + }) + + t.Run("leaving the battle zone through any destination still separates the bases", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + fireBird := putCardInBattlezone(t, scn, player.Player, soulPhoenixFireBirdUID, soulPhoenixSetupSrc) + earthDragon := putCardInBattlezone(t, scn, player.Player, soulPhoenixEarthDragonUID, soulPhoenixSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + phoenix := summonSoulPhoenix(t, scn, player, fireBird.ID, earthDragon.ID) + + _, err := player.Player.MoveCard(phoenix.ID, match.BATTLEZONE, match.HAND, soulPhoenixSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.HAND, phoenix.Zone) + assert.Equal(t, match.BATTLEZONE, fireBird.Zone) + assert.Equal(t, match.BATTLEZONE, earthDragon.Zone) + }) +} diff --git a/sim/tests/cards/wise_starnoid_avatar_of_hope_test.go b/sim/tests/cards/wise_starnoid_avatar_of_hope_test.go new file mode 100644 index 00000000..e104a526 --- /dev/null +++ b/sim/tests/cards/wise_starnoid_avatar_of_hope_test.go @@ -0,0 +1,272 @@ +package cards + +import ( + "duel-masters/game/civ" + "duel-masters/game/cnd" + "duel-masters/game/family" + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + wiseStarnoidUID = "ba894f7e-b7d7-409e-8393-cab4285a879c" + wiseStarnoidLightBringerUID = "7b58e8c2-0b1e-4ef5-812f-e667c2092c73" // Reusol, the Oracle (vanilla Light Bringer, 2000) + wiseStarnoidLightBringer2UID = "616c146e-049f-4720-a225-0a189729ca79" // Chilias, the Oracle (another Light Bringer) + wiseStarnoidCyberLordUID = "7a6f1c82-a8ac-4646-b3e9-fb8592bdd0a4" // Tropico (Cyber Lord, 3000) + wiseStarnoidSetupSrc = "wise_starnoid_avatar_of_hope_test_setup" +) + +// summonWiseStarnoid pays for Wise Starnoid with copies of itself and plays +// it, then answers the single stacking-order prompt that appears when its +// controller has exactly one Light Bringer and one Cyber Lord: with no real +// choice for either requirement, both selections resolve on their own and +// only the ordering (topID goes on top, then bottomID) is actually asked. +func summonWiseStarnoid(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, topID string, bottomID string) *match.Card { + t.Helper() + + starnoid, err := player.Player.SpawnCard(wiseStarnoidUID, match.HAND) + require.NoError(t, err) + + for range starnoid.ManaCost { + _, err := player.Player.SpawnCard(wiseStarnoidUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, starnoid.ID)) + + orderPrompt, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, orderPrompt.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return starnoid +} + +func TestWiseStarnoidAvatarOfHope(t *testing.T) { + t.Run("printed characteristics", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + starnoid := putCardInBattlezone(t, scn, player.Player, wiseStarnoidUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + assertPrinted(t, starnoid, "Wise Starnoid, Avatar of Hope", 9000, 5, []string{civ.Light, civ.Water}) + assert.True(t, starnoid.HasFamily(family.Starnoid)) + assert.True(t, starnoid.IsMulticolored()) + assert.True(t, starnoid.HasCondition(cnd.DoubleBreaker)) + }) + + t.Run("it enters the mana zone tapped", func(t *testing.T) { + _, player, _ := setupDuel(t) + + card, err := player.Player.SpawnCard(wiseStarnoidUID, match.HAND) + require.NoError(t, err) + + moved, err := player.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, wiseStarnoidSetupSrc) + require.NoError(t, err) + + assert.True(t, moved.Tapped) + }) + + t.Run("cannot be summoned without a Light Bringer and a Cyber Lord in the battle zone", func(t *testing.T) { + cases := []struct { + name string + lightBring bool + cyberLord bool + }{ + {"neither", false, false}, + {"only a Light Bringer", true, false}, + {"only a Cyber Lord", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scn, player, _ := setupDuel(t) + + if c.lightBring { + putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringerUID, wiseStarnoidSetupSrc) + } + if c.cyberLord { + putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + } + + starnoid, err := player.Player.SpawnCard(wiseStarnoidUID, match.HAND) + require.NoError(t, err) + for range starnoid.ManaCost { + _, err := player.Player.SpawnCard(wiseStarnoidUID, match.MANAZONE) + require.NoError(t, err) + } + + // Rejected before mana is even asked for, exactly like a + // regular evolution with no legal base: no prompt is ever + // opened for the caller to answer. + require.Error(t, scn.ActionPlayCard(player, starnoid.ID), "there is nothing for it to evolve from") + assert.Equal(t, match.HAND, starnoid.Zone, "it stays in hand") + }) + } + }) + + t.Run("a single creature cannot fill both roles by itself", func(t *testing.T) { + // Tropico is only a Cyber Lord, not a Light Bringer, but this proves + // the engine requires two distinct creatures rather than merely + // checking that both characteristics are present somewhere. + scn, player, _ := setupDuel(t) + putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + + starnoid, err := player.Player.SpawnCard(wiseStarnoidUID, match.HAND) + require.NoError(t, err) + for range starnoid.ManaCost { + _, err := player.Player.SpawnCard(wiseStarnoidUID, match.MANAZONE) + require.NoError(t, err) + } + + require.Error(t, scn.ActionPlayCard(player, starnoid.ID)) + assert.Equal(t, match.HAND, starnoid.Zone) + }) + + t.Run("vortex evolution consumes one of each and stacks on top of the chosen base", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + lightBringer := putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringerUID, wiseStarnoidSetupSrc) + cyberLord := putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + // Tapped, and chosen as the top of the stack: Wise Starnoid should + // take this tap state, matching a regular evolution landing on a + // tapped creature. + cyberLord.Tapped = true + + starnoid := summonWiseStarnoid(t, scn, player, cyberLord.ID, lightBringer.ID) + + require.Equal(t, match.BATTLEZONE, starnoid.Zone) + assert.Equal(t, 9000, starnoid.Power) + assert.True(t, starnoid.Tapped, "it took the tap state of the base it was put on top of") + + assert.Equal(t, match.HIDDENZONE, lightBringer.Zone) + assert.Equal(t, match.HIDDENZONE, cyberLord.Zone) + + attached := starnoid.Attachments() + require.Len(t, attached, 2) + assert.Equal(t, cyberLord.ID, attached[0].ID, "the chosen top base is attached first") + assert.Equal(t, lightBringer.ID, attached[1].ID) + + assert.False(t, starnoid.HasCondition(cnd.SummoningSickness), "vortex evolution has no summoning sickness, like a regular evolution") + }) + + t.Run("the controller chooses which creature to use when more than one qualifies", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + wanted := putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringerUID, wiseStarnoidSetupSrc) + unwanted := putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringer2UID, wiseStarnoidSetupSrc) + cyberLord := putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + starnoid, err := player.Player.SpawnCard(wiseStarnoidUID, match.HAND) + require.NoError(t, err) + for range starnoid.ManaCost { + _, err := player.Player.SpawnCard(wiseStarnoidUID, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, starnoid.ID)) + + lbPrompt, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected a choice between the two Light Bringers") + offered := make([]string, 0, len(lbPrompt.Cards)) + for _, c := range lbPrompt.Cards { + offered = append(offered, c.CardID) + } + assert.ElementsMatch(t, []string{wanted.ID, unwanted.ID}, offered) + + orderPromptStart, err := scn.MessageCount(player) + require.NoError(t, err) + require.NoError(t, scn.SubmitAction(player, wanted.ID)) + + // Only one Cyber Lord exists, so that selection resolves on its own + // and the very next prompt is the stacking order. + _, err = scn.WaitForAction(player, orderPromptStart) + require.NoError(t, err, "expected the stacking-order prompt next") + + require.NoError(t, scn.SubmitAction(player, wanted.ID, cyberLord.ID)) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.HIDDENZONE, wanted.Zone, "the chosen Light Bringer became a base") + assert.Equal(t, match.BATTLEZONE, unwanted.Zone, "the other one was left alone") + }) + + t.Run("it can attack the same turn it evolves", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + lightBringer := putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringerUID, wiseStarnoidSetupSrc) + cyberLord := putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + starnoid := summonWiseStarnoid(t, scn, player, lightBringer.ID, cyberLord.ID) + + _, err := scn.ActionAttackPlayer(player, starnoid.ID) + require.NoError(t, err, "the attack should open the shield-selection prompt, not be rejected for summoning sickness") + + require.NoError(t, scn.CancelAction(player)) + }) + + t.Run("attacking adds the top card of the deck to its controller's shields face-down", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + starnoid := putCardInBattlezone(t, scn, player.Player, wiseStarnoidUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + deckBefore, err := player.Player.Container(match.DECK) + require.NoError(t, err) + require.NotEmpty(t, deckBefore) + topOfDeck := deckBefore[0] + + shieldsBefore, err := player.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + + action, err := scn.ActionAttackPlayer(player, starnoid.ID) + require.NoError(t, err) + require.NoError(t, scn.ResolveAttack(player, action.Cards[0].CardID, action.Cards[1].CardID)) + settleTurn(t, scn) + + assert.Equal(t, match.SHIELDZONE, topOfDeck.Zone, "the top card of the deck became a shield") + + shields, err := player.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + assert.Len(t, shields, len(shieldsBefore)+1) + + deck, err := player.Player.Container(match.DECK) + require.NoError(t, err) + assert.Len(t, deck, len(deckBefore)-1) + }) + + t.Run("leaving the battle zone adds a shield and takes both bases with it", func(t *testing.T) { + scn, player, opponent := setupDuel(t) + lightBringer := putCardInBattlezone(t, scn, player.Player, wiseStarnoidLightBringerUID, wiseStarnoidSetupSrc) + cyberLord := putCardInBattlezone(t, scn, player.Player, wiseStarnoidCyberLordUID, wiseStarnoidSetupSrc) + passTurnToSelf(t, scn, player, opponent) + + starnoid := summonWiseStarnoid(t, scn, player, lightBringer.ID, cyberLord.ID) + + deckBefore, err := player.Player.Container(match.DECK) + require.NoError(t, err) + require.NotEmpty(t, deckBefore) + topOfDeck := deckBefore[0] + + shieldsBefore, err := player.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + + _, err = player.Player.MoveCard(starnoid.ID, match.BATTLEZONE, match.GRAVEYARD, wiseStarnoidSetupSrc) + require.NoError(t, err) + require.NoError(t, scn.WaitForEventLoop()) + + assert.Equal(t, match.GRAVEYARD, starnoid.Zone) + assert.Equal(t, match.GRAVEYARD, lightBringer.Zone, "its base leaves the hidden zone together with it") + assert.Equal(t, match.GRAVEYARD, cyberLord.Zone, "so does the other base") + + assert.Equal(t, match.SHIELDZONE, topOfDeck.Zone, "leaving the battle zone also adds the top card of the deck as a shield") + + shields, err := player.Player.Container(match.SHIELDZONE) + require.NoError(t, err) + assert.Len(t, shields, len(shieldsBefore)+1) + }) +} diff --git a/sim/tests/fx/evolution_event_test.go b/sim/tests/fx/evolution_event_test.go index 02bcc6b2..6a9e734d 100644 --- a/sim/tests/fx/evolution_event_test.go +++ b/sim/tests/fx/evolution_event_test.go @@ -176,3 +176,35 @@ func TestEvolutionEventIsDispatchedWithTheBaseAlreadyHidden(t *testing.T) { assert.Equal(t, match.HIDDENZONE, baseZone, "the base is under the evolution already") assert.NotEqual(t, match.BATTLEZONE, evolutionZone, "and the evolution has not landed yet") } + +// TestEvolutionEntersTappedWhenItsBaseIsTapped guards against a regression +// where an evolution creature's tap state was set while resolving +// CardPlayedEvent, only for the unconditional Tapped = false every +// Player.MoveCard applies on arrival to silently wipe it out once the +// evolution creature itself moved from hand to the battle zone. +func TestEvolutionEntersTappedWhenItsBaseIsTapped(t *testing.T) { + scn := scenario.New() + player := scn.Match.CurrentPlayer() + + base := putInBattlezone(t, scn, player.Player, evolutionEventBaseUID) + base.Tapped = true + + evolution := summonWithOwnMana(t, scn, player, evolutionEventEvolutionUID) + + require.Equal(t, match.BATTLEZONE, evolution.Zone) + assert.True(t, evolution.Tapped, "it took the tap state of the base it evolved from") +} + +// TestEvolutionEntersUntappedWhenItsBaseIsUntapped is the other half of the +// rule: landing on an untapped base leaves the evolution creature untapped. +func TestEvolutionEntersUntappedWhenItsBaseIsUntapped(t *testing.T) { + scn := scenario.New() + player := scn.Match.CurrentPlayer() + + putInBattlezone(t, scn, player.Player, evolutionEventBaseUID) + + evolution := summonWithOwnMana(t, scn, player, evolutionEventEvolutionUID) + + require.Equal(t, match.BATTLEZONE, evolution.Zone) + assert.False(t, evolution.Tapped) +} diff --git a/sim/tests/fx/vortex_evolution_test.go b/sim/tests/fx/vortex_evolution_test.go new file mode 100644 index 00000000..44f8b9e0 --- /dev/null +++ b/sim/tests/fx/vortex_evolution_test.go @@ -0,0 +1,85 @@ +package fx + +import ( + "duel-masters/game/match" + "duel-masters/tests/scenario" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // Wise Starnoid, Avatar of Hope needs a Light Bringer and a Cyber Lord. + vortexEvolutionUID = "ba894f7e-b7d7-409e-8393-cab4285a879c" + vortexEvolutionLightBringerUID = "7b58e8c2-0b1e-4ef5-812f-e667c2092c73" // Reusol, the Oracle + vortexEvolutionCyberLordUID = "7a6f1c82-a8ac-4646-b3e9-fb8592bdd0a4" // Tropico +) + +// summonVortexEvolution pays for uid with copies of itself and plays it, then +// answers the stacking-order prompt that appears once its controller has +// exactly one of each required creature: with no real choice for either +// requirement, both selections resolve on their own. +func summonVortexEvolution(t *testing.T, scn *scenario.TestScenario, player *match.PlayerReference, uid string, topID string, bottomID string) *match.Card { + t.Helper() + + card, err := player.Player.SpawnCard(uid, match.HAND) + require.NoError(t, err) + + for range card.ManaCost { + _, err := player.Player.SpawnCard(uid, match.MANAZONE) + require.NoError(t, err) + } + + require.NoError(t, scn.ActionPlayCard(player, card.ID)) + + action, err := scn.LatestAction(player, 0) + require.NoError(t, err, "expected the stacking-order prompt") + require.Len(t, action.Cards, 2) + + require.NoError(t, scn.SubmitAction(player, topID, bottomID)) + require.NoError(t, scn.WaitForEventLoop()) + + return card +} + +// TestVortexEvolutionDispatchesEvolutionEventForBothBases guards a Vortex +// Evolution's compatibility with anything that generically watches for "a +// creature evolved" (fx.CreatureEvolved) rather than a specific card: with two +// bases instead of one, both must be announced. +func TestVortexEvolutionDispatchesEvolutionEventForBothBases(t *testing.T) { + scn := scenario.New() + player := scn.Match.CurrentPlayer() + + watcher := &evolutionEventWatcher{} + observer, err := player.Player.SpawnCard(evolutionEventBaseUID, match.HAND) + require.NoError(t, err) + watcher.attach(observer) + + lightBringer := putInBattlezone(t, scn, player.Player, vortexEvolutionLightBringerUID) + cyberLord := putInBattlezone(t, scn, player.Player, vortexEvolutionCyberLordUID) + + evolution := summonVortexEvolution(t, scn, player, vortexEvolutionUID, lightBringer.ID, cyberLord.ID) + + require.Len(t, watcher.events, 2) + assert.Equal(t, evolution.ID, watcher.events[0].CardID) + assert.Equal(t, evolution.ID, watcher.events[1].CardID) + assert.ElementsMatch(t, []string{lightBringer.ID, cyberLord.ID}, []string{watcher.events[0].BaseID, watcher.events[1].BaseID}) +} + +// TestVortexEvolutionEntersTappedWhenItsTopBaseIsTapped is the vortex +// counterpart of TestEvolutionEntersTappedWhenItsBaseIsTapped: it must not +// regress the same way a plain evolution did. +func TestVortexEvolutionEntersTappedWhenItsTopBaseIsTapped(t *testing.T) { + scn := scenario.New() + player := scn.Match.CurrentPlayer() + + lightBringer := putInBattlezone(t, scn, player.Player, vortexEvolutionLightBringerUID) + cyberLord := putInBattlezone(t, scn, player.Player, vortexEvolutionCyberLordUID) + cyberLord.Tapped = true + + evolution := summonVortexEvolution(t, scn, player, vortexEvolutionUID, cyberLord.ID, lightBringer.ID) + + require.Equal(t, match.BATTLEZONE, evolution.Zone) + assert.True(t, evolution.Tapped, "it took the tap state of the base it was put on top of") +}