From ca52d62a4134818f86e871bbd5f2c55ae4045e46 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Fri, 2 Feb 2024 09:32:40 +1100 Subject: [PATCH] Add Teferi, Master of Time Fixes #85 --- lib/magic/ability.rb | 29 +++++++++++ lib/magic/actions/activate_loyalty_ability.rb | 21 +++----- lib/magic/activated_ability.rb | 28 +--------- lib/magic/card_list.rb | 4 ++ lib/magic/cards/basri_ket.rb | 6 +-- lib/magic/cards/planeswalker.rb | 4 ++ lib/magic/cards/shared/events.rb | 2 + lib/magic/cards/teferi_master_of_time.rb | 47 +++++++++++++++++ lib/magic/cards/ugin_the_spirit_dragon.rb | 6 +-- lib/magic/choice/discard.rb | 2 +- lib/magic/effects/phase_out.rb | 10 ++++ lib/magic/game.rb | 28 +++++++--- lib/magic/game/turn.rb | 1 + lib/magic/loyalty_ability.rb | 8 +-- lib/magic/permanent.rb | 13 +++++ lib/magic/permanents/planeswalker.rb | 2 +- lib/magic/zones/battlefield.rb | 2 +- spec/cards/academy_elite_spec.rb | 2 +- spec/cards/capture_sphere_spec.rb | 2 - spec/cards/frost_breath_spec.rb | 2 - spec/cards/rain_of_revelation_spec.rb | 4 +- spec/cards/rousing_read_spec.rb | 3 +- spec/cards/sanctum_of_calm_waters_spec.rb | 2 +- spec/cards/teferi_master_of_time_spec.rb | 51 +++++++++++++++++++ spec/game/integration/turns_spec.rb | 2 +- spec/game/integration/untap_spec.rb | 17 +++++++ .../integration/until_end_of_turn_spec.rb | 4 -- spec/game_spec.rb | 33 ++++++++++++ 28 files changed, 256 insertions(+), 79 deletions(-) create mode 100644 lib/magic/ability.rb create mode 100644 lib/magic/cards/teferi_master_of_time.rb create mode 100644 lib/magic/effects/phase_out.rb create mode 100644 spec/cards/teferi_master_of_time_spec.rb create mode 100644 spec/game/integration/untap_spec.rb create mode 100644 spec/game_spec.rb diff --git a/lib/magic/ability.rb b/lib/magic/ability.rb new file mode 100644 index 00000000..7b73944b --- /dev/null +++ b/lib/magic/ability.rb @@ -0,0 +1,29 @@ +module Magic + class Ability + attr_reader :source + + def initialize(source:) + @source = source + end + + def game + source.game + end + + def battlefield + game.battlefield + end + + def controller + source.controller + end + + def trigger_effect(effect, **args) + source.trigger_effect(effect, source: self, **args) + end + + def add_choice(choice, **args) + source.add_choice(choice, **args) + end + end +end diff --git a/lib/magic/actions/activate_loyalty_ability.rb b/lib/magic/actions/activate_loyalty_ability.rb index a0f80214..3d930108 100644 --- a/lib/magic/actions/activate_loyalty_ability.rb +++ b/lib/magic/actions/activate_loyalty_ability.rb @@ -4,7 +4,7 @@ class ActivateLoyaltyAbility < Action attr_reader :ability, :planeswalker, :targets, :x_value def initialize(ability:, **args) - @planeswalker = ability.planeswalker + @planeswalker = ability.source @ability = ability @targets = [] super(**args) @@ -44,19 +44,12 @@ def perform end def resolve! - if targets.any? - if ability.single_target? - ability.resolve!(target: targets.first) - else - ability.resolve!(targets: targets) - end - else - if x_value - ability.resolve!(value_for_x: x_value) - else - ability.resolve! - end - end + resolver = ability.method(:resolve!) + args = {} + args[:target] = targets.first if resolver.parameters.include?([:keyreq, :target]) + args[:targets] = targets if resolver.parameters.include?([:keyreq, :targets]) + args[:value_for_x] = x_value if x_value && resolver.parameters.include?([:keyreq, :value_for_x]) + ability.resolve!(**args) end end end diff --git a/lib/magic/activated_ability.rb b/lib/magic/activated_ability.rb index 3bfbed04..074f0f08 100644 --- a/lib/magic/activated_ability.rb +++ b/lib/magic/activated_ability.rb @@ -1,11 +1,5 @@ module Magic - class ActivatedAbility - attr_reader :source - - def initialize(source:) - @source = source - end - + class ActivatedAbility < Ability def valid_targets?(*targets) targets.all? { target_choices.include?(_1) } end @@ -13,25 +7,5 @@ def valid_targets?(*targets) def costs @costs || self.class::COSTS.dup end - - def game - source.game - end - - def battlefield - game.battlefield - end - - def controller - source.controller - end - - def trigger_effect(effect, **args) - source.trigger_effect(effect, source: self, **args) - end - - def add_choice(choice, **args) - source.add_choice(choice, **args) - end end end diff --git a/lib/magic/card_list.rb b/lib/magic/card_list.rb index 26f4fbd6..ae4f8ca8 100644 --- a/lib/magic/card_list.rb +++ b/lib/magic/card_list.rb @@ -1,5 +1,9 @@ module Magic class CardList < SimpleDelegator + def phased_out + select(&:phased_out?) + end + def controlled_by(player) select { |card| card.controller == player } end diff --git a/lib/magic/cards/basri_ket.rb b/lib/magic/cards/basri_ket.rb index 9c142c50..271a0ddb 100644 --- a/lib/magic/cards/basri_ket.rb +++ b/lib/magic/cards/basri_ket.rb @@ -45,14 +45,14 @@ class LoyaltyAbility2 < Magic::LoyaltyAbility def loyalty_change = -2 def resolve! - planeswalker.delayed_response( + source.delayed_response( turn: game.current_turn, event_type: Events::PreliminaryAttackersDeclared, response: -> { attackers = game.current_turn.attacks.count attackers.times do - token = planeswalker.controller.create_token(token_class: SoldierToken, enters_tapped: true) + token = source.controller.create_token(token_class: SoldierToken, enters_tapped: true) game.current_turn.declare_attacker(token) end @@ -65,7 +65,7 @@ class LoyaltyAbility3 < Magic::LoyaltyAbility def loyalty_change = -6 def resolve! - game.add_emblem(Emblem.new(owner: planeswalker.controller)) + game.add_emblem(Emblem.new(owner: source.controller)) end end diff --git a/lib/magic/cards/planeswalker.rb b/lib/magic/cards/planeswalker.rb index 40073e74..02df54ca 100644 --- a/lib/magic/cards/planeswalker.rb +++ b/lib/magic/cards/planeswalker.rb @@ -1,6 +1,10 @@ module Magic module Cards class Planeswalker < Card + def self.loyalty(loyalty) + const_set(:BASE_LOYALTY, loyalty) + end + def loyalty self.class::BASE_LOYALTY end diff --git a/lib/magic/cards/shared/events.rb b/lib/magic/cards/shared/events.rb index 393167ec..716967da 100644 --- a/lib/magic/cards/shared/events.rb +++ b/lib/magic/cards/shared/events.rb @@ -31,6 +31,8 @@ def trigger_effect(effect, source: self, **args) game.add_effect(Effects::LoseLife.new(source: source, **args)) when :modify_power_toughness game.add_effect(Effects::ApplyPowerToughnessModification.new(source: source, **args)) + when :phase_out + game.add_effect(Effects::PhaseOut.new(source: source, **args)) when :return_to_owners_hand game.add_effect(Effects::ReturnToOwnersHand.new(source: source, **args)) when :tap diff --git a/lib/magic/cards/teferi_master_of_time.rb b/lib/magic/cards/teferi_master_of_time.rb new file mode 100644 index 00000000..810b8326 --- /dev/null +++ b/lib/magic/cards/teferi_master_of_time.rb @@ -0,0 +1,47 @@ +module Magic + module Cards + class TeferiMasterOfTime < Planeswalker + NAME = "Teferi, Master of Time" + TYPE_LINE = "Legendary Planeswalker -- Teferi" + cost generic: 2, blue: 2 + loyalty 3 + + class LoyaltyAbility1 < LoyaltyAbility + def loyalty_change + 1 + end + + def resolve! + trigger_effect(:draw_cards) + add_choice(:discard) + end + end + + class LoyaltyAbility2 < LoyaltyAbility + def loyalty_change + -3 + end + + def target_choices + battlefield.creatures.not_controlled_by(controller) + end + + def resolve!(target:) + trigger_effect(:phase_out, target: target) + end + end + + class LoyaltyAbility3 < LoyaltyAbility + def loyalty_change + -10 + end + + def resolve! + 2.times { game.take_additional_turn } + end + end + + def loyalty_abilities = [LoyaltyAbility1, LoyaltyAbility2, LoyaltyAbility3] + end + end +end diff --git a/lib/magic/cards/ugin_the_spirit_dragon.rb b/lib/magic/cards/ugin_the_spirit_dragon.rb index 7796f1ba..47b695bc 100644 --- a/lib/magic/cards/ugin_the_spirit_dragon.rb +++ b/lib/magic/cards/ugin_the_spirit_dragon.rb @@ -11,7 +11,7 @@ def target_choices = battlefield.cards + game.players def single_target? = true def resolve!(target:) - target.take_damage(source: planeswalker, damage: 3) + target.take_damage(source: source, damage: 3) end end @@ -32,11 +32,11 @@ class LoyaltyAbility3 < Magic::LoyaltyAbility def loyalty_change = -7 def resolve! - controller = planeswalker.controller + controller = source.controller controller.gain_life(7) 7.times { controller.draw! } - game.choices.add(UginTheSpiritDragon::Choice.new(source: planeswalker)) + game.choices.add(UginTheSpiritDragon::Choice.new(source: source)) end end diff --git a/lib/magic/choice/discard.rb b/lib/magic/choice/discard.rb index 2480f349..80556f64 100644 --- a/lib/magic/choice/discard.rb +++ b/lib/magic/choice/discard.rb @@ -8,7 +8,7 @@ def initialize(player:) @cards = player.hand end - def choose(card) + def resolve!(card:) card.move_to_graveyard! end end diff --git a/lib/magic/effects/phase_out.rb b/lib/magic/effects/phase_out.rb new file mode 100644 index 00000000..7c696340 --- /dev/null +++ b/lib/magic/effects/phase_out.rb @@ -0,0 +1,10 @@ +module Magic + module Effects + class PhaseOut < TargetedEffect + + def resolve! + target.phase_out! + end + end + end +end diff --git a/lib/magic/game.rb b/lib/magic/game.rb index 25a61b4c..fc21c40f 100644 --- a/lib/magic/game.rb +++ b/lib/magic/game.rb @@ -2,7 +2,7 @@ module Magic class Game extend Forwardable - attr_reader :logger, :battlefield, :exile, :stack, :players, :emblems, :current_turn + attr_reader :logger, :battlefield, :exile, :turns, :stack, :players, :emblems, :current_turn def_delegators :@stack, :choices, :add_choice, :skip_choice!, :resolve_choice!, :effects, :add_effect @@ -33,7 +33,7 @@ def initialize( @player_count = 0 @players = players @emblems = [] - @turn_number = 0 + @turns = [] end def add_players(*players) @@ -51,7 +51,7 @@ def add_emblem(emblem) end def start! - @current_turn = Turn.new(number: 1, game: self, active_player: players.first) + @current_turn = add_turn(number: 1, active_player: players.first) players.each do |player| 7.times { player.draw! } end @@ -61,12 +61,26 @@ def notify!(*events) current_turn.notify!(*events) end + def take_additional_turn(player: current_turn.active_player) + add_turn(number: @turns.size + 1, active_player: player) + end + + def add_turn(number: @turns.size + 1, active_player: player) + turn = Turn.new(number: number, game: self, active_player: active_player) + @turns << turn + turn + end + def next_turn - @turn_number += 1 - logger.debug "Starting Turn #{@turn_number} - Active Player: #{@players.first}" - @current_turn = Turn.new(number: @turn_number, game: self, active_player: @players.first) + next_turn = turns.find { |turn| turn.number > current_turn.number } + if next_turn + @current_turn = next_turn + return next_turn + end + next_active_player - @current_turn + logger.debug "Starting Turn #{@turn_number} - Active Player: #{@players.first}" + @current_turn = add_turn(number: current_turn.number + 1, active_player: @players.first) end def next_active_player diff --git a/lib/magic/game/turn.rb b/lib/magic/game/turn.rb index 55d96bbe..c348d580 100644 --- a/lib/magic/game/turn.rb +++ b/lib/magic/game/turn.rb @@ -18,6 +18,7 @@ class Turn end after_transition to: :untap do |turn| + turn.battlefield.phased_out.permanents.controlled_by(turn.active_player).each(&:phase_in!) turn.battlefield.permanents.controlled_by(turn.active_player).each(&:untap_during_untap_step) end diff --git a/lib/magic/loyalty_ability.rb b/lib/magic/loyalty_ability.rb index 9246a020..a767d33d 100644 --- a/lib/magic/loyalty_ability.rb +++ b/lib/magic/loyalty_ability.rb @@ -1,10 +1,4 @@ module Magic - class LoyaltyAbility - attr_reader :planeswalker, :game - - def initialize(planeswalker:) - @planeswalker = planeswalker - @game = planeswalker.game - end + class LoyaltyAbility < Ability end end diff --git a/lib/magic/permanent.rb b/lib/magic/permanent.rb index 391d9aac..8d81cab8 100644 --- a/lib/magic/permanent.rb +++ b/lib/magic/permanent.rb @@ -55,6 +55,7 @@ def initialize(game:, owner:, card:, token: false, cast: true, kicked: false, ti @damage = 0 @protections = Protections.new(card.protections.dup) @exiled_cards = Magic::CardList.new([]) + @phased_out = false @timestamp = timestamp end @@ -319,6 +320,18 @@ def add_choice(choice, **args) card.add_choice(choice, **args) end + def phased_out? + @phased_out + end + + def phase_out! + @phased_out = true + end + + def phase_in! + @phased_out = false + end + private def remove_until_eot_keyword_grants! diff --git a/lib/magic/permanents/planeswalker.rb b/lib/magic/permanents/planeswalker.rb index 9c2ddc3d..9846b781 100644 --- a/lib/magic/permanents/planeswalker.rb +++ b/lib/magic/permanents/planeswalker.rb @@ -19,7 +19,7 @@ def take_damage(source:, damage:) end def loyalty_abilities - card.loyalty_abilities.map { |ability| ability.new(planeswalker: self) } + card.loyalty_abilities.map { |ability| ability.new(source: self) } end end end diff --git a/lib/magic/zones/battlefield.rb b/lib/magic/zones/battlefield.rb index d22035c7..5045c549 100644 --- a/lib/magic/zones/battlefield.rb +++ b/lib/magic/zones/battlefield.rb @@ -3,7 +3,7 @@ module Zones class Battlefield < Zone extend Forwardable - def_delegators :permanents, :creatures, :planeswalkers, :not_controlled_by, :controlled_by + def_delegators :permanents, :creatures, :planeswalkers, :not_controlled_by, :controlled_by, :phased_out def initialize(**args) super(**args) diff --git a/spec/cards/academy_elite_spec.rb b/spec/cards/academy_elite_spec.rb index 000c5258..74f3f00c 100644 --- a/spec/cards/academy_elite_spec.rb +++ b/spec/cards/academy_elite_spec.rb @@ -73,7 +73,7 @@ choice = game.choices.last expect(choice).to be_a(Magic::Choice::Discard) - choice.choose(p1.hand.cards.first) + game.resolve_choice!(card: p1.hand.cards.first) expect(p1.graveyard.cards.count).to eq(1) end end diff --git a/spec/cards/capture_sphere_spec.rb b/spec/cards/capture_sphere_spec.rb index 8e70ffae..084a49d7 100644 --- a/spec/cards/capture_sphere_spec.rb +++ b/spec/cards/capture_sphere_spec.rb @@ -16,8 +16,6 @@ # Enchanted creature doesn't untap during its controller's untap step. it "taps enchanted creature, equips aura" do - game.next_turn - expect(game.current_turn.active_player).to eq(p1) p1.add_mana(white: 4) action = cast_action(player: p1, card: subject) diff --git a/spec/cards/frost_breath_spec.rb b/spec/cards/frost_breath_spec.rb index c01a1089..9242912a 100644 --- a/spec/cards/frost_breath_spec.rb +++ b/spec/cards/frost_breath_spec.rb @@ -9,8 +9,6 @@ context "resolution" do it "taps two target creatures" do - game.next_turn - p1.add_mana(blue: 3) action = cast_action(card: subject, player: p1).targeting(wood_elves, loxodon_wayfarer) action.pay_mana(blue: 1, generic: { blue: 2 }) diff --git a/spec/cards/rain_of_revelation_spec.rb b/spec/cards/rain_of_revelation_spec.rb index 4e737e61..2ccd6548 100644 --- a/spec/cards/rain_of_revelation_spec.rb +++ b/spec/cards/rain_of_revelation_spec.rb @@ -12,8 +12,8 @@ choice = game.choices.last expect(choice).to be_a(Magic::Choice::Discard) - card = choice.cards.first - choice.choose(card) + card = p1.hand.first + game.resolve_choice!(card: card) expect(card.zone).to be_graveyard end diff --git a/spec/cards/rousing_read_spec.rb b/spec/cards/rousing_read_spec.rb index e4176c21..4ef5c791 100644 --- a/spec/cards/rousing_read_spec.rb +++ b/spec/cards/rousing_read_spec.rb @@ -18,8 +18,7 @@ game.tick! choice = game.choices.last - expect(choice).to be_a(Magic::Choice::Discard) - choice.choose(choice.cards.first) + game.resolve_choice!(card: choice.cards.first) expect(wood_elves.power).to eq(2) expect(wood_elves.toughness).to eq(2) diff --git a/spec/cards/sanctum_of_calm_waters_spec.rb b/spec/cards/sanctum_of_calm_waters_spec.rb index 20f84f2d..5f4599b4 100644 --- a/spec/cards/sanctum_of_calm_waters_spec.rb +++ b/spec/cards/sanctum_of_calm_waters_spec.rb @@ -18,7 +18,7 @@ choice = game.choices.first expect(choice).to be_a(Magic::Choice::Discard) - choice.choose(p1.hand.cards.first) + game.resolve_choice!(card: p1.hand.cards.first) end end end diff --git a/spec/cards/teferi_master_of_time_spec.rb b/spec/cards/teferi_master_of_time_spec.rb new file mode 100644 index 00000000..24b392af --- /dev/null +++ b/spec/cards/teferi_master_of_time_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +RSpec.describe Magic::Cards::TeferiMasterOfTime do + include_context "two player game" + + subject(:planeswalker) { ResolvePermanent("Teferi, Master Of Time") } + + context "+1 ability" do + let(:ability) { planeswalker.loyalty_abilities.first } + + it "draws, then discards a card" do + expect(p1).to receive(:draw!) + p1.activate_loyalty_ability(ability: ability) + game.tick! + expect(planeswalker.loyalty).to eq(4) + + choice = game.choices.last + expect(choice).to be_a(Magic::Choice::Discard) + game.resolve_choice!(card: p1.hand.first) + end + end + + context "-3 ability" do + let(:ability) { planeswalker.loyalty_abilities[1] } + let!(:wood_elves) { ResolvePermanent("Wood Elves", owner: p2) } + + it "target creature you don't control phases out" do + p1.activate_loyalty_ability(ability: ability) do + _1.targeting(wood_elves) + end + + game.tick! + + expect(wood_elves).to be_phased_out + end + end + + context "-10 ability" do + let(:ability) { planeswalker.loyalty_abilities[2] } + + it "take two additional turns" do + p1.activate_loyalty_ability(ability: ability) + + game.tick! + + expect(game.turns.count).to eq(3) + # Turn 1 (current turn), Turn 2, and Turn 3 + expect(game.turns.map(&:active_player)).to eq([p1, p1, p1]) + end + end +end diff --git a/spec/game/integration/turns_spec.rb b/spec/game/integration/turns_spec.rb index b11374a7..fe0e8ac9 100644 --- a/spec/game/integration/turns_spec.rb +++ b/spec/game/integration/turns_spec.rb @@ -20,7 +20,7 @@ expect(p1.hand.count).to eq(7) expect(p2.hand.count).to eq(7) - turn_1 = game.next_turn + turn_1 = game.current_turn turn_1.untap! turn_1.upkeep! diff --git a/spec/game/integration/untap_spec.rb b/spec/game/integration/untap_spec.rb new file mode 100644 index 00000000..1de83642 --- /dev/null +++ b/spec/game/integration/untap_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +RSpec.describe Magic::Game, "untap step" do + include_context "two player game" + + let!(:dranas_emissary) { ResolvePermanent("Drana's Emissary", owner: p1) } + + before do + dranas_emissary.phase_out! + end + + it "phases back in" do + expect(dranas_emissary).to be_phased_out + current_turn.untap! + expect(dranas_emissary).not_to be_phased_out + end +end diff --git a/spec/game/integration/until_end_of_turn_spec.rb b/spec/game/integration/until_end_of_turn_spec.rb index 10905c5a..dd65632c 100644 --- a/spec/game/integration/until_end_of_turn_spec.rb +++ b/spec/game/integration/until_end_of_turn_spec.rb @@ -5,10 +5,6 @@ let!(:dranas_emissary) { ResolvePermanent("Drana's Emissary", owner: p1) } - before do - game.battlefield.add(dranas_emissary) - end - def go_to_cleanup current_turn.untap! current_turn.upkeep! diff --git a/spec/game_spec.rb b/spec/game_spec.rb new file mode 100644 index 00000000..2a37079d --- /dev/null +++ b/spec/game_spec.rb @@ -0,0 +1,33 @@ +require "spec_helper" + +RSpec.describe Magic::Game do + include_context "two player game" + + context "take additional turn" do + it "player 1 has turns 1 and 2" do + game.take_additional_turn + expect(game.turns.size).to eq(2) + expect(game.turns.map(&:number)).to eq([1, 2]) + + expect(game.current_turn.number).to eq(1) + expect(game.current_turn.active_player).to eq(p1) + + game.next_turn + + expect(game.current_turn.number).to eq(2) + expect(game.current_turn.active_player).to eq(p1) + end + end + + context "next_turn" do + it "player 1 has turns 1 and 2" do + expect(game.current_turn.number).to eq(1) + expect(game.current_turn.active_player).to eq(p1) + + game.next_turn + + expect(game.current_turn.number).to eq(2) + expect(game.current_turn.active_player).to eq(p2) + end + end +end