Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sim/game/cards/dm03/spells.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ func BoomerangComet(c *match.Card) {
ctx.Match.ReportActionInChat(x.Player, fmt.Sprintf("%s retrieved %s from the mana zone to their hand", x.Player.Username(), x.Name))
})

card.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, card.ID)
// fx.Spell already moved this card to the graveyard as part of
// casting it, before this effect ran, so relocate it from there.
card.Player.MoveCard(card.ID, match.GRAVEYARD, match.MANAZONE, card.ID)
}))

}
Expand Down
8 changes: 4 additions & 4 deletions sim/game/cards/dm04/spells.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ func MegaDetonator(c *match.Card) {

c.Use(fx.Spell, fx.When(fx.SpellCast, func(card *match.Card, ctx *match.Context) {

handLen := len(fx.Find(card.Player, match.HAND)) - 1
// fx.Spell already moved this card to the graveyard before this
// effect ran, so the hand it counts here never includes itself.
handLen := len(fx.Find(card.Player, match.HAND))

chosenNumber := 0

fx.SelectFilter(
fx.Select(
card.Player,
ctx.Match,
card.Player,
Expand All @@ -92,8 +94,6 @@ func MegaDetonator(c *match.Card) {
1,
handLen,
true,
func(c *match.Card) bool { return c.ID != card.ID },
false,
).Map(func(x *match.Card) {
x.Player.MoveCard(x.ID, match.HAND, match.GRAVEYARD, card.ID)
ctx.Match.ReportActionInChat(x.Player, fmt.Sprintf("%s was moved to %s's graveyard from their hand by %s", x.Name, card.Player.Username(), card.Name))
Expand Down
9 changes: 5 additions & 4 deletions sim/game/cards/dm07/hedrian.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ func PropellerMutant(c *match.Card) {

c.Use(fx.Creature, fx.When(fx.Destroyed, func(card *match.Card, ctx *match.Context) {

// Reimplementing here OpponentDiscardsRandomCard with a twist. Since the discard is activated when this creature is destroyed,
// if it is destoryed by a spell, witouht this, there is a chance you would discard that spell from the hand since we only
// move the card from hand after its effect was applied.
// The proper solution would be to use a spellzone for when spells are active. This is a work around.
// Reimplementing here OpponentDiscardsRandomCard with a twist: if this
// creature was destroyed by a spell, that spell has already left the
// destroying player's hand (fx.Spell moves a cast spell to the
// graveyard immediately), so event.Source is never actually a
// candidate any more. Kept as a defensive guard rather than relied on.

event, ok := ctx.Event.(*match.CardMoved)
if !ok {
Expand Down
13 changes: 9 additions & 4 deletions sim/game/cards/dm08/earth_dragon.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,19 @@ func SuperTerradragonBailasGale(c *match.Card) {

if p == c.Player {

spell, err := p.GetCard(event.CardID, match.HAND)
// fx.Spell already moved the cast spell to the graveyard as
// part of casting it, before this card's own resolution
// finished, so relocate it from there instead of the hand.
spell, err := p.GetCard(event.CardID, match.GRAVEYARD)
if err != nil {
return
}

// prevents card from being sent to grave
// uses the fact that cards in the battlezone are handled before ones in hand
ctx.InterruptFlow()
moved, err := p.MoveCard(spell.ID, match.GRAVEYARD, match.HAND, card.ID)
if err != nil || moved.Zone != match.HAND {
return
}

ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s was returned to the hand instead of graveyard by %s", spell.Name, c.Name))

}
Expand Down
12 changes: 5 additions & 7 deletions sim/game/cards/dm11/spells.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,11 @@ func EmergencyTyphoon(c *match.Card) {
c.Use(fx.Spell, fx.ShieldTrigger,
fx.When(fx.SpellCast, fx.DrawUpTo2),
fx.When(fx.SpellResolved, func(card *match.Card, ctx *match.Context) {
// Scheduled to run after fx.Spell's own SpellResolved handler, which moves
// this card from hand to the graveyard as part of the same event. That
// ordering matters: Emergency Typhoon has already left the hand by the
// time the discard prompt opens, so it can't be chosen for its own
// "discard a card from your hand" effect. This also runs independently of
// the draw above, matching the printed text: drawing none still costs a
// card.
// fx.Spell already moved this card to the graveyard as part of
// casting it, well before SpellResolved fires, so it can't be
// chosen for its own "discard a card from your hand" effect. This
// also runs independently of the draw above, matching the printed
// text: drawing none still costs a card.
ctx.ScheduleAfter(func() {
fx.DiscardOwnXCards(1)(card, ctx)
})
Expand Down
12 changes: 4 additions & 8 deletions sim/game/cards/dm12/spells.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,16 @@ func EnigmaticCascade(c *match.Card) {
c.ManaRequirement = []string{civ.Water}

c.Use(fx.Spell, fx.When(fx.SpellCast, func(card *match.Card, ctx *match.Context) {
// A spell is still in hand while it resolves, so it has to be kept out
// of its own offer.
notItself := func(x *match.Card) bool { return x.ID != card.ID }

others := fx.FindFilter(card.Player, match.HAND, notItself)
// fx.Spell already moved this card to the graveyard before this
// effect ran, so the hand it counts here never includes itself.
others := fx.Find(card.Player, match.HAND)

if len(others) < 1 {
return
}

// "Any number" includes none, so the whole thing is declinable.
discarded := fx.SelectFilter(
discarded := fx.Select(
card.Player,
ctx.Match,
card.Player,
Expand All @@ -40,8 +38,6 @@ func EnigmaticCascade(c *match.Card) {
1,
len(others),
true,
notItself,
false,
)

drawn := 0
Expand Down
6 changes: 4 additions & 2 deletions sim/game/fx/charger.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
)

func Charger(card *match.Card, ctx *match.Context) {
// After you cast this spell, put it into your mana zone instead of your graveyard.
// After you cast this spell, put it into your mana zone instead of your
// graveyard. fx.Spell already moved it to the graveyard by the time it
// finished resolving, so relocate it from there.
if event, ok := ctx.Event.(*match.SpellResolved); ok && event.CardID == card.ID {
card.Player.MoveCard(card.ID, match.HAND, match.MANAZONE, card.ID)
card.Player.MoveCard(card.ID, match.GRAVEYARD, match.MANAZONE, card.ID)
card.Tapped = false
ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s was put in the mana zone instead of your graveyard", card.Name))
}
Expand Down
3 changes: 2 additions & 1 deletion sim/game/fx/common_effects.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ func ShuffleDeck(card *match.Card, ctx *match.Context, forOpponent bool) {

// SwapHandAndMana simultaneously exchanges the cards that were in player's
// hand and mana zone when the effect began. Cards entering the mana zone are
// tapped. If source is still in hand while resolving, it is not moved.
// tapped. source is excluded defensively in case a caller ever invokes this
// while its own card is still in hand.
func SwapHandAndMana(source *match.Card, player *match.Player) {
manaCards := Find(player, match.MANAZONE)
handCards := FindFilter(player, match.HAND, func(card *match.Card) bool {
Expand Down
25 changes: 11 additions & 14 deletions sim/game/fx/spell.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ func Spell(card *match.Card, ctx *match.Context) {
return
}

// Once a spell is cast it is no longer in hand, by rule, even while its
// own text is still resolving: something that spell's effect puts into
// play can trigger another card's own ability re-entrantly (a creature's
// "put into the battle zone" trigger, for instance), and that nested
// ability must see this card already sitting in the graveyard rather
// than mid-cast in hand. Move it synchronously, before the card's own
// effect body (registered after fx.Spell in the same c.Use chain) runs.
// A card whose text sends it elsewhere instead of the graveyard
// (fx.Charger, etc.) relocates it again once SpellResolved fires below.
card.Player.MoveCard(card.ID, match.HAND, match.GRAVEYARD, card.ID)

ctx.Match.ReportActionInChat(card.Player, fmt.Sprintf("%s casted the spell %s", card.Player.Username(), card.Name))

ctx.ScheduleAfter(func() {
Expand All @@ -143,18 +154,4 @@ func Spell(card *match.Card, ctx *match.Context) {

}

// On spell resolved
if event, ok := ctx.Event.(*match.SpellResolved); ok {

// Is this event for me or someone else?
if event.CardID != card.ID {
return
}

ctx.ScheduleAfter(func() {
card.Player.MoveCard(card.ID, match.HAND, match.GRAVEYARD, card.ID)
})

}

}
56 changes: 56 additions & 0 deletions sim/tests/cards/boomerang_comet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cards

import (
"duel-masters/game/civ"
"duel-masters/game/match"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const boomerangCometUID = "b275fbf0-5355-45ec-b3a8-a956cf898ae6"

func TestBoomerangComet(t *testing.T) {
t.Run("printed characteristics", func(t *testing.T) {
_, player, _ := setupDuel(t)

spell, err := player.Player.SpawnCard(boomerangCometUID, match.HAND)
require.NoError(t, err)

assert.Equal(t, "Boomerang Comet", spell.Name)
assert.Equal(t, 6, spell.ManaCost)
assert.Equal(t, []string{civ.Light}, spell.Civs)
})

t.Run("returns a card from mana to hand and itself ends up in the mana zone", func(t *testing.T) {
// Regression test: this card moves itself from hand to the mana zone
// with a hand-rolled MoveCard rather than fx.Charger. Before it was
// updated to move from the graveyard, fx.Spell having already sent it
// there first made this call fail silently, stranding it in the
// graveyard instead of the mana zone.
scn, player, _ := setupDuel(t)

manaCard, err := player.Player.SpawnCard(scowlingTomatoUID, match.MANAZONE)
require.NoError(t, err)

promptStart, err := scn.MessageCount(player)
require.NoError(t, err)

spell := castSpell(t, scn, player, boomerangCometUID)

action, err := scn.LatestAction(player, promptStart)
require.NoError(t, err, "expected the mana-zone selection prompt to be open")
offeredCardIDs := make([]string, 0, len(action.Cards))
for _, offered := range action.Cards {
offeredCardIDs = append(offeredCardIDs, offered.CardID)
}
require.Contains(t, offeredCardIDs, manaCard.ID)

require.NoError(t, scn.SubmitAction(player, manaCard.ID))
require.NoError(t, scn.WaitForEventLoop())

assert.Equal(t, match.HAND, manaCard.Zone)
assert.Equal(t, match.MANAZONE, spell.Zone, "Boomerang Comet goes to the mana zone instead of the graveyard")
})
}
62 changes: 62 additions & 0 deletions sim/tests/cards/mega_detonator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cards

import (
"duel-masters/game/civ"
"duel-masters/game/match"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const megaDetonatorUID = "e6c76df1-24c8-4125-9f9f-8ae3b2bc61f6"

func TestMegaDetonator(t *testing.T) {
t.Run("printed characteristics", func(t *testing.T) {
_, player, _ := setupDuel(t)

spell, err := player.Player.SpawnCard(megaDetonatorUID, match.HAND)
require.NoError(t, err)

assert.Equal(t, "Mega Detonator", spell.Name)
assert.Equal(t, 2, spell.ManaCost)
assert.Equal(t, []string{civ.Fire}, spell.Civs)
})

t.Run("allows discarding every remaining card in hand", func(t *testing.T) {
// Regression test: the maximum discard count used to be computed as
// (hand size) - 1, to compensate for Mega Detonator counting itself
// while still in hand. Now that fx.Spell moves it to the graveyard
// before this effect runs, that subtraction undercounts the true
// remaining hand by one.
scn, player, _ := setupDuel(t)

emptyHand(t, player, "mega_detonator_test_setup")
fillers := make([]*match.Card, 0, 3)
for range 3 {
c, err := player.Player.SpawnCard(scowlingTomatoUID, match.HAND)
require.NoError(t, err)
fillers = append(fillers, c)
}

promptStart, err := scn.MessageCount(player)
require.NoError(t, err)

castSpell(t, scn, player, megaDetonatorUID)

action, err := scn.LatestAction(player, promptStart)
require.NoError(t, err, "expected the discard-selection prompt to be open")
assert.Equal(t, 3, action.MaxSelections, "all 3 remaining hand cards should be discardable, not 2")

fillerIDs := make([]string, 0, len(fillers))
for _, filler := range fillers {
fillerIDs = append(fillerIDs, filler.ID)
}
require.NoError(t, scn.SubmitAction(player, fillerIDs...))
require.NoError(t, scn.WaitForEventLoop())

for _, filler := range fillers {
assert.Equal(t, match.GRAVEYARD, filler.Zone)
}
})
}
Loading
Loading