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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Auras and Equipment on the battlefield (CR 301.5, 303.4, 701.3): attaching, and the
6
+ # state-based actions that clean up illegal attachments (CR 704.5m, 704.5n, 704.5p).
7
+ # An attachment remembers the id it is attached to; when that object leaves the
8
+ # battlefield it becomes a new object (CR 400.7), so the stale id is cleaned up here.
9
+ module Attachments
10
+ def aura?(obj) = chars(obj).subtypes.include?(:aura)
11
+ def equipment?(obj) = chars(obj).subtypes.include?(:equipment)
12
+
13
+ # The objects attached to `id`, in battlefield order.
14
+ def attachments_of(id) = state.battlefield.select { object(it).attached_to == id }
15
+
16
+ # CR 700.9: equipped, or enchanted by an Aura its controller controls, makes a permanent modified.
17
+ def modifying_attachment?(obj)
18
+ attachments_of(obj.id).any? do
19
+ attachment = object(it)
20
+ equipment?(attachment) || (aura?(attachment) && attachment.controller == obj.controller)
21
+ end
22
+ end
23
+
24
+ # Whether `attachment` could legally be attached to the permanent `target_id` (CR 701.3a): not to a
25
+ # permanent with protection from it (CR 702.16c-d).
26
+ def can_attach?(attachment, target_id)
27
+ return false if target_id == attachment.id || !state.battlefield.include?(target_id) # CR 301.5c, 303.4d
28
+ return false if protected_from?(object(target_id), attachment)
29
+
30
+ if aura?(attachment)
31
+ enchantable?(attachment, target_id)
32
+ elsif equipment?(attachment)
33
+ !chars(attachment).creature? && chars(target_id).creature? # CR 301.5, 301.5c
34
+ else
35
+ false
36
+ end
37
+ end
38
+
39
+ # CR 702.5a, 702.5c, 303.4d: what the Aura's enchant abilities allow (an Aura creature can't enchant).
40
+ def enchantable?(aura, target_id)
41
+ return false if chars(aura).creature?
42
+
43
+ enchants = chars(aura).abilities.map(&:ability).select { it.kind == :enchant }
44
+ !enchants.empty? && enchants.all? { it.filter.match_for?(self, target_id, aura.controller, aura.id) }
45
+ end
46
+
47
+ # CR 701.3b-c: an illegal attachment doesn't move; reattaching to the same object does nothing.
48
+ def attach!(attachment, target_id)
49
+ return false if attachment.attached_to == target_id || !can_attach?(attachment, target_id)
50
+
51
+ attachment.attached_to = target_id
52
+ attachment.memory["attached_at"] = next_timestamp! # CR 613.7e: a new timestamp (AbilityLayers#timestamp_of)
53
+ emit Events::Attached.new(object: attachment.id, to: target_id)
54
+ true
55
+ end
56
+
57
+ # CR 701.3d: unattach (the permanent stays on the battlefield).
58
+ def unattach!(attachment)
59
+ from = attachment.attached_to or return
60
+ attachment.attached_to = nil
61
+ emit Events::Unattached.new(object: attachment.id, from: from)
62
+ end
63
+
64
+ # CR 704.5m: an Aura attached to an illegal object, or to nothing, goes to the graveyard.
65
+ def illegal_aura?(obj)
66
+ return false unless aura?(obj)
67
+
68
+ to = obj.attached_to
69
+ to.nil? || !can_attach?(obj, to)
70
+ end
71
+
72
+ # CR 704.5n (Equipment attached to an illegal permanent) and 704.5p (anything that's neither
73
+ # an Aura nor an Equipment, e.g. a permanent that stopped being one): it becomes unattached.
74
+ # Returns true when anything was unattached.
75
+ def unattach_illegal_attachments!
76
+ loose = state.battlefield.select do |id|
77
+ obj = object(id)
78
+ obj.attached_to && !aura?(obj) && !can_attach?(obj, obj.attached_to)
79
+ end
80
+ loose.each { unattach!(object(it)) }
81
+ !loose.empty?
82
+ end
83
+ end
84
+ include Attachments
85
+ end
86
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Alternative costs given by keyword abilities (CR 118.9, Abilities::AlternativeCost): flashback, harmonize,
6
+ # mayhem, warp, evoke, web-slinging and sneak, plus convoke (CR 702.51), which changes how the total cost
7
+ # is paid. Each keyword is a cast *method* of Engine::Permissions:
8
+ # {"via" => "alt:<ref>", "play" => "cast" | "play", "keyword" => "flashback", "instant" => true (sneak),
9
+ # "alt" => {"cost" => "{2}{R}", "ref" => ref, "exile" => true, "returns" => "tapped" | "unblocked",
10
+ # "harmonize" => true}}
11
+ # and a warped card in exile has the method {"via" => "warped", "play" => "cast"} (CR 702.185a).
12
+ #
13
+ # How a spell was cast is remembered on the spell (memory "cast_from": the zone, "cast_with": the keyword)
14
+ # and on the permanent it becomes (CR 400.7d), and each player's turn log keeps the keywords of the
15
+ # spells they cast this turn ("cast_with"), for "if a spell was warped this turn" (CR 702.185c).
16
+ module AltCasting
17
+ HARMONIZE_TEXT = "Tap up to one untapped creature you control (harmonize)"
18
+ RETURN_TEXT = { "tapped" => "Return a tapped creature you control to its owner's hand",
19
+ "unblocked" => "Return an unblocked attacking creature you control to its owner's hand" }.freeze
20
+
21
+ # ---- methods ------------------------------------------------------------------------------
22
+
23
+ # The cards in a hand with an alternative cost (only those can be cast from it with a method).
24
+ def alt_cost_hand(player) = player.hand.select { |id| chars(id).abilities.any? { it.ability.kind == :alternative_cost } }
25
+
26
+ # The alternative-cost methods of `obj` (a card `index` owns) that function now. A face-down card its owner
27
+ # may look at is seen face up (a foretold card, CR 702.143a, 406.3a).
28
+ def alt_methods(index, obj)
29
+ return [] unless obj.owner == index && obj.kind == "card" && may_look?(obj, index)
30
+
31
+ own = face_up(obj.id) { chars(obj).abilities }.filter_map do |inst|
32
+ ability = inst.ability
33
+ ability.method_for(inst.ref) if ability.kind == :alternative_cost && ability.functions?(self, obj, index)
34
+ end
35
+ own + warped_methods(index, obj)
36
+ end
37
+
38
+ # CR 702.185a: "Its owner may cast this card after the current turn has ended for as long as it remains
39
+ # exiled." The exiled card remembers the turn it was warped in.
40
+ def warped_methods(_index, obj)
41
+ turn = obj.zone == :exile && obj.memory["warped"]
42
+ turn && state.turn > turn ? [{ "via" => "warped", "play" => "cast" }] : []
43
+ end
44
+
45
+ # CR 702.187b: mayhem needs the card to have been discarded this turn (TurnHistory stamps it).
46
+ def discarded_this_turn?(obj) = !turned_memory(obj, "discarded").nil?
47
+
48
+ # The non-mana parts of the alternative cost being paid (a list of costs, CR 601.2h).
49
+ def alt_cost_parts(obj, method)
50
+ ref = method&.dig("alt", "ref") or return []
51
+ chars(obj).ability(ref).choosing_costs
52
+ end
53
+
54
+ def alt_parts_payable?(obj, method) = alt_cost_parts(obj, method).all? { it.payable?(self, obj) }
55
+
56
+ # ---- returning a creature (beam me up, web-slinging, sneak) -------------------------------------
57
+
58
+ # Whether an alternative cost returns a creature to its owner's hand (Engine::Permissions#beam_choices).
59
+ def returns_creature?(alt) = !alt.nil? && (alt.key?("beam") || alt.key?("returns"))
60
+
61
+ # The creatures `index` could return for the alternative cost `alt`: any (beam me up, CR 702.196a), a
62
+ # tapped one (web-slinging, CR 702.188a) or an unblocked attacking one (sneak, CR 702.190a).
63
+ def return_candidates(index, alt)
64
+ case alt["returns"]
65
+ when "tapped" then state.battlefield.select { (o = object(it)).controller == index && o.tapped && chars(o).creature? }
66
+ when "unblocked"
67
+ attackers.filter_map { |id, a| id if !a["blocked"] && object(id).controller == index && chars(id).creature? }
68
+ else beam_candidates(index, alt["not"])
69
+ end
70
+ end
71
+
72
+ # ---- harmonize (CR 702.180a-b) --------------------------------------------------------------
73
+
74
+ def harmonize_candidates(index)
75
+ state.battlefield.select { (o = object(it)).controller == index && !o.tapped && chars(o).creature? }
76
+ end
77
+
78
+ # The largest reduction harmonize could give `index` now (for whether the card is castable).
79
+ def best_harmonize_power(index, method)
80
+ return 0 unless method.dig("alt", "harmonize")
81
+
82
+ harmonize_candidates(index).map { [chars(it).power.to_i, 0].max }.max || 0
83
+ end
84
+
85
+ # CR 702.180a: the total cost is reduced by generic mana equal to the tapped creature's power.
86
+ def harmonize_reduced(cost, power)
87
+ return cost if cost.nil? || !power.positive?
88
+
89
+ cost.with(generic: [cost.generic - power, 0].max)
90
+ end
91
+
92
+ # Costs#frame_total_cost: the cast in progress's reduction.
93
+ def frame_harmonize_power(ctx) = ctx.dig("harmonize", "power").to_i
94
+
95
+ # CR 702.180b: the creature is chosen as the harmonize cost is chosen, before the total cost is locked.
96
+ def harmonize_pending?(ctx) = ctx["kind"] == "spell" && ctx.dig("method", "alt", "harmonize") && !ctx.key?("harmonize")
97
+
98
+ def ask_harmonize!(ctx)
99
+ ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => HARMONIZE_TEXT)
100
+ :wait
101
+ end
102
+
103
+ # Options: each untapped creature, or none (`{type: "cost"}`).
104
+ def harmonize_options(ctx) = harmonize_candidates(ctx["player"]).map { { object: it } } + [{}]
105
+
106
+ # The creature is tapped now (cancelling untaps it); its power is the reduction.
107
+ def choose_harmonize!(ctx, id)
108
+ ctx["harmonize"] = { "creature" => id, "power" => id ? [chars(id).power.to_i, 0].max : 0 }
109
+ if id
110
+ tap!(object(id))
111
+ ctx["tapped"] << id
112
+ end
113
+ ask!(nil)
114
+ end
115
+
116
+ # The zone a spell is (being) cast from (CR 601.2a): remembered once cast, the cast frame's origin
117
+ # while it's being cast, or the zone a card is in before it's cast (when its cost is estimated).
118
+ def cast_origin(spell)
119
+ return spell.zone.to_s unless spell.zone == :stack
120
+
121
+ spell.memory["cast_from"] || state.frames.rfind { it["object"] == spell.id }&.dig("origin", "zone") || "hand"
122
+ end
123
+
124
+ # ---- as the spell becomes cast --------------------------------------------------------------
125
+
126
+ # Where the spell was cast from and with which keyword (CR 601.2a, 400.7d); a warp spell's delayed
127
+ # trigger (CR 702.185a); the creatures that convoked it (CR 702.51c).
128
+ def record_cast_how!(obj, ctx)
129
+ obj.memory["cast_from"] = ctx.dig("origin", "zone") || "hand"
130
+ obj.memory["convoked"] = ctx["convoked"].dup if ctx["convoked"]
131
+ return unless ctx.dig("method", "alt") # mayhem without a cost: no keyword cost was paid
132
+
133
+ keyword = ctx.dig("method", "keyword") or return
134
+ obj.memory["cast_with"] = keyword
135
+ (turn_log(ctx["player"])["cast_with"] ||= []) << keyword
136
+ warp_delayed!(obj, ctx) if keyword == "warp"
137
+ end
138
+
139
+ def warp_delayed!(obj, ctx)
140
+ ability = chars(obj).ability(ctx.dig("method", "alt", "ref"))
141
+ create_delayed_trigger!("controller" => ctx["player"], "source" => obj.id, "card" => obj.card,
142
+ "ability" => ability.delayed.ref, "watched" => [obj.id])
143
+ end
144
+
145
+ # The creature returned for beam me up, web-slinging or sneak goes to its owner's hand as the spell
146
+ # becomes cast (CR 601.2h); a sneak spell remembers what it was attacking (CR 702.190b).
147
+ def returned_creature!(obj, ctx)
148
+ creature = ctx["beam_creature"]
149
+ alt = ctx.dig("method", "alt")
150
+ obj.memory["sneak_attack"] = attackers.dig(creature, "target") if alt["returns"] == "unblocked"
151
+ move!(creature, :hand, reason: alt.key?("beam") ? "beam_me_up" : ctx.dig("method", "keyword"))
152
+ end
153
+
154
+ # The permanent a spell becomes remembers how it was cast; a warp spell's permanent is watched by the
155
+ # delayed trigger; a sneak spell's enters tapped and attacking (CR 702.190b, 506.3a). Cards its costs
156
+ # exiled stay linked to it ("behold a Goblin and exile it", CR 607.2a).
157
+ def alt_enters!(spell, enters)
158
+ how = spell.memory.slice("cast_from", "cast_with", "mana_from", Mechanics::Linked::EXILED, "haste_while_controlled_by")
159
+ how["warp_spell"] = spell.id if spell.memory["cast_with"] == "warp"
160
+ (enters["memory"] ||= {}).merge!(how) unless how.empty?
161
+ target = spell.memory["sneak_attack"] or return
162
+ enters["tapped"] = true
163
+ enters["attacking"] = target if target.start_with?("player:") || state.battlefield.include?(target)
164
+ end
165
+
166
+ # ---- convoke (CR 702.51) and improvise (CR 702.126) ------------------------------------------
167
+
168
+ # Who helps pay: convoke taps creatures (for {1} or one mana of their color), improvise artifacts
169
+ # (for {1} only). Neither is an additional or alternative cost (CR 702.51b, 702.126b).
170
+ HELPERS = { "convoke" => :creature, "improvise" => :artifact }.freeze
171
+
172
+ def convoke?(spell) = helper_keywords(spell).include?("convoke")
173
+ def helper_keywords(spell) = spell.nil? ? [] : HELPERS.keys.select { chars(spell).keyword?(it.to_sym) } | granted_helpers(spell)
174
+
175
+ # "Noncreature spells you cast have improvise" (Abilities::SpellsHave, CR 611.3a).
176
+ def granted_helpers(spell)
177
+ board_abilities(:spells_have).flat_map { |perm, ability| ability.keywords_for(self, spell, perm.controller) }
178
+ end
179
+
180
+ # Mana units (Engine::ManaSources) the permanents `index` could convoke or improvise with add for
181
+ # `purpose`: one per untapped helper that isn't counted as a mana source, paying {1} or (convoke) one
182
+ # of its colors; an improvise-only unit has no type, so it pays only generic mana.
183
+ def convoke_units(index, purpose, excluded = [])
184
+ spell = purpose&.[]("spell")
185
+ spell = state.objects[spell] unless spell.is_a?(GameObject)
186
+ kinds = helper_keywords(spell)
187
+ return [] if kinds.empty?
188
+
189
+ sources = all_mana_sources(index).map(&:first)
190
+ kinds.flat_map { convoke_candidates(index, it) }.uniq.reject { excluded.include?(it) || sources.include?(it) }.map do |id|
191
+ kinds.include?("convoke") && chars(id).creature? ? chars(id).colors : []
192
+ end
193
+ end
194
+
195
+ def convoke_candidates(index, kind = "convoke")
196
+ state.battlefield.select { (o = object(it)).controller == index && !o.tapped && chars(o).type?(HELPERS[kind]) }
197
+ end
198
+
199
+ # pay_cost options: `{type: "convoke" | "improvise", object: id}` for each helper that can pay part of what is left.
200
+ def convoke_options
201
+ frame = payment
202
+ return [] unless frame["type"] == "cast" && frame["kind"] == "spell"
203
+
204
+ helper_keywords(object(frame["object"])).flat_map do |kind|
205
+ convoke_candidates(frame["player"], kind).filter_map do |id|
206
+ { type: kind, object: id } if convoke_slot(frame, id, kind)
207
+ end
208
+ end
209
+ end
210
+
211
+ # The part of the remaining cost the permanent pays: for convoke a colored or hybrid symbol of one of its
212
+ # colors, else generic (or half of a {2/B} paid as {2}, CR 107.4e, 601.2b); for improvise only generic (one
213
+ # that keeps the rest payable once the payment is irreversible).
214
+ def convoke_slot(frame, id, kind = "convoke")
215
+ rem = frame["remaining"]
216
+ colors = kind == "convoke" ? chars(id).colors : []
217
+ slots = colors.flat_map { pay_slots(rem, it.to_s) }.select { %i[color hybrid].include?(it.first) }.uniq
218
+ slots << [:generic] if rem["generic"].positive?
219
+ slots += pay_slots(rem, "C").select { it.first == :two_generic }
220
+ return slots.first unless frame["irreversible"]
221
+
222
+ pl = player(frame["player"])
223
+ slots.find do |slot|
224
+ payable_with?(frame, remaining_cost(after_paying(rem, slot)), pl.mana_pool.dup, payment_reserved(frame) + [id],
225
+ restricted: pl.restricted_mana)
226
+ end
227
+ end
228
+
229
+ # CR 702.51a, 702.126a: tapping the permanent pays that mana; cancelling untaps it.
230
+ def convoke!(id, kind = "convoke")
231
+ frame = payment
232
+ frame["remaining"] = after_paying(frame["remaining"], convoke_slot(frame, id, kind))
233
+ tap!(object(id))
234
+ frame["tapped"] << id
235
+ (frame["convoked"] ||= []) << id if kind == "convoke" # CR 702.51c
236
+ ask!(nil)
237
+ end
238
+
239
+ # ---- history ------------------------------------------------------------------------------------
240
+
241
+ # CR 702.187b: a card discarded this turn is stamped in the graveyard (TurnHistory#object_memory!).
242
+ def discard_recorded!(event)
243
+ return unless event.reason == "discard" && event.to == "graveyard" && state.objects.key?(event.new_id)
244
+
245
+ object_memory!(event.new_id, "discarded")
246
+ end
247
+
248
+ # Whether any player cast a spell with `keyword`'s cost this turn (CR 702.185c: "a spell was warped").
249
+ def cast_with_this_turn?(keyword) = state.players.each_index.any? { Array(turn_log(it)["cast_with"]).include?(keyword) }
250
+ end
251
+
252
+ include AltCasting
253
+ end
254
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Choices made while casting or activating, besides targets and mana (CR 601.2b-d, 601.2h, 602.2b):
6
+ # kicker and other additional costs, how many modes, dividing an amount among targets, costs that
7
+ # need a choice (tap or sacrifice a permanent, remove counters), and "activate only once each turn".
8
+ module CastChoices
9
+ # ---- additional costs of spells (CR 601.2b, 601.2f, 702.33) ----------------------------
10
+
11
+ def spell_cost_abilities(obj, kind) = chars(obj).abilities.map(&:ability).select { it.kind == kind }
12
+
13
+ # CR 601.2f / 118.3: the spell's mandatory non-mana additional costs can be paid.
14
+ def additional_costs_payable?(obj) = spell_cost_abilities(obj, :additional_cost).all? { it.payable?(self, obj) }
15
+
16
+ # The costs being paid, in order: an ability's own costs; a spell's additional costs (and kicker's).
17
+ def casting_costs(ctx)
18
+ case ctx["kind"]
19
+ when "ability" then frame_costs(ctx) # with the chosen option of each one_of (Engine::CostOptions)
20
+ when "spell" then frame_costs(ctx).reject(&:mana?) + alt_cost_parts(object(ctx["object"]), ctx["method"]) # Engine::AltCasting
21
+ else []
22
+ end
23
+ end
24
+
25
+ # CR 601.2b / 702.33a: true while waiting for the kicker announcement.
26
+ def ask_kicker!(ctx)
27
+ return false unless ctx["kind"] == "spell" && ctx["kicked"].nil?
28
+
29
+ kicker = spell_cost_abilities(object(ctx["object"]), :kicker).first
30
+ return ctx["kicked"] = false unless kicker
31
+
32
+ ask!("kind" => "choose_kicker", "player" => ctx["player"], "cost" => kicker.costs.join(", "))
33
+ true
34
+ end
35
+
36
+ # "Kick" when the kicked total could be paid (CR 118.3, 601.2f) and the kicked spell has targets;
37
+ # "don't kick" when the spell has targets without it (CR 702.33g).
38
+ def kicker_options
39
+ ctx = casting
40
+ obj = object(ctx["object"])
41
+ spell = spell_ability_of(obj)
42
+ kick = kicked_castable?(ctx["player"], obj)
43
+ plain = ability_targetable?(spell, ctx["player"], obj.id)
44
+ (kick ? [{ type: "kick" }] : []) + (plain ? [{ type: "no_kick" }] : [])
45
+ end
46
+
47
+ # Whether the spell could be cast kicked: kicker payable, the kicked total affordable and its
48
+ # kicked targets (or normal ones) available.
49
+ def kicked_castable?(index, obj)
50
+ return false if spell_cost_abilities(obj, :kicker).empty?
51
+
52
+ spell = spell_ability_of(obj)
53
+ targetable = if spell.nil? || spell.modal?
54
+ ability_targetable?(spell, index,
55
+ obj.id)
56
+ else
57
+ targets_available?(spell.targets_for([], kicked: true),
58
+ index, obj.id)
59
+ end
60
+ targetable && cost_variant_affordable?(index, spell_cost_list(obj, true), obj, chars(obj).mana_cost, nil, { "spell" => obj.id }) do
61
+ total_cost(index, it, spell: obj)
62
+ end
63
+ end
64
+
65
+ # The spell remembers it at once, so a "choose both if ..." mode count sees it (CR 601.2b, 702.194b);
66
+ # `kicked_with` names the optional cost paid (kicker, teamwork, an optional additional cost); only kicker
67
+ # makes the spell kicked (CR 702.33d).
68
+ def announce_kicker!(kicked)
69
+ ctx = casting
70
+ ctx["kicked"] = kicked
71
+ if kicked
72
+ obj = object(ctx["object"])
73
+ optional = spell_cost_abilities(obj, :kicker).first
74
+ obj.memory["kicked_with"] = optional.keyword
75
+ obj.memory["kicked"] = true if optional.kicks?
76
+ end
77
+ ask!(nil)
78
+ end
79
+
80
+ # ---- modes (CR 700.2) ---------------------------------------------------------------------
81
+
82
+ # [min, max], determined once as modes are first chosen.
83
+ def mode_range(ctx, ability)
84
+ ctx["choose"] ||= ability.choice_range(self, Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"],
85
+ ctx["object"], nil))
86
+ end
87
+
88
+ def modes_pending?(ctx, ability)
89
+ min, max = mode_range(ctx, ability)
90
+ chosen = ctx["modes"].size
91
+ chosen < min || (chosen < max && !ctx["modes_done"] && !choosable_modes(ctx, ability).empty?)
92
+ end
93
+
94
+ # CR 601.2b / 700.2: modes still choosable (distinct, with legal targets, and for "choose one that
95
+ # hasn't been chosen" not chosen before for this object).
96
+ def choosable_modes(ctx, ability)
97
+ modes = available_modes(ability, ctx["player"], ctx["object"], ctx["source"]) - ctx["modes"]
98
+ (modes - used_modes(ability, ctx["source"], ctx["ability"])).select { mode_affordable?(ctx, ability.modes[it]) }
99
+ end
100
+
101
+ # A spell's mode with a cost (tiered, CR 702.183a) is offered only if the spell could still be paid for
102
+ # with it (CR 601.2f, 118.3).
103
+ def mode_affordable?(ctx, mode)
104
+ base = mode.cost && base_cost(ctx) or return true
105
+
106
+ obj = object(ctx["object"])
107
+ total = total_cost(ctx["player"], Mana.sum_costs([base, mode.cost]), spell: obj)
108
+ affordable?(ctx["player"], total, nil, { "spell" => obj.id })
109
+ end
110
+
111
+ # Modes chosen before for this source's "choose one that hasn't been chosen" ability `ref`.
112
+ def used_modes(ability, source, ref)
113
+ return [] unless ability.extras.unchosen
114
+
115
+ state.objects[source]&.memory&.dig("chosen_modes", ref) || []
116
+ end
117
+
118
+ def mode_options
119
+ ctx = casting
120
+ ability = casting_ability
121
+ options = choosable_modes(ctx, ability).map { { type: "mode", mode: it } }
122
+ ctx["modes"].size >= mode_range(ctx, ability).first ? options + [{ type: "done" }] : options
123
+ end
124
+
125
+ def choose_mode!(index)
126
+ casting["modes"] << index
127
+ ask!(nil)
128
+ end
129
+
130
+ def modes_done!
131
+ casting["modes_done"] = true
132
+ ask!(nil)
133
+ end
134
+
135
+ # ---- dividing among targets (CR 601.2d) ------------------------------------------------------
136
+
137
+ # Each chosen target gets one; the rest is assigned one at a time when there's a choice.
138
+ def ask_distribution!(ctx, ability)
139
+ amount = divided_amount(ctx, ability) or return false
140
+ chosen = ctx["targets"].flatten.compact
141
+ ctx["division"] ||= chosen.to_h { [it, 1] }
142
+ left = amount - ctx["division"].values.sum
143
+ return false unless left.positive? && chosen.any?
144
+
145
+ if chosen.size == 1
146
+ ctx["division"][chosen.first] += left
147
+ return false
148
+ end
149
+
150
+ ask!("kind" => "distribute", "player" => ctx["player"], "left" => left)
151
+ true
152
+ end
153
+
154
+ # The amount divided (CR 601.2d), an amount such as "X damage, where X is the number of lands you
155
+ # control": computed once, as the spell or ability is put on the stack, and kept in the frame.
156
+ def divided_amount(ctx, ability)
157
+ divide = ability&.extras&.divide or return nil
158
+ ctx["divide"] ||= [divide.value(self, Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"],
159
+ ctx["object"], nil, [])), 0].max
160
+ end
161
+
162
+ def distribute_options = casting["division"].keys.map { { type: "assign", target: it } }
163
+
164
+ def distribute!(ref)
165
+ casting["division"][ref] += 1
166
+ ask!(nil)
167
+ end
168
+
169
+ # ---- costs that need a choice (CR 601.2h, 602.2b) -----------------------------------------
170
+
171
+ # The index (in casting_costs) of the next cost that still needs a choice.
172
+ def next_cost_choice(ctx, _ability = nil)
173
+ made = (ctx["paid"] || []).group_by { it["cost"] }
174
+ casting_costs(ctx).each_with_index.find do |cost, i|
175
+ cost.respond_to?(:choices_needed) && !cost_chosen?(ctx, cost, i, made.fetch(i, []))
176
+ end&.last
177
+ end
178
+
179
+ # A cost with a fixed number of choices is chosen once it has them; one of "any number" (crew, CR
180
+ # 702.122a) says itself, given whether the player said they're done.
181
+ def cost_chosen?(ctx, cost, index, entries)
182
+ return entries.size >= ctx["x"].to_i if cost.respond_to?(:x?) && cost.x? # "exile X cards" (CR 601.2b)
183
+ return entries.size >= cost.choices_needed unless cost.respond_to?(:chosen?)
184
+
185
+ cost.chosen?(self, cost_payer(ctx), entries, ctx.fetch("costs_done", []).include?(index))
186
+ end
187
+
188
+ # Whether the player may stop choosing for the current cost ("any number", once enough is chosen).
189
+ def cost_choice_finishable?
190
+ ctx = casting
191
+ return false if harmonize_pending?(ctx) || beam_choice_pending?(ctx)
192
+
193
+ index = next_cost_choice(ctx)
194
+ cost = casting_costs(ctx)[index]
195
+ cost.respond_to?(:finishable?) && cost.finishable?(cost_entries(ctx, index))
196
+ end
197
+
198
+ def finish_cost_choice!
199
+ ctx = casting
200
+ (ctx["costs_done"] ||= []) << next_cost_choice(ctx)
201
+ ask!(nil)
202
+ end
203
+
204
+ def ask_cost_choice!(ctx, cost)
205
+ ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => cost.to_s)
206
+ :wait
207
+ end
208
+
209
+ # The object a cost is paid for: the ability's source, or the spell itself.
210
+ def cost_payer(ctx) = object(ctx["kind"] == "ability" ? ctx["source"] : ctx["object"])
211
+
212
+ def cost_entries(ctx, index) = (ctx["paid"] || []).select { it["cost"] == index }
213
+
214
+ def cost_choice_candidates
215
+ ctx = casting
216
+ return harmonize_options(ctx) if harmonize_pending?(ctx) # Engine::AltCasting
217
+ return beam_choices(ctx).map { { object: it } } if beam_choice_pending?(ctx) # Engine::Permissions
218
+
219
+ index = next_cost_choice(ctx)
220
+ casting_costs(ctx)[index].choice_options(self, cost_payer(ctx), cost_entries(ctx, index))
221
+ end
222
+
223
+ # Records (and for tapping, pays) the chosen part of the cost (costs may be paid in any order,
224
+ # CR 601.2h). Cancelling the cast untaps what was tapped (ctx "tapped").
225
+ def choose_cost!(option)
226
+ ctx = casting
227
+ return choose_harmonize!(ctx, option[:object]) if harmonize_pending?(ctx) # Engine::AltCasting
228
+ return choose_beam_creature!(option[:object]) if beam_choice_pending?(ctx) # Engine::Permissions
229
+
230
+ index = next_cost_choice(ctx)
231
+ entry = casting_costs(ctx)[index].choose!(self, option).merge("cost" => index)
232
+ ctx["tapped"] << entry["object"] if entry["tapped"]
233
+ (ctx["paid"] ||= []) << entry
234
+ ask!(nil)
235
+ end
236
+
237
+ # As the spell or ability becomes cast: sacrifices and counter removals chosen earlier happen now.
238
+ def pay_chosen_costs!(ctx)
239
+ payer = state.objects[ctx["kind"] == "ability" ? ctx["source"] : ctx["object"]]
240
+ casting_costs(ctx).each_with_index do |cost, i|
241
+ cost.pay_chosen!(self, payer, cost_entries(ctx, i)) if cost.respond_to?(:pay_chosen!)
242
+ end
243
+ object(ctx["object"]).paid = Snapshot.deep_copy(ctx["paid"]) if ctx["paid"] # with what was sacrificed
244
+ end
245
+
246
+ # ---- once each turn (CR 602.5b) ----------------------------------------------------------
247
+
248
+ def activations_this_turn(obj, ref)
249
+ entry = obj.memory.dig("activations", ref)
250
+ entry && entry["turn"] == state.turn ? entry["count"] : 0
251
+ end
252
+
253
+ def activation_allowed?(index, obj, ref, ability)
254
+ return true unless ability.respond_to?(:restriction_met?)
255
+ return false if ability.per_turn && activations_this_turn(obj, ref) >= ability.per_turn
256
+ return false if ability.once? && obj.memory.fetch("exhausted", []).include?(ref) # CR 602.5b, 702.177a, 702.193a
257
+
258
+ ability.restriction_met?(self, index, obj.id)
259
+ end
260
+
261
+ # Kept on the source object, so the limit applies even if its controller changes (CR 602.5b). An exhaust or
262
+ # power-up ability is remembered for as long as the object exists (CR 702.177a, 702.193a; a new object can
263
+ # again, CR 400.7).
264
+ def record_activation!(ctx)
265
+ source = state.objects[ctx["source"]] or return
266
+ ability = chars(source).ability(ctx["ability"])
267
+ (source.memory["exhausted"] ||= []) << ctx["ability"] if ability.respond_to?(:once?) && ability.once?
268
+ return unless ability.respond_to?(:per_turn) && ability.per_turn
269
+
270
+ (source.memory["activations"] ||= {})[ctx["ability"]] =
271
+ { "turn" => state.turn, "count" => activations_this_turn(source, ctx["ability"]) + 1 }
272
+ end
273
+
274
+ # What was announced while casting stays with the spell or ability (CR 702.33d, 601.2d).
275
+ def record_announcements!(obj, ctx)
276
+ obj.memory["kicked"] = true if ctx["kicked"] && spell_cost_abilities(obj, :kicker).first&.kicks? # CR 702.33d
277
+ obj.memory["division"] = ctx["division"].dup if ctx["division"]
278
+ record_chosen_modes!(ctx, casting_ability)
279
+ end
280
+
281
+ # "Choose one that hasn't been chosen": the source remembers the modes chosen for it.
282
+ def record_chosen_modes!(ctx, ability)
283
+ return unless ability&.modal? && ability.extras.unchosen
284
+
285
+ source = state.objects[ctx["source"]] or return
286
+ ((source.memory["chosen_modes"] ||= {})[ctx["ability"]] ||= []).concat(ctx["modes"])
287
+ end
288
+ end
289
+
290
+ include CastChoices
291
+ end
292
+ end