enjine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/SUPPORTED.md +387 -0
- data/docs/ARCHITECTURE.md +141 -0
- data/docs/DESIGN.md +139 -0
- data/docs/MECHANICS_COVERAGE.md +670 -0
- data/docs/guides/adding-a-mechanic.md +60 -0
- data/docs/guides/api.md +614 -0
- data/docs/guides/card-data-format.md +1472 -0
- data/docs/guides/deck-builder.md +157 -0
- data/docs/plans/standard.md +371 -0
- data/docs/reference/abilities.md +1680 -0
- data/docs/reference/costs.md +339 -0
- data/docs/reference/counters.md +121 -0
- data/docs/reference/decisions.md +102 -0
- data/docs/reference/effects.md +2340 -0
- data/docs/reference/filters.md +172 -0
- data/docs/reference/keywords.md +647 -0
- data/docs/reference/subtypes.md +169 -0
- data/docs/reference/supertypes.md +31 -0
- data/docs/reference/triggers.md +797 -0
- data/docs/reference/types.md +216 -0
- data/exe/enjine +7 -0
- data/lib/enjine/actions/activate_mana_ability.rb +24 -0
- data/lib/enjine/actions/cast.rb +57 -0
- data/lib/enjine/actions/pass.rb +18 -0
- data/lib/enjine/actions/play_land.rb +52 -0
- data/lib/enjine/actions/special_action.rb +37 -0
- data/lib/enjine/actions.rb +20 -0
- data/lib/enjine/agents/busy.rb +27 -0
- data/lib/enjine/agents/random.rb +28 -0
- data/lib/enjine/card_definition.rb +433 -0
- data/lib/enjine/card_faces.rb +116 -0
- data/lib/enjine/catalog.rb +56 -0
- data/lib/enjine/characteristics.rb +39 -0
- data/lib/enjine/cli/text.rb +295 -0
- data/lib/enjine/cli.rb +312 -0
- data/lib/enjine/decisions/bottom.rb +29 -0
- data/lib/enjine/decisions/casting.rb +188 -0
- data/lib/enjine/decisions/combat.rb +75 -0
- data/lib/enjine/decisions/discard.rb +29 -0
- data/lib/enjine/decisions/effect_choice.rb +21 -0
- data/lib/enjine/decisions/enter_choice.rb +26 -0
- data/lib/enjine/decisions/legend_rule.rb +21 -0
- data/lib/enjine/decisions/mulligan.rb +26 -0
- data/lib/enjine/decisions/order_triggers.rb +21 -0
- data/lib/enjine/decisions/play_or_draw.rb +19 -0
- data/lib/enjine/decisions/priority.rb +22 -0
- data/lib/enjine/decisions/untap_choice.rb +35 -0
- data/lib/enjine/decisions.rb +22 -0
- data/lib/enjine/deck.rb +151 -0
- data/lib/enjine/deck_builder.rb +454 -0
- data/lib/enjine/docs.rb +90 -0
- data/lib/enjine/engine/ability_layers.rb +118 -0
- data/lib/enjine/engine/attachments.rb +86 -0
- data/lib/enjine/engine/casting/alt_casting.rb +254 -0
- data/lib/enjine/engine/casting/cast_choices.rb +292 -0
- data/lib/enjine/engine/casting/casting.rb +326 -0
- data/lib/enjine/engine/casting/cost_options.rb +99 -0
- data/lib/enjine/engine/casting/costs.rb +117 -0
- data/lib/enjine/engine/casting/payment.rb +353 -0
- data/lib/enjine/engine/casting/permissions.rb +412 -0
- data/lib/enjine/engine/casting/target_counts.rb +113 -0
- data/lib/enjine/engine/casting/waterbend.rb +90 -0
- data/lib/enjine/engine/characteristics.rb +379 -0
- data/lib/enjine/engine/class_levels.rb +25 -0
- data/lib/enjine/engine/combat.rb +315 -0
- data/lib/enjine/engine/copies.rb +208 -0
- data/lib/enjine/engine/damage.rb +196 -0
- data/lib/enjine/engine/designations.rb +46 -0
- data/lib/enjine/engine/emblems.rb +37 -0
- data/lib/enjine/engine/entering.rb +156 -0
- data/lib/enjine/engine/face_down.rb +30 -0
- data/lib/enjine/engine/faces.rb +222 -0
- data/lib/enjine/engine/follow_ups.rb +27 -0
- data/lib/enjine/engine/mana_sources.rb +230 -0
- data/lib/enjine/engine/phasing.rb +74 -0
- data/lib/enjine/engine/pregame.rb +95 -0
- data/lib/enjine/engine/priority.rb +47 -0
- data/lib/enjine/engine/resolution.rb +168 -0
- data/lib/enjine/engine/rules.rb +287 -0
- data/lib/enjine/engine/sagas.rb +41 -0
- data/lib/enjine/engine/speed.rb +104 -0
- data/lib/enjine/engine/spell_grants.rb +46 -0
- data/lib/enjine/engine/state_based_actions.rb +157 -0
- data/lib/enjine/engine/triggers.rb +364 -0
- data/lib/enjine/engine/turn_history.rb +240 -0
- data/lib/enjine/engine/turn_structure.rb +224 -0
- data/lib/enjine/engine/zones.rb +278 -0
- data/lib/enjine/engine.rb +55 -0
- data/lib/enjine/errors.rb +62 -0
- data/lib/enjine/events.rb +161 -0
- data/lib/enjine/frames.rb +84 -0
- data/lib/enjine/fuzz.rb +112 -0
- data/lib/enjine/game.rb +204 -0
- data/lib/enjine/history.rb +26 -0
- data/lib/enjine/mana.rb +142 -0
- data/lib/enjine/mechanics/abilities/additional_triggers.rb +86 -0
- data/lib/enjine/mechanics/abilities/chosen_values.rb +213 -0
- data/lib/enjine/mechanics/abilities/cost_changes.rb +154 -0
- data/lib/enjine/mechanics/abilities/damage_replacements.rb +569 -0
- data/lib/enjine/mechanics/abilities/double_counters.rb +71 -0
- data/lib/enjine/mechanics/abilities/entering_choices.rb +91 -0
- data/lib/enjine/mechanics/abilities/entering_copy.rb +69 -0
- data/lib/enjine/mechanics/abilities/enters_tapped.rb +159 -0
- data/lib/enjine/mechanics/abilities/enters_with_counters.rb +116 -0
- data/lib/enjine/mechanics/abilities/mana_ability.rb +209 -0
- data/lib/enjine/mechanics/abilities/play_permissions.rb +527 -0
- data/lib/enjine/mechanics/abilities/player_rules.rb +169 -0
- data/lib/enjine/mechanics/abilities/replacement_effects.rb +368 -0
- data/lib/enjine/mechanics/abilities/restrictions.rb +144 -0
- data/lib/enjine/mechanics/abilities/spell_keywords.rb +55 -0
- data/lib/enjine/mechanics/abilities/stack_abilities.rb +542 -0
- data/lib/enjine/mechanics/abilities/static.rb +365 -0
- data/lib/enjine/mechanics/abilities/tapping_power.rb +71 -0
- data/lib/enjine/mechanics/abilities/turn_rules.rb +49 -0
- data/lib/enjine/mechanics/card_spec.rb +177 -0
- data/lib/enjine/mechanics/copy_except.rb +73 -0
- data/lib/enjine/mechanics/costs/base.rb +115 -0
- data/lib/enjine/mechanics/costs/behold.rb +76 -0
- data/lib/enjine/mechanics/costs/discard.rb +65 -0
- data/lib/enjine/mechanics/costs/exert.rb +29 -0
- data/lib/enjine/mechanics/costs/exile_cards.rb +39 -0
- data/lib/enjine/mechanics/costs/from_graveyard.rb +45 -0
- data/lib/enjine/mechanics/costs/get_counters.rb +39 -0
- data/lib/enjine/mechanics/costs/mana.rb +37 -0
- data/lib/enjine/mechanics/costs/one_of.rb +49 -0
- data/lib/enjine/mechanics/costs/pay_life.rb +50 -0
- data/lib/enjine/mechanics/costs/remove_counters.rb +51 -0
- data/lib/enjine/mechanics/costs/sacrifice.rb +59 -0
- data/lib/enjine/mechanics/costs/tap.rb +56 -0
- data/lib/enjine/mechanics/costs/waterbend.rb +29 -0
- data/lib/enjine/mechanics/counters/kinds.rb +190 -0
- data/lib/enjine/mechanics/effects/add_subtypes.rb +30 -0
- data/lib/enjine/mechanics/effects/airbend.rb +41 -0
- data/lib/enjine/mechanics/effects/amass.rb +124 -0
- data/lib/enjine/mechanics/effects/assimilate.rb +72 -0
- data/lib/enjine/mechanics/effects/base.rb +148 -0
- data/lib/enjine/mechanics/effects/become.rb +182 -0
- data/lib/enjine/mechanics/effects/behold.rb +48 -0
- data/lib/enjine/mechanics/effects/blight.rb +94 -0
- data/lib/enjine/mechanics/effects/choose_cards.rb +168 -0
- data/lib/enjine/mechanics/effects/copy.rb +370 -0
- data/lib/enjine/mechanics/effects/counter_spell.rb +21 -0
- data/lib/enjine/mechanics/effects/counters.rb +161 -0
- data/lib/enjine/mechanics/effects/damage.rb +72 -0
- data/lib/enjine/mechanics/effects/delayed_triggers.rb +112 -0
- data/lib/enjine/mechanics/effects/destroy.rb +22 -0
- data/lib/enjine/mechanics/effects/discard.rb +86 -0
- data/lib/enjine/mechanics/effects/draw.rb +16 -0
- data/lib/enjine/mechanics/effects/earthbend.rb +70 -0
- data/lib/enjine/mechanics/effects/emblem.rb +52 -0
- data/lib/enjine/mechanics/effects/endure.rb +63 -0
- data/lib/enjine/mechanics/effects/exile.rb +49 -0
- data/lib/enjine/mechanics/effects/extra_turn.rb +122 -0
- data/lib/enjine/mechanics/effects/faces.rb +115 -0
- data/lib/enjine/mechanics/effects/fight.rb +76 -0
- data/lib/enjine/mechanics/effects/flip_coins.rb +97 -0
- data/lib/enjine/mechanics/effects/gain_control.rb +94 -0
- data/lib/enjine/mechanics/effects/game_end.rb +42 -0
- data/lib/enjine/mechanics/effects/library.rb +327 -0
- data/lib/enjine/mechanics/effects/life.rb +83 -0
- data/lib/enjine/mechanics/effects/look_at_hand.rb +17 -0
- data/lib/enjine/mechanics/effects/loot.rb +118 -0
- data/lib/enjine/mechanics/effects/mana.rb +96 -0
- data/lib/enjine/mechanics/effects/may.rb +82 -0
- data/lib/enjine/mechanics/effects/meld.rb +77 -0
- data/lib/enjine/mechanics/effects/mill.rb +22 -0
- data/lib/enjine/mechanics/effects/pay.rb +231 -0
- data/lib/enjine/mechanics/effects/phasing.rb +51 -0
- data/lib/enjine/mechanics/effects/proliferate.rb +72 -0
- data/lib/enjine/mechanics/effects/pump.rb +60 -0
- data/lib/enjine/mechanics/effects/put_in_library.rb +98 -0
- data/lib/enjine/mechanics/effects/return_choice.rb +29 -0
- data/lib/enjine/mechanics/effects/return_to_battlefield.rb +80 -0
- data/lib/enjine/mechanics/effects/return_to_hand.rb +20 -0
- data/lib/enjine/mechanics/effects/roll_dice.rb +45 -0
- data/lib/enjine/mechanics/effects/sacrifice.rb +118 -0
- data/lib/enjine/mechanics/effects/scry_and_surveil.rb +74 -0
- data/lib/enjine/mechanics/effects/search.rb +156 -0
- data/lib/enjine/mechanics/effects/tap_untap.rb +23 -0
- data/lib/enjine/mechanics/effects/tokens.rb +76 -0
- data/lib/enjine/mechanics/filters/any_target.rb +15 -0
- data/lib/enjine/mechanics/filters/base.rb +386 -0
- data/lib/enjine/mechanics/filters/graveyard.rb +23 -0
- data/lib/enjine/mechanics/filters/one_of.rb +55 -0
- data/lib/enjine/mechanics/filters/permanents.rb +52 -0
- data/lib/enjine/mechanics/filters/players.rb +20 -0
- data/lib/enjine/mechanics/filters/stack.rb +97 -0
- data/lib/enjine/mechanics/follow_up.rb +46 -0
- data/lib/enjine/mechanics/keywords/alternative_costs.rb +361 -0
- data/lib/enjine/mechanics/keywords/beam_me_up.rb +78 -0
- data/lib/enjine/mechanics/keywords/changeling.rb +58 -0
- data/lib/enjine/mechanics/keywords/convoke.rb +77 -0
- data/lib/enjine/mechanics/keywords/crew.rb +155 -0
- data/lib/enjine/mechanics/keywords/cycling.rb +58 -0
- data/lib/enjine/mechanics/keywords/decayed.rb +25 -0
- data/lib/enjine/mechanics/keywords/devour.rb +61 -0
- data/lib/enjine/mechanics/keywords/evergreen.rb +54 -0
- data/lib/enjine/mechanics/keywords/extort.rb +26 -0
- data/lib/enjine/mechanics/keywords/flags.rb +46 -0
- data/lib/enjine/mechanics/keywords/foretell.rb +45 -0
- data/lib/enjine/mechanics/keywords/gift.rb +50 -0
- data/lib/enjine/mechanics/keywords/hideaway.rb +28 -0
- data/lib/enjine/mechanics/keywords/increment.rb +31 -0
- data/lib/enjine/mechanics/keywords/job_select.rb +29 -0
- data/lib/enjine/mechanics/keywords/kicker.rb +92 -0
- data/lib/enjine/mechanics/keywords/mobilize_and_firebending.rb +84 -0
- data/lib/enjine/mechanics/keywords/paradigm.rb +91 -0
- data/lib/enjine/mechanics/keywords/power_up.rb +48 -0
- data/lib/enjine/mechanics/keywords/protection.rb +111 -0
- data/lib/enjine/mechanics/keywords/prowess.rb +30 -0
- data/lib/enjine/mechanics/keywords/riot.rb +35 -0
- data/lib/enjine/mechanics/keywords/saddle.rb +115 -0
- data/lib/enjine/mechanics/keywords/speed.rb +89 -0
- data/lib/enjine/mechanics/keywords/station.rb +163 -0
- data/lib/enjine/mechanics/keywords/storied.rb +41 -0
- data/lib/enjine/mechanics/keywords/storm_and_cascade.rb +86 -0
- data/lib/enjine/mechanics/keywords/sunburst.rb +29 -0
- data/lib/enjine/mechanics/keywords/suspend.rb +191 -0
- data/lib/enjine/mechanics/keywords/teamwork.rb +45 -0
- data/lib/enjine/mechanics/keywords/ward.rb +67 -0
- data/lib/enjine/mechanics/linked.rb +67 -0
- data/lib/enjine/mechanics/mana_spent.rb +99 -0
- data/lib/enjine/mechanics/selector.rb +207 -0
- data/lib/enjine/mechanics/subtypes/auras_and_equipment.rb +176 -0
- data/lib/enjine/mechanics/subtypes/basic_land_types.rb +36 -0
- data/lib/enjine/mechanics/subtypes/class_levels.rb +102 -0
- data/lib/enjine/mechanics/subtypes/saga.rb +89 -0
- data/lib/enjine/mechanics/supertypes/basic.rb +17 -0
- data/lib/enjine/mechanics/supertypes/legendary.rb +18 -0
- data/lib/enjine/mechanics/triggers/ability_triggers.rb +51 -0
- data/lib/enjine/mechanics/triggers/any_of.rb +43 -0
- data/lib/enjine/mechanics/triggers/base.rb +228 -0
- data/lib/enjine/mechanics/triggers/casting.rb +219 -0
- data/lib/enjine/mechanics/triggers/combat.rb +123 -0
- data/lib/enjine/mechanics/triggers/damage.rb +130 -0
- data/lib/enjine/mechanics/triggers/dilemma.rb +37 -0
- data/lib/enjine/mechanics/triggers/draw_and_discard.rb +85 -0
- data/lib/enjine/mechanics/triggers/keyword_actions.rb +55 -0
- data/lib/enjine/mechanics/triggers/life.rb +35 -0
- data/lib/enjine/mechanics/triggers/steps.rb +39 -0
- data/lib/enjine/mechanics/triggers/tap_and_counters.rb +111 -0
- data/lib/enjine/mechanics/triggers/zone_changes.rb +249 -0
- data/lib/enjine/mechanics/types/kindred.rb +30 -0
- data/lib/enjine/mechanics/types/land.rb +24 -0
- data/lib/enjine/mechanics/types/permanent_types.rb +67 -0
- data/lib/enjine/mechanics/types/spell_types.rb +38 -0
- data/lib/enjine/mechanics/values.rb +675 -0
- data/lib/enjine/mechanics.rb +74 -0
- data/lib/enjine/player.rb +30 -0
- data/lib/enjine/primitive.rb +61 -0
- data/lib/enjine/replacements.rb +191 -0
- data/lib/enjine/rng.rb +57 -0
- data/lib/enjine/rules_monitor.rb +404 -0
- data/lib/enjine/setup.rb +45 -0
- data/lib/enjine/snapshot/player_checks.rb +86 -0
- data/lib/enjine/snapshot/turn_checks.rb +91 -0
- data/lib/enjine/snapshot.rb +307 -0
- data/lib/enjine/state.rb +173 -0
- data/lib/enjine/steps.rb +117 -0
- data/lib/enjine/tokens.rb +25 -0
- data/lib/enjine/verify.rb +233 -0
- data/lib/enjine/version.rb +7 -0
- data/lib/enjine/view.rb +162 -0
- data/lib/enjine.rb +46 -0
- metadata +315 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Combat (CR 506-511). state.attackers maps attacker id => {"target", "blocked"};
|
|
6
|
+
# state.blockers maps blocker id => attacker id.
|
|
7
|
+
module Combat
|
|
8
|
+
def defending_player = state.opponent(state.active)
|
|
9
|
+
|
|
10
|
+
# ---- declare attackers (CR 508) ------------------------------------------
|
|
11
|
+
|
|
12
|
+
def begin_declare_attackers!
|
|
13
|
+
return turn_based_actions_done! if eligible_attackers.empty?
|
|
14
|
+
|
|
15
|
+
ask!("kind" => "declare_attackers", "player" => state.active)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# CR 508.1a: untapped creatures you've controlled continuously since your turn began (or with haste).
|
|
19
|
+
# CR 508.1c: restrictions (defender, "can't attack", a player who can't attack) are obeyed.
|
|
20
|
+
def eligible_attackers
|
|
21
|
+
return [] if player_rule?(state.active, "cant_attack")
|
|
22
|
+
|
|
23
|
+
only = combat_attack_filter # "only land creatures can attack during that combat phase" (TurnStructure)
|
|
24
|
+
state.battlefield.select do |id|
|
|
25
|
+
obj = object(id)
|
|
26
|
+
current = chars(obj)
|
|
27
|
+
obj.controller == state.active && !obj.tapped && current.creature? &&
|
|
28
|
+
(!current.keyword?(:defender) || current.keyword?(:attacks_despite_defender)) &&
|
|
29
|
+
!current.keyword?(:cant_attack) && !attackers.key?(id) && !summoning_sick_for_tap?(obj) &&
|
|
30
|
+
(only.nil? || only.match?(self, id, state.active))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# CR 508.1d: creatures that attack each combat if able and could still be declared. The
|
|
35
|
+
# declaration may end only once every such requirement is obeyed (no attack has a cost yet).
|
|
36
|
+
def unmet_attack_requirements = attack_candidates.select { chars(it).keyword?(:attacks_each_combat) }
|
|
37
|
+
|
|
38
|
+
# CR 506.3, 508.1b: the defending player or a planeswalker they control.
|
|
39
|
+
def attack_targets
|
|
40
|
+
defender = defending_player
|
|
41
|
+
[Mechanics::Filters.player_ref(self, defender)] +
|
|
42
|
+
state.battlefield.select { |id| object(id).controller == defender && chars(id).planeswalker? }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Creatures that may be declared next. CR 506.5, 508.1c: one that can't attack alone is offered
|
|
46
|
+
# only while another creature attacks or could still be declared.
|
|
47
|
+
def attack_candidates
|
|
48
|
+
eligible = eligible_attackers
|
|
49
|
+
eligible.select { |id| !attacks_alone_restricted?(id) || !attackers.empty? || eligible.size > 1 }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def attacks_alone_restricted?(id) = chars(id).keyword?(:cant_attack_alone)
|
|
53
|
+
|
|
54
|
+
# CR 508.1c: the declaration is illegal while a creature that can't attack alone attacks alone.
|
|
55
|
+
def attack_declaration_legal? = !(attackers.size == 1 && attacks_alone_restricted?(attackers.keys.first))
|
|
56
|
+
|
|
57
|
+
def declare_attacker!(id, target)
|
|
58
|
+
attackers[id] = { "target" => target, "blocked" => false }
|
|
59
|
+
state.attacks_declared += 1
|
|
60
|
+
emit Events::AttackerDeclared.new(object: id, target: target)
|
|
61
|
+
eligible_attackers.empty? && attack_declaration_legal? ? finish_attackers! : nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Undo one attack while the declaration isn't legal yet (CR 508.1c). The declaration it was part of
|
|
65
|
+
# never happened, so "whenever this attacks" abilities it triggered go too.
|
|
66
|
+
def withdraw_attacker!(id)
|
|
67
|
+
attackers.delete(id)
|
|
68
|
+
state.attacks_declared -= 1
|
|
69
|
+
attack_withdrawn!(id) # Engine::TurnHistory
|
|
70
|
+
state.pending_triggers.reject! { it["event"]["type"] == "attacker_declared" && it["event"]["object"] == id }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# CR 508.4: a creature put onto the battlefield attacking attacks the defending player. (Choosing one
|
|
74
|
+
# of their planeswalkers instead isn't offered yet.) It was never declared, so it never "attacked".
|
|
75
|
+
# Only called while its controller controls an attacking creature (see Abilities::EntersAttacking).
|
|
76
|
+
def enter_attacking!(obj, target = nil)
|
|
77
|
+
target = Mechanics::Filters.player_ref(self, state.opponent(obj.controller)) unless target.is_a?(String) # CR 506.3a
|
|
78
|
+
attackers[obj.id] = { "target" => target, "blocked" => false }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# CR 508.1f: attacking creatures become tapped. CR 508.3d: "whenever you attack" triggers
|
|
82
|
+
# once the declaration is complete, if one or more creatures attack.
|
|
83
|
+
def finish_attackers!
|
|
84
|
+
attackers.each_key { |id| tap!(object(id)) unless chars(id).keyword?(:vigilance) } # CR 702.20b
|
|
85
|
+
emit Events::AttacksDeclared.new(player: name(state.active), attackers: attackers.keys) unless attackers.empty?
|
|
86
|
+
turn_based_actions_done!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ---- declare blockers (CR 509) -----------------------------------------------
|
|
90
|
+
|
|
91
|
+
def begin_declare_blockers!
|
|
92
|
+
return turn_based_actions_done! if block_options.empty? || attackers.empty?
|
|
93
|
+
|
|
94
|
+
ask!("kind" => "declare_blockers", "player" => defending_player)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# CR 509.1a; CR 509.1b: a creature that can't block is never offered.
|
|
98
|
+
def eligible_blockers = able_blockers.reject { state.blockers.key?(it) }
|
|
99
|
+
|
|
100
|
+
# The defending player's creatures that could block, whether they block already or not.
|
|
101
|
+
def able_blockers
|
|
102
|
+
state.battlefield.select do |id|
|
|
103
|
+
obj = object(id)
|
|
104
|
+
obj.controller == defending_player && !obj.tapped && chars(obj).creature? &&
|
|
105
|
+
!chars(obj).keyword?(:cant_block) && !chars(obj).keyword?(:decayed) # CR 702.147a
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def declare_blocker!(id, attacker)
|
|
110
|
+
state.blockers[id] = attacker
|
|
111
|
+
attackers[attacker]["blocked"] = true # CR 509.1h: stays blocked even if the blocker leaves
|
|
112
|
+
emit Events::BlockerDeclared.new(object: id, attacker: attacker)
|
|
113
|
+
finish_blockers! if block_options.empty? && blocks_legal?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The declaration is final: each blocked attacker "becomes blocked" once (CR 509.1h, 509.3c, 603.2e).
|
|
117
|
+
def finish_blockers!
|
|
118
|
+
attackers.each do |id, info|
|
|
119
|
+
emit Events::BecameBlocked.new(object: id, blockers: blockers_of(id)) if info["blocked"]
|
|
120
|
+
end
|
|
121
|
+
turn_based_actions_done!
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# CR 509.1b restrictions: "can't be blocked", "can't be blocked by [quality] creatures", flying
|
|
125
|
+
# (702.9b) / reach (702.17b), protection from the blocker's quality (702.16f).
|
|
126
|
+
def can_block?(blocker, attacker)
|
|
127
|
+
return false if chars(attacker).keyword?(:cant_be_blocked)
|
|
128
|
+
return false if protected_from?(object(attacker), object(blocker))
|
|
129
|
+
return false if chars(attacker).abilities.any? { it.ability.kind == :cant_be_blocked_by && it.ability.forbids?(self, blocker) }
|
|
130
|
+
return false if chars(blocker).abilities.any? { it.ability.kind == :can_block_only && !it.ability.allows?(self, blocker, attacker) }
|
|
131
|
+
return true unless chars(attacker).keyword?(:flying)
|
|
132
|
+
|
|
133
|
+
chars(blocker).keyword?(:flying) || chars(blocker).keyword?(:reach)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def block_options
|
|
137
|
+
eligible_blockers.flat_map do |id|
|
|
138
|
+
attackers.keys.filter_map { |attacker| [id, attacker] if can_block?(id, attacker) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# CR 702.111b: an attacker with menace can't be blocked by just one creature.
|
|
143
|
+
# CR 509.1c: an attacker that must be blocked if able is blocked whenever some creature could block it.
|
|
144
|
+
def blocks_legal? = menace_violations.empty? && unmet_block_requirements.empty?
|
|
145
|
+
|
|
146
|
+
# Attackers that must be blocked if able, unblocked while creatures could block them: blocking another
|
|
147
|
+
# attacker instead obeys fewer requirements (CR 509.1c). With menace it takes two (CR 509.1b).
|
|
148
|
+
def unmet_block_requirements
|
|
149
|
+
attackers.keys.select do |id|
|
|
150
|
+
next false unless chars(id).keyword?(:must_be_blocked) && blockers_of(id).empty?
|
|
151
|
+
|
|
152
|
+
able_blockers.count { could_obey?(it, id) } >= (chars(id).keyword?(:menace) ? 2 : 1)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether `blocker` blocking `attacker` would obey more requirements: it can block it and blocks nothing,
|
|
157
|
+
# or blocks an attacker without such a requirement (moving it from one that must be blocked gains nothing).
|
|
158
|
+
def could_obey?(blocker, attacker)
|
|
159
|
+
return false unless can_block?(blocker, attacker)
|
|
160
|
+
|
|
161
|
+
blocking = state.blockers[blocker]
|
|
162
|
+
blocking.nil? || !chars(blocking).keyword?(:must_be_blocked)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Blocks to undo so a creature can block an attacker that must be blocked.
|
|
166
|
+
def requirement_unblocks
|
|
167
|
+
unmet_block_requirements.flat_map { |id| state.blockers.keys.select { could_obey?(it, id) } }.uniq
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def menace_violations
|
|
171
|
+
attackers.keys.select { |id| chars(id).keyword?(:menace) && blockers_of(id).size == 1 }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Undo one block (offered while the declaration isn't legal yet).
|
|
175
|
+
def remove_blocker!(id)
|
|
176
|
+
attacker = state.blockers.delete(id)
|
|
177
|
+
attackers[attacker]["blocked"] = blockers_of(attacker).any? # the declaration isn't final yet
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def blockers_of(attacker) = state.blockers.select { |_, a| a == attacker }.keys
|
|
181
|
+
|
|
182
|
+
# ---- combat damage (CR 510) -----------------------------------------------
|
|
183
|
+
|
|
184
|
+
# CR 510.1c: an attacker blocked by several creatures divides its damage among them
|
|
185
|
+
# as its controller chooses — asked one point at a time.
|
|
186
|
+
# CR 510.4: if any attacking or blocking creature has first or double strike, there are
|
|
187
|
+
# two combat damage steps: first strikers deal damage in the first, the rest in the second.
|
|
188
|
+
def begin_combat_damage!
|
|
189
|
+
state.combat_damage_pass ||= combatants.any? { first_striker?(it) } ? "first" : "regular"
|
|
190
|
+
queue = attackers.keys.select { |id| needs_assignment?(id) }
|
|
191
|
+
state.frames << { "type" => "combat_damage", "queue" => queue, "assigned" => {} }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def combatants = attackers.keys + state.blockers.keys
|
|
195
|
+
|
|
196
|
+
def first_striker?(id) = chars(id).keyword?(:first_strike) || chars(id).keyword?(:double_strike)
|
|
197
|
+
|
|
198
|
+
# Whether this creature deals combat damage in the current pass (CR 702.4b, 702.7b).
|
|
199
|
+
def deals_damage_now?(id)
|
|
200
|
+
case state.combat_damage_pass
|
|
201
|
+
when "first" then first_striker?(id)
|
|
202
|
+
when "regular_after_first" then !chars(id).keyword?(:first_strike) || chars(id).keyword?(:double_strike)
|
|
203
|
+
else true
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The second combat damage step follows a first-strike step (CR 510.4).
|
|
208
|
+
def second_combat_damage_step?
|
|
209
|
+
return false unless state.step == :combat_damage && state.combat_damage_pass == "first"
|
|
210
|
+
|
|
211
|
+
state.combat_damage_pass = "regular_after_first"
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def trample?(id) = chars(id).keyword?(:trample)
|
|
216
|
+
|
|
217
|
+
# CR 510.1a: the combat damage a creature assigns: its power, or its toughness when an effect says so
|
|
218
|
+
# and its toughness is greater (assigns_damage_by_toughness).
|
|
219
|
+
def combat_damage_amount(id)
|
|
220
|
+
obj = object(id)
|
|
221
|
+
amount = power(obj)
|
|
222
|
+
chars(id).keyword?(:assigns_damage_by_toughness) ? [amount, toughness(obj)].max : amount
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# CR 510.1c-d / 702.19b: several blockers, or trample, means the attacking player divides damage.
|
|
226
|
+
def needs_assignment?(id)
|
|
227
|
+
return false unless attackers[id]["blocked"] && deals_damage_now?(id) && combat_damage_amount(id).positive?
|
|
228
|
+
|
|
229
|
+
count = blockers_of(id).size
|
|
230
|
+
count > 1 || (count == 1 && trample?(id))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def combat_damage_frame = state.frames.last
|
|
234
|
+
|
|
235
|
+
# Steps the combat_damage frame: ask for the next point of damage to divide, or deal it all.
|
|
236
|
+
def step_combat_damage!(frame)
|
|
237
|
+
while (attacker = frame["queue"].first)
|
|
238
|
+
assigned = frame["assigned"][attacker] ||= {}
|
|
239
|
+
remaining = combat_damage_amount(attacker) - assigned.values.sum
|
|
240
|
+
if remaining.positive?
|
|
241
|
+
ask!("kind" => "assign_damage", "player" => state.active, "attacker" => attacker, "remaining" => remaining)
|
|
242
|
+
return :wait
|
|
243
|
+
end
|
|
244
|
+
frame["queue"].shift
|
|
245
|
+
end
|
|
246
|
+
deal_combat_damage!(frame["assigned"])
|
|
247
|
+
:done
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Where the next point of an attacker's damage may go: any blocker; with trample, also
|
|
251
|
+
# the player or planeswalker it attacks once every blocker has lethal damage assigned.
|
|
252
|
+
def assignment_targets(attacker)
|
|
253
|
+
blockers = blockers_of(attacker)
|
|
254
|
+
assigned = combat_damage_frame["assigned"].fetch(attacker, {})
|
|
255
|
+
return blockers unless trample?(attacker) && blockers.all? { lethal_assigned?(attacker, it, assigned) }
|
|
256
|
+
|
|
257
|
+
blockers + [attackers[attacker]["target"]]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# CR 702.19b: lethal damage counts damage already marked; with deathtouch, 1 is lethal (702.2c).
|
|
261
|
+
def lethal_assigned?(attacker, blocker, assigned)
|
|
262
|
+
assigned.fetch(blocker, 0) >= [lethal_damage(attacker, blocker), 1].max
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def assign_damage!(attacker, target)
|
|
266
|
+
assigned = combat_damage_frame["assigned"][attacker]
|
|
267
|
+
assigned[target] = assigned.fetch(target, 0) + 1
|
|
268
|
+
emit Events::DamageAssigned.new(attacker: attacker, blocker: target)
|
|
269
|
+
ask!(nil)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# CR 510.2: all combat damage in a step is dealt simultaneously.
|
|
273
|
+
def deal_combat_damage!(assigned)
|
|
274
|
+
hits = []
|
|
275
|
+
attackers.each do |id, info|
|
|
276
|
+
next unless deals_damage_now?(id)
|
|
277
|
+
|
|
278
|
+
amount = combat_damage_amount(id)
|
|
279
|
+
blockers = blockers_of(id)
|
|
280
|
+
if !info["blocked"] then hits << [id, info["target"], amount]
|
|
281
|
+
elsif blockers.empty? then hits << [id, info["target"], amount] if trample?(id) # CR 702.19e
|
|
282
|
+
elsif assigned.key?(id) then assigned[id].each { |target, n| hits << [id, target, n] }
|
|
283
|
+
else hits << [id, blockers.first, amount]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
state.blockers.each do |blocker, attacker|
|
|
287
|
+
hits << [blocker, attacker, combat_damage_amount(blocker)] if deals_damage_now?(blocker)
|
|
288
|
+
end
|
|
289
|
+
simultaneous_damage do # CR 510.2
|
|
290
|
+
dealt = Hash.new(0)
|
|
291
|
+
lifelinked = hits.map(&:first).uniq.select { damage_source_keyword?(it, :lifelink) }
|
|
292
|
+
hits.each { |source, target, amount| dealt[source] += deal_damage!(source, target, amount, combat: true, lifelink: false) }
|
|
293
|
+
lifelinked.each { lifelink!(it, dealt[it]) if dealt[it].positive? } # CR 120.3f, 119.9: one gain per source
|
|
294
|
+
end
|
|
295
|
+
turn_based_actions_done!
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# CR 506.4: a permanent that leaves the battlefield is removed from combat.
|
|
299
|
+
def leave_combat!(id)
|
|
300
|
+
attackers.delete(id)
|
|
301
|
+
state.blockers.delete(id)
|
|
302
|
+
state.blockers.reject! { |_, attacker| attacker == id }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# CR 511.3: creatures stop being attacking/blocking at the end of combat.
|
|
306
|
+
def end_combat!
|
|
307
|
+
state.attacks_declared = 0
|
|
308
|
+
state.combat_damage_pass = nil
|
|
309
|
+
attackers.clear
|
|
310
|
+
state.blockers.clear
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
include Combat
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Copying objects (CR 707).
|
|
6
|
+
#
|
|
7
|
+
# A copy takes the copiable values of the object it copies (CR 707.2): the values printed on its card,
|
|
8
|
+
# as modified by other copy effects and their exceptions (CR 707.9b). Every copiable value comes from a
|
|
9
|
+
# card definition plus a few exceptions, so a copy is kept as plain data on the GameObject:
|
|
10
|
+
#
|
|
11
|
+
# obj.card the card whose printed values it has: the copied card while a copy
|
|
12
|
+
# effect applies (so its abilities, triggers and stack objects all
|
|
13
|
+
# resolve against that card's data)
|
|
14
|
+
# memory["own_card"] the card the object really is, while a copy effect changed obj.card
|
|
15
|
+
# memory["copy_effects"] copy effects on a permanent, in timestamp order (CR 613.7): each
|
|
16
|
+
# {"card", "except", "duration" ("permanent" | "end_of_turn"), "timestamp"}
|
|
17
|
+
# memory["copy_except"] the exceptions of an object created as a copy (a token copy, CR 707.9)
|
|
18
|
+
# memory["token"] true for a token that is a copy of a card (CR 111.1, 608.3f)
|
|
19
|
+
# memory["copy"] "spell" for a copy of a spell (CR 707.10), "card" for a copy of a card
|
|
20
|
+
# (CR 707.12); they cease to exist outside the stack (and battlefield)
|
|
21
|
+
#
|
|
22
|
+
# Exceptions (CR 707.9a-b) are {"legendary": false, "keywords": [...], "types": [...], "subtypes": [...],
|
|
23
|
+
# "supertypes": [...], "name": ..., "power": n, "toughness": n, "colors": [...]}; they are part of the
|
|
24
|
+
# copy's copiable values, so copying a copy keeps them (CR 707.2).
|
|
25
|
+
module Copies
|
|
26
|
+
# ---- reading -------------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def own_card(obj) = obj.memory["own_card"] || obj.card
|
|
29
|
+
|
|
30
|
+
# A token (CR 111.1): created as one, or a copy of a permanent spell that resolved (CR 608.3f).
|
|
31
|
+
def token?(obj) = obj.memory["token"] == true || catalog.fetch(own_card(obj)).token?
|
|
32
|
+
|
|
33
|
+
# The exceptions of the copy effect that applies (nil if none).
|
|
34
|
+
def copy_exceptions(obj)
|
|
35
|
+
effects = obj.memory["copy_effects"]
|
|
36
|
+
effects && !effects.empty? ? effects.last["except"] : obj.memory["copy_except"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# What a copy of `obj` gets: [card id, exceptions] (CR 707.2, 707.3).
|
|
40
|
+
def copiable_values(obj) = [obj.card, copy_exceptions(obj) || {}]
|
|
41
|
+
|
|
42
|
+
# 707.9b: exceptions of a later copy effect apply on top of those it copied.
|
|
43
|
+
def self.merge_except(old, new)
|
|
44
|
+
old ||= {}
|
|
45
|
+
merged = old.merge(new.except("keywords", "types", "subtypes", "supertypes"))
|
|
46
|
+
%w[keywords types subtypes supertypes].each do |key|
|
|
47
|
+
list = Array(old[key]) | Array(new[key])
|
|
48
|
+
merged[key] = list unless list.empty?
|
|
49
|
+
end
|
|
50
|
+
merged
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The characteristics with a copy effect's exceptions applied (CR 707.9a-b).
|
|
54
|
+
def self.apply_exceptions(chars, except)
|
|
55
|
+
attrs = {}
|
|
56
|
+
attrs[:name] = except["name"] if except["name"]
|
|
57
|
+
attrs[:supertypes] = chars.supertypes | sym(except["supertypes"])
|
|
58
|
+
attrs[:supertypes] -= [:legendary] if except["legendary"] == false
|
|
59
|
+
attrs[:types] = chars.types | sym(except["types"])
|
|
60
|
+
attrs[:subtypes] = chars.subtypes | sym(except["subtypes"])
|
|
61
|
+
attrs[:keywords] = chars.keywords | sym(except["keywords"])
|
|
62
|
+
attrs[:colors] = sym(except["colors"]) if except["colors"]
|
|
63
|
+
attrs[:power] = except["power"] if except.key?("power")
|
|
64
|
+
attrs[:toughness] = except["toughness"] if except.key?("toughness")
|
|
65
|
+
chars.with(**attrs.transform_values { it.is_a?(Array) ? it.freeze : it })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.sym(list) = Array(list).map(&:to_sym)
|
|
69
|
+
|
|
70
|
+
# ---- making copies ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# CR 707.8, 111.1: a token that's a copy of [card, except], created under `controller`'s control.
|
|
73
|
+
def create_token_copy!(values, controller, tapped: false)
|
|
74
|
+
card, except = values
|
|
75
|
+
memory = { "token" => true }
|
|
76
|
+
memory["copy_except"] = except unless except.empty?
|
|
77
|
+
create_token!(catalog.fetch(card), controller, tapped: tapped, memory: memory)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# CR 707.4, 613.2a: `obj` becomes a copy of [card, except]; no enter or leave triggers. A
|
|
81
|
+
# "permanent" copy effect lasts as long as the object stays on the battlefield.
|
|
82
|
+
def become_copy!(obj, values, duration: "permanent")
|
|
83
|
+
card, except = values
|
|
84
|
+
obj.memory["own_card"] ||= obj.card
|
|
85
|
+
(obj.memory["copy_effects"] ||= []) << { "card" => card, "except" => except, "duration" => duration,
|
|
86
|
+
"timestamp" => next_timestamp! }
|
|
87
|
+
obj.card = card
|
|
88
|
+
emit Events::EffectStarted.new(kind: "copy", affected: [obj.id], details: { "card" => card })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# CR 707.5: a permanent entering as a copy ({"card", "except"}) is one as it enters.
|
|
92
|
+
def enter_as_copy!(fresh, copy)
|
|
93
|
+
fresh.memory["own_card"] = fresh.card
|
|
94
|
+
fresh.memory["copy_effects"] = [copy.merge("duration" => "permanent", "timestamp" => next_timestamp!)]
|
|
95
|
+
fresh.card = copy["card"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# CR 707.12: a copy of a card, created in the zone the card is in (owned by `controller`).
|
|
99
|
+
def create_card_copy!(values, controller, zone)
|
|
100
|
+
card, except = values
|
|
101
|
+
obj = create_object(card, controller, zone)
|
|
102
|
+
obj.memory["copy"] = "card"
|
|
103
|
+
obj.memory["copy_except"] = except unless except.empty?
|
|
104
|
+
state.zone_list(zone, controller).unshift(obj.id)
|
|
105
|
+
record_result!(obj.id)
|
|
106
|
+
emit Events::ZoneChanged.new(card: card, old_id: nil, new_id: obj.id, owner: name(controller),
|
|
107
|
+
from: "none", to: zone.to_s, reason: "copy")
|
|
108
|
+
obj
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# CR 707.10: a copy of a spell or ability on the stack, with the same modes, X, kicker and other
|
|
112
|
+
# choices, put on top of the stack by `controller` (who owns and controls it). A spell copy isn't
|
|
113
|
+
# cast, an ability copy isn't activated; an ability copy has the same source (CR 707.10b) and keeps
|
|
114
|
+
# what the ability knew (its trigger event, the objects paid for its costs).
|
|
115
|
+
def copy_spell!(spell, controller, targets, except: {})
|
|
116
|
+
card, copied = spell.ability? ? [spell.card, {}] : copiable_values(spell)
|
|
117
|
+
except = except.empty? ? copied : Copies.merge_except(copied, except) # CR 707.9a
|
|
118
|
+
copy = create_object(card, controller, :stack)
|
|
119
|
+
if spell.ability?
|
|
120
|
+
copy.kind = "ability"
|
|
121
|
+
copy.source = spell.source
|
|
122
|
+
copy.ability = spell.ability
|
|
123
|
+
copy.memory = Snapshot.deep_copy(spell.memory.except("trigger_id")) # the copy wasn't triggered
|
|
124
|
+
else
|
|
125
|
+
copy.memory = Marshal.load(Marshal.dump(spell.memory.slice("kicked", "kicked_with", "gift", "division"))).merge("copy" => "spell")
|
|
126
|
+
end
|
|
127
|
+
copy.memory["copy_except"] = except unless except.empty?
|
|
128
|
+
copy.targets = targets
|
|
129
|
+
copy.modes = spell.modes&.dup
|
|
130
|
+
copy.x = spell.x
|
|
131
|
+
copy.mana_spent = nil # CR 707.10: mana isn't copied; no mana was spent to cast a copy
|
|
132
|
+
copy.paid = spell.paid&.map(&:dup)
|
|
133
|
+
state.stack.unshift(copy.id)
|
|
134
|
+
record_result!(copy.id)
|
|
135
|
+
emit Events::SpellCopied.new(player: name(controller), object: copy.id, card: card, original: spell.id,
|
|
136
|
+
targets: targets)
|
|
137
|
+
copy
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ---- zone changes and state-based actions ------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
# CR 400.7: the new object is the card itself again; a token or copy stays one. A copy of a permanent
|
|
143
|
+
# spell becomes a token as it enters (CR 608.3f, 707.10f).
|
|
144
|
+
def carry_copy_status!(fresh, old, to)
|
|
145
|
+
fresh.memory["token"] = true if old.memory["token"]
|
|
146
|
+
fresh.memory["copy_except"] = old.memory["copy_except"] if old.memory["copy_except"]
|
|
147
|
+
kind = old.memory["copy"] or return
|
|
148
|
+
return fresh.memory["token"] = true if kind == "spell" && to == :battlefield
|
|
149
|
+
|
|
150
|
+
fresh.memory["copy"] = kind
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# CR 704.5d, 111.7, 707.10a: tokens outside the battlefield, spell copies outside the stack and card
|
|
154
|
+
# copies outside the stack and battlefield cease to exist (state-based actions).
|
|
155
|
+
# Zones a token or card copy can be in (a phased-out permanent is still on the battlefield, CR 702.26d).
|
|
156
|
+
STAYING = %i[battlefield stack phased_out].freeze
|
|
157
|
+
|
|
158
|
+
def ceases_to_exist?(obj)
|
|
159
|
+
return false if obj.ability?
|
|
160
|
+
|
|
161
|
+
case obj.memory["copy"]
|
|
162
|
+
when "spell" then obj.zone != :stack
|
|
163
|
+
when "card" then !STAYING.include?(obj.zone) && !prepared_copy?(obj) # CR 723.3c (Engine::Faces)
|
|
164
|
+
else !STAYING.include?(obj.zone) && token?(obj)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# CR 514.2: "until end of turn" copy effects end; the permanent has the copiable values of the
|
|
169
|
+
# newest copy effect left, or its own.
|
|
170
|
+
def end_copy_effects!
|
|
171
|
+
state.battlefield.each do |id|
|
|
172
|
+
obj = object(id)
|
|
173
|
+
effects = obj.memory["copy_effects"] or next
|
|
174
|
+
next unless effects.any? { it["duration"] == "end_of_turn" }
|
|
175
|
+
|
|
176
|
+
effects.reject! { it["duration"] == "end_of_turn" }
|
|
177
|
+
obj.card = effects.last&.fetch("card") || obj.memory["own_card"]
|
|
178
|
+
obj.memory.delete("copy_effects") if effects.empty?
|
|
179
|
+
obj.memory.delete("own_card") if effects.empty?
|
|
180
|
+
emit Events::EffectsEnded.new(count: 1)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ---- abilities granted to a card (CR 113.6b, 611.3) ----------------------------------------
|
|
185
|
+
|
|
186
|
+
# Abilities an object gained from a resolved effect (state.effects "card_ability", lasting while it
|
|
187
|
+
# stays in its zone or until end of turn), e.g. an exiled card: "granted:<granting card>:<ref>".
|
|
188
|
+
def card_granted_abilities(obj)
|
|
189
|
+
state.effects.filter_map do |effect|
|
|
190
|
+
next unless effect["kind"] == "card_ability" && effect["affected"].include?(obj.id)
|
|
191
|
+
|
|
192
|
+
ability = catalog.fetch(effect["card"]).ability(effect["ability"]) or next
|
|
193
|
+
AbilityInstance.new(ref: "granted:#{effect["card"]}:#{effect["ability"]}", ability: ability)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Starts a "card_ability" effect; ones whose cards no longer exist are dropped.
|
|
198
|
+
def grant_card_ability!(ctx, affected, card, ref, duration)
|
|
199
|
+
state.effects.reject! { it["kind"] == "card_ability" && it["affected"].none? { state.objects.key?(it) } }
|
|
200
|
+
duration = "while_in_zone" if duration == "permanent" # a new object is a different one (CR 400.7)
|
|
201
|
+
state.effects << { "kind" => "card_ability", "timestamp" => next_timestamp!, "controller" => ctx.controller,
|
|
202
|
+
"affected" => affected, "duration" => duration, "card" => card, "ability" => ref }
|
|
203
|
+
emit Events::EffectStarted.new(kind: "card_ability", affected: affected, details: { "card" => card })
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
include Copies
|
|
207
|
+
end
|
|
208
|
+
end
|