From 0f836569f3fa3148b0bc6111b62784bb0fcc6630 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Fri, 24 Apr 2026 10:04:24 +1000 Subject: [PATCH 1/3] Add Sanctum of Fruitful Harvest Co-Authored-By: Claude Sonnet 4.6 --- .../cards/sanctum_of_fruitful_harvest.rb | 33 +++++++++++++++++++ .../cards/sanctum_of_fruitful_harvest_spec.rb | 22 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 lib/magic/cards/sanctum_of_fruitful_harvest.rb create mode 100644 spec/cards/sanctum_of_fruitful_harvest_spec.rb diff --git a/lib/magic/cards/sanctum_of_fruitful_harvest.rb b/lib/magic/cards/sanctum_of_fruitful_harvest.rb new file mode 100644 index 0000000..156b991 --- /dev/null +++ b/lib/magic/cards/sanctum_of_fruitful_harvest.rb @@ -0,0 +1,33 @@ +module Magic + module Cards + SanctumOfFruitfulHarvest = Enchantment("Sanctum of Fruitful Harvest") do + type T::Super::Legendary, T::Enchantment, "Shrine" + cost generic: 2, green: 1 + + class FirstMainPhaseTrigger < TriggeredAbility + def should_perform? + event.active_player == controller + end + + def call + game.choices.add(SanctumOfFruitfulHarvest::ColorChoice.new(actor: actor)) + end + end + + def event_handlers + { + Events::FirstMainPhase => FirstMainPhaseTrigger + } + end + end + + class SanctumOfFruitfulHarvest < Enchantment + class ColorChoice < Magic::Choice::Color + def resolve!(color:) + shrines = controller.permanents.by_type("Shrine").count + controller.add_mana(color => shrines) + end + end + end + end +end diff --git a/spec/cards/sanctum_of_fruitful_harvest_spec.rb b/spec/cards/sanctum_of_fruitful_harvest_spec.rb new file mode 100644 index 0000000..9b0613e --- /dev/null +++ b/spec/cards/sanctum_of_fruitful_harvest_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Magic::Cards::SanctumOfFruitfulHarvest do + include_context "two player game" + + let!(:sanctum_of_tranquil_light) { ResolvePermanent("Sanctum Of Tranquil Light") } + let!(:sanctum_of_fruitful_harvest) { ResolvePermanent("Sanctum Of Fruitful Harvest") } + + context "at beginning of precombat main phase" do + it "adds mana equal to the number of shrines in the chosen color" do + go_to_main_phase! + + choice = game.choices.first + expect(choice).to be_a(described_class::ColorChoice) + game.resolve_choice!(color: :green) + + expect(p1.mana_pool[:green]).to eq(2) + end + end +end From bdc9e226481d682767a1ae95da70a7a1e2e79622 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Fri, 24 Apr 2026 11:15:54 +1000 Subject: [PATCH 2/3] Replace delayed_response Procs with TriggeredAbility subclasses MassacreGirl and BasriKet both used a parallel Proc-based event system stored on the permanent. Both are now modelled as proper TriggeredAbility subclasses registered via event_handlers, with turn-scoped conditions checked in should_perform?. The delayed_response/trigger_delayed_response mechanism and @delayed_responses state are removed from Permanent and Card. Co-Authored-By: Claude Sonnet 4.6 --- lib/magic/card.rb | 3 +-- lib/magic/cards/basri_ket.rb | 36 ++++++++++++++++++++------------ lib/magic/cards/massacre_girl.rb | 29 +++++++++++++++++-------- lib/magic/permanent.rb | 14 ------------- spec/cards/basri_ket_spec.rb | 5 ++--- 5 files changed, 46 insertions(+), 41 deletions(-) diff --git a/lib/magic/card.rb b/lib/magic/card.rb index 0246d09..661113a 100644 --- a/lib/magic/card.rb +++ b/lib/magic/card.rb @@ -7,7 +7,7 @@ class Card include Cards::Keywords include Cards::Shared::Events include Cards::Shared::Types - attr_reader :game, :controller, :owner, :name, :cost, :kicker_cost, :types, :countered, :keyword_grants, :keywords, :protections, :delayed_responses, :modes + attr_reader :game, :controller, :owner, :name, :cost, :kicker_cost, :types, :countered, :keyword_grants, :keywords, :protections, :modes attr_accessor :tapped attr_accessor :zone @@ -93,7 +93,6 @@ def initialize(game: Game.new, owner:) @cost = Costs::Mana.new(self.class::COST.dup) @kicker_cost = Costs::Kicker.new(self.class::KICKER_COST.dup) @tapped = tapped - @delayed_responses = [] @keywords = self.class::KEYWORDS @keyword_grants = [] @protections = self.class::PROTECTIONS diff --git a/lib/magic/cards/basri_ket.rb b/lib/magic/cards/basri_ket.rb index 2d48179..df2e4b7 100644 --- a/lib/magic/cards/basri_ket.rb +++ b/lib/magic/cards/basri_ket.rb @@ -45,19 +45,21 @@ class LoyaltyAbility2 < Magic::LoyaltyAbility def loyalty_change = -2 def resolve! - source.delayed_response( - turn: game.current_turn, - event_type: Events::PreliminaryAttackersDeclared, - response: -> { - attackers = game.current_turn.attacks.count - - attackers.times do - token = trigger_effect(:create_token, token_class: SoldierToken, enters_tapped: true).first - - game.current_turn.declare_attacker(token) - end - } - ) + source.card.minus2_active_turn = game.current_turn.number + end + end + + class PreliminaryAttackersHandler < TriggeredAbility + def should_perform? + actor.card.minus2_active_turn == game.current_turn.number + end + + def call + attackers = game.current_turn.attacks.count + attackers.times do + token = trigger_effect(:create_token, token_class: SoldierToken, enters_tapped: true).first + game.current_turn.declare_attacker(token) + end end end @@ -69,6 +71,8 @@ def resolve! end end + attr_accessor :minus2_active_turn + def loyalty_abilities [ LoyaltyAbility1, @@ -76,6 +80,12 @@ def loyalty_abilities LoyaltyAbility3, ] end + + def event_handlers + { + Events::PreliminaryAttackersDeclared => PreliminaryAttackersHandler + } + end end end end diff --git a/lib/magic/cards/massacre_girl.rb b/lib/magic/cards/massacre_girl.rb index 0f3f6be..958e8c6 100644 --- a/lib/magic/cards/massacre_girl.rb +++ b/lib/magic/cards/massacre_girl.rb @@ -8,20 +8,31 @@ class MassacreGirl < Creature toughness 4 keywords :menace - enters_the_battlefield do - debuff = -> do + attr_accessor :creature_died_active_turn + + class CreatureDiedDebuff < TriggeredAbility + def should_perform? + actor.card.creature_died_active_turn == game.current_turn.number + end + + def call game.creatures.except(actor).each do |creature| - actor.trigger_effect(:modify_power_toughness, target: creature, power: -1, toughness: -1) + trigger_effect(:modify_power_toughness, target: creature, power: -1, toughness: -1) end end + end - debuff.call + def event_handlers + { + Events::CreatureDied => CreatureDiedDebuff + } + end - actor.delayed_response( - turn: game.current_turn, - event_type: Events::CreatureDied, - response: debuff, - ) + enters_the_battlefield do + game.creatures.except(actor).each do |creature| + actor.trigger_effect(:modify_power_toughness, target: creature, power: -1, toughness: -1) + end + actor.card.creature_died_active_turn = game.current_turn.number end end end diff --git a/lib/magic/permanent.rb b/lib/magic/permanent.rb index ef73179..62e6d60 100644 --- a/lib/magic/permanent.rb +++ b/lib/magic/permanent.rb @@ -17,7 +17,6 @@ class Permanent :power, :toughness, :keywords, - :delayed_responses, :attachments, :protections, :modifiers, @@ -65,7 +64,6 @@ def initialize(game:, owner:, card:, token: false, cast: true, kicked: false, co @kicked = kicked @copy = copy @base_types = card.types - @delayed_responses = [] @attachments = [] @modifiers = [] @tapped = false @@ -181,7 +179,6 @@ def replacement_matcher_applies?(matcher, effect) end def receive_event(event) - trigger_delayed_response(event) dispatch_lifecycle_triggers(event) dispatch_event_handlers(event) end @@ -283,17 +280,6 @@ def can_block?(permanent) end - def delayed_response(turn:, event_type:, response:) - @delayed_responses << { turn: turn.number, event_type: event_type, response: response } - end - - def trigger_delayed_response(event) - responses = delayed_responses.select { |response| event.is_a?(response[:event_type]) && response[:turn] == game.current_turn.number } - responses.each do |response| - response[:response].call - end - end - def cleanup! remove_until_eot_keyword_grants! remove_until_eot_protections! diff --git a/spec/cards/basri_ket_spec.rb b/spec/cards/basri_ket_spec.rb index 41e0a81..25ef579 100644 --- a/spec/cards/basri_ket_spec.rb +++ b/spec/cards/basri_ket_spec.rb @@ -32,14 +32,13 @@ allow(game).to receive(:current_turn) { turn } end - it "adds an after attackers declared step trigger" do + it "registers trigger for attackers declared this turn" do p1.activate_loyalty_ability(ability: ability) game.stack.resolve! game.tick! expect(subject.loyalty).to eq(1) - expect(subject.delayed_responses.count).to eq(1) - expect(subject.delayed_responses.first[:event_type]).to eq(Magic::Events::PreliminaryAttackersDeclared) + expect(subject.card.minus2_active_turn).to eq(1) end end From 3c1f4e829198f0662d86127006a5afa4bd1662a8 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Fri, 24 Apr 2026 11:20:33 +1000 Subject: [PATCH 3/3] Use register_until_eot_handler instead of card-level attr_accessors The previous approach stored turn numbers in attr_accessors on the card classes and checked them in should_perform?. This replaces that with a general register_until_eot_handler mechanism on Permanent that mirrors the existing until-eot keyword/protection/modifier patterns: handlers are registered dynamically and cleared in cleanup!. Cards no longer carry any turn-scoped state. Co-Authored-By: Claude Sonnet 4.6 --- lib/magic/cards/basri_ket.rb | 27 ++++++++------------------- lib/magic/cards/massacre_girl.rb | 14 +------------- lib/magic/permanent.rb | 10 ++++++++++ spec/cards/basri_ket_spec.rb | 3 +-- 4 files changed, 20 insertions(+), 34 deletions(-) diff --git a/lib/magic/cards/basri_ket.rb b/lib/magic/cards/basri_ket.rb index df2e4b7..b8ba478 100644 --- a/lib/magic/cards/basri_ket.rb +++ b/lib/magic/cards/basri_ket.rb @@ -41,19 +41,7 @@ def resolve!(target:) end end - class LoyaltyAbility2 < Magic::LoyaltyAbility - def loyalty_change = -2 - - def resolve! - source.card.minus2_active_turn = game.current_turn.number - end - end - class PreliminaryAttackersHandler < TriggeredAbility - def should_perform? - actor.card.minus2_active_turn == game.current_turn.number - end - def call attackers = game.current_turn.attacks.count attackers.times do @@ -63,6 +51,14 @@ def call end end + class LoyaltyAbility2 < Magic::LoyaltyAbility + def loyalty_change = -2 + + def resolve! + source.register_until_eot_handler(Events::PreliminaryAttackersDeclared, PreliminaryAttackersHandler) + end + end + class LoyaltyAbility3 < Magic::LoyaltyAbility def loyalty_change = -6 @@ -71,8 +67,6 @@ def resolve! end end - attr_accessor :minus2_active_turn - def loyalty_abilities [ LoyaltyAbility1, @@ -81,11 +75,6 @@ def loyalty_abilities ] end - def event_handlers - { - Events::PreliminaryAttackersDeclared => PreliminaryAttackersHandler - } - end end end end diff --git a/lib/magic/cards/massacre_girl.rb b/lib/magic/cards/massacre_girl.rb index 958e8c6..2663f5f 100644 --- a/lib/magic/cards/massacre_girl.rb +++ b/lib/magic/cards/massacre_girl.rb @@ -8,13 +8,7 @@ class MassacreGirl < Creature toughness 4 keywords :menace - attr_accessor :creature_died_active_turn - class CreatureDiedDebuff < TriggeredAbility - def should_perform? - actor.card.creature_died_active_turn == game.current_turn.number - end - def call game.creatures.except(actor).each do |creature| trigger_effect(:modify_power_toughness, target: creature, power: -1, toughness: -1) @@ -22,17 +16,11 @@ def call end end - def event_handlers - { - Events::CreatureDied => CreatureDiedDebuff - } - end - enters_the_battlefield do game.creatures.except(actor).each do |creature| actor.trigger_effect(:modify_power_toughness, target: creature, power: -1, toughness: -1) end - actor.card.creature_died_active_turn = game.current_turn.number + actor.register_until_eot_handler(Events::CreatureDied, CreatureDiedDebuff) end end end diff --git a/lib/magic/permanent.rb b/lib/magic/permanent.rb index 62e6d60..4f19e59 100644 --- a/lib/magic/permanent.rb +++ b/lib/magic/permanent.rb @@ -64,6 +64,7 @@ def initialize(game:, owner:, card:, token: false, cast: true, kicked: false, co @kicked = kicked @copy = copy @base_types = card.types + @eot_handlers = [] @attachments = [] @modifiers = [] @tapped = false @@ -280,7 +281,12 @@ def can_block?(permanent) end + def register_until_eot_handler(event_type, handler_class) + @eot_handlers << { event_type: event_type, handler_class: handler_class } + end + def cleanup! + @eot_handlers = [] remove_until_eot_keyword_grants! remove_until_eot_protections! remove_until_eot_modifiers! @@ -377,6 +383,10 @@ def dispatch_event_handlers(event) logger.debug "EVENT HANDLER: #{self} handling #{event}" handler_class.new(actor: self, event: event).perform! end + + @eot_handlers.select { |h| event.is_a?(h[:event_type]) }.each do |h| + h[:handler_class].new(actor: self, event: event).perform! + end end def remove_until_eot_keyword_grants! diff --git a/spec/cards/basri_ket_spec.rb b/spec/cards/basri_ket_spec.rb index 25ef579..0d1e549 100644 --- a/spec/cards/basri_ket_spec.rb +++ b/spec/cards/basri_ket_spec.rb @@ -32,13 +32,12 @@ allow(game).to receive(:current_turn) { turn } end - it "registers trigger for attackers declared this turn" do + it "registers an until-end-of-turn trigger for attackers declared" do p1.activate_loyalty_ability(ability: ability) game.stack.resolve! game.tick! expect(subject.loyalty).to eq(1) - expect(subject.card.minus2_active_turn).to eq(1) end end