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,569 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "replacement_effects"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
# Which damage a damage replacement or prevention effect of a permanent watches, relative to that
|
|
8
|
+
# permanent (the "holder"): its sources (`by`) or recipients (`to`).
|
|
9
|
+
# "self" | "attached" the holder, or what it enchants or equips
|
|
10
|
+
# {"each": filter, "controller": you|opponent|any, "other": bool}
|
|
11
|
+
# `each` is optional (any player or object); players match on the filter (`player`, `opponent`) and
|
|
12
|
+
# on `controller` (themselves), so {"controller": "opponent"} is "an opponent or a permanent an
|
|
13
|
+
# opponent controls" and {"controller": "you", "other": true} "you and other permanents you control".
|
|
14
|
+
class DamageScope
|
|
15
|
+
CONTROLLERS = %w[you opponent any].freeze
|
|
16
|
+
|
|
17
|
+
def self.build(data, errors, path)
|
|
18
|
+
return nil if data.nil?
|
|
19
|
+
return new(data.to_sym, nil, "any", false) if %w[self attached].include?(data)
|
|
20
|
+
|
|
21
|
+
unless data.is_a?(Hash)
|
|
22
|
+
errors << "#{path}: must be \"self\", \"attached\" or {\"each\": filter, \"controller\": you|opponent|any, " \
|
|
23
|
+
"\"other\": bool}"
|
|
24
|
+
return nil
|
|
25
|
+
end
|
|
26
|
+
Mechanics.check_keys(data, %w[each controller other], errors, path)
|
|
27
|
+
controller = data.fetch("controller", "any")
|
|
28
|
+
errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}" unless CONTROLLERS.include?(controller)
|
|
29
|
+
filter = data["each"] && Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each")
|
|
30
|
+
new(:each, filter, controller, data["other"] == true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(kind, filter, controller, other)
|
|
34
|
+
@kind = kind
|
|
35
|
+
@filter = filter
|
|
36
|
+
@controller = controller
|
|
37
|
+
@other = other
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether `ref` (a player reference or an object id) is in scope for the permanent `holder`.
|
|
42
|
+
def match?(engine, ref, holder)
|
|
43
|
+
return ref == holder.id if @kind == :self
|
|
44
|
+
return !holder.attached_to.nil? && ref == holder.attached_to if @kind == :attached
|
|
45
|
+
return false if @other && ref == holder.id
|
|
46
|
+
|
|
47
|
+
index = Filters.player_index(engine, ref)
|
|
48
|
+
owner = index || engine.state.objects[ref]&.controller
|
|
49
|
+
return false if owner.nil?
|
|
50
|
+
return false unless @controller == "any" || (@controller == "you") == (owner == holder.controller)
|
|
51
|
+
|
|
52
|
+
@filter.nil? || @filter.match_for?(engine, ref, holder.controller, holder.id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The permanent a redirection sends damage to (CR 614.9).
|
|
56
|
+
def redirect_target(holder) = @kind == :self ? holder.id : holder.attached_to
|
|
57
|
+
|
|
58
|
+
def to_s
|
|
59
|
+
return "this" if @kind == :self
|
|
60
|
+
return "the attached creature" if @kind == :attached
|
|
61
|
+
|
|
62
|
+
what = @filter ? Triggers.filter_words(@filter) : "anything"
|
|
63
|
+
"#{"other " if @other}#{what}#{{ "you" => " (yours)", "opponent" => " (an opponent's)" }[@controller]}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The damage pass of the replacement step (CR 614.1a, 614.9, 615, 616.1): redirection, prevention and
|
|
68
|
+
# damage modification, from static abilities of permanents and from resolved prevention effects.
|
|
69
|
+
#
|
|
70
|
+
# The affected player (or the affected permanent's controller) chooses the order (CR 616.1). The engine
|
|
71
|
+
# applies them in the order that player would choose to take the least damage: redirections first (the
|
|
72
|
+
# damage then affects the new recipient), then prevention, then multiplying, then adding.
|
|
73
|
+
module DamageEvents
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def replace!(engine, proposal)
|
|
77
|
+
redirect!(engine, proposal)
|
|
78
|
+
proposal.data["unpreventable"] = true if unpreventable?(engine, proposal.data) # CR 615.12 (also shield counters)
|
|
79
|
+
prevent!(engine, proposal)
|
|
80
|
+
modify!(engine, proposal)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def statics(engine, kind)
|
|
84
|
+
Replacements.battlefield_abilities(engine, :damage_scope).select { |_, ability| ability.kind == kind }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def applicable(engine, proposal, kind)
|
|
88
|
+
statics(engine, kind).select do |holder, ability|
|
|
89
|
+
!proposal.applied.include?("#{holder.id}:#{kind}") && ability.applies?(engine, proposal.data, holder)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# CR 614.9: "all damage that would be dealt to you is dealt to [this creature] instead". The new
|
|
94
|
+
# recipient must still be a creature or planeswalker on the battlefield, else nothing happens.
|
|
95
|
+
def redirect!(engine, proposal)
|
|
96
|
+
loop do # each redirection applies once (CR 614.5); the next one sees the new recipient (CR 616.1f)
|
|
97
|
+
holder, ability = applicable(engine, proposal, :damage_replacement).find do |source, found|
|
|
98
|
+
found.redirect && redirect_ok?(engine, found.redirect.redirect_target(source), proposal.data["target"])
|
|
99
|
+
end
|
|
100
|
+
return unless holder
|
|
101
|
+
|
|
102
|
+
proposal.applied << "#{holder.id}:damage_replacement"
|
|
103
|
+
proposal.data["target"] = ability.redirect.redirect_target(holder)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def redirect_ok?(engine, to, from)
|
|
108
|
+
to && to != from && engine.state.battlefield.include?(to) && (engine.chars(to).creature? || engine.chars(to).planeswalker?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# CR 615: prevention effects of static abilities, then shields from resolved effects (CR 615.7).
|
|
112
|
+
# Damage that can't be prevented still passes through them without being prevented (CR 615.12).
|
|
113
|
+
def prevent!(engine, proposal)
|
|
114
|
+
data = proposal.data
|
|
115
|
+
return if data["amount"].zero? || data["unpreventable"]
|
|
116
|
+
|
|
117
|
+
data["amount"] = 0 if engine.damage_prevented?(data["target"]) # "prevent all damage to you" (player_rule)
|
|
118
|
+
data["amount"] = 0 if protected?(engine, data) # CR 702.16e
|
|
119
|
+
applicable(engine, proposal, :prevent_damage).each do |holder, ability|
|
|
120
|
+
break if data["amount"].zero?
|
|
121
|
+
|
|
122
|
+
follow_up!(engine, data, ability.follow_up&.entry(holder.card, holder.controller, holder.id, {}), data["amount"])
|
|
123
|
+
data["amount"] = 0
|
|
124
|
+
end
|
|
125
|
+
engine.state.effects.select { it["kind"] == "prevent_damage" }.each do |effect|
|
|
126
|
+
break if data["amount"].zero?
|
|
127
|
+
next unless PreventionEffect.applies?(engine, effect, data)
|
|
128
|
+
|
|
129
|
+
before = data["amount"]
|
|
130
|
+
PreventionEffect.use!(engine, effect, data)
|
|
131
|
+
follow_up!(engine, data, effect["follow_up"], before - data["amount"])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# CR 615.5: the rest of a prevention effect happens right after the event (Engine#run_follow_ups!), seeing
|
|
136
|
+
# the damage prevented, its source and the source's controller.
|
|
137
|
+
def follow_up!(engine, data, entry, prevented)
|
|
138
|
+
return unless entry && prevented.positive?
|
|
139
|
+
|
|
140
|
+
facts = { "amount" => prevented, "object" => data["source"], "player" => engine.damage_source_controller(data["source"]) }
|
|
141
|
+
(data["follow_ups"] ||= []) << entry.merge("facts" => facts.compact)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Protection from the source's quality prevents the damage (CR 702.16e), also a player's protection from each
|
|
145
|
+
# of their opponents (CR 702.16k).
|
|
146
|
+
def protected?(engine, data)
|
|
147
|
+
source = engine.state.objects[data["source"]]
|
|
148
|
+
player = Filters.player_index(engine, data["target"])
|
|
149
|
+
if player
|
|
150
|
+
return !source.nil? && engine.player_rule?(player, "protection_from_opponents") &&
|
|
151
|
+
Abilities::QualityAbility.opponents?(source, player)
|
|
152
|
+
end
|
|
153
|
+
target = engine.state.objects[data["target"]]
|
|
154
|
+
target&.zone == :battlefield && engine.protected_from?(target, source)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# CR 615.12: a game rule (Engine::Rules#unpreventable?), a static ability or a resolved effect.
|
|
158
|
+
def unpreventable?(engine, data)
|
|
159
|
+
engine.unpreventable?(data["combat"]) ||
|
|
160
|
+
statics(engine, :damage_unpreventable).any? { |holder, ability| ability.applies?(engine, data, holder) } ||
|
|
161
|
+
engine.state.effects.any? do
|
|
162
|
+
it["kind"] == "damage_unpreventable" && (it["combat"].nil? || it["combat"] == (data["combat"] ? "combat" : "noncombat"))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# "Deals double that damage instead" and "that much damage plus N instead" (CR 614.1a).
|
|
167
|
+
def modify!(engine, proposal)
|
|
168
|
+
data = proposal.data
|
|
169
|
+
return if data["amount"].zero?
|
|
170
|
+
|
|
171
|
+
found = applicable(engine, proposal, :damage_replacement).reject { |_, ability| ability.redirect }
|
|
172
|
+
found.sort_by { |_, ability| ability.multiply ? 0 : 1 }.each do |holder, ability|
|
|
173
|
+
proposal.applied << "#{holder.id}:damage_replacement"
|
|
174
|
+
data["amount"] = ability.modified(engine, data["amount"], holder)
|
|
175
|
+
end
|
|
176
|
+
modify_by_effects!(engine, proposal)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Resolved "until your next turn, if a source would deal damage to that player or a permanent that player
|
|
180
|
+
# controls, it deals double that damage instead" effects (Effects::DamageReplacementEffect): doubling first.
|
|
181
|
+
def modify_by_effects!(engine, proposal)
|
|
182
|
+
data = proposal.data
|
|
183
|
+
found = engine.state.effects.select do |effect|
|
|
184
|
+
effect["kind"] == "damage_replacement" && !proposal.applied.include?("effect:#{effect["timestamp"]}") &&
|
|
185
|
+
ModifyingEffect.applies?(engine, effect, data)
|
|
186
|
+
end
|
|
187
|
+
found.sort_by { it["multiply"] ? 0 : 1 }.each do |effect|
|
|
188
|
+
proposal.applied << "effect:#{effect["timestamp"]}"
|
|
189
|
+
data["amount"] = effect["multiply"] ? data["amount"] * effect["multiply"] : data["amount"] + effect["plus"]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Damage replacement effects of resolved spells and abilities (state.effects, kind "damage_replacement"):
|
|
194
|
+
# {"to": [refs], "permanents": bool, "combat": ..., "multiply": n | nil, "plus": n | nil}. A player in `to`
|
|
195
|
+
# with "permanents" also covers the permanents they control as the damage is dealt (CR 611.2c).
|
|
196
|
+
module ModifyingEffect
|
|
197
|
+
module_function
|
|
198
|
+
|
|
199
|
+
def applies?(engine, effect, data)
|
|
200
|
+
return false unless DamageEvents.combat_ok?(effect["combat"], data["combat"])
|
|
201
|
+
|
|
202
|
+
target = data["target"]
|
|
203
|
+
return true if effect["to"].include?(target)
|
|
204
|
+
|
|
205
|
+
controller = engine.state.battlefield.include?(target) && engine.object(target).controller
|
|
206
|
+
effect["permanents"] && controller && effect["to"].include?(Filters.player_ref(engine, controller))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Prevention effects started by a resolved spell or ability (state.effects, kind "prevent_damage"):
|
|
211
|
+
# {"to": [refs] | nil, "to_each": damage scope | nil, "by": [refs] | nil, "combat": "combat" | "noncombat" | nil,
|
|
212
|
+
# "remaining": n | nil, "next_time": true | nil, "follow_up": FollowUp entry | nil}.
|
|
213
|
+
module PreventionEffect
|
|
214
|
+
module_function
|
|
215
|
+
|
|
216
|
+
# The effect's controller, standing in for the holder of a `to_each` damage scope (CR 611.2c: the
|
|
217
|
+
# recipients are checked as the damage is dealt).
|
|
218
|
+
Holder = Data.define(:id, :controller, :attached_to)
|
|
219
|
+
|
|
220
|
+
def applies?(engine, effect, data)
|
|
221
|
+
(effect["to"].nil? || effect["to"].include?(data["target"])) &&
|
|
222
|
+
(effect["to_each"].nil? || to_each?(engine, effect, data["target"])) &&
|
|
223
|
+
(effect["by"].nil? || effect["by"].include?(data["source"])) &&
|
|
224
|
+
DamageEvents.combat_ok?(effect["combat"], data["combat"])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def to_each?(engine, effect, ref)
|
|
228
|
+
scope = DamageScope.build(effect["to_each"], [], "to_each")
|
|
229
|
+
scope.match?(engine, ref, Holder.new(id: nil, controller: effect["controller"], attached_to: nil))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A shield prevents that much and is used up (CR 615.7); without one, all of it is prevented.
|
|
233
|
+
def use!(engine, effect, data)
|
|
234
|
+
engine.state.effects.delete(effect) if effect["next_time"] # CR 615.8: the next time only
|
|
235
|
+
return data["amount"] = 0 unless effect["remaining"]
|
|
236
|
+
|
|
237
|
+
prevented = [effect["remaining"], data["amount"]].min
|
|
238
|
+
effect["remaining"] -= prevented
|
|
239
|
+
data["amount"] -= prevented
|
|
240
|
+
engine.state.effects.delete(effect) if effect["remaining"].zero?
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
COMBAT = %w[combat noncombat].freeze
|
|
245
|
+
|
|
246
|
+
def combat_ok?(wanted, combat) = wanted.nil? || (wanted == "combat") == combat
|
|
247
|
+
|
|
248
|
+
def parse_combat(params, errors, path)
|
|
249
|
+
combat = Mechanics.fetch_param(params, :combat)
|
|
250
|
+
errors << "#{path}.combat: must be one of #{COMBAT.join(", ")}" unless combat.nil? || COMBAT.include?(combat)
|
|
251
|
+
combat
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
module Abilities
|
|
256
|
+
# Base for static abilities of a permanent about some damage events: damage from sources `by`,
|
|
257
|
+
# to recipients `to`, combat or noncombat damage.
|
|
258
|
+
class DamageStatic < StaticReplacement
|
|
259
|
+
attr_reader :by, :to, :combat
|
|
260
|
+
|
|
261
|
+
def self.parse_scopes(params, errors, path)
|
|
262
|
+
[DamageScope.build(Mechanics.fetch_param(params, :by), errors, "#{path}.by"),
|
|
263
|
+
DamageScope.build(Mechanics.fetch_param(params, :to), errors, "#{path}.to"),
|
|
264
|
+
DamageEvents.parse_combat(params, errors, path)]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def initialize(sources, recipients, combat)
|
|
268
|
+
super()
|
|
269
|
+
@by = sources
|
|
270
|
+
@to = recipients
|
|
271
|
+
@combat = combat
|
|
272
|
+
freeze
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def damage_scope = true
|
|
276
|
+
|
|
277
|
+
def applies?(engine, data, holder)
|
|
278
|
+
DamageEvents.combat_ok?(combat, data["combat"]) &&
|
|
279
|
+
(by.nil? || by.match?(engine, data["source"], holder)) && (to.nil? || to.match?(engine, data["target"], holder))
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def scope_text
|
|
283
|
+
["#{"#{combat} " if combat}damage", by && "by #{by}", to && "to #{to}"].compact.join(" ")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# "If a source you control would deal damage to an opponent or a permanent an opponent controls, it
|
|
288
|
+
# deals double that damage instead" / "... that much damage plus 1 instead" / "All damage that would
|
|
289
|
+
# be dealt to you is dealt to this creature instead" (CR 614.1a, 614.9).
|
|
290
|
+
class DamageReplacement < DamageStatic
|
|
291
|
+
primitive :ability, :damage_replacement,
|
|
292
|
+
description: "While this permanent is on the battlefield, damage from sources in `by` to recipients " \
|
|
293
|
+
"in `to` (both optional; `combat`: combat | noncombat) is changed: `multiply` (2 = " \
|
|
294
|
+
"\"double that damage\"), `plus` an amount (\"that much damage plus 1\"), or " \
|
|
295
|
+
"`redirect` (\"self\" | \"attached\": dealt to this permanent or the one it's attached " \
|
|
296
|
+
"to instead). A scope is \"self\", \"attached\" or {\"each\": filter, \"controller\": " \
|
|
297
|
+
"you|opponent|any, \"other\": bool}; `each` is optional and players match too, so " \
|
|
298
|
+
"{\"controller\": \"opponent\"} is \"an opponent or a permanent an opponent controls\". " \
|
|
299
|
+
"Several apply in the order the affected player would pick: redirections, then " \
|
|
300
|
+
"prevention, then multiplying, then adding.",
|
|
301
|
+
rules: %w[614.1a 614.5 614.9 616.1 616.1e 616.1f 120.2b],
|
|
302
|
+
params: { "by" => "damage scope", "to" => "damage scope", "combat" => "combat | noncombat",
|
|
303
|
+
"multiply" => "integer >= 2", "plus" => "amount", "redirect" => "\"self\" | \"attached\"" },
|
|
304
|
+
example: { "damage_replacement" => { "by" => { "controller" => "you" }, "to" => { "controller" => "opponent" },
|
|
305
|
+
"multiply" => 2 } }
|
|
306
|
+
|
|
307
|
+
attr_reader :multiply, :plus, :redirect
|
|
308
|
+
|
|
309
|
+
def self.build(params, errors, path, **)
|
|
310
|
+
Mechanics.check_keys(params, %w[by to combat multiply plus redirect], errors, path)
|
|
311
|
+
modes = %w[multiply plus redirect].select { params.key?(it) }
|
|
312
|
+
errors << "#{path}: needs exactly one of multiply, plus, redirect" unless modes.size == 1
|
|
313
|
+
multiply = params.key?("multiply") ? Mechanics.integer_param(params, :multiply, errors, path, min: 2) : nil
|
|
314
|
+
plus = params.key?("plus") ? Amount.build(params["plus"], errors, "#{path}.plus", targets_count: 0) : nil
|
|
315
|
+
redirect = params["redirect"]
|
|
316
|
+
errors << "#{path}.redirect: must be \"self\" or \"attached\"" if params.key?("redirect") && !%w[self attached].include?(redirect)
|
|
317
|
+
new(*parse_scopes(params, errors, path), multiply, plus, redirect && DamageScope.new(redirect.to_sym, nil, "any", false))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def initialize(sources, recipients, combat, multiply, plus, redirect)
|
|
321
|
+
@multiply = multiply
|
|
322
|
+
@plus = plus
|
|
323
|
+
@redirect = redirect
|
|
324
|
+
super(sources, recipients, combat)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def kind = :damage_replacement
|
|
328
|
+
|
|
329
|
+
def modified(engine, amount, holder)
|
|
330
|
+
return amount * multiply if multiply
|
|
331
|
+
|
|
332
|
+
amount + [plus.value(engine, EffectContext.new(holder.controller, holder.id, holder.id, [], [])), 0].max
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def to_s
|
|
336
|
+
instead = if multiply then "#{multiply} times that damage"
|
|
337
|
+
elsif plus then "that much plus #{plus}"
|
|
338
|
+
else "that damage to #{redirect}"
|
|
339
|
+
end
|
|
340
|
+
"#{scope_text}: #{instead} instead"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# "Prevent all combat damage that would be dealt to and dealt by this creature" / "Prevent all
|
|
345
|
+
# noncombat damage that would be dealt to other creatures you control" (CR 615.1).
|
|
346
|
+
class PreventDamage < DamageStatic
|
|
347
|
+
primitive :ability, :prevent_damage,
|
|
348
|
+
description: "While this permanent is on the battlefield, all damage from sources in `by` to " \
|
|
349
|
+
"recipients in `to` (damage scopes as in damage_replacement, both optional; " \
|
|
350
|
+
"`combat`: combat | noncombat) is prevented. \"To and by this creature\" is two of them. " \
|
|
351
|
+
"`then`: effects right after it prevents damage (\"prevent that damage and put that many " \
|
|
352
|
+
"+1/+1 counters on him\"), reading {\"triggering\": \"amount\"} (the damage prevented), " \
|
|
353
|
+
"\"object\" (the source) and \"player\" (its controller).",
|
|
354
|
+
rules: %w[615.1 615.1a 615.5 615.6],
|
|
355
|
+
params: { "by" => "damage scope", "to" => "damage scope", "combat" => "combat | noncombat",
|
|
356
|
+
"then" => "list of effects" },
|
|
357
|
+
example: { "prevent_damage" => { "to" => { "each" => "creature", "controller" => "you", "other" => true },
|
|
358
|
+
"combat" => "noncombat" } }
|
|
359
|
+
|
|
360
|
+
attr_reader :follow_up
|
|
361
|
+
|
|
362
|
+
def self.build(params, errors, path, **)
|
|
363
|
+
Mechanics.check_keys(params, %w[by to combat then], errors, path)
|
|
364
|
+
new(*parse_scopes(params, errors, path), FollowUp.build(params, errors, path))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def initialize(sources, recipients, combat, follow_up = nil)
|
|
368
|
+
@follow_up = follow_up
|
|
369
|
+
super(sources, recipients, combat)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def kind = :prevent_damage
|
|
373
|
+
def try_effects = [follow_up].compact
|
|
374
|
+
def to_s = "prevent all #{scope_text}#{", then #{follow_up}" if follow_up}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# "Damage can't be prevented." / "Combat damage that would be dealt by creatures you control can't be
|
|
378
|
+
# prevented." (CR 615.12)
|
|
379
|
+
class DamageUnpreventable < DamageStatic
|
|
380
|
+
primitive :ability, :damage_unpreventable,
|
|
381
|
+
description: "While this permanent is on the battlefield, damage from sources in `by` to recipients " \
|
|
382
|
+
"in `to` (both optional; `combat`: combat | noncombat) can't be prevented: prevention " \
|
|
383
|
+
"effects don't reduce it and shields aren't used up.",
|
|
384
|
+
rules: %w[615.12],
|
|
385
|
+
params: { "by" => "damage scope", "to" => "damage scope", "combat" => "combat | noncombat" },
|
|
386
|
+
example: { "damage_unpreventable" => { "combat" => "combat" } }
|
|
387
|
+
|
|
388
|
+
def self.build(params, errors, path, **)
|
|
389
|
+
Mechanics.check_keys(params, %w[by to combat], errors, path)
|
|
390
|
+
new(*parse_scopes(params, errors, path))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def kind = :damage_unpreventable
|
|
394
|
+
def to_s = "#{scope_text} can't be prevented"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
module Effects
|
|
399
|
+
# "Prevent all combat damage that would be dealt to it this turn", "Prevent the next 3 damage that
|
|
400
|
+
# would be dealt to any target this turn", "Prevent all damage that would be dealt by target creature
|
|
401
|
+
# this turn", "Prevent all damage that would be dealt to creatures you control this turn", "The next time
|
|
402
|
+
# a source of your choice would deal damage to you this turn, prevent that damage. When damage is
|
|
403
|
+
# prevented this way, ..." (CR 615.1, 615.3, 615.5, 615.7, 615.8, 609.7a, 611.2b-c).
|
|
404
|
+
class PreventDamageEffect < ChoiceEffect
|
|
405
|
+
DURATIONS = %w[end_of_turn until_your_next_turn while_source_on_battlefield].freeze
|
|
406
|
+
|
|
407
|
+
primitive :effect, :prevent_damage,
|
|
408
|
+
description: "For the `duration` (end_of_turn by default, until_your_next_turn, or " \
|
|
409
|
+
"while_source_on_battlefield: for as long as this effect's source remains on the " \
|
|
410
|
+
"battlefield), damage that would be dealt to the referenced players and permanents " \
|
|
411
|
+
"(`to`, fixed as it resolves) or to those matching `to_each` as the damage is dealt " \
|
|
412
|
+
"(a damage scope seen from the controller, e.g. {\"each\": \"creature\", " \
|
|
413
|
+
"\"controller\": \"you\"}), and/or by the referenced sources (`by`; \"chosen_source\": " \
|
|
414
|
+
"a source the controller chooses as it resolves) is prevented (`combat`: combat | " \
|
|
415
|
+
"noncombat only). With `next`, it is a shield that prevents only the next that much " \
|
|
416
|
+
"damage; with `next_time: true` it prevents only the next damage event it applies to. " \
|
|
417
|
+
"`then`: effects right after it prevents damage, reading {\"triggering\": \"amount\"} " \
|
|
418
|
+
"(the damage prevented), \"object\" (the source) and \"player\" (its controller).",
|
|
419
|
+
rules: %w[615.1 615.3 615.5 615.7 615.8 609.7a 611.2a 611.2b 611.2c],
|
|
420
|
+
params: { "to" => "selector (recipients)", "to_each" => "damage scope (recipients, as damage is dealt)",
|
|
421
|
+
"by" => "selector (sources) | \"chosen_source\"", "combat" => "combat | noncombat",
|
|
422
|
+
"next" => "amount (a shield)", "next_time" => "boolean", "duration" => DURATIONS.join(" | "),
|
|
423
|
+
"then" => "list of effects" },
|
|
424
|
+
example: { "prevent_damage" => { "to" => { "target" => 0 }, "combat" => "combat" } }
|
|
425
|
+
|
|
426
|
+
attr_reader :by, :combat, :shield, :duration, :to_each, :follow_up
|
|
427
|
+
|
|
428
|
+
def self.keys = %w[to to_each by combat next next_time duration then if]
|
|
429
|
+
|
|
430
|
+
# `to` may be left out when `by` names the sources ("damage that would be dealt by target creature") or
|
|
431
|
+
# `to_each` the recipients.
|
|
432
|
+
def self.build(params, errors, path, targets_count: 0, results_count: 0, **)
|
|
433
|
+
if Mechanics.fetch_param(params, :to) || (!Mechanics.fetch_param(params, :by) && !Mechanics.fetch_param(params, :to_each))
|
|
434
|
+
return super
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
438
|
+
new(to: nil, params: params, errors: errors, path: path, targets_count: targets_count, results_count: results_count)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def setup(params, errors, path)
|
|
442
|
+
raw_by = Mechanics.fetch_param(params, :by)
|
|
443
|
+
@chosen_source = raw_by == "chosen_source"
|
|
444
|
+
unless raw_by.nil? || @chosen_source
|
|
445
|
+
@by = Selector.build(raw_by, errors, "#{path}.by", targets_count: @targets_count, results_count: @results_count)
|
|
446
|
+
end
|
|
447
|
+
@to_each_data = Mechanics.fetch_param(params, :to_each)
|
|
448
|
+
@to_each = DamageScope.build(@to_each_data, errors, "#{path}.to_each")
|
|
449
|
+
errors << "#{path}.to_each: must be a damage scope {\"each\": filter, \"controller\": ...}" if @to_each_data.is_a?(String)
|
|
450
|
+
@combat = DamageEvents.parse_combat(params, errors, path)
|
|
451
|
+
raw_next = Mechanics.fetch_param(params, :next)
|
|
452
|
+
@shield = raw_next && Amount.build(raw_next, errors, "#{path}.next", targets_count: @targets_count, min: 1,
|
|
453
|
+
results_count: @results_count)
|
|
454
|
+
@next_time = Mechanics.flag_param(params, :next_time, errors, path)
|
|
455
|
+
errors << "#{path}: next and next_time can't be combined" if @shield && @next_time
|
|
456
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
457
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
458
|
+
@follow_up = FollowUp.build(params, errors, path)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def nested_effects = [follow_up].compact
|
|
462
|
+
|
|
463
|
+
# A source of the controller's choice (CR 609.7a): a permanent or a spell on the stack, chosen as it resolves.
|
|
464
|
+
def run(engine, ctx, locals)
|
|
465
|
+
return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
|
|
466
|
+
return start(engine, ctx, nil) unless @chosen_source
|
|
467
|
+
|
|
468
|
+
answer = answers(locals).first
|
|
469
|
+
return start(engine, ctx, [answer["object"]]) if answer
|
|
470
|
+
|
|
471
|
+
sources = engine.state.battlefield + engine.state.stack.reject { it == ctx.object }
|
|
472
|
+
return :done if sources.empty?
|
|
473
|
+
|
|
474
|
+
ask(engine, locals, ctx.controller, "Choose a source of damage", sources.map { { "type" => "source", "object" => it } })
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def start(engine, ctx, chosen)
|
|
478
|
+
# CR 611.2b: "for as long as" a source that has already left never starts.
|
|
479
|
+
return :done if duration == "while_source_on_battlefield" && !engine.state.battlefield.include?(ctx.source)
|
|
480
|
+
|
|
481
|
+
to = self.to && refs(engine, ctx)
|
|
482
|
+
by = chosen || @by&.resolve(engine, ctx)
|
|
483
|
+
details = { "to" => to, "to_each" => @to_each_data, "by" => by, "combat" => combat, "duration" => duration,
|
|
484
|
+
"source" => ctx.source, "next_time" => (true if @next_time) }.compact
|
|
485
|
+
details["to"] = nil if to.nil? # "to": nil means any recipient
|
|
486
|
+
details["by"] = nil if by.nil?
|
|
487
|
+
details["remaining"] = shield.value(engine, ctx) if shield
|
|
488
|
+
card = engine.state.objects[ctx.object]&.card
|
|
489
|
+
details["follow_up"] = follow_up.entry(card, ctx.controller, ctx.source, {}) if follow_up && card
|
|
490
|
+
engine.add_continuous_effect!("prevent_damage", ctx, (to || []) + (by || []), details)
|
|
491
|
+
:done
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def to_s
|
|
495
|
+
what = if shield then "the next #{shield}"
|
|
496
|
+
elsif @next_time then "the next time"
|
|
497
|
+
else "all"
|
|
498
|
+
end
|
|
499
|
+
["prevent #{what} #{"#{combat} " if combat}damage", to && "to #{to}", @to_each && "to #{@to_each}",
|
|
500
|
+
(@chosen_source ? "by a source of your choice" : by && "by #{by}"), follow_up && "then #{follow_up}"].compact.join(" ")
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# "Until your next turn, if a source would deal damage to that player or a permanent that player controls,
|
|
505
|
+
# it deals double that damage instead." (CR 614.1a, 611.2c, 616.1)
|
|
506
|
+
class DamageReplacementEffect < Effect
|
|
507
|
+
DURATIONS = %w[end_of_turn until_your_next_turn].freeze
|
|
508
|
+
|
|
509
|
+
primitive :effect, :damage_replacement,
|
|
510
|
+
description: "For the `duration` (end_of_turn by default, or until_your_next_turn), damage that " \
|
|
511
|
+
"would be dealt to the referenced players and permanents (`to`, fixed as it resolves; " \
|
|
512
|
+
"with `permanents: true` also the permanents a referenced player controls as the damage " \
|
|
513
|
+
"is dealt) is changed: `multiply` (2 = \"double that damage\") or `plus` an amount " \
|
|
514
|
+
"(computed as it resolves). `combat`: combat | noncombat only. Doubling applies before " \
|
|
515
|
+
"adding.",
|
|
516
|
+
rules: %w[614.1a 611.2a 611.2c 616.1],
|
|
517
|
+
params: { "to" => "selector", "permanents" => "boolean", "combat" => "combat | noncombat",
|
|
518
|
+
"multiply" => "integer >= 2", "plus" => "amount", "duration" => DURATIONS.join(" | ") },
|
|
519
|
+
example: { "damage_replacement" => { "to" => { "triggering" => "player" }, "permanents" => true,
|
|
520
|
+
"multiply" => 2, "duration" => "until_your_next_turn" } }
|
|
521
|
+
|
|
522
|
+
attr_reader :multiply, :plus, :combat, :duration
|
|
523
|
+
|
|
524
|
+
def self.keys = %w[to permanents combat multiply plus duration if]
|
|
525
|
+
|
|
526
|
+
def setup(params, errors, path)
|
|
527
|
+
modes = %w[multiply plus].select { params.key?(it) }
|
|
528
|
+
errors << "#{path}: needs exactly one of multiply, plus" unless modes.size == 1
|
|
529
|
+
@multiply = params.key?("multiply") ? Mechanics.integer_param(params, :multiply, errors, path, min: 2) : nil
|
|
530
|
+
@plus = params.key?("plus") ? Amount.build(params["plus"], errors, "#{path}.plus", targets_count: @targets_count) : nil
|
|
531
|
+
@permanents = Mechanics.flag_param(params, :permanents, errors, path)
|
|
532
|
+
@combat = DamageEvents.parse_combat(params, errors, path)
|
|
533
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
534
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def apply(engine, ctx)
|
|
538
|
+
to = refs(engine, ctx)
|
|
539
|
+
details = { "to" => to, "permanents" => @permanents == true, "combat" => combat, "duration" => duration }
|
|
540
|
+
multiply ? details["multiply"] = multiply : details["plus"] = [plus.value(engine, ctx), 0].max
|
|
541
|
+
engine.add_continuous_effect!("damage_replacement", ctx, to, details)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def to_s
|
|
545
|
+
instead = multiply ? "#{multiply} times that damage" : "that much plus #{plus}"
|
|
546
|
+
"#{"#{combat} " if combat}damage to #{to}#{" and their permanents" if @permanents}: #{instead} instead"
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# "Damage can't be prevented this turn." (CR 615.12)
|
|
551
|
+
class DamageUnpreventableEffect < Effect
|
|
552
|
+
primitive :effect, :damage_unpreventable,
|
|
553
|
+
description: "Until end of turn, damage (`combat`: combat | noncombat only) can't be prevented.",
|
|
554
|
+
rules: %w[615.12 611.2a],
|
|
555
|
+
params: { "combat" => "combat | noncombat" },
|
|
556
|
+
example: "damage_unpreventable"
|
|
557
|
+
|
|
558
|
+
attr_reader :combat
|
|
559
|
+
|
|
560
|
+
def self.keys = %w[combat if]
|
|
561
|
+
def self.default_to = "you"
|
|
562
|
+
|
|
563
|
+
def setup(params, errors, path) = @combat = DamageEvents.parse_combat(params, errors, path)
|
|
564
|
+
def apply(engine, ctx) = engine.add_continuous_effect!("damage_unpreventable", ctx, [], "combat" => combat)
|
|
565
|
+
def to_s = "#{"#{combat} " if combat}damage can't be prevented this turn"
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Counter doubling: a replacement effect (CR 614.1a) of a permanent on the battlefield that
|
|
7
|
+
# doubles the number of counters its controller puts on matching permanents. Counters a
|
|
8
|
+
# permanent enters with are counters put on it (CR 122.6), by its controller unless an effect
|
|
9
|
+
# says otherwise (CR 122.6a).
|
|
10
|
+
class DoubleCounters < Primitive
|
|
11
|
+
primitive :ability, :double_counters,
|
|
12
|
+
description: "While this permanent is on the battlefield, if its controller would put one or more " \
|
|
13
|
+
"counters (only `counter`, if given) on a permanent matching `affects` " \
|
|
14
|
+
"({\"each\": filter, \"controller\": you|opponent|any}, relative to this permanent's " \
|
|
15
|
+
"controller), the number put is doubled. This includes counters a " \
|
|
16
|
+
"permanent enters with.",
|
|
17
|
+
rules: %w[614.1a 122.6 122.6a 616.1],
|
|
18
|
+
params: { "affects" => "{\"each\": filter, \"controller\": ...}", "counter" => "optional counter name" },
|
|
19
|
+
example: { "double_counters" => { "affects" => { "each" => "creature", "controller" => "you" } } }
|
|
20
|
+
|
|
21
|
+
attr_reader :filter, :controller, :counter
|
|
22
|
+
|
|
23
|
+
def self.build(params, errors, path, **)
|
|
24
|
+
Mechanics.check_keys(params, %w[affects counter], errors, path)
|
|
25
|
+
affects = Mechanics.fetch_param(params, :affects)
|
|
26
|
+
unless affects.is_a?(Hash) && affects["each"]
|
|
27
|
+
errors << "#{path}.affects: must be {\"each\": filter, ...}"
|
|
28
|
+
affects = { "each" => "permanent" }
|
|
29
|
+
end
|
|
30
|
+
Mechanics.check_keys(affects, %w[each controller], errors, "#{path}.affects")
|
|
31
|
+
filter = Mechanics.build_entry(:filter, affects["each"], errors, "#{path}.affects.each")
|
|
32
|
+
live = filter ? filter.live_keys : []
|
|
33
|
+
errors << "#{path}.affects.each: #{live.join(", ")} can't be used here yet" unless live.empty?
|
|
34
|
+
controller = affects.fetch("controller", "any")
|
|
35
|
+
errors << "#{path}.affects.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
|
|
36
|
+
counter = Mechanics.fetch_param(params, :counter)
|
|
37
|
+
counter &&= Counters.parse(counter, errors, "#{path}.counter")
|
|
38
|
+
new(filter, controller, counter)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(filter, controller, counter)
|
|
42
|
+
super()
|
|
43
|
+
@filter = filter
|
|
44
|
+
@controller = controller
|
|
45
|
+
@counter = counter
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def kind = :double_counters
|
|
50
|
+
def mana_ability? = false
|
|
51
|
+
def allowed_types = nil
|
|
52
|
+
def try_effects = []
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
label = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
|
|
56
|
+
whose = { "you" => " you control", "opponent" => " an opponent controls" }[controller]
|
|
57
|
+
"you put twice as many #{counter || "counters"} on each #{label}#{whose}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether `putter` putting `kind` counters on a permanent (controlled by `owner_controller`,
|
|
61
|
+
# described by `facts`) is doubled by this ability of `source`.
|
|
62
|
+
def doubles?(source, putter, owner_controller, kind, facts)
|
|
63
|
+
return false unless putter == source.controller && (counter.nil? || counter == kind)
|
|
64
|
+
return false unless controller == "any" || (controller == "you") == (owner_controller == source.controller)
|
|
65
|
+
|
|
66
|
+
filter.matches_facts?(facts)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|