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,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # A card filter for cards outside the battlefield: a CardSpec (`for`) plus card types the card must
6
+ # not have (`not_types`, "a nonland card", "a noncreature spell").
7
+ class CardFilter
8
+ def self.build(params, errors, path)
9
+ spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
10
+ raw = Mechanics.fetch_param(params, :not_types) || []
11
+ unless raw.is_a?(Array) && raw.all? { Enjine.registry.fetch(:type, it.to_s) }
12
+ errors << "#{path}.not_types: must be a list of card types"
13
+ raw = []
14
+ end
15
+ new(spec, raw.map(&:to_sym))
16
+ end
17
+
18
+ def initialize(spec, not_types)
19
+ @spec = spec
20
+ @not_types = not_types.freeze
21
+ freeze
22
+ end
23
+
24
+ def match?(engine, obj) = @spec.match?(engine, obj) && !engine.chars(obj).types.intersect?(@not_types)
25
+
26
+ def to_s
27
+ words = @not_types.map { "non#{it}" }
28
+ words.empty? ? @spec.to_s : "#{words.join(" ")} #{@spec}"
29
+ end
30
+ end
31
+
32
+ # "If that spell would be put into a graveyard, exile it instead" (CR 614.1a), a rider of a permission or of
33
+ # casting while an effect resolves: `"exile_instead": true`, or a card spec the spell must match ("if an
34
+ # instant or sorcery spell cast this way would be put into your graveyard"). Kept as plain data in the play
35
+ # method and checked as the spell becomes cast (Engine::Permissions#finish_cast_method!).
36
+ module ExileInstead
37
+ module_function
38
+
39
+ # The rider's data from `params` (true or a card spec), or nil without one.
40
+ def build(params, errors, path)
41
+ raw = Mechanics.fetch_param(params, :exile_instead)
42
+ return nil if raw.nil? || raw == false
43
+ return true if raw == true
44
+
45
+ CardSpec.build(raw, errors, "#{path}.exile_instead")
46
+ raw
47
+ end
48
+
49
+ def applies?(engine, data, spell) = data == true || CardSpec.build(data, [], "exile_instead").match?(engine, spell)
50
+ def words(data) = "exile it#{" (#{CardSpec.build(data, [], "")})" unless data == true} instead of a graveyard"
51
+ end
52
+
53
+ module Abilities
54
+ # A card's own permission to be cast from a zone other than the hand (CR 113.6e-f, 601.3), or a
55
+ # permanent's static permission for its controller's cards in a zone, possibly limited to once per
56
+ # turn (CR 604.1). Offered as cast / play_land options with a `via`.
57
+ class PlayFrom < Primitive
58
+ ZONES = %w[graveyard exile library_top outside].freeze
59
+ PLAYS = %w[cast play].freeze
60
+
61
+ primitive :ability, :play_from,
62
+ description: "With `cards: \"self\"` (default): its owner may cast this card from `zone` (the " \
63
+ "ability functions there). With a card spec: while this permanent is on the " \
64
+ "battlefield, its controller may play their own matching cards from `zone`, or from " \
65
+ "any of `zones` (\"outside\": cards they own outside the game, the deck's `outside` " \
66
+ "list, CR 400.11a). `play`: \"cast\" (spells only, default) or \"play\" (lands too, " \
67
+ "using a land play). `once_each_turn`: once during each of your turns, one use shared " \
68
+ "by all its zones. `enters_with`: a permanent spell cast this way enters with these " \
69
+ "counters. Timing and costs are the usual ones.",
70
+ rules: %w[601.3 113.6e 113.6f 305.1 401.5 122.1 614.12 400.11a 400.11b 108.3],
71
+ params: { "zone" => ZONES.join(" | "), "zones" => "list of zones (instead of `zone`)",
72
+ "cards" => "\"self\" or a card spec", "play" => PLAYS.join(" | "),
73
+ "once_each_turn" => "boolean", "enters_with" => "{counter name: n}" },
74
+ example: { "play_from" => { "zone" => "graveyard", "enters_with" => { "finality" => 1 } } }
75
+
76
+ attr_reader :zones, :cards, :play, :once, :enters_with
77
+
78
+ def self.build(params, errors, path, **)
79
+ Mechanics.check_keys(params, %w[zone zones cards play once_each_turn enters_with], errors, path)
80
+ zones = zones_param(params, errors, path)
81
+ play = Mechanics.fetch_param(params, :play) || "cast"
82
+ errors << "#{path}.play: must be one of #{PLAYS.join(", ")}" unless PLAYS.include?(play)
83
+ raw = Mechanics.fetch_param(params, :cards) || "self"
84
+ cards = raw == "self" ? :self : CardSpec.build(raw, errors, "#{path}.cards")
85
+ # CR 400.11c: a card outside the game has no abilities that work there; a permanent brings cards in.
86
+ errors << "#{path}.zones: \"outside\" needs a card spec in `cards`" if cards == :self && zones.include?("outside")
87
+ once = Mechanics.flag_param(params, :once_each_turn, errors, path)
88
+ extra = Mechanics.fetch_param(params, :enters_with)
89
+ extra &&= EntersWithCounters.read_counters(extra, errors, "#{path}.enters_with")
90
+ new(zones, cards, play, once, extra)
91
+ end
92
+
93
+ # `zone: "graveyard"` or `zones: ["outside", "graveyard"]`, one of the two.
94
+ def self.zones_param(params, errors, path)
95
+ one = Mechanics.fetch_param(params, :zone)
96
+ many = Mechanics.fetch_param(params, :zones)
97
+ if many.nil?
98
+ errors << "#{path}.zone: must be one of #{ZONES.join(", ")}" unless ZONES.include?(one)
99
+ return [one]
100
+ end
101
+ errors << "#{path}: give `zone` or `zones`, not both" if one
102
+ return many.uniq if many.is_a?(Array) && !many.empty? && many.all? { ZONES.include?(it) }
103
+
104
+ errors << "#{path}.zones: must be a non-empty list of #{ZONES.join(", ")}"
105
+ []
106
+ end
107
+
108
+ def initialize(zones, cards, play, once, enters_with)
109
+ super()
110
+ @zones = zones.freeze
111
+ @cards = cards
112
+ @play = play
113
+ @once = once
114
+ @enters_with = enters_with&.freeze
115
+ freeze
116
+ end
117
+
118
+ def self? = @cards == :self
119
+ def kind = :play_from
120
+ def mana_ability? = false
121
+ def allowed_types = nil
122
+ def try_effects = []
123
+
124
+ def outside? = zones.include?("outside")
125
+
126
+ # Whether the card is in a zone this permission names. A card outside the game is a detached object
127
+ # in the pseudo-zone :outside (Engine::Permissions#outside_object; outside the game isn't a zone).
128
+ def holds?(engine, obj)
129
+ zones.any? do |zone|
130
+ next obj.zone == :library && engine.player(obj.owner).library.first == obj.id if zone == "library_top"
131
+
132
+ obj.zone.to_s == zone
133
+ end
134
+ end
135
+
136
+ WHERE = { "library_top" => "the top of your library", "outside" => "outside the game" }.freeze
137
+
138
+ def to_s
139
+ what = self? ? "this card" : "#{cards}s"
140
+ where = zones.map { WHERE.fetch(it) { |zone| "your #{zone}" } }.join(" or from ")
141
+ text = "#{"Once during each of your turns, " if once}you may #{play} #{what} from #{where}"
142
+ enters_with ? "#{text}; it enters with #{enters_with.map { |k, n| "#{n} #{k}" }.join(", ")} counter(s)" : text
143
+ end
144
+ end
145
+
146
+ # Looking at the top card of your library at any time (CR 401.5).
147
+ class LookAtTop < Primitive
148
+ primitive :ability, :look_at_top,
149
+ description: "While this permanent is on the battlefield, its controller sees the top card of " \
150
+ "their library (the `library_top` of their view).",
151
+ rules: %w[401.5 604.1], example: "look_at_top"
152
+
153
+ INSTANCE = new.freeze
154
+
155
+ def self.build(params, errors, path, **)
156
+ Mechanics.check_keys(params, [], errors, path)
157
+ INSTANCE
158
+ end
159
+
160
+ def kind = :look_at_top
161
+ def mana_ability? = false
162
+ def allowed_types = nil
163
+ def try_effects = []
164
+ def to_s = "Its controller sees the top card of their library (CR 401.5)"
165
+ end
166
+ end
167
+
168
+ module Effects
169
+ # (exile_top, exiling the top N cards: see effects/library.rb.)
170
+
171
+ # Exiling cards from the top of a library one at a time until a nonland card is exiled (CR 701.13).
172
+ class ExileUntil < Effect
173
+ UNTIL = %w[nonland].freeze
174
+
175
+ primitive :effect, :exile_until,
176
+ description: "Each referenced player exiles cards from the top of their library until they exile a " \
177
+ "card matching `until` (or the library is empty). All exiled cards are the result.",
178
+ rules: %w[406.1 701.13a 400.7j], params: { "until" => UNTIL.join(" | "), "to" => "selector" },
179
+ example: { "exile_until" => { "until" => "nonland", "to" => { "target" => 0 } } }
180
+
181
+ def self.keys = %w[until to if]
182
+ def self.default_to = "you"
183
+
184
+ def setup(params, errors, path)
185
+ @until = Mechanics.fetch_param(params, :until)
186
+ errors << "#{path}.until: must be one of #{UNTIL.join(", ")}" unless UNTIL.include?(@until)
187
+ end
188
+
189
+ def to_s = "exile from the top until a #{@until} card → #{to}"
190
+
191
+ def apply(engine, ctx)
192
+ players(engine, ctx).each do |index|
193
+ library = engine.player(index).library
194
+ until library.empty?
195
+ exiled = engine.move!(library.first, :exile, reason: "exile")
196
+ break if exiled.nil? || !engine.chars(exiled).land?
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # Permission to play exiled cards for a duration (CR 611.2, 601.3; see Engine::Permissions). With
203
+ # `choose`, the controller first chooses one matching card per owner among them.
204
+ #
205
+ # Who may play them and how (riders):
206
+ # `to_player` "controller" (default), "owner" (each card's owner, CR 108.3) or a player selector
207
+ # `cost_increase` generic mana added to the total cost of a spell cast with this permission (601.2f)
208
+ # `any_mana_type` mana of any type may be spent to cast it (CR 609.4b)
209
+ # Riders like these are stored on the permission (`state.effects` entry) and copied into the play
210
+ # method, so only casts that use this permission (`via: "effect:<timestamp>"`) see them.
211
+ class PlayPermission < ChoiceEffect
212
+ DURATIONS = %w[end_of_turn end_of_your_next_turn while_exiled].freeze
213
+ PLAYERS = %w[controller owner].freeze
214
+
215
+ primitive :effect, :play_permission,
216
+ description: "A player may play (`play: \"play\"`, default) or cast (`\"cast\"`) each referenced " \
217
+ "exiled card matching `for`/`not_types`, paying its costs and following timing rules, " \
218
+ "for `duration`: end_of_turn (default), end_of_your_next_turn or while_exiled. With " \
219
+ "`choose: \"one_per_owner\"` only one chosen card per owner. `to_player`: who may " \
220
+ "play it, \"controller\" (default, the effect's controller), \"owner\" (the card's " \
221
+ "owner) or a player selector (a face-down card: only someone who may look at it). " \
222
+ "`cost_increase`: spells cast this way cost that much generic mana more. " \
223
+ "`any_mana_type: true`: mana of any type can be spent to cast those spells.",
224
+ rules: %w[611.2a 601.3 305.1 400.7 514.2 601.2f 108.3 406.3b 609.4b],
225
+ params: { "to" => "selector (usually {\"result\": n})", "duration" => DURATIONS.join(" | "),
226
+ "play" => "play | cast", "for" => "card spec", "not_types" => "list of card types",
227
+ "choose" => "one_per_owner", "to_player" => "controller | owner | player selector",
228
+ "cost_increase" => "integer >= 1", "any_mana_type" => "boolean",
229
+ "exile_instead" => "true or a card spec: if a spell cast this way (matching it) would be put " \
230
+ "into a graveyard, it's exiled instead (CR 614.1a)" },
231
+ example: { "play_permission" => { "to" => { "result" => 0 }, "duration" => "end_of_your_next_turn" } }
232
+
233
+ def self.keys = %w[to duration play for not_types choose to_player cost_increase any_mana_type exile_instead if]
234
+
235
+ def setup(params, errors, path)
236
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
237
+ durations = DURATIONS
238
+ errors << "#{path}.duration: must be one of #{durations.join(", ")}" unless durations.include?(@duration)
239
+ @play = Mechanics.fetch_param(params, :play) || "play"
240
+ errors << "#{path}.play: must be play or cast" unless %w[play cast].include?(@play)
241
+ @choose = Mechanics.fetch_param(params, :choose)
242
+ errors << "#{path}.choose: must be one_per_owner" unless [nil, "one_per_owner"].include?(@choose)
243
+ @filter = CardFilter.build(params, errors, path)
244
+ @to_player = player_param(Mechanics.fetch_param(params, :to_player) || "controller", errors, path)
245
+ @riders = riders(params, errors, path)
246
+ end
247
+
248
+ def to_s
249
+ who = { "controller" => "you", "owner" => "its owner" }.fetch(@to_player) { @to_player.to_s }
250
+ more = @riders["cost_increase"] ? ", costs {#{@riders["cost_increase"]}} more" : ""
251
+ more += ", mana of any type" if @riders["any_mana_type"]
252
+ more += ", #{ExileInstead.words(@riders["exile_instead"])}" if @riders["exile_instead"]
253
+ "#{who} may #{@play} #{@filter} (#{@duration.tr("_", " ")}#{more}) → #{to}"
254
+ end
255
+
256
+ def run(engine, ctx, locals)
257
+ return :done unless locals["cards"] || condition.nil? || condition.true?(engine, ctx)
258
+
259
+ cards = (locals["cards"] ||= refs(engine, ctx).select { exiled_match?(engine, it) })
260
+ picks = @choose ? choose(engine, ctx, locals, cards) : cards
261
+ return :wait if picks == :wait
262
+
263
+ picks.flat_map { |id| players_for(engine, ctx, id).map { [it, id] } }.group_by(&:first).each do |index, pairs|
264
+ engine.grant_play_permission!(index, pairs.map(&:last), duration: @duration, play: @play, **@riders)
265
+ end
266
+ :done
267
+ end
268
+
269
+ private
270
+
271
+ def player_param(raw, errors, path)
272
+ return raw if PLAYERS.include?(raw)
273
+
274
+ Selector.build(raw, errors, "#{path}.to_player", targets_count: @targets_count, results_count: @results_count)
275
+ end
276
+
277
+ def riders(params, errors, path)
278
+ increase = Mechanics.fetch_param(params, :cost_increase)
279
+ ok = increase.nil? || (increase.is_a?(Integer) && increase.positive?)
280
+ errors << "#{path}.cost_increase: must be an integer >= 1" unless ok
281
+ any_type = Mechanics.flag_param(params, :any_mana_type, errors, path)
282
+ { "cost_increase" => increase, "any_mana_type" => (true if any_type),
283
+ "exile_instead" => ExileInstead.build(params, errors, path) }.compact
284
+ end
285
+
286
+ # The players who may play `id`: the effect's controller, the card's owner, or the selected players.
287
+ def players_for(engine, ctx, id)
288
+ case @to_player
289
+ when "controller" then [ctx.controller]
290
+ when "owner" then [engine.object(id).owner]
291
+ else @to_player.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.uniq
292
+ end
293
+ end
294
+
295
+ # A face-down card is judged face up (the permission is to play it face up, CR 406.3a).
296
+ def exiled_match?(engine, id)
297
+ obj = engine.state.objects[id]
298
+ obj && obj.zone == :exile && engine.face_up(id) { @filter.match?(engine, obj) }
299
+ end
300
+
301
+ # One card per owner, in seat order; asked only when there's a choice.
302
+ def choose(engine, ctx, locals, cards)
303
+ asked = 0
304
+ cards.group_by { engine.object(it).owner }.sort.map do |_owner, group|
305
+ next group.first if group.size == 1
306
+
307
+ answer = answers(locals)[asked]
308
+ asked += 1
309
+ next answer["object"] if answer
310
+
311
+ return ask(engine, locals, ctx.controller, "Choose a card (#{@filter}) you may play",
312
+ group.map { { "type" => "choose", "object" => it } })
313
+ end
314
+ end
315
+ end
316
+
317
+ # Optional casting without paying mana costs during resolution (CR 608.2g, 118.9, 601.2a):
318
+ # the controller picks a card, casts it right away (a cast frame on top of the resolving one; nobody
319
+ # gets priority), then the resolution goes on. `count: "any"` repeats until they stop.
320
+ class CastFree < ChoiceEffect
321
+ primitive :effect, :cast_free,
322
+ description: "The controller may cast referenced cards without paying their mana costs while this " \
323
+ "resolves. `to` names the cards ({\"result\": n}), or a player for the cards in their " \
324
+ "hand (default: your hand). Only cards matching `for`/`not_types` with mana value up to " \
325
+ "`max_mana_value` (optional amount) that could be cast now (legal targets). `count`: 1 " \
326
+ "(default) or \"any\" (any number, one at a time). X is 0 (CR 107.3b). `play: true` " \
327
+ "(\"you may play\"): a land among them may be played, on your turn with a land play " \
328
+ "left (`{type: \"play_land\"}`). A face-down card is judged face up.",
329
+ rules: %w[608.2g 118.9 601.2a 107.3b 118.6a 305.2a 305.3 406.3a],
330
+ params: { "to" => "selector", "for" => "card spec", "not_types" => "list of card types",
331
+ "max_mana_value" => "amount", "count" => "1 or \"any\"", "play" => "boolean",
332
+ "exile_instead" => "true or a card spec: if a spell cast this way (matching it) would be put " \
333
+ "into a graveyard, it's exiled instead (CR 614.1a)",
334
+ "any_mana_type" => "boolean: mana of any type can be spent to cast it (CR 609.4b)" },
335
+ example: { "cast_free" => { "to" => { "result" => 0 }, "max_mana_value" => 3, "count" => "any" } }
336
+
337
+ def self.keys = %w[to for not_types max_mana_value count play exile_instead any_mana_type if]
338
+ def self.default_to = "you"
339
+
340
+ def setup(params, errors, path)
341
+ @filter = CardFilter.build(params, errors, path)
342
+ any_type = Mechanics.flag_param(params, :any_mana_type, errors, path)
343
+ @riders = { "exile_instead" => ExileInstead.build(params, errors, path), "any_mana_type" => (true if any_type) }.compact
344
+ raw = Mechanics.fetch_param(params, :max_mana_value)
345
+ @max = raw.nil? ? nil : Amount.build(raw, errors, "#{path}.max_mana_value", targets_count: @targets_count)
346
+ @count = Mechanics.fetch_param(params, :count) || 1
347
+ errors << "#{path}.count: must be 1 or \"any\"" unless [1, "any"].include?(@count)
348
+ @play = Mechanics.flag_param(params, :play, errors, path)
349
+ end
350
+
351
+ def to_s
352
+ more = @riders["any_mana_type"] ? ", mana of any type" : ""
353
+ more += ", #{ExileInstead.words(@riders["exile_instead"])}" if @riders["exile_instead"]
354
+ "you may cast #{@count == "any" ? "any number of" : "a"} #{@filter} free#{more} → #{to}"
355
+ end
356
+
357
+ def run(engine, ctx, locals)
358
+ return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
359
+
360
+ index = ctx.controller
361
+ return :wait if locals["playing"] && play_land!(engine, index, locals) == :wait
362
+
363
+ settle_cast!(engine, locals)
364
+ answer = answers(locals)[locals.fetch("seen", 0)]
365
+ if answer
366
+ locals["seen"] = locals.fetch("seen", 0) + 1
367
+ return :done if answer["type"] == "done"
368
+ return start_cast!(engine, index, answer["object"], locals) unless answer["type"] == "play_land"
369
+
370
+ engine.object(answer["object"]).memory.delete("face_down") # turned face up to be played (CR 406.3a)
371
+ locals["playing"] = answer["object"]
372
+ return :wait if play_land!(engine, index, locals) == :wait
373
+ end
374
+ return :done if @count == 1 && locals["cast"].to_i >= 1
375
+
376
+ choices = candidates(engine, ctx, locals)
377
+ return :done if choices.empty?
378
+
379
+ ask(engine, locals, index, prompt, choices + [{ "type" => "done" }])
380
+ end
381
+
382
+ # How the cards are cast (Engine::Permissions#play_method); Effects::Cast pays the costs instead.
383
+ def via = "free"
384
+ def prompt = "You may cast a spell without paying its mana cost"
385
+ def castable_now?(engine, index, id) = engine.free_castable?(index, id)
386
+
387
+ private
388
+
389
+ # After the cast frame: the card left its zone (it was cast), or it's back (cancelled: declined).
390
+ def settle_cast!(engine, locals)
391
+ id = locals.delete("casting") or return
392
+ engine.state.objects.key?(id) ? (locals["declined"] ||= []) << id : locals["cast"] = locals["cast"].to_i + 1
393
+ end
394
+
395
+ # `{"type" => "cast" | "play", "object" => id}` for each card that may be cast (or, with `play`, a land
396
+ # played) now; a face-down card is judged face up (CR 406.3a).
397
+ def candidates(engine, ctx, locals)
398
+ limit = @max&.value(engine, ctx)
399
+ index = ctx.controller
400
+ cards(engine, ctx).filter_map do |id|
401
+ next if locals.fetch("declined", []).include?(id)
402
+
403
+ engine.face_up(id) do
404
+ obj = engine.object(id)
405
+ next unless @filter.match?(engine, obj) && (limit.nil? || engine.chars(obj).mana_cost&.mana_value.to_i <= limit)
406
+
407
+ if engine.chars(obj).land? then { "type" => "play_land", "object" => id } if @play && land_play?(engine, index)
408
+ elsif castable_now?(engine, index, id) then { "type" => "cast", "object" => id }
409
+ end
410
+ end
411
+ end
412
+ end
413
+
414
+ def start_cast!(engine, index, id, locals)
415
+ locals["casting"] = id
416
+ engine.begin_cast!(index, id, via: via, riders: @riders)
417
+ :wait
418
+ end
419
+
420
+ # CR 305.2a, 305.3: a land is played during resolution only on your turn, with a land play left.
421
+ def land_play?(engine, index) = engine.state.active == index && engine.land_play_available?(index)
422
+
423
+ # Plays the land (CR 305.1) after its "as this enters" choice (CR 614.12); it counts as a played card.
424
+ def play_land!(engine, index, locals)
425
+ id = locals["playing"]
426
+ return :wait if engine.ask_entering_choices!(locals, [[id, index]]) == :wait
427
+
428
+ locals.delete("playing")
429
+ locals["cast"] = locals["cast"].to_i + 1
430
+ engine.player(index).lands_played += 1
431
+ engine.put_played_land!(index, id, enters: engine.with_entering_answer(locals, id, index, nil))
432
+ :done
433
+ end
434
+
435
+ # Referenced cards still in the zone they were put in; a player stands for their hand.
436
+ def cards(engine, ctx)
437
+ refs(engine, ctx).flat_map do |ref|
438
+ index = Filters.player_index(engine, ref)
439
+ next engine.player(index).hand if index
440
+ next [] unless engine.state.objects.key?(ref)
441
+
442
+ %i[stack battlefield].include?(engine.object(ref).zone) ? [] : [ref]
443
+ end
444
+ end
445
+ end
446
+
447
+ # Discover N (CR 701.57a): exile from the top until a nonland card with mana value N or less; cast it
448
+ # without paying its mana cost or put it into your hand; the rest go on the bottom in a random order.
449
+ class Discover < ChoiceEffect
450
+ primitive :effect, :discover,
451
+ description: "Discover `amount`: exile cards from the top of your library until you exile a nonland " \
452
+ "card with mana value `amount` or less. You may cast it without paying its mana cost; " \
453
+ "if you don't, it goes into your hand. The other exiled cards go on the bottom of " \
454
+ "your library in a random order.",
455
+ rules: %w[701.57a 701.57b 701.57c 608.2g 118.9],
456
+ params: { "amount" => "amount" },
457
+ example: { "discover" => { "amount" => 5 } }
458
+
459
+ def self.keys = %w[amount if]
460
+ def self.default_to = "you"
461
+
462
+ def run(engine, ctx, locals)
463
+ return :done unless locals["exiled"] || condition.nil? || condition.true?(engine, ctx)
464
+
465
+ index = ctx.controller
466
+ exile_until_hit!(engine, ctx, locals) unless locals["exiled"]
467
+ hit = locals["hit"]
468
+ return :wait if hit && decide(engine, index, locals, hit) == :wait
469
+
470
+ rest = locals["exiled"].select { engine.state.exile.include?(it) } # all but a card cast or kept
471
+ engine.state.rng.shuffle!(rest)
472
+ rest.each { engine.move!(it, :library, position: :bottom, reason: "discover") }
473
+ :done
474
+ end
475
+
476
+ private
477
+
478
+ # The greatest mana value a hit may have.
479
+ def limit(engine, ctx) = amount_for(engine, ctx)
480
+
481
+ # What happens to a hit that isn't cast: an option type, a prompt, and the move.
482
+ def miss_type = "hand"
483
+ def prompt = "Discover: cast it without paying its mana cost, or put it into your hand"
484
+ def miss!(engine, hit) = engine.move!(hit, :hand, reason: "discover")
485
+
486
+ def exile_until_hit!(engine, ctx, locals)
487
+ n = limit(engine, ctx)
488
+ library = engine.player(ctx.controller).library
489
+ locals["exiled"] = []
490
+ until library.empty?
491
+ card = engine.move!(library.first, :exile, reason: "discover") or break
492
+ locals["exiled"] << card.id
493
+ c = engine.chars(card)
494
+ next if c.land? || c.mana_cost&.mana_value.to_i > n
495
+
496
+ locals["hit"] = card.id
497
+ break
498
+ end
499
+ end
500
+
501
+ # Cast it (a frame), or not (`miss!`); a cancelled cast counts as not casting it.
502
+ def decide(engine, index, locals, hit)
503
+ return if locals["decided"]
504
+
505
+ if locals.key?("casting")
506
+ locals.delete("casting")
507
+ else
508
+ answer = answers(locals).first
509
+ unless answer
510
+ choices = [{ "type" => miss_type, "object" => hit }]
511
+ choices.unshift({ "type" => "cast", "object" => hit }) if engine.free_castable?(index, hit)
512
+ return ask(engine, locals, index, prompt, choices)
513
+ end
514
+ if answer["type"] == "cast"
515
+ locals["casting"] = hit
516
+ engine.begin_cast!(index, hit, via: "free")
517
+ return :wait
518
+ end
519
+ end
520
+ locals["decided"] = true
521
+ miss!(engine, hit) if engine.state.objects.key?(hit)
522
+ nil
523
+ end
524
+ end
525
+ end
526
+ end
527
+ end