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,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Playing cards from zones other than the hand (CR 601.3, 116.2a, 305.1): a card's own permission
6
+ # to be cast from the graveyard or exile, impulse draw (exiled cards playable for a while),
7
+ # beam me up (CR 702.196) and casting during resolution without paying the mana cost (CR 608.2g,
8
+ # 118.9).
9
+ #
10
+ # A way to play a card is a *method*, plain data carried by the priority option (`via`) and by the
11
+ # cast frame (`"method"`):
12
+ # {"via" => "effect:12" | "self:printed:1" | "static:<permanent>:<ref>" | "beam:<ref>" |
13
+ # "beam_granted:<permanent>:<ref>" | "free",
14
+ # "play" => "cast" | "play", # "play" also allows a land to be played (CR 305.1)
15
+ # "alt" => nil | {"free" => true} | {"beam" => "{3}{W}", "not" => id}, # alternative cost (CR 118.9)
16
+ # "enters_with" => {counter => n}, # "if you cast it this way, it enters with ..."
17
+ # "once" => {"source" => id, "ref" => ref}, # "once during each of your turns"
18
+ # "cost_increase" => n} # riders of a permission effect (PlayPermission), e.g. 601.2f
19
+ #
20
+ # Permissions granted by resolved effects are `state.effects` entries of kind "permission"
21
+ # (see #grant_play_permission!); the others come from abilities (Abilities::PlayFrom, BeamMeUp).
22
+ #
23
+ # Cards outside the game (CR 400.11) aren't objects: a player's are definition ids in
24
+ # PlayerState#outside. Options name one as `"outside:<card id>"` (#outside_key); legality checks use a
25
+ # detached GameObject in the pseudo-zone :outside that is never stored (#outside_object), and the
26
+ # object is created only when the card is brought into the game (#bring_in_from_outside!).
27
+ module Permissions
28
+ FREE = { "free" => true }.freeze
29
+ ZERO = Mana::Cost.new(generic: 0, colored: {}.freeze)
30
+ BEAM_COST_TEXT = "Return a creature you control to its owner's hand"
31
+ OUTSIDE = "outside:"
32
+ # Permission effect fields copied into the play method (see Effects::PlayPermission); "alt" is an
33
+ # alternative cost, {"cost" => "{2}"} (airbend, CR 701.65a, 118.9).
34
+ RIDERS = %w[cost_increase any_mana_type alt exile_instead].freeze
35
+
36
+ # ---- legal actions --------------------------------------------------------------------
37
+
38
+ # Cards `index` might play by a permission or an alternative cost: their hand (alternative costs such
39
+ # as warp, Engine::AltCasting), graveyard, every exiled card and their library's top card (deterministic
40
+ # order: hand, graveyard, exile, library), then the cards they own outside the game (one key per
41
+ # distinct card) while something lets them play those.
42
+ def permission_candidates(index, sources = permission_sources(index))
43
+ pl = player(index)
44
+ ids = alt_cost_hand(pl) + pl.graveyard + state.exile
45
+ ids += [pl.library.first] unless pl.library.empty?
46
+ return ids unless sources.any? { |_, inst| inst.ability.kind == :play_from && inst.ability.outside? }
47
+
48
+ ids + pl.outside.uniq.map { outside_key(it) }
49
+ end
50
+
51
+ # Priority options: `{type: "cast", object: id, via: method}` for every castable card and method.
52
+ def permission_cast_options(index)
53
+ sources = permission_sources(index)
54
+ permission_candidates(index, sources).flat_map do |ref|
55
+ obj = candidate_object(index, ref)
56
+ play_methods(index, obj, sources).filter_map do |method|
57
+ { type: "cast", object: ref, via: method["via"] } if castable_via?(index, obj, method)
58
+ end
59
+ end
60
+ end
61
+
62
+ # Priority options: lands `index` may play from outside their hand (CR 305.1, 305.2).
63
+ def permission_land_options(index)
64
+ sources = permission_sources(index)
65
+ permission_candidates(index, sources).flat_map do |ref|
66
+ obj = candidate_object(index, ref)
67
+ next [] unless face_up(obj.id) { chars(obj).land? } # a face-down card is played face up (CR 406.3a)
68
+
69
+ play_methods(index, obj, sources).filter_map do |method|
70
+ { type: "play_land", object: ref, via: method["via"] } if method["play"] == "play"
71
+ end
72
+ end
73
+ end
74
+
75
+ # ---- cards outside the game (CR 400.11, 400.11b, 108.3) -------------------------------------
76
+
77
+ def outside_key(card) = "#{OUTSIDE}#{card}"
78
+ def outside_key?(ref) = ref.is_a?(String) && ref.start_with?(OUTSIDE)
79
+ def outside_card(ref) = ref.delete_prefix(OUTSIDE)
80
+
81
+ # A detached object standing for a card `index` owns outside the game, for legality checks only.
82
+ def outside_object(index, card)
83
+ GameObject.new(id: outside_key(card), card: card, owner: index, controller: index, zone: :outside, tapped: false,
84
+ controlled_since: nil, kind: "card", counters: {}, damage: 0, source: nil, ability: nil,
85
+ targets: nil, loyalty_turn: nil, deathtouched: false, modes: nil, memory: {}, attached_to: nil)
86
+ end
87
+
88
+ # The object an option names: a game object, or the detached stand-in of a card outside the game.
89
+ def candidate_object(index, ref) = outside_key?(ref) ? outside_object(index, outside_card(ref)) : object(ref)
90
+
91
+ # CR 400.11b, 108.3: one copy of `card` leaves `index`'s outside list and enters the game in `to` as a
92
+ # new object they own, through the usual entering replacements (CR 614.12) when it's the battlefield.
93
+ def bring_in_from_outside!(index, card, to, reason:, enters: nil)
94
+ list = player(index).outside
95
+ at = list.index(card) or raise ArgumentError, "#{name(index)} has no #{card} outside the game"
96
+ list.delete_at(at)
97
+ obj = create_object(card, index, to)
98
+ data = { "object" => obj.id, "to" => to, "controller" => index, "reason" => reason }
99
+ if to == :battlefield
100
+ data["enters"] = { "tapped" => enters&.fetch("tapped", false) == true, "counters" => {} }
101
+ data = propose("zone_change", data).data
102
+ merge_enters!(data["enters"], enters.except("tapped")) if enters
103
+ end
104
+ arrive!(obj, obj, data)
105
+ state.zone_list(to, index).unshift(obj.id)
106
+ record_result!(obj.id)
107
+ emit Events::ZoneChanged.new(card: card, old_id: nil, new_id: obj.id, owner: name(index), from: "outside",
108
+ to: to.to_s, reason: reason)
109
+ obj
110
+ end
111
+
112
+ # CR 601.2a: a spell cast from outside the game is brought into the game onto the stack.
113
+ def begin_outside_cast!(index, ref, via)
114
+ method = play_method(index, ref, via)
115
+ card = outside_card(ref)
116
+ origin = { "zone" => "outside", "card" => card, "index" => player(index).outside.index(card) }
117
+ obj = bring_in_from_outside!(index, card, :stack, reason: "cast")
118
+ start_casting!(index, obj.id, "spell", origin: origin, extra: { "method" => method })
119
+ end
120
+
121
+ # CR 305.1: a land played (from a zone or from outside the game) is put onto the battlefield.
122
+ def put_played_land!(index, ref, enters: nil)
123
+ return bring_in_from_outside!(index, outside_card(ref), :battlefield, reason: "play_land", enters: enters) if outside_key?(ref)
124
+
125
+ move!(ref, :battlefield, controller: index, reason: "play_land", enters: enters)
126
+ end
127
+
128
+ # Permanents `index` controls whose static abilities let them play cards from other zones:
129
+ # [[permanent, AbilityInstance], ...].
130
+ def permission_sources(index)
131
+ state.battlefield.flat_map do |id|
132
+ obj = object(id)
133
+ next [] unless obj.controller == index
134
+
135
+ chars(obj).abilities.filter_map do |inst|
136
+ [obj, inst] if inst.ability.kind == :grants_beam_me_up || (inst.ability.kind == :play_from && !inst.ability.self?)
137
+ end
138
+ end
139
+ end
140
+
141
+ # Every method `index` has to play the card `id` (an object, its id or an outside key) now (before
142
+ # timing and cost checks).
143
+ def play_methods(index, id, sources = permission_sources(index))
144
+ obj = id.is_a?(GameObject) ? id : candidate_object(index, id)
145
+ granted_methods(index, obj) + own_methods(index, obj) + static_methods(index, obj, sources) +
146
+ beam_methods(index, obj, sources) + alt_methods(index, obj)
147
+ end
148
+
149
+ # The method named `via`, or an error (the option was offered from this list).
150
+ def play_method(index, id, via)
151
+ return { "via" => "free", "play" => "cast", "alt" => FREE } if via == "free"
152
+ return { "via" => "resolving", "play" => "cast" } if via == "resolving" # CR 608.2g, 707.12: costs paid
153
+
154
+ play_methods(index, id).find { it["via"] == via } or raise ArgumentError, "#{id} can't be played via #{via}"
155
+ end
156
+
157
+ # CR 601.3: whether `index` may begin to cast the card this way now. Timing still applies outside
158
+ # resolution (CR 304.1, 307.1); a card without a mana cost needs an alternative cost (CR 118.6a).
159
+ def castable_via?(index, id, method)
160
+ obj = id.is_a?(GameObject) ? id : candidate_object(index, id)
161
+ return face_up(obj.id) { castable_via?(index, obj, method) } if face_down?(obj) && @face_up != obj.id # CR 406.3a
162
+
163
+ c = chars(obj)
164
+ return false if c.land? || (method["alt"].nil? && c.mana_cost.nil?) # CR 305.9
165
+ return false unless c.type?(:instant) || c.keyword?(:flash) || method["instant"] || sorcery_timing?(index)
166
+ return false if cast_forbidden?(index, obj) # CR 101.2
167
+ return false if returns_creature?(method["alt"]) && return_candidates(index, method["alt"]).empty?
168
+ return false unless additional_costs_payable?(obj) && alt_parts_payable?(obj, method) # CR 601.2f
169
+
170
+ # CR 609.4b; a stand-in for a card outside the game is passed itself (it isn't in state.objects).
171
+ purpose = { "spell" => state.objects.key?(obj.id) ? obj.id : obj, "any_type" => method["any_mana_type"] }.compact
172
+ ability_targetable?(spell_ability_of(obj), index, obj.id) &&
173
+ cost_variant_affordable?(index, spell_cost_list(obj, false), obj, method_cost(obj, method), nil, purpose) do
174
+ harmonize_reduced(total_cost(index, it, spell: obj), best_harmonize_power(index, method)) # CR 601.2b
175
+ end
176
+ end
177
+
178
+ # CR 608.2g: whether a card could be cast now during resolution without paying its mana cost
179
+ # (timing doesn't apply; lands can't be cast, CR 305.9; it needs legal targets, CR 601.2c).
180
+ def free_castable?(index, id)
181
+ obj = object(id)
182
+ obj.kind == "card" && !chars(obj).land? && !cast_forbidden?(index, obj) && additional_costs_payable?(obj) &&
183
+ ability_targetable?(spell_ability_of(obj), index, id)
184
+ end
185
+
186
+ # CR 608.2g, 707.12: whether a card could be cast now during resolution, paying its costs.
187
+ # `any_mana_type`: mana of any type can be spent to cast it (CR 609.4b).
188
+ def castable_while_resolving?(index, id, any_mana_type: nil)
189
+ obj = object(id)
190
+ own = chars(obj).mana_cost or return false # CR 118.6a: no mana cost, can't be cast paying it
191
+ purpose = { "spell" => id, "any_type" => any_mana_type }.compact
192
+ free_castable?(index, id) &&
193
+ cost_variant_affordable?(index, spell_cost_list(obj, false), obj, own, nil, purpose) { total_cost(index, it, spell: obj) }
194
+ end
195
+
196
+ # The mana cost paid with a method: the card's own, nothing (CR 118.9) or the beam me up cost, plus a
197
+ # permission's cost increase ("spells cast this way cost {2} more", CR 601.2f).
198
+ def method_cost(obj, method)
199
+ alt = method && method["alt"]
200
+ cost = if alt.nil? then chars(obj).mana_cost
201
+ elsif alt["free"] then ZERO
202
+ else Mana::Cost.parse(alt["beam"] || alt["cost"]) # beam me up, or a keyword's (Engine::AltCasting)
203
+ end
204
+ increase = method && method["cost_increase"]
205
+ increase && cost ? cost.with(generic: cost.generic + increase) : cost
206
+ end
207
+
208
+ # Costs#base_cost for a spell cast with an alternative cost (CR 118.9c: its mana cost is unchanged).
209
+ def alternative_base_cost(ctx) = method_cost(object(ctx["object"]), ctx["method"])
210
+
211
+ # ---- where methods come from ------------------------------------------------------------
212
+
213
+ # Permissions from resolved effects (impulse draw, CR 611.2): the card is still the exiled object. A
214
+ # face-down card only for a player who may look at it (CR 406.3b). `any_mana_type`: mana of any type
215
+ # can be spent to cast it (CR 609.4b).
216
+ def granted_methods(index, obj)
217
+ state.effects.filter_map do |e|
218
+ next unless e["kind"] == "permission" && e["controller"] == index && e["affected"].include?(obj.id)
219
+ next unless obj.zone == :exile && may_look?(obj, index)
220
+
221
+ { "via" => "effect:#{e["timestamp"]}", "play" => e["play"], **e.slice(*RIDERS) }
222
+ end
223
+ end
224
+
225
+ # The card's own permission to be cast from its graveyard or exile (CR 113.6e-f): its owner's.
226
+ def own_methods(index, obj)
227
+ return [] unless obj.owner == index
228
+
229
+ chars(obj).abilities.filter_map do |inst|
230
+ ability = inst.ability
231
+ next unless ability.kind == :play_from && ability.self? && ability.holds?(self, obj)
232
+
233
+ { "via" => "self:#{inst.ref}", "play" => ability.play, "enters_with" => ability.enters_with }.compact
234
+ end
235
+ end
236
+
237
+ # Static abilities of permanents `index` controls, for cards they own in the named zone.
238
+ def static_methods(index, obj, sources)
239
+ return [] unless obj.owner == index
240
+
241
+ sources.filter_map do |perm, inst|
242
+ ability = inst.ability
243
+ next unless ability.kind == :play_from && ability.holds?(self, obj) && ability.cards.match?(self, obj)
244
+ next if ability.once && (state.active != index || once_used?(perm, inst.ref))
245
+
246
+ { "via" => "static:#{perm.id}:#{inst.ref}", "play" => ability.play, "enters_with" => ability.enters_with,
247
+ "once" => ability.once ? { "source" => perm.id, "ref" => inst.ref } : nil }.compact
248
+ end
249
+ end
250
+
251
+ # CR 702.196a: beam me up functions from the graveyard, for an instant or sorcery card. A permanent
252
+ # may grant it during its controller's main phase with the card's mana cost as the cost.
253
+ def beam_methods(index, obj, sources)
254
+ c = chars(obj)
255
+ return [] unless obj.zone == :graveyard && obj.owner == index && (c.type?(:instant) || c.type?(:sorcery))
256
+
257
+ own = c.abilities.filter_map do |inst|
258
+ next unless inst.ability.kind == :beam_me_up
259
+
260
+ { "via" => "beam:#{inst.ref}", "play" => "cast", "alt" => { "beam" => inst.ability.cost.to_s } }
261
+ end
262
+ own + granted_beams(index, c, sources)
263
+ end
264
+
265
+ def granted_beams(index, chars, sources)
266
+ return [] unless chars.mana_cost && state.active == index && Steps::MAIN_PHASE_STEPS.include?(state.step)
267
+
268
+ sources.filter_map do |perm, inst|
269
+ next unless inst.ability.kind == :grants_beam_me_up
270
+
271
+ { "via" => "beam_granted:#{perm.id}:#{inst.ref}", "play" => "cast",
272
+ "alt" => { "beam" => chars.mana_cost.to_s, "not" => perm.id } } # not the granting creature itself
273
+ end
274
+ end
275
+
276
+ # A once-per-turn permission: the permanent remembers the turn it was last used.
277
+ def once_used?(perm, ref) = perm.memory.fetch("play_from_turns", {})[ref] == state.turn
278
+
279
+ def use_play_method!(method)
280
+ once = method && method["once"] or return
281
+ perm = state.objects[once["source"]] or return
282
+ (perm.memory["play_from_turns"] ||= {})[once["ref"]] = state.turn
283
+ end
284
+
285
+ # ---- permissions granted by effects -------------------------------------------------------
286
+
287
+ # Permission to play exiled cards for a duration (CR 611.2): a continuous effect for the exiled
288
+ # objects (a card that leaves exile is a new object, CR 400.7, so the permission ends with it).
289
+ # `riders` (RIDERS) change how a card is played with this permission, and only then.
290
+ def grant_play_permission!(index, ids, duration:, play:, **riders)
291
+ return if ids.empty?
292
+
293
+ state.effects << { "kind" => "permission", "timestamp" => next_timestamp!, "controller" => index,
294
+ "affected" => ids, "duration" => duration, "turn" => state.turn, "play" => play,
295
+ **riders.transform_keys(&:to_s).slice(*RIDERS) }
296
+ emit Events::EffectStarted.new(kind: "permission", affected: ids, details: { "duration" => duration, "play" => play })
297
+ end
298
+
299
+ # CR 514.2: at cleanup, an end_of_your_next_turn permission ends on its controller's next turn, and a
300
+ # permission whose cards all left exile is gone. (end_of_turn ones end with the other
301
+ # until-end-of-turn effects.)
302
+ def expire_permissions!
303
+ state.effects.reject! do |e|
304
+ next false unless e["kind"] == "permission"
305
+
306
+ (e["duration"] == "end_of_your_next_turn" && state.active == e["controller"] && state.turn > e["turn"]) ||
307
+ e["affected"].none? { state.exile.include?(it) }
308
+ end
309
+ end
310
+
311
+ # ---- beam me up: the creature returned as part of the cost (CR 702.196a-b) ---------------------
312
+
313
+ # Creatures `index` could return: not one that can't be beamed up (CR 702.196b), nor `excluded` (a
314
+ # permanent granting beam me up that says it can't be beamed up to cast a spell its way).
315
+ def beam_candidates(index, excluded = nil)
316
+ state.battlefield.select do |id|
317
+ obj = object(id)
318
+ c = chars(obj)
319
+ obj.controller == index && id != excluded && c.creature? && !c.keyword?(:cant_be_beamed_up)
320
+ end
321
+ end
322
+
323
+ # The choices for the cast in progress.
324
+ def beam_choices(ctx) = return_candidates(ctx["player"], ctx.dig("method", "alt"))
325
+
326
+ # True while the returned creature still has to be chosen (or the chosen one is no longer there).
327
+ def beam_choice_pending?(ctx)
328
+ return false unless returns_creature?(ctx.dig("method", "alt"))
329
+ return false if ctx["beam_creature"] && beam_choices(ctx).include?(ctx["beam_creature"])
330
+
331
+ ctx.delete("beam_creature")
332
+ true
333
+ end
334
+
335
+ def ask_beam_choice!(ctx)
336
+ text = AltCasting::RETURN_TEXT.fetch(ctx.dig("method", "alt", "returns"), BEAM_COST_TEXT)
337
+ ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => text)
338
+ :wait
339
+ end
340
+
341
+ # CR 601.2h: the creature is chosen now and returned when the spell becomes cast, so cancelling
342
+ # never has to bring a permanent back.
343
+ def choose_beam_creature!(id)
344
+ casting["beam_creature"] = id
345
+ ask!(nil)
346
+ end
347
+
348
+ # As the spell becomes cast (CR 601.2h-i): remember where it was cast from and how (Engine::AltCasting),
349
+ # return the creature an alternative cost returns, remember the riders and use up a "once each turn"
350
+ # permission.
351
+ def finish_cast_method!(obj, ctx)
352
+ record_cast_how!(obj, ctx)
353
+ method = ctx["method"] or return
354
+ returned_creature!(obj, ctx) if ctx["beam_creature"]
355
+ obj.memory["leaves_to_exile"] = true if method.dig("alt", "beam") || method.dig("alt", "exile")
356
+ # "If that spell would be put into a graveyard, exile it instead" (CR 614.1a)
357
+ obj.memory["graveyard_to_exile"] = true if method["exile_instead"] &&
358
+ Mechanics::ExileInstead.applies?(self, method["exile_instead"], obj)
359
+ obj.memory["enters_with"] = method["enters_with"] if method["enters_with"]
360
+ use_play_method!(method)
361
+ end
362
+
363
+ # Replacement effects of how a spell was cast (CR 614.1a, 614.12): a beamed-up or flashed-back spell
364
+ # is exiled whenever it would leave the stack (CR 702.196a, 702.34a), one cast with an `exile_instead`
365
+ # rider whenever it would be put into a graveyard; a permanent spell cast with an
366
+ # "enters with" rider gets those counters as it enters; a permanent remembers how its spell was cast
367
+ # (CR 400.7d, Engine::AltCasting#alt_enters!).
368
+ def cast_replacements!(proposal)
369
+ data = proposal.data
370
+ obj = state.objects[data["object"]]
371
+ return unless obj && obj.zone == :stack
372
+
373
+ data["to"] = :exile if obj.memory["leaves_to_exile"] && data["to"] != :exile
374
+ data["to"] = :exile if obj.memory["graveyard_to_exile"] && data["to"] == :graveyard
375
+ return unless data["to"] == :battlefield
376
+
377
+ alt_enters!(obj, data["enters"])
378
+ extra = obj.memory["enters_with"] or return
379
+ extra.each { |name, n| data["enters"]["counters"][name] = data["enters"]["counters"].fetch(name, 0) + n }
380
+ end
381
+
382
+ # A cancelled cast (Casting#cancel_casting!): the card goes back where it came from as the same object
383
+ # (CR 734.1), hand, graveyard, exile or library, at its old position. So a permission naming it applies.
384
+ def restore_to_origin!(obj, origin)
385
+ return player(obj.owner).outside.insert(origin["index"] || 0, obj.card) if origin["zone"] == "outside"
386
+
387
+ zone = origin.fetch("zone", "hand").to_sym
388
+ state.objects[origin["id"]] = GameObject.new(
389
+ id: origin["id"], card: card_after_move(obj, zone), owner: obj.owner, controller: obj.owner, zone: zone, tapped: false,
390
+ controlled_since: nil, kind: "card", counters: {}, damage: 0, source: nil, ability: nil, targets: nil,
391
+ loyalty_turn: nil, deathtouched: false, modes: nil, memory: Snapshot.deep_copy(origin.fetch("memory", {})),
392
+ attached_to: nil # a face-down card goes back face down (CR 406.3)
393
+ )
394
+ carry_copy_status!(state.objects[origin["id"]], obj, zone) # a card copy stays one (Engine::Copies)
395
+ list = state.zone_list(zone, obj.owner)
396
+ list.insert([origin["index"], list.size].min, origin["id"])
397
+ end
398
+
399
+ # ---- looking at the top card (CR 401.5) ------------------------------------------------
400
+
401
+ # A permanent letting its controller look at the top card of their library at any time.
402
+ def may_look_at_top?(index)
403
+ state.battlefield.any? do |id|
404
+ obj = object(id)
405
+ obj.controller == index && chars(obj).abilities.any? { it.ability.kind == :look_at_top }
406
+ end
407
+ end
408
+ end
409
+
410
+ include Permissions
411
+ end
412
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # A variable number of targets for one instance of the word "target" (CR 601.2c, 115.1): "X target
6
+ # creatures", "any number of target players", "up to two target creatures". Such a target spec fills
7
+ # one slot of the targets list with a list of objects: the player first announces how many (a
8
+ # choose_target_count decision, unless the number is fixed), then chooses that many different ones
9
+ # (CR 115.3). {"target": n} then refers to each of them. The number never changes afterwards.
10
+ module TargetCounts
11
+ def counted_target?(spec) = spec.respond_to?(:counted?) && spec.counted?
12
+
13
+ # The slot being filled: the open list while choosing its objects, else the next slot.
14
+ def target_slot(ctx) = ctx["counting"] ? ctx["targets"].size - 1 : ctx["targets"].size
15
+
16
+ # Objects already chosen for the open list (CR 115.3: each at most once).
17
+ def open_target_group(ctx) = ctx["counting"] ? ctx["targets"].last : []
18
+
19
+ # Asks for the next target (or how many), true while waiting; false once every slot is filled.
20
+ def ask_targets!(ctx, specs)
21
+ loop do
22
+ return false if !ctx["counting"] && ctx["targets"].size >= specs.size
23
+
24
+ spec = specs[target_slot(ctx)]
25
+ if ctx["counting"] || !counted_target?(spec)
26
+ ask!("kind" => "choose_target", "player" => ctx["player"], "index" => target_slot(ctx))
27
+ return true
28
+ end
29
+ min, max = target_count_range(ctx, spec)
30
+ if min != max
31
+ ask!("kind" => "choose_target_count", "player" => ctx["player"], "index" => target_slot(ctx), "min" => min,
32
+ "max" => max)
33
+ return true
34
+ end
35
+ start_target_group!(ctx, min)
36
+ end
37
+ end
38
+
39
+ def target_count_options
40
+ pending = state.pending
41
+ (pending["min"]..pending["max"]).map { { type: "count", count: it } }
42
+ end
43
+
44
+ # CR 601.2c: the number is announced before the targets.
45
+ def choose_target_count!(count)
46
+ start_target_group!(casting, count)
47
+ ask!(nil)
48
+ end
49
+
50
+ def start_target_group!(ctx, count)
51
+ ctx["targets"] << []
52
+ ctx["counting"] = count if count.positive?
53
+ end
54
+
55
+ # Records a chosen target: into the open list, or as the next slot.
56
+ def add_chosen_target!(ctx, ref)
57
+ return ctx["targets"] << ref unless ctx["counting"]
58
+
59
+ ctx["targets"].last << ref
60
+ ctx["counting"] -= 1
61
+ ctx.delete("counting") if ctx["counting"].zero?
62
+ end
63
+
64
+ # [min, max] targets to announce: the spec's count (0 up to it with "up to"; any number with "any"),
65
+ # never more than there are different legal targets, nor (dividing, CR 601.2d) more than the amount.
66
+ # Too few legal targets for an exact count makes the cast illegal: only cancelling is left (CR 734.1).
67
+ # A triggered ability can't be rewound: one whose count turns out larger than the legal targets
68
+ # (an X only known once it is put on the stack) gets as many as there are.
69
+ def target_count_range(ctx, spec)
70
+ count_ctx = Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"], ctx["object"], nil, [])
71
+ min, max = spec.count_range(self, count_ctx)
72
+ available = target_candidates.size
73
+ divide = divided_amount(ctx, casting_ability) # Engine::CastChoices
74
+ return [min, min] if min > available && cancellable?
75
+
76
+ max = [max || available, available, divide].compact.min
77
+ min = 1 if divide&.positive? && max.positive? # CR 601.2d: divided among one or more targets
78
+ [[min, max].min, max]
79
+ end
80
+
81
+ # The fewest targets the spec needs, to tell whether it can be cast or activated now (CR 601.2c);
82
+ # an X not announced yet counts as 0.
83
+ def target_minimum(spec, index, source)
84
+ return 0 if optional_target?(spec)
85
+ return 1 unless counted_target?(spec)
86
+
87
+ spec.count_range(self, Mechanics::EffectContext.new(index, source, nil, nil, [])).first
88
+ end
89
+
90
+ # Each target with the spec it was chosen for, flattening counted slots: [[ref, spec], ...].
91
+ def target_slots(targets, specs)
92
+ targets.each_with_index.flat_map do |ref, i|
93
+ ref.is_a?(Array) ? ref.map { [it, specs[i]] } : [[ref, specs[i]]]
94
+ end
95
+ end
96
+
97
+ # The flat list `flat` shaped like `template` (a copy keeps the number of targets, CR 707.10c).
98
+ def regroup_targets(flat, template)
99
+ flat = flat.dup
100
+ template.map { it.is_a?(Array) ? flat.shift(it.size) : flat.shift }
101
+ end
102
+
103
+ # CR 115.7d: `player` chose new targets for the spell or ability `spell` (shaped like its old ones).
104
+ def change_targets!(spell, targets, player)
105
+ added = targets.flatten.compact - Array(spell.targets).flatten
106
+ spell.targets = targets
107
+ added.each { emit Events::TargetChosen.new(player: name(player), object: spell.id, target: it) }
108
+ end
109
+ end
110
+
111
+ include TargetCounts
112
+ end
113
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Waterbend (CR 701.67, Costs::Waterbend): while a waterbend cost is part of what is being paid, each
6
+ # untapped artifact or creature its payer controls may be tapped to pay {1} of it. Only the waterbend
7
+ # cost's own generic mana can be paid that way (CR 701.67b), so the payment frame keeps how much is
8
+ # left ("waterbend"), and affordability counts at most that many permanents (as generic-only mana units,
9
+ # see ManaSources#mana_units). A purpose ({"spell" => id} or {"ability" => id}) carries the same number
10
+ # under "waterbend". Paying the cost, however it was paid, is waterbending (CR 701.67c).
11
+ module Waterbending
12
+ WATERBEND = Mechanics::Costs::Waterbend
13
+
14
+ # The generic mana tapping may pay among `costs`, with X announced.
15
+ def waterbend_generic(costs, x_value = 0) = costs.grep(WATERBEND).sum { it.generic(x_value) }
16
+
17
+ # A purpose's waterbend part for `costs` ({} when there is none).
18
+ def waterbend_purpose(costs, x_value = 0)
19
+ n = waterbend_generic(costs, x_value)
20
+ n.positive? ? { "waterbend" => n } : {}
21
+ end
22
+
23
+ def spell_cost_list(obj, kicked)
24
+ abilities = spell_cost_abilities(obj, :additional_cost)
25
+ abilities += spell_cost_abilities(obj, :kicker) if kicked
26
+ abilities.flat_map(&:costs)
27
+ end
28
+
29
+ # CR 601.2f: as the total cost is locked in, so is the waterbend cost's generic part.
30
+ def lock_waterbend!(ctx)
31
+ costs = frame_costs(ctx) # Engine::CostOptions
32
+ ctx["waterbend"] = waterbend_generic(costs, ctx["x"].to_i) if costs.any?(WATERBEND)
33
+ end
34
+
35
+ def waterbend_candidates(index, excluded = [])
36
+ state.battlefield.select do |id|
37
+ obj = object(id)
38
+ c = chars(obj)
39
+ obj.controller == index && !obj.tapped && !excluded.include?(id) && (c.creature? || c.type?(:artifact))
40
+ end
41
+ end
42
+
43
+ # Generic-only mana units (an empty list of types) for the permanents that could be tapped, capped at the
44
+ # waterbend generic. Mana sources are counted as sources instead.
45
+ def waterbend_units(index, purpose, excluded = [])
46
+ n = purpose&.[]("waterbend").to_i
47
+ return [] unless n.positive?
48
+
49
+ sources = all_mana_sources(index).map(&:first)
50
+ Array.new([waterbend_candidates(index, excluded).count { !sources.include?(it) }, n].min) { [] }
51
+ end
52
+
53
+ # pay_cost options: `{type: "waterbend", object: id}` while generic mana of the waterbend cost is left.
54
+ def waterbend_options
55
+ frame = payment
56
+ return [] unless frame["waterbend"].to_i.positive? && frame["remaining"]["generic"].positive?
57
+
58
+ reserved = payment_reserved(frame)
59
+ waterbend_candidates(frame["player"], reserved + [payment_excluded_source(frame)]).filter_map do |id|
60
+ { type: "waterbend", object: id } if !frame["irreversible"] || waterbend_keeps_payable?(frame, reserved, id)
61
+ end
62
+ end
63
+
64
+ def waterbend_keeps_payable?(frame, reserved, id)
65
+ pl = player(frame["player"])
66
+ payable_with?(frame.merge("waterbend" => frame["waterbend"] - 1), remaining_cost(after_paying(frame["remaining"], [:generic])),
67
+ pl.mana_pool.dup, reserved + [id], restricted: pl.restricted_mana)
68
+ end
69
+
70
+ # CR 701.67a: tapping the permanent pays {1}; cancelling untaps it.
71
+ def waterbend!(id)
72
+ frame = payment
73
+ frame["remaining"] = after_paying(frame["remaining"], [:generic])
74
+ frame["waterbend"] -= 1
75
+ tap!(object(id))
76
+ frame["tapped"] << id
77
+ ask!(nil)
78
+ end
79
+
80
+ # CR 701.67c: paying a waterbend cost is waterbending, however it was paid.
81
+ def waterbent!(frame)
82
+ return unless frame.key?("waterbend")
83
+
84
+ emit Events::KeywordAction.new(player: name(frame["player"]), action: "waterbend", amount: nil)
85
+ end
86
+ end
87
+
88
+ include Waterbending
89
+ end
90
+ end