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,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Cards an effect puts onto the battlefield under one player's control: each one's "as this enters"
7
+ # choice is made first, one card at a time, then they all enter at once (CR 614.12, 614.12a). The
8
+ # including effect has a `put` zone; other zones move one card at a time as before.
9
+ module EntersBattlefield
10
+ # Whether a choice is now pending (the effect returns :wait and asks again on resume).
11
+ def entering_choices?(engine, controller, locals, ids)
12
+ @put.to_s == "battlefield" && engine.ask_entering_choices!(locals, ids.map { [it, controller] }) == :wait
13
+ end
14
+
15
+ # Moves the cards to `@put`; onto the battlefield, under `controller`'s control and all at once.
16
+ def put_cards!(engine, locals, ids, controller, reason:, enters: nil)
17
+ return engine.enter_battlefield!(locals, ids.map { [it, controller, enters] }, reason: reason) if @put.to_s == "battlefield"
18
+
19
+ ids.each { engine.move!(it, @put.to_sym, reason: reason, enters: enters) }
20
+ end
21
+ end
22
+
23
+ # Where the cards a library effect didn't take go: the bottom of the library in a random order or in
24
+ # an order the player chooses (CR 401.4), or the graveyard.
25
+ module RestOfCards
26
+ REST = %w[bottom_random bottom_any graveyard].freeze
27
+
28
+ def parse_rest(params, errors, path)
29
+ rest = (Mechanics.fetch_param(params, :rest) || "bottom_random").to_s
30
+ errors << "#{path}.rest: must be one of #{REST.join(", ")}" unless REST.include?(rest)
31
+ rest
32
+ end
33
+
34
+ # Asks for the bottom order one card at a time ("the first chosen goes on the bottom first").
35
+ # Returns :wait while asking, else places the cards.
36
+ def place_rest(engine, locals, index, rest_ids)
37
+ if @rest == "graveyard"
38
+ engine.unrecorded { rest_ids.each { engine.move!(it, :graveyard, reason: "put") } }
39
+ return :done
40
+ end
41
+ if @rest == "bottom_any"
42
+ order = answers(locals).select { it["type"] == "bottom" }.map { it["object"] }
43
+ left = rest_ids - order
44
+ if left.size > 1
45
+ return ask(engine, locals, index, "Choose the next card to put on the bottom of your library",
46
+ left.map { { "type" => "bottom", "object" => it } })
47
+ end
48
+ rest_ids = order + left
49
+ end
50
+ engine.put_on_bottom!(index, rest_ids, random: @rest == "bottom_random")
51
+ :done
52
+ end
53
+ end
54
+
55
+ # Look at (or reveal) the top N cards of your library, take the ones matching a card spec (all, a
56
+ # number, or up to a number), then put the rest away (CR 701.20a, 701.20e, 401.4).
57
+ class Dig < ChoiceEffect
58
+ include RestOfCards
59
+ include EntersBattlefield
60
+
61
+ PUT = %w[hand battlefield graveyard exile].freeze
62
+ REVEAL = %w[none chosen all].freeze
63
+
64
+ primitive :effect, :dig,
65
+ description: "Look at the top `amount` cards of your library (`reveal: \"all\"`: reveal them). Take " \
66
+ "`count` (default 1; \"all\": every matching card, no choice; \"any\": any number) " \
67
+ "cards matching `for` and put them into `put` (`tapped` onto the battlefield); " \
68
+ "`optional`: you may take fewer. `reveal: \"chosen\"` reveals the cards taken. The rest go " \
69
+ "to `rest`: \"bottom_random\" (default), \"bottom_any\" (in an order you choose) or \"graveyard\". " \
70
+ "`put: \"exile\"` takes `face_down: true` (only you may look at them) and `link: true` " \
71
+ "(exiled with the source, CR 607.2a: hideaway).",
72
+ rules: %w[701.20a 701.20e 401.4 400.7j 406.3 607.2a],
73
+ params: { "amount" => "amount (how many cards)", "for" => "card spec (default any card)",
74
+ "count" => "integer, \"all\" or \"any\"", "optional" => "boolean", "put" => PUT.join(" | "),
75
+ "tapped" => "boolean", "reveal" => REVEAL.join(" | "), "rest" => RestOfCards::REST.join(" | "),
76
+ "face_down" => "boolean (put exile)", "link" => "boolean (put exile)" },
77
+ example: { "dig" => { "amount" => 6, "for" => { "types" => ["artifact"] }, "optional" => true,
78
+ "reveal" => "chosen", "put" => "hand" } }
79
+
80
+ attr_reader :spec, :count, :put
81
+
82
+ def self.keys = %w[amount for count optional put tapped reveal rest face_down link if]
83
+ def self.default_to = "you"
84
+
85
+ def setup(params, errors, path)
86
+ @spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
87
+ @count = Mechanics.fetch_param(params, :count) || 1
88
+ unless %w[all any].include?(@count) || (@count.is_a?(Integer) && @count.positive?)
89
+ errors << "#{path}.count: must be a positive integer, \"all\" or \"any\""
90
+ end
91
+ @optional = Mechanics.flag_param(params, "optional", errors, path)
92
+ @put = (Mechanics.fetch_param(params, :put) || "hand").to_s
93
+ errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
94
+ @tapped = Mechanics.flag_param(params, "tapped", errors, path)
95
+ @reveal = (Mechanics.fetch_param(params, :reveal) || "none").to_s
96
+ errors << "#{path}.reveal: must be one of #{REVEAL.join(", ")}" unless REVEAL.include?(@reveal)
97
+ @rest = parse_rest(params, errors, path)
98
+ @face_down, @link = %i[face_down link].map { Mechanics.flag_param(params, it, errors, path) }
99
+ errors << "#{path}: face_down and link need put: \"exile\"" if (@face_down || @link) && @put != "exile"
100
+ end
101
+
102
+ def to_s
103
+ many = { "all" => "all", "any" => "any number of" }[count] || "#{"up to " if @optional}#{count}"
104
+ "#{@reveal == "all" ? "reveal" : "look at"} the top #{amount} cards, #{many} #{spec} → #{put}, rest → " \
105
+ "#{@rest.tr("_", " ")}"
106
+ end
107
+
108
+ def run(engine, ctx, locals)
109
+ return :done unless locals.key?("seen") || condition.nil? || condition.true?(engine, ctx)
110
+
111
+ index = ctx.controller
112
+ seen = (locals["seen"] ||= first_look(engine, ctx, index))
113
+ unless locals["taken"]
114
+ taken = answers(locals).select { it["type"] == "take" }.map { it["object"] }
115
+ choices = count == "all" ? [] : choices(engine, ctx, seen, taken, locals)
116
+ return ask(engine, locals, index, "Choose #{spec} to take", choices) unless choices.empty?
117
+
118
+ taken = seen.select { spec.match?(engine, engine.object(it), ctx) } if count == "all"
119
+ return :wait if entering_choices?(engine, ctx.controller, locals, taken)
120
+
121
+ take!(engine, ctx, index, taken, locals)
122
+ locals["taken"] = taken
123
+ end
124
+ place_rest(engine, locals, index, seen - locals["taken"])
125
+ end
126
+
127
+ private
128
+
129
+ def first_look(engine, ctx, index)
130
+ seen = engine.player(index).library.first(amount_for(engine, ctx))
131
+ @reveal == "all" ? engine.reveal!(index, seen) : engine.look_at!(index, index, seen)
132
+ seen
133
+ end
134
+
135
+ def choices(engine, ctx, seen, taken, locals)
136
+ return [] if answers(locals).any? { it["type"] == "done" } || (count != "any" && taken.size >= count)
137
+
138
+ ids = (seen - taken).select { spec.match?(engine, engine.object(it), ctx) }
139
+ options = ids.uniq { engine.object(it).card }.map { { "type" => "take", "object" => it } }
140
+ options << { "type" => "done" } if (@optional || count == "any") && !options.empty?
141
+ options
142
+ end
143
+
144
+ def take!(engine, ctx, index, taken, locals)
145
+ engine.reveal!(index, taken) if @reveal == "chosen"
146
+ return exile!(engine, ctx, index, taken) if @put == "exile"
147
+
148
+ put_cards!(engine, locals, taken, ctx.controller, reason: "put", enters: @tapped ? { "tapped" => true } : nil)
149
+ end
150
+
151
+ # CR 406.3, 607.2a: face down (only you may look), linked to the source.
152
+ def exile!(engine, ctx, index, taken)
153
+ taken.each do |id|
154
+ fresh = engine.move!(id, :exile, reason: "exile", face_down: @face_down ? [index] : nil)
155
+ engine.link_exiled!(ctx.source, fresh.id) if fresh && @link
156
+ end
157
+ end
158
+ end
159
+
160
+ # Reveal cards from the top of your library one at a time until N cards matching a spec are revealed;
161
+ # those go to a zone, the other revealed cards are put away (CR 701.20a, 401.4).
162
+ class RevealUntil < ChoiceEffect
163
+ include RestOfCards
164
+ include EntersBattlefield
165
+
166
+ PUT = %w[hand battlefield].freeze
167
+
168
+ primitive :effect, :reveal_until,
169
+ description: "Reveal cards from the top of your library until `count` (an amount, default 1) cards " \
170
+ "matching `for` are revealed (or the library runs out). Put those into `put` " \
171
+ "(`tapped` onto the battlefield, under your control) and the rest to `rest` (default " \
172
+ "the bottom in a random order).",
173
+ rules: %w[701.20a 401.4 400.7j],
174
+ params: { "for" => "card spec", "count" => "amount", "put" => PUT.join(" | "), "tapped" => "boolean",
175
+ "rest" => RestOfCards::REST.join(" | ") },
176
+ example: { "reveal_until" => { "for" => { "types" => ["land"] }, "count" => 2, "put" => "battlefield",
177
+ "tapped" => true } }
178
+
179
+ def self.keys = %w[for count put tapped rest if]
180
+ def self.default_to = "you"
181
+
182
+ def setup(params, errors, path)
183
+ @spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
184
+ @count = Amount.build(Mechanics.fetch_param(params, :count) || 1, errors, "#{path}.count", targets_count: @targets_count)
185
+ @put = (Mechanics.fetch_param(params, :put) || "hand").to_s
186
+ errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
187
+ @tapped = Mechanics.flag_param(params, "tapped", errors, path)
188
+ @rest = parse_rest(params, errors, path)
189
+ end
190
+
191
+ def to_s = "reveal until #{@count} #{@spec} → #{@put}#{" tapped" if @tapped}, rest → #{@rest.tr("_", " ")}"
192
+
193
+ def run(engine, ctx, locals)
194
+ return :done unless locals.key?("found") || condition.nil? || condition.true?(engine, ctx)
195
+
196
+ index = ctx.controller
197
+ unless locals.key?("found")
198
+ revealed, found = reveal(engine, ctx, index)
199
+ locals["found"] = found
200
+ locals["rest"] = revealed - found
201
+ end
202
+ unless locals["moved"]
203
+ return :wait if entering_choices?(engine, index, locals, locals["found"])
204
+
205
+ put_cards!(engine, locals, locals["found"], index, reason: "put", enters: @tapped ? { "tapped" => true } : nil)
206
+ locals["moved"] = true
207
+ end
208
+ place_rest(engine, locals, index, locals["rest"])
209
+ end
210
+
211
+ private
212
+
213
+ def reveal(engine, ctx, index)
214
+ wanted = @count.value(engine, ctx)
215
+ revealed = []
216
+ found = []
217
+ engine.player(index).library.each do |id|
218
+ break if found.size >= wanted
219
+
220
+ revealed << id
221
+ found << id if @spec.match?(engine, engine.object(id), ctx)
222
+ end
223
+ engine.reveal!(index, revealed)
224
+ [revealed, found]
225
+ end
226
+ end
227
+
228
+ # Exile the top N cards of a library (CR 406.1). What was exiled is the effect's result ({"result": n}),
229
+ # for later effects of the list (returning the permanent cards among them, permissions to play them);
230
+ # `link: true` exiles them with the source (CR 607.2a).
231
+ class ExileTop < Effect
232
+ primitive :effect, :exile_top,
233
+ description: "Each referenced player (default: you) exiles the top `amount` cards of their library " \
234
+ "(face up). Later effects name them with {\"result\": n}; `link: true` exiles them with " \
235
+ "the source. `face_down: true` exiles them face down: only the `looker` players " \
236
+ "(default: you) may look at them.",
237
+ rules: %w[406.1 400.7j 607.2a 406.3 406.3a],
238
+ params: { "amount" => "amount", "to" => "selector of players", "link" => "boolean",
239
+ "face_down" => "boolean", "looker" => "selector of players who may look (default \"you\")" },
240
+ example: { "exile_top" => { "amount" => 4 } }
241
+
242
+ def self.keys = %w[amount to link face_down looker if]
243
+ def self.default_to = "you"
244
+
245
+ def setup(params, errors, path)
246
+ @link = Mechanics.flag_param(params, "link", errors, path)
247
+ @face_down = Mechanics.flag_param(params, :face_down, errors, path)
248
+ raw = Mechanics.fetch_param(params, :looker)
249
+ errors << "#{path}.looker: needs face_down: true" if raw && !@face_down
250
+ @looker = Selector.build(raw || "you", errors, "#{path}.looker", targets_count: @targets_count,
251
+ results_count: @results_count)
252
+ end
253
+
254
+ def to_s = @face_down ? super.sub(" → ", " face down → ") : super
255
+
256
+ def apply(engine, ctx)
257
+ n = amount_for(engine, ctx)
258
+ lookers = @face_down ? @looker.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) } : nil
259
+ players(engine, ctx).each do |index|
260
+ engine.player(index).library.first(n).each do |id|
261
+ fresh = engine.move!(id, :exile, reason: "exile", face_down: lookers)
262
+ engine.link_exiled!(ctx.source, fresh.id) if fresh && @link
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ # Explore (CR 701.44a): reveal the top card of your library; a land card goes to your hand,
269
+ # otherwise the exploring permanent gets a +1/+1 counter and you may put the card into your graveyard.
270
+ class Explore < ChoiceEffect
271
+ primitive :effect, :explore,
272
+ description: "Each referenced permanent explores: its controller reveals the top card of their " \
273
+ "library. A land card goes to their hand; otherwise the permanent gets a +1/+1 counter " \
274
+ "(if it's still on the battlefield) and they may put the card into their graveyard.",
275
+ rules: %w[701.44a 701.44b 701.44c],
276
+ params: { "to" => "selector of permanents" },
277
+ example: { "explore" => { "to" => "self" } }
278
+
279
+ def self.keys = %w[to if]
280
+
281
+ def run(engine, ctx, locals)
282
+ return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
283
+
284
+ queue = (locals["queue"] ||= refs(engine, ctx).select { engine.state.objects.key?(it) })
285
+ while (id = queue.first)
286
+ return :wait if explore(engine, ctx, locals, id) == :wait
287
+
288
+ queue.shift
289
+ locals.delete("card")
290
+ locals.delete("answers")
291
+ end
292
+ :done
293
+ end
294
+
295
+ private
296
+
297
+ # CR 701.44c: a permanent that has left uses its last known controller (here: the effect's controller).
298
+ def explore(engine, ctx, locals, id)
299
+ obj = engine.object(id)
300
+ index = obj.zone == :battlefield ? obj.controller : ctx.controller
301
+ unless locals.key?("card")
302
+ locals["card"] = engine.player(index).library.first
303
+ return land_or_counter(engine, locals, id, index)
304
+ end
305
+ answer = answers(locals).first
306
+ card = locals["card"]
307
+ engine.move!(card, :graveyard, reason: "explore") if answer["type"] == "graveyard"
308
+ :done
309
+ end
310
+
311
+ def land_or_counter(engine, locals, id, index)
312
+ card = locals["card"]
313
+ engine.reveal!(index, [card]) if card
314
+ if card && engine.chars(card).land?
315
+ engine.move!(card, :hand, reason: "explore")
316
+ return :done
317
+ end
318
+ engine.add_counters!(engine.object(id), "+1/+1", 1, by_player: index) if engine.state.battlefield.include?(id)
319
+ return :done unless card
320
+
321
+ ask(engine, locals, index, "Explore: keep the revealed card on top or put it into your graveyard?",
322
+ [{ "type" => "top", "object" => card }, { "type" => "graveyard", "object" => card }])
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Gain life (CR 119.3).
7
+ class GainLife < Effect
8
+ primitive :effect, :gain_life, description: "Each referenced player gains `amount` life (default: you).",
9
+ rules: %w[119.3], params: { "amount" => "integer", "to" => "selector" },
10
+ example: { "gain_life" => { "amount" => 3 } }
11
+ def self.default_to = "you"
12
+ def apply(engine, ctx) = players(engine, ctx).each { LoseLife.change!(engine, it, amount_for(engine, ctx)) }
13
+ end
14
+
15
+ # Lose life (CR 119.3).
16
+ class LoseLife < Effect
17
+ primitive :effect, :lose_life, description: "Each referenced player loses `amount` life. The life lost is " \
18
+ "recorded for {\"this_way\": n}.",
19
+ rules: %w[119.3], params: { "amount" => "integer", "to" => "selector" },
20
+ example: { "lose_life" => { "amount" => 2, "to" => "each_opponent" } }
21
+ def apply(engine, ctx) = players(engine, ctx).each { LoseLife.change!(engine, it, -amount_for(engine, ctx)) }
22
+
23
+ # Changes a life total and records how much it changed (life lost "this way", CR 608.2c).
24
+ def self.change!(engine, index, delta)
25
+ before = engine.player(index).life
26
+ engine.change_life!(index, delta)
27
+ engine.record_amount!((engine.player(index).life - before).abs)
28
+ end
29
+ end
30
+
31
+ # "Your life total becomes 10" (CR 119.5): the player gains or loses the difference.
32
+ class SetLife < Effect
33
+ primitive :effect, :set_life,
34
+ description: "Each referenced player's life total becomes `amount` (default: you): they gain or " \
35
+ "lose the difference (so \"can't gain life\" and life-gain triggers apply).",
36
+ rules: %w[119.5 119.7], params: { "amount" => "amount", "to" => "selector" },
37
+ example: { "set_life" => { "amount" => 10 } }
38
+ def self.default_to = "you"
39
+
40
+ def apply(engine, ctx)
41
+ players(engine, ctx).each { LoseLife.change!(engine, it, amount_for(engine, ctx) - engine.player(it).life) }
42
+ end
43
+ end
44
+
45
+ # "Exchange life totals with target opponent" (CR 701.12a, 701.12c): the player `to` (default you) and
46
+ # the player `with` each gain or lose life to end at the other's previous total; if either can't (CR
47
+ # 119.7), nothing happens. The life the first player lost this way is recorded for {"this_way": n}.
48
+ class ExchangeLifeTotals < Effect
49
+ primitive :effect, :exchange_life_totals,
50
+ description: "The player `to` references (default you) and the one `with` references exchange " \
51
+ "life totals: each gains or loses the difference. If either can't gain life and " \
52
+ "would, no exchange happens. The life the first player lost this way is recorded for " \
53
+ "{\"this_way\": n}.",
54
+ rules: %w[701.12a 701.12c 119.7],
55
+ params: { "to" => "selector (a player, default you)", "with" => "selector (a player)" },
56
+ example: { "exchange_life_totals" => { "with" => { "target" => 0 } } }
57
+
58
+ attr_reader :with
59
+
60
+ def self.keys = %w[to with if]
61
+ def self.default_to = "you"
62
+
63
+ def setup(params, errors, path)
64
+ @with = Exchange.with(params, errors, path, @targets_count, @results_count)
65
+ end
66
+
67
+ def apply(engine, ctx)
68
+ one, other = [to, with].map { |sel| sel.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.first }
69
+ return engine.record_amount!(0) if one.nil? || other.nil? || one == other
70
+
71
+ lives = [one, other].to_h { [it, engine.player(it).life] }
72
+ gains = { one => lives[other] - lives[one], other => lives[one] - lives[other] }
73
+ return engine.record_amount!(0) if gains.any? { |index, delta| delta.positive? && engine.player_rule?(index, "cant_gain_life") }
74
+
75
+ gains.each { |index, delta| engine.change_life!(index, delta) }
76
+ engine.record_amount!([lives[one] - engine.player(one).life, 0].max)
77
+ end
78
+
79
+ def to_s = "exchange life totals: #{to} and #{with}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Looking at a player's hand (CR 701.20e): the controller sees those cards; others don't.
7
+ class LookAtHand < Effect
8
+ primitive :effect, :look_at_hand,
9
+ description: "You look at each referenced player's hand (other players don't see the cards).",
10
+ rules: %w[701.20e], params: { "to" => "selector of players" },
11
+ example: { "look_at_hand" => { "to" => { "target" => 0 } } }
12
+ def self.keys = %w[to if]
13
+ def apply(engine, ctx) = players(engine, ctx).each { engine.look_at!(ctx.controller, it, engine.player(it).hand) }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "Draw N cards, then discard N cards; then something for the nonland cards discarded": the shared
7
+ # shape of connive (CR 701.50) and recruit (CR 701.70). The player draws, then chooses the discards one
8
+ # `effect_choice` at a time (fewer if their hand runs out); the reward counts the nonland cards among
9
+ # them by their card types as they were discarded.
10
+ class Loot < ChoiceEffect
11
+ def run(engine, ctx, locals)
12
+ return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
13
+
14
+ queue = (locals["queue"] ||= loot_queue(engine, ctx))
15
+ while (entry = queue.first)
16
+ return :wait if loot(engine, locals, entry) == :wait
17
+
18
+ reward(engine, ctx, entry, locals["nonland"])
19
+ queue.shift
20
+ %w[drawn nonland applied answers asked].each { locals.delete(it) }
21
+ end
22
+ :done
23
+ end
24
+
25
+ private
26
+
27
+ def loot(engine, locals, entry)
28
+ index = entry["player"]
29
+ unless locals["drawn"]
30
+ entry["n"].times { engine.draw!(index) }
31
+ locals.merge!("drawn" => true, "nonland" => 0, "applied" => 0)
32
+ end
33
+ chosen = answers(locals)
34
+ chosen.drop(locals["applied"]).each do |answer|
35
+ locals["nonland"] += 1 unless engine.chars(answer["object"]).land?
36
+ engine.move!(answer["object"], :graveyard, reason: "discard")
37
+ end
38
+ locals["applied"] = chosen.size
39
+ hand = engine.player(index).hand
40
+ return :done if chosen.size >= entry["n"] || hand.empty?
41
+
42
+ ask(engine, locals, index, "Discard a card (#{entry["n"] - chosen.size} to go)",
43
+ hand.map { { "type" => "discard", "object" => it } })
44
+ end
45
+ end
46
+
47
+ # Connive / connive N (CR 701.50a, 701.50d): the permanent's controller draws N cards, then discards N
48
+ # cards, then puts a +1/+1 counter on it for each nonland card discarded. A permanent that has left the
49
+ # battlefield connives as it last existed: its last controller (here, the effect's controller) loots
50
+ # and no counters are put (CR 701.50b). Connive 0 does nothing (CR 701.50e).
51
+ class Connive < Loot
52
+ primitive :effect, :connive,
53
+ description: "Each referenced permanent (default: this one) connives `amount` (default 1): its " \
54
+ "controller draws that many cards, then discards that many, then it gets a +1/+1 " \
55
+ "counter for each nonland card discarded.",
56
+ rules: %w[701.50a 701.50b 701.50d 701.50e],
57
+ params: { "amount" => "amount (default 1)", "to" => "selector of permanents (default self)" },
58
+ example: { "connive" => { "to" => "self" } }
59
+
60
+ def self.default_to = "self"
61
+
62
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
63
+ params = params.transform_keys(&:to_s)
64
+ params["amount"] = 1 unless params.key?("amount")
65
+ super
66
+ end
67
+
68
+ def to_s = "connive #{amount} → #{to}"
69
+
70
+ private
71
+
72
+ def loot_queue(engine, ctx)
73
+ n = amount_for(engine, ctx)
74
+ return [] unless n.positive?
75
+
76
+ refs(engine, ctx).filter_map do |id|
77
+ obj = engine.state.objects[id] or next
78
+ { "player" => obj.zone == :battlefield ? obj.controller : ctx.controller, "n" => n, "object" => id }
79
+ end
80
+ end
81
+
82
+ def reward(engine, _ctx, entry, nonland)
83
+ perm = engine.state.objects[entry["object"]]
84
+ engine.add_counters!(perm, "+1/+1", nonland, by_player: entry["player"]) if perm&.zone == :battlefield
85
+ end
86
+ end
87
+
88
+ # Recruit (CR 701.70a): "Draw a card, then discard a card. If you discarded a nonland card this way,
89
+ # create a 1/1 white Human Soldier creature token."
90
+ class Recruit < Loot
91
+ primitive :effect, :recruit,
92
+ description: "Each referenced player (default: you) recruits: draws a card, then discards a card; " \
93
+ "if a nonland card was discarded, they create a 1/1 white Human Soldier creature token.",
94
+ rules: %w[701.70a],
95
+ params: { "to" => "player selector (default you)" },
96
+ example: { "recruit" => {} }
97
+
98
+ TOKEN = { "name" => "Human Soldier", "types" => ["creature"], "subtypes" => %w[human soldier], "colors" => ["W"],
99
+ "power" => 1, "toughness" => 1 }.freeze
100
+
101
+ def self.keys = %w[to if]
102
+ def self.default_to = "you"
103
+ def self.token = @token ||= CardDefinition.token(TOKEN)
104
+
105
+ def created_tokens = [Recruit.token]
106
+ def to_s = "recruit → #{to}"
107
+
108
+ private
109
+
110
+ def loot_queue(engine, ctx) = players(engine, ctx).map { { "player" => it, "n" => 1 } }
111
+
112
+ def reward(engine, _ctx, entry, nonland)
113
+ engine.create_token!(Recruit.token, entry["player"]) if nonland.positive?
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Adding mana as the effect of a resolving spell or ability (CR 106.4): "Add {R}{R}{R}." or
7
+ # "Add two mana of any one color." The color is chosen as the effect resolves (CR 608.2d).
8
+ # Activated abilities without targets that add mana are mana abilities instead (CR 605.1a).
9
+ class AddMana < ChoiceEffect
10
+ COLORS = Mana::COLORS.map(&:to_s).freeze
11
+ CHOICES = %w[any_one any chosen].freeze
12
+
13
+ primitive :effect, :add_mana,
14
+ description: "You add mana to your mana pool: the fixed mana in `produce`, or `amount` mana of a " \
15
+ "color you choose as it resolves (`color: \"any_one\"`: all of one color; " \
16
+ "`color: \"any\"`: each mana's color chosen separately, \"in any combination of " \
17
+ "colors\"; `\"chosen\"`: of the color chosen for its source or by it, CR 607.2d). " \
18
+ "`colors` limits the choice (\"add {R} or {G}\"). `spend_only` restricts " \
19
+ "how the mana can be spent (CR 106.6). `until: \"end_of_combat\"`: you don't lose this " \
20
+ "mana as steps end until end of combat (CR 702.189a).",
21
+ rules: %w[106.1a 106.4 608.2d 605.1a 106.6 500.5 607.2d],
22
+ params: { "produce" => "mana symbols, e.g. \"{R}{R}\"", "amount" => "amount (default 1; with produce: how many times)",
23
+ "color" => CHOICES.join(" | "), "colors" => "list of colors to choose from (default all five)",
24
+ "spend_only" => "{\"spells\": card spec, \"abilities\": card spec}",
25
+ "until" => "\"end_of_combat\" (optional)" },
26
+ example: { "add_mana" => { "amount" => 2, "color" => "any_one" } }
27
+
28
+ attr_reader :produce, :color
29
+
30
+ def self.keys = %w[produce amount color colors spend_only until if]
31
+ def self.default_to = "you"
32
+
33
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
34
+ params = params.transform_keys(&:to_s)
35
+ params["amount"] = 1 unless params.key?("amount")
36
+ super
37
+ end
38
+
39
+ def setup(params, errors, path)
40
+ raw = Mechanics.fetch_param(params, :produce)
41
+ @color = Mechanics.fetch_param(params, :color)
42
+ @produce = raw && Mana.parse_produce(raw, errors, "#{path}.produce")
43
+ errors << "#{path}: needs exactly one of produce, color" unless raw.nil? ^ @color.nil?
44
+ errors << "#{path}.color: must be one of #{CHOICES.join(", ")}" if @color && !CHOICES.include?(@color)
45
+ raw_spend = Mechanics.fetch_param(params, :spend_only)
46
+ @spend_only = raw_spend && SpendOnly.build(raw_spend, errors, "#{path}.spend_only")
47
+ @until = Mechanics.fetch_param(params, :until)
48
+ errors << "#{path}.until: must be \"end_of_combat\"" unless [nil, "end_of_combat"].include?(@until)
49
+ raw_colors = Mechanics.fetch_param(params, :colors)
50
+ @colors = raw_colors || COLORS
51
+ return if raw_colors.nil?
52
+ return if %w[any_one any].include?(@color) && @colors.is_a?(Array) && @colors.size >= 2 &&
53
+ (@colors - COLORS).empty? && @colors.uniq == @colors
54
+
55
+ errors << "#{path}.colors: must list two or more colors (W U B R G), with color"
56
+ end
57
+
58
+ def run(engine, ctx, locals)
59
+ return :done unless condition.nil? || condition.true?(engine, ctx)
60
+
61
+ index = ctx.controller
62
+ return add(engine, index, @produce * amount_for(engine, ctx), ctx) unless @color # the fixed mana, `amount` times
63
+ return add(engine, index, Array(chosen_color(engine, ctx)) * amount_for(engine, ctx), ctx) if @color == "chosen"
64
+
65
+ n = (locals["n"] ||= amount_for(engine, ctx))
66
+ picks = answers(locals)
67
+ if picks.size < (@color == "any_one" ? [n, 1].min : n)
68
+ return ask(engine, locals, index, "Choose the color of #{@color == "any_one" ? "the" : "the next"} mana",
69
+ @colors.map { { "type" => "color", "color" => it } })
70
+ end
71
+ colors = picks.map { it["color"].to_sym }
72
+ add(engine, index, @color == "any_one" ? colors * n : colors, ctx)
73
+ end
74
+
75
+ # CR 607.2d: the color chosen for the source, or by this spell or ability as it resolved ("choose a color").
76
+ def chosen_color(engine, ctx) = Filters.chosen_memory(engine, ctx.source, ctx)["chosen_color"]&.to_sym
77
+
78
+ def to_s
79
+ return "add #{produce.map { "{#{it}}" }.join}" if produce
80
+ return "add #{amount} mana of the chosen color" if @color == "chosen"
81
+
82
+ return "add #{amount} mana: #{@colors.map { "{#{it}}" }.join(" or ")}" unless @colors == COLORS
83
+
84
+ "add #{amount} mana of #{@color == "any_one" ? "any one color" : "any combination of colors"}"
85
+ end
86
+
87
+ private
88
+
89
+ def add(engine, index, produce, ctx)
90
+ engine.add_mana!(index, produce, ctx.source, restriction: @spend_only&.data, keep_until: @until) unless produce.nil?
91
+ :done
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end