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,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Paying mana one decision at a time (CR 601.2g-h, 605.3a), shared by casting/activating (a
6
+ # "cast" frame) and costs paid while a spell or ability resolves (a "pay" frame, CR 118.12).
7
+ #
8
+ # The frame holds what is left to pay ("remaining": generic, one key per color, and "hybrid":
9
+ # the hybrid symbols still unpaid, CR 107.4e), the pool before payment started ("pool") and the
10
+ # sources tapped for it ("tapped"), so an unfinished payment can be rewound (CR 734.1). Once a
11
+ # mana ability with a cost other than {T} has been activated (a Treasure's sacrifice), the payment
12
+ # is "irreversible": it can't be cancelled any more, and only choices that keep the rest payable
13
+ # are offered, so the player can't get stuck.
14
+ module Payment
15
+ # The payment in progress: the top frame (a cast or a pay frame while a casting decision is pending).
16
+ def payment = state.frames.last
17
+
18
+ # Whether the process on top can still be rewound with a "cancel" option.
19
+ def cancellable?
20
+ frame = payment
21
+ !frame.nil? && frame["kind"] != "trigger" && !frame["irreversible"]
22
+ end
23
+
24
+ # ---- the remaining cost ---------------------------------------------------------
25
+
26
+ def remaining_hash(cost)
27
+ rem = { "generic" => cost&.generic.to_i }
28
+ cost&.colored&.each { |color, n| rem[color.to_s] = n }
29
+ rem["hybrid"] = cost.hybrid.map { |pair| pair.map(&:to_s) } unless cost.nil? || cost.hybrid.empty?
30
+ rem
31
+ end
32
+
33
+ def remaining_cost(rem)
34
+ colored = rem.except("generic", "hybrid").transform_keys(&:to_sym).reject { |_, n| n.zero? }
35
+ Mana::Cost.new(generic: rem["generic"], colored: colored,
36
+ hybrid: rem.fetch("hybrid", []).map { |pair| pair.map(&:to_sym) })
37
+ end
38
+
39
+ def remaining_left?(rem) = remaining_cost(rem).mana_value.positive?
40
+
41
+ # What is still to pay, as mana symbols.
42
+ def remaining_text(frame = payment) = remaining_cost(frame["remaining"]).to_s
43
+
44
+ # ---- paying from the pool ----------------------------------------------------------
45
+
46
+ # What the payment is for, deciding where restricted mana may be spent (CR 106.6) and whether any
47
+ # mana may pay any symbol (CR 609.4b, a permission's `any_mana_type`): {"spell" => id} or
48
+ # {"ability" => source id}, plus "any_type"; nil for a cost paid while resolving (CR 118.12).
49
+ def payment_purpose(frame)
50
+ waterbend = frame["waterbend"] ? { "waterbend" => frame["waterbend"] } : {} # CR 701.67b (Engine::Waterbending)
51
+ return (waterbend unless waterbend.empty?) unless frame["type"] == "cast"
52
+
53
+ purpose = frame["kind"] == "spell" ? { "spell" => frame["object"] } : { "ability" => frame["source"] }
54
+ purpose["any_type"] = true if frame.dig("method", "any_mana_type")
55
+ purpose.merge(waterbend)
56
+ end
57
+
58
+ # Pool mana that can pay part of the remaining cost (CR 601.2h): `{type: "pay_mana", mana: "R"}`, and
59
+ # `restricted: i` for restricted mana (entry i of the restricted pool) that may be spent here.
60
+ def payable_mana_options
61
+ frame = payment
62
+ pl = player(frame["player"])
63
+ any = payment_purpose(frame)&.[]("any_type") == true
64
+ # Once a mana can go somewhere, some place keeps the rest payable (pay_mana! picks it), so no
65
+ # pool mana can strand an irreversible payment.
66
+ plain = pl.mana_pool.filter_map do |type, n|
67
+ { type: "pay_mana", mana: type.to_s } if n.positive? && !pay_slots(frame["remaining"], type.to_s, any: any).empty?
68
+ end
69
+ plain + sourced_mana_options(pl, plain) + restricted_mana_options(frame, pl, any)
70
+ end
71
+
72
+ # When pool mana of a type both remembers a source (an artifact's, CR 106.6) and doesn't, which one is spent
73
+ # matters ("if mana from a Treasure was spent"): `from: i` spends the mana entry i of pool_sources stands for.
74
+ # (Plain `pay_mana` spends mana without a source first.)
75
+ def sourced_mana_options(player, plain)
76
+ types = plain.map { it[:mana] }.select { unsourced_mana(player, it).positive? }
77
+ seen = []
78
+ (player.pool_sources || []).each_with_index.filter_map do |entry, i|
79
+ next if seen.include?(entry) || !types.include?(entry["mana"])
80
+
81
+ seen << entry
82
+ { type: "pay_mana", mana: entry["mana"], from: i }
83
+ end
84
+ end
85
+
86
+ # One option per distinct restricted mana that may be spent on this payment.
87
+ def restricted_mana_options(frame, player, any)
88
+ purpose = payment_purpose(frame)
89
+ seen = []
90
+ player.restricted_mana.each_with_index.filter_map do |entry, i|
91
+ next if seen.include?(entry) || !spendable?(entry, purpose) ||
92
+ pay_slots(frame["remaining"], entry["mana"], any: any).empty?
93
+
94
+ seen << entry
95
+ { type: "pay_mana", mana: entry["mana"], restricted: i }
96
+ end
97
+ end
98
+
99
+ # Where one mana of `type` can go: a colored symbol (its own, or any with "any_type"), a hybrid symbol
100
+ # that includes it, generic mana, or half of a {2/B}. [[:color, key], [:hybrid, i], [:generic], [:two_generic, i]].
101
+ def pay_slots(rem, type, any: false)
102
+ slots = (any ? Mana::TYPES.map(&:to_s) : [type]).filter_map { [:color, it] if rem.fetch(it, 0).positive? }
103
+ hybrid = rem.fetch("hybrid", [])
104
+ hybrid.each_with_index { |pair, i| slots << [:hybrid, i] if any || pair.include?(type) }
105
+ slots << [:generic] if rem["generic"].positive?
106
+ # CR 107.4e: other mana pays {2/B} as two generic mana: one now, {1} still to pay.
107
+ hybrid.each_with_index { |pair, i| slots << [:two_generic, i] if Mana.mono_hybrid?(pair) }
108
+ slots
109
+ end
110
+
111
+ # Spends one mana (restricted entry `restricted`, or a plain one); it pays the first slot that keeps
112
+ # the rest payable (a hybrid symbol the other color could also pay waits for it), deterministically.
113
+ # `from`: the pool_sources entry of the pool mana spent (#sourced_mana_options).
114
+ def pay_mana!(type, restricted = nil, from = nil)
115
+ frame = payment
116
+ pl = player(frame["player"])
117
+ slots = pay_slots(frame["remaining"], type, any: payment_purpose(frame)&.[]("any_type") == true)
118
+ slot = slots.find { completable_after_paying?(frame, type, it, restricted) } || slots.first
119
+ entry = restricted ? pl.restricted_mana.delete_at(restricted) : spend_pool_mana!(pl, type, from)
120
+ frame["remaining"] = after_paying(frame["remaining"], slot)
121
+ (frame["spent"] ||= []) << type # what was spent matters later (sunburst, CR 702.44a; 609.4b: its real type)
122
+ (frame["spent_from"] ||= []) << entry["from"] if entry&.key?("from") # "if mana from a Treasure was spent"
123
+ emit Events::ManaPaid.new(player: name(frame["player"]), mana: "{#{type}}", object: frame["object"])
124
+ ask!(nil)
125
+ end
126
+
127
+ # Spends one pool mana of `type`: the one pool_sources entry `from` stands for, else one without a source, else
128
+ # one with. Returns the entry of its source, or nil.
129
+ def spend_pool_mana!(player, type, from)
130
+ from ||= (player.pool_sources || []).index { it["mana"] == type } unless unsourced_mana(player, type).positive?
131
+ player.mana_pool[type.to_sym] -= 1
132
+ from && player.pool_sources.delete_at(from)
133
+ end
134
+
135
+ def after_paying(rem, slot)
136
+ rem = Snapshot.deep_copy(rem)
137
+ case slot.first
138
+ when :color then rem[slot.last] -= 1
139
+ when :hybrid then rem["hybrid"].delete_at(slot.last)
140
+ when :two_generic
141
+ rem["hybrid"].delete_at(slot.last)
142
+ rem["generic"] += 1
143
+ else rem["generic"] -= 1
144
+ end
145
+ rem.delete("hybrid") if rem["hybrid"] && rem["hybrid"].empty?
146
+ rem
147
+ end
148
+
149
+ def completable_after_paying?(frame, type, slot, restricted)
150
+ pl = player(frame["player"])
151
+ pool = pl.mana_pool.dup
152
+ rest = pl.restricted_mana.dup
153
+ restricted ? rest.delete_at(restricted) : pool[type.to_sym] -= 1
154
+ payable_with?(frame, remaining_cost(after_paying(frame["remaining"], slot)), pool, payment_reserved(frame),
155
+ restricted: rest)
156
+ end
157
+
158
+ # ---- mana abilities during payment (CR 601.2g, 605.3a) ---------------------------------
159
+
160
+ # Mana ability activation options usable during payment: not a source the spell or ability still
161
+ # needs for one of its own costs ({T}, or a cost that sacrifices or exiles it), unless the mana
162
+ # ability only taps it and the cost doesn't; not a source of restricted mana that can't be spent
163
+ # here; and, once the payment is irreversible, only activations that keep the rest payable.
164
+ def payment_mana_options
165
+ frame = payment
166
+ purpose = payment_purpose(frame)
167
+ sources = all_mana_sources(frame["player"]).reject do |id, ref|
168
+ spend_only = chars(id).ability(ref).spend_only
169
+ reserved_for_cost?(frame, id, ref) || (spend_only && !spend_only.allows?(self, purpose))
170
+ end
171
+ mana_activation_options(sources).select do |option|
172
+ id = option[:object]
173
+ next !frame["irreversible"] && !irreversible_mana_ability?(id, option[:ability]) if chars(id).ability(option[:ability]).mana_cost
174
+
175
+ (!frame["irreversible"] && !irreversible_mana_ability?(id, option[:ability])) ||
176
+ completable_after_activating?(frame, option)
177
+ end
178
+ end
179
+
180
+ # A mana ability whose costs include more than {T} and mana (a sacrifice, life, a discard) can't be undone.
181
+ def irreversible_mana_ability?(id, ref) = chars(id).ability(ref).costs.any? { !it.is_a?(Mechanics::Costs::Tap) && !it.mana? }
182
+
183
+ def reserved_for_cost?(frame, id, ref)
184
+ return true if id == payment_excluded_source(frame)
185
+
186
+ payment_reserved(frame).include?(id) && chars(id).ability(ref).costs.any? { !it.is_a?(Mechanics::Costs::Tap) }
187
+ end
188
+
189
+ # Permanents a cost of the spell or ability will still use: its source when it has a non-mana cost
190
+ # about itself, and permanents chosen for a cost paid when it becomes cast (CR 601.2h).
191
+ def payment_reserved(frame)
192
+ return [] unless frame["type"] == "cast"
193
+
194
+ reserved = (frame["paid"] || []).map { it["object"] }
195
+ reserved << frame["source"] if frame["kind"] == "ability" && casting_ability.costs.any? { it.respond_to?(:uses_source?) }
196
+ reserved
197
+ end
198
+
199
+ def completable_after_activating?(frame, option)
200
+ id = option[:object]
201
+ ability = chars(id).ability(option[:ability])
202
+ # A mana ability that would add nothing (mana: "") produces none.
203
+ produced = (option[:mana] ? Mana.parse_produce(option[:mana], [], "mana") : ability.outcomes(self, object(id)).first) || []
204
+ pool = player(frame["player"]).mana_pool.dup
205
+ rest = player(frame["player"]).restricted_mana
206
+ if ability.spend_only
207
+ rest += produced.map { { "mana" => it.to_s, "spend_only" => ability.spend_only.data } }
208
+ else
209
+ produced.each { pool[it] += 1 }
210
+ end
211
+ payable_with?(frame, remaining_cost(frame["remaining"]), pool, payment_reserved(frame) + [id], restricted: rest)
212
+ end
213
+
214
+ # CR 605.3: activating a mana ability while paying. One whose costs include more than {T} (a
215
+ # sacrifice, life, a discard) can't be undone, so the payment stops being cancellable.
216
+ def tap_for_payment!(id, ability_ref, mana = nil)
217
+ frame = payment
218
+ frame["irreversible"] = true if irreversible_mana_ability?(id, ability_ref)
219
+ activate_mana_ability!(frame["player"], id, ability_ref, mana)
220
+ frame["tapped"] << id
221
+ ask!(nil)
222
+ end
223
+
224
+ # Whether `cost` can be paid from `pool` (and the `restricted` pool) plus the player's untapped
225
+ # sources, minus `excluded` and a source the ability taps for its own cost, for the payment `frame`.
226
+ def payable_with?(frame, cost, pool, excluded, restricted:)
227
+ return true if cost.mana_value.zero?
228
+
229
+ index = frame["player"]
230
+ excluded = [*excluded, payment_excluded_source(frame)].compact
231
+ purpose = payment_purpose(frame)
232
+ units = pool_units(index, pool, purpose, restricted) + source_units(index, excluded, purpose) +
233
+ convoke_units(index, purpose, excluded) + # CR 702.51a (Engine::AltCasting)
234
+ waterbend_units(index, purpose, excluded) # CR 701.67a (Engine::Waterbending)
235
+ covers?(cost, units)
236
+ end
237
+
238
+ # ---- cancelling ----------------------------------------------------------------------
239
+
240
+ # The "cancel" option of a payment decision: rewinds a cast, or declines a resolution payment.
241
+ def cancel_payment!
242
+ payment["type"] == "cast" ? cancel_casting! : decline_payment!
243
+ end
244
+
245
+ # Undoes the mana abilities activated for the payment (CR 734.1): untaps their sources and restores
246
+ # the pool. Abilities that triggered meanwhile didn't trigger (CR 734.1).
247
+ def rewind_payment!(frame)
248
+ frame["tapped"].select { state.objects.key?(it) }.each do |id|
249
+ object(id).tapped = false
250
+ emit Events::Untapped.new(object: id)
251
+ end
252
+ player(frame["player"]).mana_pool = Mana::TYPES.to_h { |t| [t, frame["pool"].fetch(t.to_s, 0)] }
253
+ player(frame["player"]).restricted_mana = Snapshot.deep_copy(frame["restricted"]) if frame["restricted"]
254
+ player(frame["player"]).pool_sources = Snapshot.deep_copy(frame["pool_sources"]) if frame.key?("pool_sources")
255
+ state.pending_triggers.select! { frame["triggers_before"].include?(it["id"]) }
256
+ end
257
+
258
+ # ---- countering (CR 701.6) ----------------------------------------------------------------
259
+
260
+ # CR 701.6a: a countered spell goes to its owner's graveyard; a countered ability ceases to exist.
261
+ # "This spell can't be countered" and "Spells [and abilities] you control can't be countered" win (CR 101.2).
262
+ # Returns whether it was countered.
263
+ def counter!(id)
264
+ obj = object(id)
265
+ return false if obj.ability? && player_rule?(obj.controller, "abilities_cant_be_countered")
266
+ return false if !obj.ability? && (chars(obj).keyword?(:cant_be_countered) ||
267
+ player_rule?(obj.controller, "spells_cant_be_countered", obj))
268
+
269
+ if obj.ability?
270
+ emit Events::AbilityCountered.new(object: id, card: obj.card, source: obj.source)
271
+ remove_stack_object!(id)
272
+ else
273
+ move!(id, :graveyard, reason: "countered")
274
+ end
275
+ true
276
+ end
277
+
278
+ # ---- paying while resolving (CR 118.12) -------------------------------------------------
279
+
280
+ # Starts paying `cost` for an effect that is resolving; Effects::Payment learns the outcome as an
281
+ # answer ("paid" or "declined") when the pay frame is done.
282
+ # `waterbend`: the generic mana of a waterbend cost that tapping may pay (CR 701.67a, Engine::Waterbending).
283
+ # `special`: the cost of a special action ({"object", "ability"}, Actions::SpecialAction), taken once paid.
284
+ def begin_payment!(index, cost, object, waterbend: nil, special: nil)
285
+ state.frames << { "type" => "pay", "player" => index, "object" => object, "remaining" => remaining_hash(cost),
286
+ "pool" => player(index).mana_pool.transform_keys(&:to_s), "tapped" => [],
287
+ "restricted" => Snapshot.deep_copy(player(index).restricted_mana),
288
+ "pool_sources" => Snapshot.deep_copy(player(index).pool_sources),
289
+ "triggers_before" => state.pending_triggers.map { it["id"] },
290
+ "waterbend" => waterbend, "special" => special }.compact
291
+ ask!(nil)
292
+ end
293
+
294
+ def continue_payment!(frame)
295
+ if remaining_left?(frame["remaining"])
296
+ ask!("kind" => "pay_cost", "player" => frame["player"], "remaining" => remaining_text(frame))
297
+ return :wait
298
+ end
299
+ finish_payment!(frame, "paid")
300
+ end
301
+
302
+ def decline_payment!
303
+ frame = payment
304
+ rewind_payment!(frame)
305
+ state.frames.delete(frame)
306
+ ask!(nil)
307
+ finish_payment!(frame, "declined")
308
+ end
309
+
310
+ def finish_payment!(frame, outcome)
311
+ waterbent!(frame) if outcome == "paid" # CR 701.67c (Engine::Waterbending)
312
+ emit Events::PaymentFinished.new(player: name(frame["player"]), object: frame["object"], outcome: outcome)
313
+ return finish_special_action!(frame, outcome) if frame["special"]
314
+
315
+ resolving = state.frames.rfind { it["type"] == "resolve" }
316
+ locals = resolving["locals"][resolving["pc"].to_s]
317
+ locals = locals["child"] while locals["child"]
318
+ (locals["answers"] ||= []) << { "type" => outcome }
319
+ :done
320
+ end
321
+
322
+ # CR 116.2f, 116.2h, 116.3: the special action is taken once its cost is paid; either way the player
323
+ # receives priority again.
324
+ def finish_special_action!(frame, outcome)
325
+ index = frame["player"]
326
+ special = frame["special"]
327
+ if outcome == "paid" && state.objects.key?(special["object"])
328
+ obj = object(special["object"])
329
+ chars(obj).ability(special["ability"]).special!(self, obj, index)
330
+ end
331
+ action_taken!(index)
332
+ :done
333
+ end
334
+ end
335
+
336
+ include Payment
337
+ end
338
+
339
+ module Events
340
+ PaymentFinished = define(:payment_finished, :player, :object, :outcome) # CR 118.12: paid or declined
341
+ AbilityCountered = define(:ability_countered, :object, :card, :source) # CR 701.6a
342
+ end
343
+
344
+ module Frames
345
+ # Paying a cost while a spell or ability resolves (CR 118.12, 701.6), e.g. "counter it unless its
346
+ # controller pays {2}". The payer may activate mana abilities (CR 605.3a).
347
+ class Pay < Base
348
+ def self.step(engine, frame) = engine.continue_payment!(frame)
349
+ def self.decisions = %w[pay_cost]
350
+ end
351
+ register("pay", Pay)
352
+ end
353
+ end