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.
Files changed (269) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/SUPPORTED.md +387 -0
  6. data/docs/ARCHITECTURE.md +141 -0
  7. data/docs/DESIGN.md +139 -0
  8. data/docs/MECHANICS_COVERAGE.md +670 -0
  9. data/docs/guides/adding-a-mechanic.md +60 -0
  10. data/docs/guides/api.md +614 -0
  11. data/docs/guides/card-data-format.md +1472 -0
  12. data/docs/guides/deck-builder.md +157 -0
  13. data/docs/plans/standard.md +371 -0
  14. data/docs/reference/abilities.md +1680 -0
  15. data/docs/reference/costs.md +339 -0
  16. data/docs/reference/counters.md +121 -0
  17. data/docs/reference/decisions.md +102 -0
  18. data/docs/reference/effects.md +2340 -0
  19. data/docs/reference/filters.md +172 -0
  20. data/docs/reference/keywords.md +647 -0
  21. data/docs/reference/subtypes.md +169 -0
  22. data/docs/reference/supertypes.md +31 -0
  23. data/docs/reference/triggers.md +797 -0
  24. data/docs/reference/types.md +216 -0
  25. data/exe/enjine +7 -0
  26. data/lib/enjine/actions/activate_mana_ability.rb +24 -0
  27. data/lib/enjine/actions/cast.rb +57 -0
  28. data/lib/enjine/actions/pass.rb +18 -0
  29. data/lib/enjine/actions/play_land.rb +52 -0
  30. data/lib/enjine/actions/special_action.rb +37 -0
  31. data/lib/enjine/actions.rb +20 -0
  32. data/lib/enjine/agents/busy.rb +27 -0
  33. data/lib/enjine/agents/random.rb +28 -0
  34. data/lib/enjine/card_definition.rb +433 -0
  35. data/lib/enjine/card_faces.rb +116 -0
  36. data/lib/enjine/catalog.rb +56 -0
  37. data/lib/enjine/characteristics.rb +39 -0
  38. data/lib/enjine/cli/text.rb +295 -0
  39. data/lib/enjine/cli.rb +312 -0
  40. data/lib/enjine/decisions/bottom.rb +29 -0
  41. data/lib/enjine/decisions/casting.rb +188 -0
  42. data/lib/enjine/decisions/combat.rb +75 -0
  43. data/lib/enjine/decisions/discard.rb +29 -0
  44. data/lib/enjine/decisions/effect_choice.rb +21 -0
  45. data/lib/enjine/decisions/enter_choice.rb +26 -0
  46. data/lib/enjine/decisions/legend_rule.rb +21 -0
  47. data/lib/enjine/decisions/mulligan.rb +26 -0
  48. data/lib/enjine/decisions/order_triggers.rb +21 -0
  49. data/lib/enjine/decisions/play_or_draw.rb +19 -0
  50. data/lib/enjine/decisions/priority.rb +22 -0
  51. data/lib/enjine/decisions/untap_choice.rb +35 -0
  52. data/lib/enjine/decisions.rb +22 -0
  53. data/lib/enjine/deck.rb +151 -0
  54. data/lib/enjine/deck_builder.rb +454 -0
  55. data/lib/enjine/docs.rb +90 -0
  56. data/lib/enjine/engine/ability_layers.rb +118 -0
  57. data/lib/enjine/engine/attachments.rb +86 -0
  58. data/lib/enjine/engine/casting/alt_casting.rb +254 -0
  59. data/lib/enjine/engine/casting/cast_choices.rb +292 -0
  60. data/lib/enjine/engine/casting/casting.rb +326 -0
  61. data/lib/enjine/engine/casting/cost_options.rb +99 -0
  62. data/lib/enjine/engine/casting/costs.rb +117 -0
  63. data/lib/enjine/engine/casting/payment.rb +353 -0
  64. data/lib/enjine/engine/casting/permissions.rb +412 -0
  65. data/lib/enjine/engine/casting/target_counts.rb +113 -0
  66. data/lib/enjine/engine/casting/waterbend.rb +90 -0
  67. data/lib/enjine/engine/characteristics.rb +379 -0
  68. data/lib/enjine/engine/class_levels.rb +25 -0
  69. data/lib/enjine/engine/combat.rb +315 -0
  70. data/lib/enjine/engine/copies.rb +208 -0
  71. data/lib/enjine/engine/damage.rb +196 -0
  72. data/lib/enjine/engine/designations.rb +46 -0
  73. data/lib/enjine/engine/emblems.rb +37 -0
  74. data/lib/enjine/engine/entering.rb +156 -0
  75. data/lib/enjine/engine/face_down.rb +30 -0
  76. data/lib/enjine/engine/faces.rb +222 -0
  77. data/lib/enjine/engine/follow_ups.rb +27 -0
  78. data/lib/enjine/engine/mana_sources.rb +230 -0
  79. data/lib/enjine/engine/phasing.rb +74 -0
  80. data/lib/enjine/engine/pregame.rb +95 -0
  81. data/lib/enjine/engine/priority.rb +47 -0
  82. data/lib/enjine/engine/resolution.rb +168 -0
  83. data/lib/enjine/engine/rules.rb +287 -0
  84. data/lib/enjine/engine/sagas.rb +41 -0
  85. data/lib/enjine/engine/speed.rb +104 -0
  86. data/lib/enjine/engine/spell_grants.rb +46 -0
  87. data/lib/enjine/engine/state_based_actions.rb +157 -0
  88. data/lib/enjine/engine/triggers.rb +364 -0
  89. data/lib/enjine/engine/turn_history.rb +240 -0
  90. data/lib/enjine/engine/turn_structure.rb +224 -0
  91. data/lib/enjine/engine/zones.rb +278 -0
  92. data/lib/enjine/engine.rb +55 -0
  93. data/lib/enjine/errors.rb +62 -0
  94. data/lib/enjine/events.rb +161 -0
  95. data/lib/enjine/frames.rb +84 -0
  96. data/lib/enjine/fuzz.rb +112 -0
  97. data/lib/enjine/game.rb +204 -0
  98. data/lib/enjine/history.rb +26 -0
  99. data/lib/enjine/mana.rb +142 -0
  100. data/lib/enjine/mechanics/abilities/additional_triggers.rb +86 -0
  101. data/lib/enjine/mechanics/abilities/chosen_values.rb +213 -0
  102. data/lib/enjine/mechanics/abilities/cost_changes.rb +154 -0
  103. data/lib/enjine/mechanics/abilities/damage_replacements.rb +569 -0
  104. data/lib/enjine/mechanics/abilities/double_counters.rb +71 -0
  105. data/lib/enjine/mechanics/abilities/entering_choices.rb +91 -0
  106. data/lib/enjine/mechanics/abilities/entering_copy.rb +69 -0
  107. data/lib/enjine/mechanics/abilities/enters_tapped.rb +159 -0
  108. data/lib/enjine/mechanics/abilities/enters_with_counters.rb +116 -0
  109. data/lib/enjine/mechanics/abilities/mana_ability.rb +209 -0
  110. data/lib/enjine/mechanics/abilities/play_permissions.rb +527 -0
  111. data/lib/enjine/mechanics/abilities/player_rules.rb +169 -0
  112. data/lib/enjine/mechanics/abilities/replacement_effects.rb +368 -0
  113. data/lib/enjine/mechanics/abilities/restrictions.rb +144 -0
  114. data/lib/enjine/mechanics/abilities/spell_keywords.rb +55 -0
  115. data/lib/enjine/mechanics/abilities/stack_abilities.rb +542 -0
  116. data/lib/enjine/mechanics/abilities/static.rb +365 -0
  117. data/lib/enjine/mechanics/abilities/tapping_power.rb +71 -0
  118. data/lib/enjine/mechanics/abilities/turn_rules.rb +49 -0
  119. data/lib/enjine/mechanics/card_spec.rb +177 -0
  120. data/lib/enjine/mechanics/copy_except.rb +73 -0
  121. data/lib/enjine/mechanics/costs/base.rb +115 -0
  122. data/lib/enjine/mechanics/costs/behold.rb +76 -0
  123. data/lib/enjine/mechanics/costs/discard.rb +65 -0
  124. data/lib/enjine/mechanics/costs/exert.rb +29 -0
  125. data/lib/enjine/mechanics/costs/exile_cards.rb +39 -0
  126. data/lib/enjine/mechanics/costs/from_graveyard.rb +45 -0
  127. data/lib/enjine/mechanics/costs/get_counters.rb +39 -0
  128. data/lib/enjine/mechanics/costs/mana.rb +37 -0
  129. data/lib/enjine/mechanics/costs/one_of.rb +49 -0
  130. data/lib/enjine/mechanics/costs/pay_life.rb +50 -0
  131. data/lib/enjine/mechanics/costs/remove_counters.rb +51 -0
  132. data/lib/enjine/mechanics/costs/sacrifice.rb +59 -0
  133. data/lib/enjine/mechanics/costs/tap.rb +56 -0
  134. data/lib/enjine/mechanics/costs/waterbend.rb +29 -0
  135. data/lib/enjine/mechanics/counters/kinds.rb +190 -0
  136. data/lib/enjine/mechanics/effects/add_subtypes.rb +30 -0
  137. data/lib/enjine/mechanics/effects/airbend.rb +41 -0
  138. data/lib/enjine/mechanics/effects/amass.rb +124 -0
  139. data/lib/enjine/mechanics/effects/assimilate.rb +72 -0
  140. data/lib/enjine/mechanics/effects/base.rb +148 -0
  141. data/lib/enjine/mechanics/effects/become.rb +182 -0
  142. data/lib/enjine/mechanics/effects/behold.rb +48 -0
  143. data/lib/enjine/mechanics/effects/blight.rb +94 -0
  144. data/lib/enjine/mechanics/effects/choose_cards.rb +168 -0
  145. data/lib/enjine/mechanics/effects/copy.rb +370 -0
  146. data/lib/enjine/mechanics/effects/counter_spell.rb +21 -0
  147. data/lib/enjine/mechanics/effects/counters.rb +161 -0
  148. data/lib/enjine/mechanics/effects/damage.rb +72 -0
  149. data/lib/enjine/mechanics/effects/delayed_triggers.rb +112 -0
  150. data/lib/enjine/mechanics/effects/destroy.rb +22 -0
  151. data/lib/enjine/mechanics/effects/discard.rb +86 -0
  152. data/lib/enjine/mechanics/effects/draw.rb +16 -0
  153. data/lib/enjine/mechanics/effects/earthbend.rb +70 -0
  154. data/lib/enjine/mechanics/effects/emblem.rb +52 -0
  155. data/lib/enjine/mechanics/effects/endure.rb +63 -0
  156. data/lib/enjine/mechanics/effects/exile.rb +49 -0
  157. data/lib/enjine/mechanics/effects/extra_turn.rb +122 -0
  158. data/lib/enjine/mechanics/effects/faces.rb +115 -0
  159. data/lib/enjine/mechanics/effects/fight.rb +76 -0
  160. data/lib/enjine/mechanics/effects/flip_coins.rb +97 -0
  161. data/lib/enjine/mechanics/effects/gain_control.rb +94 -0
  162. data/lib/enjine/mechanics/effects/game_end.rb +42 -0
  163. data/lib/enjine/mechanics/effects/library.rb +327 -0
  164. data/lib/enjine/mechanics/effects/life.rb +83 -0
  165. data/lib/enjine/mechanics/effects/look_at_hand.rb +17 -0
  166. data/lib/enjine/mechanics/effects/loot.rb +118 -0
  167. data/lib/enjine/mechanics/effects/mana.rb +96 -0
  168. data/lib/enjine/mechanics/effects/may.rb +82 -0
  169. data/lib/enjine/mechanics/effects/meld.rb +77 -0
  170. data/lib/enjine/mechanics/effects/mill.rb +22 -0
  171. data/lib/enjine/mechanics/effects/pay.rb +231 -0
  172. data/lib/enjine/mechanics/effects/phasing.rb +51 -0
  173. data/lib/enjine/mechanics/effects/proliferate.rb +72 -0
  174. data/lib/enjine/mechanics/effects/pump.rb +60 -0
  175. data/lib/enjine/mechanics/effects/put_in_library.rb +98 -0
  176. data/lib/enjine/mechanics/effects/return_choice.rb +29 -0
  177. data/lib/enjine/mechanics/effects/return_to_battlefield.rb +80 -0
  178. data/lib/enjine/mechanics/effects/return_to_hand.rb +20 -0
  179. data/lib/enjine/mechanics/effects/roll_dice.rb +45 -0
  180. data/lib/enjine/mechanics/effects/sacrifice.rb +118 -0
  181. data/lib/enjine/mechanics/effects/scry_and_surveil.rb +74 -0
  182. data/lib/enjine/mechanics/effects/search.rb +156 -0
  183. data/lib/enjine/mechanics/effects/tap_untap.rb +23 -0
  184. data/lib/enjine/mechanics/effects/tokens.rb +76 -0
  185. data/lib/enjine/mechanics/filters/any_target.rb +15 -0
  186. data/lib/enjine/mechanics/filters/base.rb +386 -0
  187. data/lib/enjine/mechanics/filters/graveyard.rb +23 -0
  188. data/lib/enjine/mechanics/filters/one_of.rb +55 -0
  189. data/lib/enjine/mechanics/filters/permanents.rb +52 -0
  190. data/lib/enjine/mechanics/filters/players.rb +20 -0
  191. data/lib/enjine/mechanics/filters/stack.rb +97 -0
  192. data/lib/enjine/mechanics/follow_up.rb +46 -0
  193. data/lib/enjine/mechanics/keywords/alternative_costs.rb +361 -0
  194. data/lib/enjine/mechanics/keywords/beam_me_up.rb +78 -0
  195. data/lib/enjine/mechanics/keywords/changeling.rb +58 -0
  196. data/lib/enjine/mechanics/keywords/convoke.rb +77 -0
  197. data/lib/enjine/mechanics/keywords/crew.rb +155 -0
  198. data/lib/enjine/mechanics/keywords/cycling.rb +58 -0
  199. data/lib/enjine/mechanics/keywords/decayed.rb +25 -0
  200. data/lib/enjine/mechanics/keywords/devour.rb +61 -0
  201. data/lib/enjine/mechanics/keywords/evergreen.rb +54 -0
  202. data/lib/enjine/mechanics/keywords/extort.rb +26 -0
  203. data/lib/enjine/mechanics/keywords/flags.rb +46 -0
  204. data/lib/enjine/mechanics/keywords/foretell.rb +45 -0
  205. data/lib/enjine/mechanics/keywords/gift.rb +50 -0
  206. data/lib/enjine/mechanics/keywords/hideaway.rb +28 -0
  207. data/lib/enjine/mechanics/keywords/increment.rb +31 -0
  208. data/lib/enjine/mechanics/keywords/job_select.rb +29 -0
  209. data/lib/enjine/mechanics/keywords/kicker.rb +92 -0
  210. data/lib/enjine/mechanics/keywords/mobilize_and_firebending.rb +84 -0
  211. data/lib/enjine/mechanics/keywords/paradigm.rb +91 -0
  212. data/lib/enjine/mechanics/keywords/power_up.rb +48 -0
  213. data/lib/enjine/mechanics/keywords/protection.rb +111 -0
  214. data/lib/enjine/mechanics/keywords/prowess.rb +30 -0
  215. data/lib/enjine/mechanics/keywords/riot.rb +35 -0
  216. data/lib/enjine/mechanics/keywords/saddle.rb +115 -0
  217. data/lib/enjine/mechanics/keywords/speed.rb +89 -0
  218. data/lib/enjine/mechanics/keywords/station.rb +163 -0
  219. data/lib/enjine/mechanics/keywords/storied.rb +41 -0
  220. data/lib/enjine/mechanics/keywords/storm_and_cascade.rb +86 -0
  221. data/lib/enjine/mechanics/keywords/sunburst.rb +29 -0
  222. data/lib/enjine/mechanics/keywords/suspend.rb +191 -0
  223. data/lib/enjine/mechanics/keywords/teamwork.rb +45 -0
  224. data/lib/enjine/mechanics/keywords/ward.rb +67 -0
  225. data/lib/enjine/mechanics/linked.rb +67 -0
  226. data/lib/enjine/mechanics/mana_spent.rb +99 -0
  227. data/lib/enjine/mechanics/selector.rb +207 -0
  228. data/lib/enjine/mechanics/subtypes/auras_and_equipment.rb +176 -0
  229. data/lib/enjine/mechanics/subtypes/basic_land_types.rb +36 -0
  230. data/lib/enjine/mechanics/subtypes/class_levels.rb +102 -0
  231. data/lib/enjine/mechanics/subtypes/saga.rb +89 -0
  232. data/lib/enjine/mechanics/supertypes/basic.rb +17 -0
  233. data/lib/enjine/mechanics/supertypes/legendary.rb +18 -0
  234. data/lib/enjine/mechanics/triggers/ability_triggers.rb +51 -0
  235. data/lib/enjine/mechanics/triggers/any_of.rb +43 -0
  236. data/lib/enjine/mechanics/triggers/base.rb +228 -0
  237. data/lib/enjine/mechanics/triggers/casting.rb +219 -0
  238. data/lib/enjine/mechanics/triggers/combat.rb +123 -0
  239. data/lib/enjine/mechanics/triggers/damage.rb +130 -0
  240. data/lib/enjine/mechanics/triggers/dilemma.rb +37 -0
  241. data/lib/enjine/mechanics/triggers/draw_and_discard.rb +85 -0
  242. data/lib/enjine/mechanics/triggers/keyword_actions.rb +55 -0
  243. data/lib/enjine/mechanics/triggers/life.rb +35 -0
  244. data/lib/enjine/mechanics/triggers/steps.rb +39 -0
  245. data/lib/enjine/mechanics/triggers/tap_and_counters.rb +111 -0
  246. data/lib/enjine/mechanics/triggers/zone_changes.rb +249 -0
  247. data/lib/enjine/mechanics/types/kindred.rb +30 -0
  248. data/lib/enjine/mechanics/types/land.rb +24 -0
  249. data/lib/enjine/mechanics/types/permanent_types.rb +67 -0
  250. data/lib/enjine/mechanics/types/spell_types.rb +38 -0
  251. data/lib/enjine/mechanics/values.rb +675 -0
  252. data/lib/enjine/mechanics.rb +74 -0
  253. data/lib/enjine/player.rb +30 -0
  254. data/lib/enjine/primitive.rb +61 -0
  255. data/lib/enjine/replacements.rb +191 -0
  256. data/lib/enjine/rng.rb +57 -0
  257. data/lib/enjine/rules_monitor.rb +404 -0
  258. data/lib/enjine/setup.rb +45 -0
  259. data/lib/enjine/snapshot/player_checks.rb +86 -0
  260. data/lib/enjine/snapshot/turn_checks.rb +91 -0
  261. data/lib/enjine/snapshot.rb +307 -0
  262. data/lib/enjine/state.rb +173 -0
  263. data/lib/enjine/steps.rb +117 -0
  264. data/lib/enjine/tokens.rb +25 -0
  265. data/lib/enjine/verify.rb +233 -0
  266. data/lib/enjine/version.rb +7 -0
  267. data/lib/enjine/view.rb +162 -0
  268. data/lib/enjine.rb +46 -0
  269. 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