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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Remove N counters from among [permanents] you control" (CR 122.8 / 118.3): each choice is one
7
+ # counter on one permanent; they are removed as the ability becomes cast.
8
+ class RemoveCounters < Choosing
9
+ primitive :cost, :remove_counters,
10
+ description: "Remove `count` counters (of kind `counter`, or any kind) from among permanents you " \
11
+ "control matching `filter` (default creature), one counter per choice. Removed as " \
12
+ "it becomes cast.",
13
+ rules: %w[602.2b 601.2h 118.3 122.1],
14
+ params: { "count" => "integer", "filter" => "filter (default creature)", "counter" => "counter name (optional)" },
15
+ example: { "remove_counters" => { "count" => 3 } }
16
+
17
+ def self.keys = %w[filter count counter]
18
+ def self.default_filter = "creature"
19
+
20
+ def initialize(filter, count, other, params, errors, path)
21
+ @counter = Mechanics.fetch_param(params, :counter)
22
+ errors << "#{path}.counter: #{Counters.error(@counter)}" if @counter && Counters.error(@counter)
23
+ super
24
+ end
25
+
26
+ # Counters still available: [object, kind] once per counter, minus those already chosen.
27
+ def units(engine, object, chosen)
28
+ taken = chosen.map { [it["object"], it["counter"]] }
29
+ matching(engine, object).flat_map do |id|
30
+ engine.object(id).counters.flat_map do |kind, n|
31
+ next [] unless @counter.nil? || kind == @counter
32
+
33
+ left = n - taken.count([id, kind])
34
+ left.positive? ? [[id, kind]] * left : []
35
+ end
36
+ end
37
+ end
38
+
39
+ def payable?(engine, object) = units(engine, object, []).size >= count
40
+ def choice_options(engine, object, chosen) = units(engine, object, chosen).uniq.map { { object: it[0], counter: it[1] } }
41
+ def choose!(_engine, option) = { "object" => option[:object], "counter" => option[:counter] }
42
+
43
+ def pay_chosen!(engine, _object, entries)
44
+ entries.each { engine.remove_counters!(engine.object(it["object"]), it["counter"], 1) }
45
+ end
46
+
47
+ def to_s = "Remove #{count} #{"#{@counter} " if @counter}counters from among #{what}s you control"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Sacrifice this permanent" as a cost (CR 701.21, 602.2b).
7
+ class SacrificeSelf < Primitive
8
+ primitive :cost, :sacrifice,
9
+ description: "Sacrifice the permanent the ability belongs to (it goes to its owner's graveyard).",
10
+ rules: %w[701.21a 602.2b 118.3],
11
+ example: "sacrifice"
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 mana? = false
21
+ def uses_source? = true
22
+ # CR 701.21a, 614.17b: only a permanent on the battlefield its controller may sacrifice.
23
+ def payable?(engine, object) = engine.can_sacrifice?(object, object.controller)
24
+ def pay!(engine, object) = engine.move!(object.id, :graveyard, reason: "sacrifice")
25
+ def to_s = "Sacrifice this"
26
+ end
27
+
28
+ # "Sacrifice a creature" / "Sacrifice another creature or artifact" as a cost (CR 701.21a, 601.2h).
29
+ # The permanent is chosen while casting and sacrificed as the spell or ability becomes cast; what
30
+ # it was (types, subtypes, power) is kept for "if you sacrificed a Klingon this way" (CR 608.2h).
31
+ class SacrificePermanent < Choosing
32
+ primitive :cost, :sacrifice_permanent,
33
+ description: "Sacrifice `count` permanents you control matching `filter` (`other: true`: not the " \
34
+ "source). Chosen while casting or activating, sacrificed as it becomes cast; the " \
35
+ "sacrificed permanent's last known types and subtypes are remembered (condition " \
36
+ "`sacrificed`).",
37
+ rules: %w[701.21a 601.2h 602.2b 118.3 608.2h],
38
+ params: { "filter" => "filter (default permanent)", "count" => "integer (default 1)",
39
+ "other" => "true: not the source itself" },
40
+ example: { "sacrifice_permanent" => { "filter" => "creature", "other" => true } }
41
+
42
+ def pay_chosen!(engine, _object, entries)
43
+ entries.each do |entry| # still there: chosen permanents can't be sacrificed for mana (Engine::Payment)
44
+ perm = engine.object(entry["object"])
45
+ facts = engine.chars(perm)
46
+ entry.merge!("types" => facts.types.map(&:to_s), "subtypes" => facts.subtypes.map(&:to_s),
47
+ "power" => engine.power(perm))
48
+ engine.move!(perm.id, :graveyard, reason: "sacrifice")
49
+ end
50
+ end
51
+
52
+ # CR 701.21a, 614.17b: only permanents their controller may sacrifice (Engine::Rules).
53
+ def candidates(engine, object) = matching(engine, object).select { engine.can_sacrifice?(engine.object(it), object.controller) }
54
+
55
+ def to_s = "Sacrifice #{amount_words} #{what}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Tap N untapped [permanents] you control" (CR 601.2h, 602.2b): Collation Trimatrix taps three
7
+ # artifact creatures. Summoning sickness doesn't matter (CR 302.6 is about {T} abilities only).
8
+ class TapPermanents < Choosing
9
+ primitive :cost, :tap_permanents,
10
+ description: "Tap `count` untapped permanents you control matching `filter` (default: creature), " \
11
+ "chosen while casting or activating; `other: true` excludes the source. They are " \
12
+ "tapped as they are chosen; cancelling untaps them.",
13
+ rules: %w[601.2h 602.2b 302.6 118.3],
14
+ params: { "count" => "integer (default 1)", "filter" => "filter (default creature)",
15
+ "other" => "true: not the source itself" },
16
+ example: { "tap_permanents" => { "count" => 3, "filter" => { "creature" => { "types" => ["artifact"] } } } }
17
+
18
+ def self.default_filter = "creature"
19
+
20
+ def candidates(engine, object) = matching(engine, object).reject { engine.object(it).tapped }
21
+
22
+ def choose!(engine, option)
23
+ perm = engine.object(option[:object])
24
+ engine.tap!(perm)
25
+ { "object" => perm.id, "power" => engine.power(perm), "tapped" => true }
26
+ end
27
+
28
+ def to_s = "Tap #{count == 1 ? "an" : count} untapped #{what}#{"s" if count > 1} you control"
29
+ end
30
+
31
+ # {T}: tap this permanent (CR 107.5, 602.5a).
32
+ class Tap < Primitive
33
+ primitive :cost, :tap,
34
+ description: "{T} — tap the permanent the ability belongs to. Requires it to be untapped.",
35
+ rules: %w[107.5 602.5a],
36
+ example: "tap"
37
+
38
+ INSTANCE = new.freeze
39
+
40
+ def self.build(params, errors, path, **)
41
+ Mechanics.check_keys(params, [], errors, path)
42
+ INSTANCE
43
+ end
44
+
45
+ def payable?(engine, object) = !object.tapped && !engine.summoning_sick_for_tap?(object)
46
+
47
+ def pay!(engine, object)
48
+ engine.tap!(object)
49
+ end
50
+
51
+ def mana? = false
52
+ def to_s = "{T}"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mana"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Costs
8
+ # Waterbend [cost] (CR 701.67a): "Pay [cost]. For each generic mana in that cost, you may tap an untapped
9
+ # artifact or creature you control rather than pay that mana." A mana cost, so it adds to the total cost
10
+ # like any other (an activated ability's cost, an additional cost, kicker); while paying, the `pay_cost`
11
+ # option `{type: "waterbend", object: id}` taps one of those permanents for {1}, at most as many times as
12
+ # the waterbend cost has generic mana (CR 701.67b). Paying it is waterbending (CR 701.67c).
13
+ class Waterbend < ManaPayment
14
+ primitive :cost, :waterbend,
15
+ description: "Waterbend [cost]: pay this mana cost; for each generic mana in it you may instead tap " \
16
+ "an untapped artifact or creature you control (pay_cost option `{type: \"waterbend\", " \
17
+ "object: id}`). Only the waterbend cost's own generic mana can be paid this way.",
18
+ rules: %w[701.67a 701.67b 701.67c],
19
+ params: { "(value)" => "mana cost string, e.g. \"{3}\" or \"{X}\"" },
20
+ example: { "waterbend" => "{3}" }
21
+
22
+ # The generic mana tapping may pay for, with X announced.
23
+ def generic(x_value) = mana_cost.with_x(x_value).generic
24
+
25
+ def to_s = "Waterbend #{mana_cost}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Counters
6
+ # Counters (CR 122). Any counter name is valid card data: a counter with no rules of its own
7
+ # (oil, charge, lore, ...) is just a marker that abilities can count (CR 122.1). The kinds
8
+ # registered here carry rules of their own:
9
+ # - power/toughness counters "+X/+Y" / "-X/-Y" (CR 122.1a, layer 7c, 613.4c)
10
+ # - keyword counters "flying", "hexproof", ... (CR 122.1b, layer 6, 613.1f)
11
+ # - stun counters stop untapping (CR 122.1d), shield counters protect (CR 122.1c)
12
+ # Replacement and prevention effects of counters run through Engine#propose (CR 614, 615).
13
+ NAME = %r{\A(?:[+-]\d+/[+-]\d+|[a-z][a-z0-9_]*)\z}
14
+
15
+ POWER_TOUGHNESS = %r{\A([+-]\d+)/([+-]\d+)\z}
16
+
17
+ # CR 122.1b: the keywords a keyword counter can be (those the engine implements).
18
+ KEYWORDS = %i[flying first_strike double_strike deathtouch decayed haste hexproof indestructible lifelink menace
19
+ reach trample vigilance].freeze
20
+
21
+ module_function
22
+
23
+ # "first strike" and "First-Strike" are written first_strike.
24
+ def normalize(name)
25
+ return name unless name.is_a?(String) || name.is_a?(Symbol)
26
+
27
+ POWER_TOUGHNESS.match?(name.to_s) ? name.to_s : name.to_s.downcase.tr(" -", "__")
28
+ end
29
+
30
+ def valid?(name) = name.is_a?(String) && NAME.match?(name)
31
+
32
+ # A counter name from card data, normalized; an invalid one is reported at `path`.
33
+ def parse(raw, errors, path)
34
+ name = normalize(raw)
35
+ message = error(name)
36
+ errors << "#{path}: #{message}" if message
37
+ name
38
+ end
39
+
40
+ # Error message for an invalid counter name, or nil.
41
+ def error(name)
42
+ return nil if valid?(name)
43
+
44
+ "must be a counter name: +X/+Y, -X/-Y or a lowercase word (e.g. +1/+1, stun, shield, oil)"
45
+ end
46
+
47
+ # [power delta, toughness delta] from all power/toughness counters (CR 122.1a, 613.4c).
48
+ def power_toughness(counters)
49
+ counters.each_with_object([0, 0]) do |(name, n), delta|
50
+ m = POWER_TOUGHNESS.match(name) or next
51
+ delta[0] += Integer(m[1]) * n
52
+ delta[1] += Integer(m[2]) * n
53
+ end
54
+ end
55
+
56
+ # Keywords granted by keyword counters (CR 122.1b).
57
+ def keywords(counters)
58
+ counters.filter_map do |name, n|
59
+ name.to_sym if n.positive? && KEYWORDS.include?(name.to_sym) && Enjine.registry.fetch(:keyword, name)
60
+ end
61
+ end
62
+
63
+ def any?(counters) = counters.any? { |_, n| n.positive? }
64
+
65
+ # Whether `name` is a keyword counter (CR 122.1b).
66
+ def keyword?(name) = KEYWORDS.include?(name.to_s.to_sym)
67
+
68
+ # Replacement / prevention effects created by the counters on the object a proposal affects.
69
+ def replace!(engine, proposal)
70
+ id = proposal.data[proposal.kind == "damage" ? "target" : "object"]
71
+ obj = id && engine.state.objects[id]
72
+ return unless obj && obj.zone == :battlefield
73
+
74
+ if proposal.kind == "damage" && engine.damage_batch&.include?(obj.id) # its shield already applied (CR 615.12a)
75
+ proposal.data["amount"] = 0 unless proposal.data["unpreventable"]
76
+ return
77
+ end
78
+
79
+ obj.counters.each do |name, n|
80
+ kind = Enjine.registry.fetch(:counter, name)
81
+ next unless n.positive? && kind.respond_to?(:replace!) && !proposal.applied.include?("counter:#{name}")
82
+
83
+ proposal.applied << "counter:#{name}" # CR 614.5: each replacement applies once
84
+ kind.replace!(engine, obj, proposal)
85
+ end
86
+ end
87
+
88
+ # A counter kind with rules (documentation and behavior).
89
+ class Kind < Primitive
90
+ def self.build(_params, _errors, _path, **) = self
91
+ end
92
+
93
+ # +1/+1 counters (CR 122.1a). Annihilate with -1/-1 counters as a state-based action (CR 704.5q).
94
+ class PlusOne < Kind
95
+ primitive :counter, :"+1/+1",
96
+ description: "Adds 1 to power and toughness (layer 7c). Any +X/+Y or -X/-Y counter works the same " \
97
+ "way. If a permanent has both +1/+1 and -1/-1 counters, N of each are removed as a " \
98
+ "state-based action (N = the smaller count).",
99
+ rules: %w[122.1a 122.3 613.4c 704.5q],
100
+ example: { "add_counters" => { "counter" => "+1/+1", "amount" => 1, "to" => "self" } }
101
+ end
102
+
103
+ # -1/-1 counters (CR 122.1a).
104
+ class MinusOne < Kind
105
+ primitive :counter, :"-1/-1", description: "Subtracts 1 from power and toughness (layer 7c).",
106
+ rules: %w[122.1a 122.3 613.4c 704.5q],
107
+ example: { "add_counters" => { "counter" => "-1/-1", "amount" => 1, "to" => { "target" => 0 } } }
108
+ end
109
+
110
+ # Loyalty counters (CR 122.1e, 306.5b).
111
+ class Loyalty < Kind
112
+ primitive :counter, :loyalty, description: "A planeswalker's loyalty; at 0 it is put into its owner's graveyard.",
113
+ rules: %w[122.1e 306.5b 704.5i],
114
+ example: { "add_counters" => { "counter" => "loyalty", "amount" => 1, "to" => "self" } }
115
+ end
116
+
117
+ # Keyword counters (CR 122.1b). Registered once under the name "keyword" for documentation;
118
+ # the counter itself is named after its keyword ("flying", "hexproof", "first_strike", ...).
119
+ class KeywordCounter < Kind
120
+ primitive :counter, :keyword,
121
+ description: "A counter named after a keyword (#{KEYWORDS.join(", ")}) gives the permanent that " \
122
+ "keyword (layer 6). Write the keyword itself as the counter name.",
123
+ rules: %w[122.1b 613.1f],
124
+ example: { "add_counters" => { "counter" => "hexproof", "amount" => 1, "to" => "self" } }
125
+ end
126
+
127
+ # Stun counters (CR 122.1d): "If a permanent with a stun counter on it would become untapped,
128
+ # instead remove a stun counter from it."
129
+ class Stun < Kind
130
+ primitive :counter, :stun,
131
+ description: "If the permanent would become untapped (untap step or an effect), instead one stun " \
132
+ "counter is removed from it.",
133
+ rules: %w[122.1d 502.3 701.26b],
134
+ example: { "add_counters" => { "counter" => "stun", "amount" => 1, "to" => { "target" => 0 } } }
135
+
136
+ def self.replace!(engine, obj, proposal)
137
+ return unless proposal.kind == "untap"
138
+
139
+ proposal.data["cancelled"] = true
140
+ engine.remove_counters!(obj, "stun", 1)
141
+ end
142
+ end
143
+
144
+ # Finality counters (CR 122.1h): "If this permanent would be put into a graveyard from the
145
+ # battlefield, exile it instead."
146
+ class Finality < Kind
147
+ primitive :counter, :finality,
148
+ description: "If the permanent would be put into a graveyard from the battlefield, it is exiled instead.",
149
+ rules: %w[122.1h 614.1a],
150
+ example: { "enters_with_counters" => { "counters" => { "finality" => 1 } } }
151
+
152
+ def self.replace!(_engine, _obj, proposal)
153
+ proposal.data["to"] = :exile if proposal.kind == "zone_change" && proposal.data["to"] == :graveyard
154
+ end
155
+ end
156
+
157
+ # Shield counters (CR 122.1c): one replacement effect ("if it would be destroyed as the result of
158
+ # an effect, instead remove a shield counter") and one prevention effect ("if damage would be dealt
159
+ # to it, prevent that damage and remove a shield counter").
160
+ class Shield < Kind
161
+ primitive :counter, :shield,
162
+ description: "If the permanent would be destroyed by an effect, instead remove a shield counter. If " \
163
+ "damage would be dealt to it, prevent it and remove a shield counter (simultaneous " \
164
+ "damage, such as combat damage, removes only one).",
165
+ rules: %w[122.1c 614.1a 615.1a 701.8b],
166
+ example: { "add_counters" => { "counter" => "shield", "amount" => 1, "to" => { "target" => 0 } } }
167
+
168
+ def self.replace!(engine, obj, proposal)
169
+ case proposal.kind
170
+ when "zone_change"
171
+ return unless proposal.data["reason"] == "destroy" # CR 701.8b: lethal-damage SBAs aren't "an effect"
172
+
173
+ proposal.data["cancelled"] = true
174
+ engine.remove_counters!(obj, "shield", 1)
175
+ when "damage"
176
+ prevent_damage!(engine, obj, proposal)
177
+ end
178
+ end
179
+
180
+ # CR 615.1a. Damage dealt at the same time by several sources is one event: one counter. Damage that
181
+ # can't be prevented is dealt, and the counter is still removed (CR 615.12).
182
+ def self.prevent_damage!(engine, obj, proposal)
183
+ proposal.data["amount"] = 0 unless proposal.data["unpreventable"]
184
+ engine.damage_batch&.push(obj.id)
185
+ engine.remove_counters!(obj, "shield", 1)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "Until end of turn, they become Warriors in addition to their other types." (CR 613.1d, 205.1b)
7
+ class AddSubtypes < Effect
8
+ primitive :effect, :add_subtypes,
9
+ description: "Each referenced permanent gets the `subtypes` in addition to its other types until " \
10
+ "end of turn (layer 4).",
11
+ rules: %w[613.1d 205.1b 611.2a 611.2c],
12
+ params: { "subtypes" => "list of lowercase subtypes", "to" => "selector" },
13
+ example: { "add_subtypes" => { "subtypes" => ["warrior"], "to" => { "target" => 0 } } }
14
+
15
+ attr_reader :subtypes
16
+
17
+ def self.keys = %w[subtypes to if]
18
+
19
+ def setup(params, errors, path)
20
+ @subtypes = Mechanics.list_param(params, :subtypes, errors, path).map(&:to_s)
21
+ ok = !@subtypes.empty? && @subtypes.all? { CardDefinition::SUBTYPE_FORMAT.match?(it) }
22
+ errors << "#{path}.subtypes: must be a non-empty list of lowercase subtypes" unless ok
23
+ end
24
+
25
+ def apply(engine, ctx) = engine.add_continuous_effect!("add_subtypes", ctx, objects(engine, ctx), "subtypes" => subtypes)
26
+ def to_s = "becomes #{subtypes.join(" ")} in addition until end of turn → #{to}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Airbend (CR 701.65a): "exile those objects. For each card exiled this way, for as long as it remains
7
+ # exiled, its owner may cast it by paying {2} rather than paying its mana cost." The permanents and spells
8
+ # are the referenced ones (usually targets); each exiled card gets a permission for its owner whose
9
+ # alternative cost is {2} (CR 118.9, Engine::Permissions). A token or a copy of a spell ceases to exist
10
+ # (CR 111.7, 707.10a), so it can't be cast. Exiling one or more objects this way is airbending (CR 701.65b).
11
+ class Airbend < ZoneEffect
12
+ COST = "{2}"
13
+
14
+ primitive :effect, :airbend,
15
+ description: "Airbend: exile each referenced permanent or spell; for as long as each card stays " \
16
+ "exiled, its owner may cast it by paying {2} rather than its mana cost (cast option " \
17
+ "`via: \"effect:<n>\"`). The exiled cards are the effect's results.",
18
+ rules: %w[701.65a 701.65b 118.9 601.2f],
19
+ params: { "to" => "selector of permanents and/or spells" },
20
+ example: { "airbend" => { "to" => { "target" => 0 } } }
21
+
22
+ def self.zone = :exile
23
+ def self.reason = "airbend"
24
+ def self.from_zones = %i[battlefield stack]
25
+
26
+ def to_s = "airbend → #{to}"
27
+
28
+ def apply(engine, ctx)
29
+ moved = movable(engine, ctx).filter_map { move(engine, ctx, it) }
30
+ return if moved.empty?
31
+
32
+ moved.select { it.zone == :exile }.group_by(&:owner).each do |owner, cards|
33
+ engine.grant_play_permission!(owner, cards.map(&:id), duration: "while_exiled", play: "cast",
34
+ alt: { "cost" => COST })
35
+ end
36
+ engine.emit Events::KeywordAction.new(player: engine.name(ctx.controller), action: "airbend", amount: moved.size)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "If you don't control a [kind of permanent], create one as a token. Choose one you control. Put N
7
+ # [counters] on it.": the shared shape of amass (CR 701.47a) and empower Jace (CR 701.71a). The choice
8
+ # is an `effect_choice` only when there is more than one candidate. The chosen permanent is the
9
+ # effect's result ("the amassed Army", CR 701.47c); a token just created already is.
10
+ class ChooseOrCreate < ChoiceEffect
11
+ def self.keys = %w[amount if]
12
+ def self.default_to = "you"
13
+
14
+ def run(engine, ctx, locals)
15
+ return :done unless locals.key?("n") || condition.nil? || condition.true?(engine, ctx)
16
+
17
+ unless locals.key?("n")
18
+ locals["n"] = amount_for(engine, ctx)
19
+ locals["created"] = candidates(engine, ctx.controller).empty?
20
+ engine.create_token!(token, ctx.controller) if locals["created"]
21
+ end
22
+ options = candidates(engine, ctx.controller)
23
+ answer = answers(locals).first
24
+ if answer.nil? && options.size > 1
25
+ return ask(engine, locals, ctx.controller, "#{self}: choose one",
26
+ options.map { { "type" => "choose", "object" => it } })
27
+ end
28
+ chosen = answer ? answer["object"] : options.first
29
+ finish(engine, ctx, chosen, locals) if chosen
30
+ :done
31
+ end
32
+
33
+ def created_tokens = [token]
34
+
35
+ private
36
+
37
+ def candidates(engine, index)
38
+ engine.state.battlefield.select { engine.object(it).controller == index && candidate?(engine, engine.object(it)) }
39
+ end
40
+
41
+ def finish(engine, ctx, chosen, locals)
42
+ perm = engine.object(chosen)
43
+ engine.record_result!(chosen) unless locals["created"]
44
+ engine.add_counters!(perm, counter, locals["n"], by_player: ctx.controller)
45
+ end
46
+ end
47
+
48
+ # Amass [subtype] N (CR 701.47a): if you don't control an Army creature, create a 0/0 black [subtype]
49
+ # Army creature token; choose an Army creature you control; put N +1/+1 counters on it; if it isn't a
50
+ # [subtype], it becomes one in addition to its other types (layer 4, for as long as it stays).
51
+ class Amass < ChooseOrCreate
52
+ primitive :effect, :amass,
53
+ description: "Amass `subtype` `amount`: if you control no Army creature, create a 0/0 black " \
54
+ "[subtype] Army creature token; then put `amount` +1/+1 counters on an Army creature " \
55
+ "you control (chosen if there are several), which becomes a [subtype] too.",
56
+ rules: %w[701.47a 701.47b 701.47c 613.1d],
57
+ params: { "subtype" => "lowercase creature type, e.g. \"orc\"", "amount" => "amount" },
58
+ example: { "amass" => { "subtype" => "orc", "amount" => 2 } }
59
+
60
+ attr_reader :subtype, :token
61
+
62
+ def self.keys = %w[subtype amount if]
63
+
64
+ def setup(params, errors, path)
65
+ @subtype = Mechanics.fetch_param(params, :subtype).to_s
66
+ unless CardDefinition::SUBTYPE_FORMAT.match?(@subtype) && @subtype != "army"
67
+ errors << "#{path}.subtype: must be a lowercase creature type other than army"
68
+ @subtype = "zombie"
69
+ end
70
+ @token = CardDefinition.token({ "name" => "#{@subtype.split("_").map(&:capitalize).join(" ")} Army",
71
+ "types" => ["creature"], "subtypes" => [@subtype, "army"], "colors" => ["B"],
72
+ "power" => 0, "toughness" => 0 })
73
+ end
74
+
75
+ def counter = "+1/+1"
76
+ def to_s = "amass #{subtype.tr("_", " ")} #{amount}"
77
+
78
+ private
79
+
80
+ def candidate?(engine, perm)
81
+ chars = engine.chars(perm)
82
+ chars.creature? && chars.subtypes.include?(:army)
83
+ end
84
+
85
+ def finish(engine, ctx, chosen, locals)
86
+ super
87
+ return if engine.chars(chosen).subtypes.include?(subtype.to_sym)
88
+
89
+ engine.add_continuous_effect!("add_subtypes", ctx, [chosen], "subtypes" => [subtype], "duration" => "permanent")
90
+ end
91
+ end
92
+
93
+ # Empower Jace N (CR 701.71a): if you don't control a Jace planeswalker token, create a blue Jace
94
+ # planeswalker token with 0 loyalty, "[-1]: Surveil 1" and "[-3]: Draw a card"; choose a Jace
95
+ # planeswalker token you control; put N loyalty counters on it. State-based actions aren't checked
96
+ # while the effect resolves, so the new token gets its counters first (CR 704.3).
97
+ class EmpowerJace < ChooseOrCreate
98
+ primitive :effect, :empower_jace,
99
+ description: "Empower Jace `amount`: if you control no Jace planeswalker token, create a blue Jace " \
100
+ "planeswalker token (0 loyalty; -1: surveil 1; -3: draw a card); then put `amount` " \
101
+ "loyalty counters on a Jace planeswalker token you control.",
102
+ rules: %w[701.71a 704.3],
103
+ params: { "amount" => "amount" },
104
+ example: { "empower_jace" => { "amount" => 2 } }
105
+
106
+ TOKEN = { "name" => "Jace", "types" => ["planeswalker"], "subtypes" => ["jace"], "colors" => ["U"], "loyalty" => 0,
107
+ "abilities" => [{ "loyalty" => { "cost" => -1, "effects" => [{ "surveil" => { "amount" => 1 } }] } },
108
+ { "loyalty" => { "cost" => -3, "effects" => [{ "draw" => { "amount" => 1 } }] } }] }.freeze
109
+
110
+ def token = EmpowerJace.token
111
+ def self.token = @token ||= CardDefinition.token(TOKEN)
112
+ def counter = "loyalty"
113
+ def to_s = "empower Jace #{amount}"
114
+
115
+ private
116
+
117
+ def candidate?(engine, perm)
118
+ chars = engine.chars(perm)
119
+ engine.token?(perm) && chars.planeswalker? && chars.subtypes.include?(:jace)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Assimilate (CR 701.72). A permanent: gain control of it and put a
7
+ # +1/+1 counter on it (701.72a). A permanent card in another zone: put it onto the battlefield under
8
+ # your control with a +1/+1 counter (701.72b). Either way it's a Borg artifact creature from then on:
9
+ # it keeps its other card types and subtypes except its creature types (CR 205.1b).
10
+ #
11
+ # `choose: true` assimilates one of the referenced cards, chosen by the controller as it resolves
12
+ # ("assimilate a creature card exiled with it", 701.72c, 607.2a); `for` keeps only cards matching a
13
+ # card spec. What it put onto the battlefield is its result, so a later effect can say "if you can't".
14
+ class Assimilate < ChoiceEffect
15
+ primitive :effect, :assimilate,
16
+ description: "Assimilate each referenced permanent or permanent card: you gain control of it (or " \
17
+ "put it onto the battlefield under your control) with a +1/+1 counter, and it " \
18
+ "becomes a Borg artifact creature while it stays on the battlefield. `for`: only cards " \
19
+ "matching a card spec; `choose: true`: you choose one of them.",
20
+ rules: %w[701.72a 701.72b 701.72c 607.2a 205.1b 613.1b 613.1d 614.12a],
21
+ params: { "to" => "selector (permanents, or a target card in a graveyard, or \"exiled_with\")",
22
+ "for" => "card spec (default: any)", "choose" => "boolean" },
23
+ example: { "assimilate" => { "to" => { "target" => 0 } } }
24
+
25
+ def self.keys = %w[to for choose if]
26
+
27
+ def setup(params, errors, path)
28
+ raw = Mechanics.fetch_param(params, :for)
29
+ @spec = raw && CardSpec.build(raw, errors, "#{path}.for")
30
+ @choose = Mechanics.flag_param(params, "choose", errors, path)
31
+ end
32
+
33
+ def to_s = "assimilate#{" one" if @choose}#{" #{@spec}" if @spec} → #{to}"
34
+
35
+ def run(engine, ctx, locals)
36
+ return :done unless locals.key?("refs") || condition.nil? || condition.true?(engine, ctx)
37
+
38
+ refs = (locals["refs"] ||= candidates(engine, ctx))
39
+ unless locals.key?("chosen")
40
+ return ask_which(engine, ctx, locals, refs) if @choose && refs.size > 1 && answers(locals).empty?
41
+
42
+ picked = answers(locals).map { it["object"] }
43
+ picked = refs.first(1) if picked.empty?
44
+ locals["chosen"] = @choose ? picked : refs
45
+ end
46
+ chosen = locals["chosen"].select { engine.state.objects.key?(it) }
47
+ entering = chosen.reject { engine.object(it).zone == :battlefield }.map { [it, ctx.controller] }
48
+ return :wait if engine.ask_entering_choices!(locals, entering) == :wait
49
+
50
+ chosen.each do |id|
51
+ engine.assimilate!(id, ctx.controller, ctx, enters: engine.with_entering_answer(locals, id, ctx.controller, nil))
52
+ end
53
+ :done
54
+ end
55
+
56
+ private
57
+
58
+ def candidates(engine, ctx)
59
+ refs(engine, ctx).select do |id|
60
+ obj = engine.state.objects[id]
61
+ obj && !obj.ability? && (@spec.nil? || @spec.match?(engine, obj, ctx))
62
+ end
63
+ end
64
+
65
+ def ask_which(engine, ctx, locals, refs)
66
+ ask(engine, locals, ctx.controller, "Choose a card to assimilate",
67
+ refs.map { { "type" => "assimilate", "object" => it } })
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end