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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # Shared behaviour of "as this enters, choose ..." abilities (CR 614.12, 614.12a): the controller
7
+ # chooses before the permanent enters (an enter_choice decision) and the permanent remembers the
8
+ # choice in its memory, where linked abilities read it (CR 607.2).
9
+ class EnteringChoice < Primitive
10
+ def kind = :entering_choice
11
+ def mana_ability? = false
12
+ def allowed_types = nil
13
+ def try_effects = []
14
+ end
15
+
16
+ # "As this enters, choose a number between 0 and 10."
17
+ class ChooseNumber < EnteringChoice
18
+ primitive :ability, :choose_number,
19
+ description: "As this permanent enters, its controller chooses a number from `min` to `max`; abilities " \
20
+ "of it that say \"the chosen number\" use it.",
21
+ rules: %w[614.12 614.12a 607.2],
22
+ params: { "min" => "integer (default 0)", "max" => "integer" },
23
+ example: { "choose_number" => { "min" => 0, "max" => 10 } }
24
+
25
+ attr_reader :min, :max
26
+
27
+ def self.build(params, errors, path, **)
28
+ Mechanics.check_keys(params, %w[min max], errors, path)
29
+ min = Mechanics.integer_param(params, :min, errors, path, default: 0, min: 0)
30
+ max = Mechanics.integer_param(params, :max, errors, path, min: 0)
31
+ errors << "#{path}.max: must be at least min" if max < min
32
+ new(min, max)
33
+ end
34
+
35
+ def initialize(min, max)
36
+ super()
37
+ @min = min
38
+ @max = max
39
+ freeze
40
+ end
41
+
42
+ def to_s = "as this enters, choose a number from #{min} to #{max}"
43
+ def options(_engine, _controller) = (min..max).map { { type: "number", number: it } }
44
+ def enters_for(answer) = { "memory" => { "chosen_number" => answer[:number] } }
45
+ end
46
+
47
+ # "As this enters, choose a nonland card name." The names offered are those of the cards in the game's
48
+ # decklists (CR 201.4: a player chooses a card name; names of cards outside the game can't matter).
49
+ class ChooseCardName < EnteringChoice
50
+ primitive :ability, :choose_card_name,
51
+ description: "As this permanent enters, its controller chooses a card name among the cards in the " \
52
+ "game (`not`: card types the named card mustn't have, e.g. [\"land\"]); abilities of it " \
53
+ "that say \"the chosen name\" use it.",
54
+ rules: %w[614.12 614.12a 201.4 201.4a 607.2],
55
+ params: { "not" => "list of card types" },
56
+ example: { "choose_card_name" => { "not" => ["land"] } }
57
+
58
+ attr_reader :excluded
59
+
60
+ def self.build(params, errors, path, **)
61
+ Mechanics.check_keys(params, %w[not], errors, path)
62
+ excluded = Mechanics.fetch_param(params, :not) || []
63
+ types = Filters::Restricted::TYPES
64
+ unless excluded.is_a?(Array) && (excluded - types).empty?
65
+ errors << "#{path}.not: must be a list of card types (#{types.join(", ")})"
66
+ excluded = []
67
+ end
68
+ new(excluded.map(&:to_sym))
69
+ end
70
+
71
+ def initialize(excluded)
72
+ super()
73
+ @excluded = excluded.freeze
74
+ freeze
75
+ end
76
+
77
+ def to_s = "as this enters, choose a #{excluded.map { "non#{it}" }.join(" ")} card name".squeeze(" ")
78
+
79
+ def options(engine, _controller)
80
+ names = engine.state.decklists.flatten.uniq.filter_map do |card|
81
+ defn = engine.catalog.fetch(card)
82
+ defn.name unless defn.types.intersect?(excluded)
83
+ end
84
+ names.uniq.sort.map { { type: "name", name: it } }
85
+ end
86
+
87
+ def enters_for(answer) = { "memory" => { "chosen_name" => answer[:name] } }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "entering_choices"
4
+ require_relative "../copy_except"
5
+
6
+ module Enjine
7
+ module Mechanics
8
+ module Abilities
9
+ # "You may have this creature enter as a copy of any artifact or creature on the battlefield" (CR 707.5,
10
+ # 614.1c, 614.12): as it would enter, its controller chooses a permanent matching `of` (or none); it
11
+ # enters as a copy of it, with the `except` modifications, and with `with_counters: true` also with
12
+ # the same number and kinds of counters the copied permanent has.
13
+ class EnterAsCopy < EnteringChoice
14
+ primitive :ability, :enter_as_copy,
15
+ description: "As this permanent enters, its controller may choose a permanent on the battlefield " \
16
+ "matching `of`; it enters as a copy of that permanent (its copiable values, with the " \
17
+ "`except` modifications) and its enter abilities trigger. `with_counters: true`: it " \
18
+ "also enters with the counters the copied permanent has. Choosing none: it enters as " \
19
+ "itself.",
20
+ rules: %w[707.5 707.9b 614.1c 614.12],
21
+ params: { "of" => "filter (the permanents it may copy)", "except" => Mechanics::CopyExcept::PARAM,
22
+ "with_counters" => "boolean" },
23
+ example: { "enter_as_copy" => { "of" => { "permanent" => { "any_of" => %w[artifact creature] } },
24
+ "except" => { "legendary" => false }, "with_counters" => true } }
25
+
26
+ attr_reader :filter, :except, :with_counters
27
+
28
+ def self.build(params, errors, path, **)
29
+ Mechanics.check_keys(params, %w[of except with_counters], errors, path)
30
+ raw = Mechanics.fetch_param(params, :of)
31
+ filter = raw.nil? ? (errors << "#{path}.of: required") && nil : Mechanics.build_entry(:filter, raw, errors, "#{path}.of")
32
+ except = Mechanics::CopyExcept.build(Mechanics.fetch_param(params, :except), errors, "#{path}.except")
33
+ new(filter, except, Mechanics.flag_param(params, :with_counters, errors, path))
34
+ end
35
+
36
+ def initialize(filter, except, with_counters)
37
+ super()
38
+ @filter = filter
39
+ @except = except
40
+ @with_counters = with_counters
41
+ freeze
42
+ end
43
+
44
+ def to_s
45
+ what = filter.is_a?(Class) ? filter.primitive_name : filter
46
+ "you may have it enter as a copy of a #{what}#{Mechanics::CopyExcept.to_s(except)}#{" with its counters" if with_counters}"
47
+ end
48
+
49
+ def options(engine, controller)
50
+ candidates = filter ? filter.candidates_for(engine, controller, nil) : []
51
+ candidates.select { engine.state.battlefield.include?(it) }.map { { type: "copy", object: it } } +
52
+ [{ type: "dont_copy" }]
53
+ end
54
+
55
+ # What it enters with (Engine#enters_from_answer): the copy, and the copied permanent's counters.
56
+ def enters_with(engine, answer)
57
+ obj = answer[:type] == "copy" && engine.state.objects[answer[:object]]
58
+ return nil unless obj && obj.zone == :battlefield
59
+
60
+ card, except = engine.copiable_values(obj)
61
+ enters = { "copy" => { "card" => card, "except" => Engine::Copies.merge_except(except, @except) } }
62
+ with_counters && !obj.counters.empty? ? enters.merge("counters" => obj.counters.dup) : enters
63
+ end
64
+
65
+ def enters_for(_answer) = nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "This permanent enters tapped." A replacement effect on its own entering (CR 614.1c, 614.12).
7
+ class EntersTapped < Primitive
8
+ primitive :ability, :enters_tapped,
9
+ description: "This permanent enters the battlefield tapped (a self-replacement effect).",
10
+ rules: %w[614.1c 614.12],
11
+ example: "enters_tapped"
12
+
13
+ INSTANCE = new.freeze
14
+
15
+ def self.build(params, errors, path, **)
16
+ Mechanics.check_keys(params, [], errors, path)
17
+ INSTANCE
18
+ end
19
+
20
+ def kind = :enters_tapped
21
+ def mana_ability? = false
22
+ def allowed_types = nil
23
+ def to_s = "enters tapped"
24
+ def try_effects = []
25
+
26
+ # Called by the replacement pass for the entering object.
27
+ def modify_entering(enters) = enters["tapped"] = true
28
+ end
29
+
30
+ # "As this enters, you may pay N life. If you don't, it enters tapped." (CR 614.12, 119.4).
31
+ # The controller chooses before it enters.
32
+ class EntersTappedUnlessPayLife < Primitive
33
+ primitive :ability, :enters_tapped_unless_pay_life,
34
+ description: "As this permanent enters, its controller may pay `life` life; if they don't, it enters " \
35
+ "tapped. You can't pay more life than you have.",
36
+ rules: %w[614.12 119.4],
37
+ params: { "life" => "integer" },
38
+ example: { "enters_tapped_unless_pay_life" => { "life" => 2 } }
39
+
40
+ attr_reader :life
41
+
42
+ def self.build(params, errors, path, **)
43
+ Mechanics.check_keys(params, %w[life], errors, path)
44
+ new(Mechanics.integer_param(params, :life, errors, path, min: 1))
45
+ end
46
+
47
+ def initialize(life)
48
+ super()
49
+ @life = life
50
+ freeze
51
+ end
52
+
53
+ def kind = :entering_choice
54
+ def mana_ability? = false
55
+ def allowed_types = nil
56
+ def try_effects = []
57
+ def to_s = "as this enters, pay #{life} life or it enters tapped"
58
+
59
+ # Options for the controller (CR 119.4: paying life needs at least that much).
60
+ def options(engine, controller)
61
+ pay = engine.player(controller).life >= life ? [{ type: "pay_life", life: life }] : []
62
+ pay + [{ type: "enter_tapped" }]
63
+ end
64
+
65
+ def enters_for(answer) = { "tapped" => answer[:type] != "pay_life" }
66
+
67
+ # Paid as it enters (CR 119.4).
68
+ def pay!(engine, controller, answer)
69
+ engine.change_life!(controller, -answer["life"]) if answer["type"] == "pay_life"
70
+ end
71
+ end
72
+
73
+ # "As this land enters, you may behold a Jace. If you don't, this land enters tapped." (CR 614.12,
74
+ # 701.4a). The controller chooses what to behold (or not) before it enters; a card is revealed as it enters.
75
+ class EntersTappedUnlessBehold < Primitive
76
+ primitive :ability, :enters_tapped_unless_behold,
77
+ description: "As this permanent enters, its controller may behold an object matching the card spec " \
78
+ "`card` (reveal such a card from their hand or choose such a permanent they control); " \
79
+ "if they don't, it enters tapped.",
80
+ rules: %w[614.12 701.4a],
81
+ params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"jace\"]}" },
82
+ example: { "enters_tapped_unless_behold" => { "card" => { "subtypes" => ["jace"] } } }
83
+
84
+ attr_reader :card
85
+
86
+ def self.build(params, errors, path, **)
87
+ Mechanics.check_keys(params, %w[card], errors, path)
88
+ raw = Mechanics.fetch_param(params, :card)
89
+ errors << "#{path}.card: required" if raw.nil?
90
+ new(CardSpec.build(raw, errors, "#{path}.card"))
91
+ end
92
+
93
+ def initialize(card)
94
+ super()
95
+ @card = card
96
+ freeze
97
+ end
98
+
99
+ def kind = :entering_choice
100
+ def mana_ability? = false
101
+ def allowed_types = nil
102
+ def try_effects = []
103
+ def to_s = "as this enters, you may behold a #{card.to_s.delete_suffix(" card")}; if you don't, it enters tapped"
104
+
105
+ def options(engine, controller)
106
+ Beholding.candidates(engine, controller, card).map { { type: "behold", object: it } } + [{ type: "enter_tapped" }]
107
+ end
108
+
109
+ def enters_for(answer) = { "tapped" => answer[:type] != "behold" }
110
+
111
+ # The card is revealed as it enters (CR 701.4a).
112
+ def pay!(engine, controller, answer)
113
+ id = answer["object"]
114
+ Beholding.behold!(engine, controller, id) if answer["type"] == "behold" && engine.state.objects.key?(id)
115
+ end
116
+ end
117
+
118
+ # "This land enters tapped unless you control two or more other lands." A self-replacement effect that
119
+ # checks its condition as the permanent would enter (CR 614.1c, 614.12): it isn't on the battlefield
120
+ # yet, so "you control" counts only the others.
121
+ class EntersTappedUnless < Primitive
122
+ primitive :ability, :enters_tapped_unless,
123
+ description: "This permanent enters tapped unless `condition` is true as it would enter (checked for " \
124
+ "its controller; the permanent itself isn't on the battlefield yet).",
125
+ rules: %w[614.1c 614.12],
126
+ params: { "condition" => "a condition, e.g. {\"controls\": {\"each\": \"land\", \"controller\": \"you\"}, " \
127
+ "\"at_least\": 2}" },
128
+ example: { "enters_tapped_unless" => { "condition" => { "controls" => { "each" => "land", "controller" => "you" },
129
+ "at_least" => 2 } } }
130
+
131
+ attr_reader :condition
132
+
133
+ def self.build(params, errors, path, **)
134
+ Mechanics.check_keys(params, %w[condition], errors, path)
135
+ raw = Mechanics.fetch_param(params, :condition)
136
+ condition = raw.nil? ? ((errors << "#{path}.condition: required") && nil) : Condition.build(raw, errors, "#{path}.condition")
137
+ new(condition)
138
+ end
139
+
140
+ def initialize(condition)
141
+ super()
142
+ @condition = condition
143
+ freeze
144
+ end
145
+
146
+ def kind = :enters_tapped
147
+ def mana_ability? = false
148
+ def allowed_types = nil
149
+ def try_effects = []
150
+ def to_s = "enters tapped unless #{condition}"
151
+
152
+ # Called by the replacement pass for the entering object (Replacements.entering!).
153
+ def modify_entering_for(engine, controller, object_id, enters)
154
+ enters["tapped"] = true unless condition&.true?(engine, EffectContext.new(controller, object_id, nil, []))
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # Entering with counters: a permanent's own counters as it enters, or extra counters a permanent
7
+ # on the battlefield gives to matching permanents as they enter. Replacement effects that modify
8
+ # how a permanent enters (CR 614.1c, 614.12); counters given as it enters count as counters put
9
+ # on it (CR 122.6).
10
+ class EntersWithCounters < Primitive
11
+ primitive :ability, :enters_with_counters,
12
+ description: "Without `affects`: this permanent enters with the `counters` on it; a count may be an " \
13
+ "amount computed as it enters (e.g. the mana spent to cast it, CR 601.2h). With `affects` " \
14
+ "({\"each\": filter, \"controller\": you|opponent|any, \"other\": true}, relative to " \
15
+ "this permanent's controller): while this permanent is on the battlefield, each " \
16
+ "matching permanent enters with the `counters` in addition to any others.",
17
+ rules: %w[614.1c 614.12 122.6 122.6a],
18
+ params: { "counters" => "{counter name: n} (n >= 1; without `affects`, n may be an amount)",
19
+ "affects" => "optional {\"each\": filter, \"controller\": ..., \"other\": bool}" },
20
+ example: { "enters_with_counters" => { "counters" => { "+1/+1" => 1 },
21
+ "affects" => { "each" => "creature", "controller" => "you",
22
+ "other" => true } } }
23
+
24
+ attr_reader :counters, :filter, :controller, :amounts
25
+
26
+ def self.build(params, errors, path, **)
27
+ Mechanics.check_keys(params, %w[counters affects], errors, path)
28
+ affects = Mechanics.fetch_param(params, :affects)
29
+ counters = read_counters(Mechanics.fetch_param(params, :counters), errors, "#{path}.counters", amounts: affects.nil?)
30
+ counters, amounts = counters.partition { |_, n| n.is_a?(Integer) }.map(&:to_h)
31
+ return new(counters, nil, nil, amounts) if affects.nil?
32
+
33
+ unless affects.is_a?(Hash) && affects["each"]
34
+ errors << "#{path}.affects: must be {\"each\": filter, ...}"
35
+ return new(counters, nil, nil)
36
+ end
37
+ Mechanics.check_keys(affects, %w[each controller other], errors, "#{path}.affects")
38
+ filter = Mechanics.build_entry(:filter, affects["each"], errors, "#{path}.affects.each")
39
+ live = filter ? filter.live_keys : []
40
+ errors << "#{path}.affects.each: #{live.join(", ")} can't be used for an entering permanent" unless live.empty?
41
+ controller = affects.fetch("controller", "any")
42
+ errors << "#{path}.affects.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
43
+ new(counters, filter, controller)
44
+ end
45
+
46
+ # `amounts`: a count may be an amount (Amount), computed as the permanent enters.
47
+ def self.read_counters(raw, errors, path, amounts: false)
48
+ unless raw.is_a?(Hash) && !raw.empty?
49
+ errors << "#{path}: must be a non-empty object {counter name: n}"
50
+ return {}
51
+ end
52
+ raw.to_h do |name, n|
53
+ name = Counters.normalize(name)
54
+ Counters.parse(name, errors, "#{path}.#{name}")
55
+ next [name, Amount.build(n, errors, "#{path}.#{name}", targets_count: 0)] if amounts && !n.is_a?(Integer)
56
+
57
+ errors << "#{path}.#{name}: must be an integer >= 1" unless n.is_a?(Integer) && n >= 1
58
+ [name, n]
59
+ end
60
+ end
61
+
62
+ def initialize(counters, filter, controller, amounts = {})
63
+ super()
64
+ @counters = counters.freeze
65
+ @amounts = amounts.freeze
66
+ @filter = filter
67
+ @controller = controller
68
+ freeze
69
+ end
70
+
71
+ def kind = :enters_with_counters
72
+ def mana_ability? = false
73
+ def allowed_types = nil
74
+ def try_effects = []
75
+
76
+ def to_s
77
+ list = (counters.map { |name, n| "#{n} #{name} counter#{"s" if n > 1}" } +
78
+ amounts.map { |name, n| "#{name} counters equal to #{n}" }).join(" and ")
79
+ return "enters with #{list}" unless filter
80
+
81
+ whose = { "you" => " you control", "opponent" => " your opponents control" }[controller]
82
+ label = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
83
+ "#{label}s#{whose} enter with an additional #{list}"
84
+ end
85
+
86
+ # The entering permanent's own replacement effect (called by Replacements for it alone).
87
+ def modify_entering(enters) = filter ? nil : add!(enters)
88
+
89
+ # Counts computed as it enters, from the entering object (a resolving spell: what was spent on it).
90
+ def modify_entering_for(engine, controller, object_id, enters)
91
+ ctx = EffectContext.new(controller, object_id, object_id, [])
92
+ amounts.each do |name, amount|
93
+ n = amount.value(engine, ctx)
94
+ enters["counters"][name] = enters["counters"].fetch(name, 0) + n if n.positive?
95
+ end
96
+ end
97
+
98
+ # A permanent on the battlefield modifying another one as it enters.
99
+ def affects_entering?(source, entering_controller, facts)
100
+ return false unless filter
101
+ return false unless controller == "any" || (controller == "you") == (entering_controller == source.controller)
102
+
103
+ filter.matches_facts?(facts)
104
+ end
105
+
106
+ def modify_other_entering(enters) = add!(enters)
107
+
108
+ private
109
+
110
+ def add!(enters)
111
+ counters.each { |name, n| enters["counters"][name] = enters["counters"].fetch(name, 0) + n }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../keywords/alternative_costs"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ # "Spend this mana only to cast [quality] spells or activate abilities of [quality] sources" (CR 106.6):
8
+ # a restriction on mana, kept with that mana in the pool. Data:
9
+ # {"spells": card spec, "abilities": card spec} at least one; "card" matches every spell or source
10
+ # The spec is matched against the spell being cast or the source of the ability being activated. Mana
11
+ # paid for a cost while a spell or ability resolves (CR 118.12) is neither, so restricted mana can't pay it.
12
+ # `from` / `not_from` a zone limit the spells to those cast from (not from) it ("spend this mana only to cast
13
+ # a spell from anywhere other than your hand": {"not_from": "hand"}; CR 601.2a); alone, they mean spells.
14
+ class SpendOnly
15
+ KEYS = (%w[spells abilities] + CastFrom::KEYS).freeze
16
+
17
+ attr_reader :data
18
+
19
+ def self.build(data, errors, path)
20
+ unless data.is_a?(Hash) && !data.empty? && (data.keys.map(&:to_s) - KEYS).empty?
21
+ errors << "#{path}: must be {\"spells\": card spec, \"abilities\": card spec, \"from\" | \"not_from\": zone} " \
22
+ "(at least one)"
23
+ return nil
24
+ end
25
+ data = data.transform_keys(&:to_s)
26
+ zones = CastFrom.build(data, errors, path)
27
+ specs = data.slice("spells", "abilities").to_h { |key, spec| [key, CardSpec.build(spec, errors, "#{path}.#{key}")] }
28
+ specs["spells"] ||= CardSpec.build("card", errors, path) if data.keys.intersect?(CastFrom::KEYS)
29
+ new(data, specs, zones)
30
+ end
31
+
32
+ # The restriction kept on pool mana (plain data, CR 106.6), rebuilt once per distinct data.
33
+ def self.from_data(data)
34
+ (@cache ||= {})[data] ||= build(data, [], "spend_only")
35
+ end
36
+
37
+ def initialize(data, specs, zones = CastFrom.new(nil, nil))
38
+ @data = Snapshot.deep_copy(data).freeze
39
+ @specs = specs.freeze
40
+ @zones = zones
41
+ freeze
42
+ end
43
+
44
+ # `purpose`: {"spell" => id} for a spell being cast, {"ability" => source id} for an ability being
45
+ # activated, nil for anything else.
46
+ def allows?(engine, purpose)
47
+ return false if purpose.nil?
48
+
49
+ spell = purpose.key?("spell")
50
+ spec = spell ? @specs["spells"] : @specs["abilities"]
51
+ ref = purpose["spell"] || purpose["ability"]
52
+ obj = ref.is_a?(GameObject) ? ref : engine.state.objects[ref] # a card outside the game is its stand-in
53
+ !spec.nil? && !obj.nil? && spec.match?(engine, obj) && (!spell || @zones.match?(engine.cast_origin(obj)))
54
+ end
55
+
56
+ def to_s
57
+ parts = []
58
+ parts << "cast #{"#{@specs["spells"]} " unless @specs["spells"].any?}spells#{@zones}" if @specs["spells"]
59
+ parts << "activate abilities of #{@specs["abilities"]}s" if @specs["abilities"]
60
+ "spend this mana only to #{parts.join(" or ")}"
61
+ end
62
+ end
63
+
64
+ module Abilities
65
+ # Activated mana ability (CR 605). Resolves immediately, never uses the stack. It adds the fixed
66
+ # mana in `produce` (`amount` times), or `amount` mana of colors chosen as it is activated
67
+ # (CR 605.3b, 106.1a): all of one color (`color: "any_one"`) or each separately (`"any"`).
68
+ class ManaAbility < Primitive
69
+ COLORS = Mana::COLORS.map(&:to_s).freeze
70
+ CHOICES = %w[any_one any chosen].freeze
71
+
72
+ primitive :ability, :mana,
73
+ description: "An activated mana ability: pay the cost, add the mana to your mana pool " \
74
+ "immediately. It does not use the stack and can be activated whenever you " \
75
+ "have priority. `produce`: the fixed mana (`amount` times). Or `color`: " \
76
+ "`amount` mana (default 1) of one color you choose as you activate it " \
77
+ "(`\"any_one\"`) or in any combination of colors (`\"any\"`); `colors` limits " \
78
+ "the choice. `\"chosen\"`: of the color chosen as this entered (choose_color). " \
79
+ "`spend_only` restricts how the mana can be spent. `only_if`: \"Activate only if ...\".",
80
+ rules: %w[605.1a 605.3a 605.3b 106.1a 106.4 106.6 607.2d 602.5b],
81
+ params: { "cost" => "list of costs (e.g. [\"tap\"])",
82
+ "produce" => "mana symbols added, e.g. \"{G}\" or \"{C}{C}\"",
83
+ "amount" => "amount (default 1; with produce: how many times)",
84
+ "color" => CHOICES.join(" | "), "colors" => "list of colors to choose from (default all five)",
85
+ "spend_only" => "{\"spells\": card spec, \"abilities\": card spec, \"from\" | \"not_from\": zone}",
86
+ "only_if" => "condition: it can be activated only while it holds (CR 602.5b)" },
87
+ example: { "mana" => { "cost" => ["tap"], "produce" => "{C}" } }
88
+
89
+ attr_reader :costs, :produce, :color, :colors, :amount, :spend_only
90
+
91
+ def self.build(params, errors, path, **)
92
+ Mechanics.check_keys(params, %w[cost produce amount color colors spend_only only_if], errors, path)
93
+ costs = build_costs(Mechanics.fetch_param(params, :cost), errors, path)
94
+ raw = Mechanics.fetch_param(params, :produce)
95
+ color = Mechanics.fetch_param(params, :color)
96
+ errors << "#{path}: needs exactly one of produce, color" unless raw.nil? ^ color.nil?
97
+ produce = raw && Mana.parse_produce(raw, errors, "#{path}.produce")
98
+ errors << "#{path}.color: must be one of #{CHOICES.join(", ")}" if color && !CHOICES.include?(color)
99
+ raw_amount = Mechanics.fetch_param(params, :amount)
100
+ amount = raw_amount.nil? ? nil : Amount.build(raw_amount, errors, "#{path}.amount", targets_count: 0)
101
+ raw_spend = Mechanics.fetch_param(params, :spend_only)
102
+ spend_only = raw_spend.nil? ? nil : SpendOnly.build(raw_spend, errors, "#{path}.spend_only")
103
+ raw_if = Mechanics.fetch_param(params, :only_if)
104
+ only_if = raw_if && Condition.build(raw_if, errors, "#{path}.only_if")
105
+ new(costs.compact.freeze, produce, color: color, colors: build_colors(params, color, errors, path),
106
+ amount: amount, spend_only: spend_only, only_if: only_if)
107
+ end
108
+
109
+ # CR 605.3a: a mana ability is paid for and resolves at once, so none of its costs can wait for a
110
+ # choice, an announced X or a {2/B} paid one way or the other.
111
+ def self.build_costs(raw, errors, path)
112
+ unless raw.is_a?(Array) && !raw.empty?
113
+ errors << "#{path}.cost: must be a non-empty list"
114
+ return []
115
+ end
116
+
117
+ raw.each_with_index.map do |c, i|
118
+ cost = Mechanics.build_entry(:cost, c, errors, "#{path}.cost[#{i}]")
119
+ errors << "#{path}.cost[#{i}]: a mana ability's cost can't need a choice, X or {2/B}" if choosing_cost?(cost)
120
+ cost
121
+ end
122
+ end
123
+
124
+ def self.choosing_cost?(cost)
125
+ cost.respond_to?(:choices_needed) || (cost.respond_to?(:x?) && cost.x?) ||
126
+ (cost.respond_to?(:mana_cost) && cost.mana_cost.hybrid.any? { Mana.mono_hybrid?(it) })
127
+ end
128
+
129
+ def self.build_colors(params, color, errors, path)
130
+ raw = Mechanics.fetch_param(params, :colors)
131
+ return COLORS if raw.nil?
132
+ if color && color != "chosen" && raw.is_a?(Array) && raw.size >= 2 && (raw - COLORS).empty? && raw.uniq == raw
133
+ return raw.map(&:to_s).freeze
134
+ end
135
+
136
+ errors << "#{path}.colors: must list two or more colors (W U B R G), with color"
137
+ COLORS
138
+ end
139
+
140
+ def initialize(costs, produce, color: nil, colors: COLORS, amount: nil, spend_only: nil, only_if: nil)
141
+ super()
142
+ @costs = costs
143
+ @produce = produce
144
+ @color = color
145
+ @colors = colors
146
+ @amount = amount
147
+ @spend_only = spend_only
148
+ @only_if = only_if
149
+ list = costs.select(&:mana?).map(&:mana_cost)
150
+ @mana_cost = list.empty? ? nil : Mana.sum_costs(list)
151
+ freeze
152
+ end
153
+
154
+ def mana_ability? = true
155
+ def try_effects = []
156
+ def kind = :mana
157
+ def allowed_types = nil
158
+
159
+ # A mana cost ("{1}, {T}: Add …") is paid from the mana already in the pool (CR 605.3a, 601.2g-h).
160
+ # "Activate only if ..." (CR 602.5b) is checked for its controller.
161
+ def activatable?(engine, object)
162
+ return false if @only_if && !@only_if.true?(engine, EffectContext.new(object.controller, object.id, nil, nil))
163
+
164
+ costs.all? { |c| c.payable?(engine, object) } && (mana_cost.nil? || engine.pool_covers?(object.controller, mana_cost))
165
+ end
166
+
167
+ # The sum of its mana costs, or nil.
168
+ attr_reader :mana_cost
169
+
170
+ # Whether the mana is chosen as it's activated (so each activation option names it).
171
+ def choice? = !color.nil? && color != "chosen"
172
+
173
+ # CR 607.2d: "one mana of the chosen color", chosen as the source entered (choose_color); none, no mana.
174
+ def chosen_color(object) = object.memory["chosen_color"]&.to_sym
175
+
176
+ # How much mana (or how many times `produce`) one activation adds now.
177
+ def count(engine, object)
178
+ return 1 if amount.nil?
179
+
180
+ [amount.value(engine, EffectContext.new(object.controller, object.id, nil, [])), 0].max
181
+ end
182
+
183
+ # Every mana an activation could add now, each as a list of pool types (CR 605.3b). One entry
184
+ # without a choice; with `any_one` one per color; with `any` one per combination of colors.
185
+ def outcomes(engine, object)
186
+ n = count(engine, object)
187
+ return [Array(chosen_color(object)) * n] if color == "chosen"
188
+ return [(produce || []) * n] unless choice?
189
+ return [[]] if n.zero?
190
+
191
+ picks = colors.map(&:to_sym)
192
+ return picks.map { [it] * n } if color == "any_one"
193
+
194
+ picks.repeated_combination(n).map(&:freeze)
195
+ end
196
+
197
+ def to_s
198
+ what = if produce then "Add #{produce.map { "{#{it}}" }.join}#{" #{amount} times" if amount}"
199
+ elsif color == "chosen" then "Add #{amount || 1} mana of the chosen color"
200
+ else "Add #{amount || 1} mana of #{color == "any_one" ? "any one color" : "any combination of colors"}" \
201
+ "#{" (#{colors.join("/")})" unless colors == COLORS}"
202
+ end
203
+ "#{costs.join(", ")}: #{what}.#{" #{spend_only.to_s.capitalize}." if spend_only}" \
204
+ "#{" Activate only if #{@only_if}." if @only_if}"
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end