From 97b09a6eaed8248f5f6ceb7facec9e04ab065180 Mon Sep 17 00:00:00 2001 From: padfoothelix Date: Fri, 16 May 2025 22:18:28 +0200 Subject: [PATCH 1/3] Implement River Song's Diary --- .../src/mage/cards/r/RiverSongsDiary.java | 195 ++++++++++++++++++ Mage.Sets/src/mage/sets/DoctorWho.java | 8 +- 2 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 Mage.Sets/src/mage/cards/r/RiverSongsDiary.java diff --git a/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java new file mode 100644 index 00000000000..917600c5aa5 --- /dev/null +++ b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java @@ -0,0 +1,195 @@ +package mage.cards.r; + +import mage.MageObjectReference; +import mage.abilities.Ability; +import mage.abilities.condition.Condition; +import mage.abilities.decorator.ConditionalInterveningIfTriggeredAbility; +import mage.abilities.effects.Effect; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.ReplacementEffectImpl; +import mage.abilities.TriggeredAbilityImpl; +import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility; +import mage.cards.Card; +import mage.cards.Cards; +import mage.cards.CardImpl; +import mage.cards.CardsImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.events.ZoneChangeEvent; +import mage.game.ExileZone; +import mage.game.stack.Spell; +import mage.players.Player; +import mage.target.targetpointer.FixedTarget; +import mage.util.CardUtil; + +import java.util.UUID; + +/** + * @author padfoothelix + */ +public final class RiverSongsDiary extends CardImpl { + + public RiverSongsDiary(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ARTIFACT}, "{3}"); + + + // Imprint -- Whenever a player casts an instant or sorcery spell from their hand, exile it instead of putting it into a graveyard as it resolves. + this.addAbility(new RiverSongsDiaryImprintAbility().setAbilityWord(AbilityWord.IMPRINT)); + + // At the beginning of your upkeep, if there are four or more cards exiled with River Song's Diary, choose one of them at random. You may cast it without paying its mana cost. + this.addAbility(new ConditionalInterveningIfTriggeredAbility( + new BeginningOfUpkeepTriggeredAbility( + new RiverSongsDiaryCastEffect() + ), RiverSongsDiaryCondition.instance, + "At the beginning of your upkeep, if there are four or more cards exiled with " + + " {this}, choose one of them at random. You may cast it without paying its mana cost." + )); + + } + + private RiverSongsDiary(final RiverSongsDiary card) { + super(card); + } + + @Override + public RiverSongsDiary copy() { + return new RiverSongsDiary(this); + } +} + +enum RiverSongsDiaryCondition implements Condition { + instance; + + @Override + public boolean apply(Game game, Ability source) { + ExileZone exileZone = game.getExile().getExileZone(CardUtil.getExileZoneId( + game, source.getSourceId(), game.getState().getZoneChangeCounter(source.getSourceId()) + )); + return exileZone != null && exileZone.size() > 3; + } +} + +class RiverSongsDiaryImprintAbility extends TriggeredAbilityImpl { + + public RiverSongsDiaryImprintAbility() { + super(Zone.BATTLEFIELD, null, false); + setTriggerPhrase("Whenever a player casts an instant or sorcery spell from their hand" + + ", exile it instead of putting it into a graveyard as it resolves."); + } + + private RiverSongsDiaryImprintAbility(final RiverSongsDiaryImprintAbility ability) { + super(ability); + } + + @Override + public RiverSongsDiaryImprintAbility copy() { + return new RiverSongsDiaryImprintAbility(this); + } + + @Override + public boolean checkEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.SPELL_CAST; + } + + @Override + public boolean checkTrigger(GameEvent event, Game game) { + if (event.getZone() != Zone.HAND) { + return false; + } + Spell spell = game.getStack().getSpell(event.getTargetId()); + if (spell == null || !spell.isInstantOrSorcery(game)) { + return false; + } + this.getEffects().clear(); + this.addEffect(new RiverSongsDiaryExileEffect(spell, game)); + return true; + } +} + +class RiverSongsDiaryExileEffect extends ReplacementEffectImpl { + + private final MageObjectReference mor; + + RiverSongsDiaryExileEffect(Spell spell, Game game) { + super(Duration.OneUse, Outcome.Benefit); + this.mor = new MageObjectReference(spell.getCard(), game); + } + + private RiverSongsDiaryExileEffect(final RiverSongsDiaryExileEffect effect) { + super(effect); + this.mor = effect.mor; + } + + @Override + public boolean replaceEvent(GameEvent event, Ability source, Game game) { + Player player = game.getPlayer(source.getControllerId()); + Spell sourceSpell = game.getStack().getSpell(event.getTargetId()); + if (player == null) { + return false; + } + player.moveCardsToExile( + sourceSpell, source, game, false, + CardUtil.getExileZoneId(game, source), + CardUtil.getSourceName(game, source) + ); + return true; + } + + @Override + public boolean checksEventType(GameEvent event, Game game) { + return event.getType() == GameEvent.EventType.ZONE_CHANGE; + } + + @Override + public boolean applies(GameEvent event, Ability source, Game game) { + ZoneChangeEvent zEvent = ((ZoneChangeEvent) event); + if (zEvent.getFromZone() != Zone.STACK + || zEvent.getToZone() != Zone.GRAVEYARD + || event.getSourceId() == null + || !event.getSourceId().equals(event.getTargetId()) + || !mor.equals(new MageObjectReference(event.getTargetId(), game))) { + return false; + } + return true; + } + + @Override + public RiverSongsDiaryExileEffect copy() { + return new RiverSongsDiaryExileEffect(this); + } +} + +class RiverSongsDiaryCastEffect extends OneShotEffect { + + RiverSongsDiaryCastEffect() { + super(Outcome.Benefit); + } + + private RiverSongsDiaryCastEffect(final RiverSongsDiaryCastEffect effect) { + super(effect); + } + + @Override + public RiverSongsDiaryCastEffect copy() { + return new RiverSongsDiaryCastEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player player = game.getPlayer(source.getControllerId()); + ExileZone exileZone = game.getExile().getExileZone(CardUtil.getExileZoneId(game, source)); + if (exileZone == null || exileZone.isEmpty()) { + return false; + } + Cards cards = new CardsImpl(exileZone); + if (player == null || cards.isEmpty()) { + return false; + } + Card randomCard = cards.getRandom(game); + CardUtil.castSpellWithAttributesForFree(player, source, game, randomCard); + return true; + } +} + diff --git a/Mage.Sets/src/mage/sets/DoctorWho.java b/Mage.Sets/src/mage/sets/DoctorWho.java index e90aca9d94d..79586c65b5c 100644 --- a/Mage.Sets/src/mage/sets/DoctorWho.java +++ b/Mage.Sets/src/mage/sets/DoctorWho.java @@ -652,10 +652,10 @@ public final class DoctorWho extends ExpansionSet { cards.add(new SetCardInfo("River Song", 436, Rarity.RARE, mage.cards.r.RiverSong.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("River Song", 547, Rarity.RARE, mage.cards.r.RiverSong.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("River Song", 757, Rarity.RARE, mage.cards.r.RiverSong.class, NON_FULL_USE_VARIOUS)); - //cards.add(new SetCardInfo("River Song's Diary", 1051, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); - //cards.add(new SetCardInfo("River Song's Diary", 182, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); - //cards.add(new SetCardInfo("River Song's Diary", 460, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); - //cards.add(new SetCardInfo("River Song's Diary", 787, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("River Song's Diary", 1051, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("River Song's Diary", 182, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("River Song's Diary", 460, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("River Song's Diary", 787, Rarity.RARE, mage.cards.r.RiverSongsDiary.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("RMS Titanic", 389, Rarity.RARE, mage.cards.r.RMSTitanic.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("RMS Titanic", 698, Rarity.RARE, mage.cards.r.RMSTitanic.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("RMS Titanic", 93, Rarity.RARE, mage.cards.r.RMSTitanic.class, NON_FULL_USE_VARIOUS)); From 90ec56812df399f8744c04ba64ce9f8955c18c20 Mon Sep 17 00:00:00 2001 From: padfoothelix Date: Wed, 21 May 2025 12:16:27 +0200 Subject: [PATCH 2/3] Add hint and null test as requested --- .../src/mage/cards/r/RiverSongsDiary.java | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java index 917600c5aa5..cfdb65155c8 100644 --- a/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java +++ b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java @@ -4,9 +4,12 @@ import mage.MageObjectReference; import mage.abilities.Ability; import mage.abilities.condition.Condition; import mage.abilities.decorator.ConditionalInterveningIfTriggeredAbility; +import mage.abilities.dynamicvalue.DynamicValue; import mage.abilities.effects.Effect; import mage.abilities.effects.OneShotEffect; import mage.abilities.effects.ReplacementEffectImpl; +import mage.abilities.hint.Hint; +import mage.abilities.hint.ValueConditionHint; import mage.abilities.TriggeredAbilityImpl; import mage.abilities.triggers.BeginningOfUpkeepTriggeredAbility; import mage.cards.Card; @@ -45,7 +48,8 @@ public final class RiverSongsDiary extends CardImpl { ), RiverSongsDiaryCondition.instance, "At the beginning of your upkeep, if there are four or more cards exiled with " + " {this}, choose one of them at random. You may cast it without paying its mana cost." - )); + ).addHint(RiverSongsDiaryExiledSpellsCount.getHint())); + } @@ -64,13 +68,51 @@ enum RiverSongsDiaryCondition implements Condition { @Override public boolean apply(Game game, Ability source) { + return RiverSongsDiaryExiledSpellsCount.instance.calculate(game, source, null) > 3; + + } + + @Override + public String toString() { + return "there are four or more cards exiled with {this}"; + } + +} + +enum RiverSongsDiaryExiledSpellsCount implements DynamicValue { + instance; + + private static final Hint hint = new ValueConditionHint(instance, RiverSongsDiaryCondition.instance); + + @Override + public int calculate(Game game, Ability sourceAbility, Effect effect) { ExileZone exileZone = game.getExile().getExileZone(CardUtil.getExileZoneId( - game, source.getSourceId(), game.getState().getZoneChangeCounter(source.getSourceId()) + game, sourceAbility.getSourceId(), + game.getState().getZoneChangeCounter(sourceAbility.getSourceId()) )); - return exileZone != null && exileZone.size() > 3; + if (exileZone != null) { + return exileZone.size(); + } + return 0; + } + + @Override + public DynamicValue copy() { + return instance; + } + + @Override + public String getMessage() { + return "spells exiled with {this}"; + } + + public static Hint getHint() { + return hint; } } + + class RiverSongsDiaryImprintAbility extends TriggeredAbilityImpl { public RiverSongsDiaryImprintAbility() { @@ -126,7 +168,7 @@ class RiverSongsDiaryExileEffect extends ReplacementEffectImpl { public boolean replaceEvent(GameEvent event, Ability source, Game game) { Player player = game.getPlayer(source.getControllerId()); Spell sourceSpell = game.getStack().getSpell(event.getTargetId()); - if (player == null) { + if (player == null || sourceSpell == null || sourceSpell.isCopy()) { return false; } player.moveCardsToExile( From 15dd149c678aef295aa9a446faf1960dfcf77058 Mon Sep 17 00:00:00 2001 From: padfoothelix Date: Wed, 18 Jun 2025 11:15:49 +0200 Subject: [PATCH 3/3] Fix River Song's Diary logic to work with MDFC and split cards --- .../src/mage/cards/r/RiverSongsDiary.java | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java index cfdb65155c8..589224b022f 100644 --- a/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java +++ b/Mage.Sets/src/mage/cards/r/RiverSongsDiary.java @@ -3,7 +3,6 @@ package mage.cards.r; import mage.MageObjectReference; import mage.abilities.Ability; import mage.abilities.condition.Condition; -import mage.abilities.decorator.ConditionalInterveningIfTriggeredAbility; import mage.abilities.dynamicvalue.DynamicValue; import mage.abilities.effects.Effect; import mage.abilities.effects.OneShotEffect; @@ -24,7 +23,6 @@ import mage.game.events.ZoneChangeEvent; import mage.game.ExileZone; import mage.game.stack.Spell; import mage.players.Player; -import mage.target.targetpointer.FixedTarget; import mage.util.CardUtil; import java.util.UUID; @@ -42,15 +40,12 @@ public final class RiverSongsDiary extends CardImpl { this.addAbility(new RiverSongsDiaryImprintAbility().setAbilityWord(AbilityWord.IMPRINT)); // At the beginning of your upkeep, if there are four or more cards exiled with River Song's Diary, choose one of them at random. You may cast it without paying its mana cost. - this.addAbility(new ConditionalInterveningIfTriggeredAbility( - new BeginningOfUpkeepTriggeredAbility( - new RiverSongsDiaryCastEffect() - ), RiverSongsDiaryCondition.instance, - "At the beginning of your upkeep, if there are four or more cards exiled with " + - " {this}, choose one of them at random. You may cast it without paying its mana cost." - ).addHint(RiverSongsDiaryExiledSpellsCount.getHint())); - - + this.addAbility(new BeginningOfUpkeepTriggeredAbility( + new RiverSongsDiaryCastEffect() + .setText("choose one of them at random. You may cast it without paying its mana cost.") + ).withInterveningIf(RiverSongsDiaryCondition.instance) + .addHint(RiverSongsDiaryExiledSpellsCount.getHint()) + ); } private RiverSongsDiary(final RiverSongsDiary card) { @@ -76,7 +71,6 @@ enum RiverSongsDiaryCondition implements Condition { public String toString() { return "there are four or more cards exiled with {this}"; } - } enum RiverSongsDiaryExiledSpellsCount implements DynamicValue { @@ -152,22 +146,26 @@ class RiverSongsDiaryImprintAbility extends TriggeredAbilityImpl { class RiverSongsDiaryExileEffect extends ReplacementEffectImpl { - private final MageObjectReference mor; + // we store both Spell and Card to work properly on split cards + private final MageObjectReference morSpell; + private final MageObjectReference morCard; RiverSongsDiaryExileEffect(Spell spell, Game game) { super(Duration.OneUse, Outcome.Benefit); - this.mor = new MageObjectReference(spell.getCard(), game); + this.morSpell = new MageObjectReference(spell.getCard(), game); + this.morCard = new MageObjectReference(spell.getMainCard(), game); } private RiverSongsDiaryExileEffect(final RiverSongsDiaryExileEffect effect) { super(effect); - this.mor = effect.mor; + this.morSpell = effect.morSpell; + this.morCard = effect.morCard; } @Override public boolean replaceEvent(GameEvent event, Ability source, Game game) { Player player = game.getPlayer(source.getControllerId()); - Spell sourceSpell = game.getStack().getSpell(event.getTargetId()); + Spell sourceSpell = morSpell.getSpell(game); if (player == null || sourceSpell == null || sourceSpell.isCopy()) { return false; } @@ -187,14 +185,10 @@ class RiverSongsDiaryExileEffect extends ReplacementEffectImpl { @Override public boolean applies(GameEvent event, Ability source, Game game) { ZoneChangeEvent zEvent = ((ZoneChangeEvent) event); - if (zEvent.getFromZone() != Zone.STACK - || zEvent.getToZone() != Zone.GRAVEYARD - || event.getSourceId() == null - || !event.getSourceId().equals(event.getTargetId()) - || !mor.equals(new MageObjectReference(event.getTargetId(), game))) { - return false; - } - return true; + return Zone.STACK.equals(zEvent.getFromZone()) + && Zone.GRAVEYARD.equals(zEvent.getToZone()) + && morSpell.refersTo(event.getSourceId(), game) // this is how we check that the spell resolved properly (and was not countered or the like) + && morCard.refersTo(event.getTargetId(), game); // this is how we check that the card being moved is the one we want. } @Override