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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "entering_choices"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ # "Choose a color", "choose a creature type", "choose a card type" and "choose Khans or Dragons"
8
+ # (CR 105.4, 205.3m, 205.2a, 607.2d, 607.2f). The choice is remembered in an object's memory under a
9
+ # `chosen_*` key, where filters ("of the chosen type", Filters::Restricted), conditions ({"chosen": word})
10
+ # and linked abilities read it (CR 607.2d). Made as a permanent enters (an ability, CR 614.12) or while
11
+ # a spell or ability resolves (an effect, remembered on the resolving object).
12
+ module Chosen
13
+ # What is chosen -> [memory key, option key].
14
+ MEMORY = { "color" => "chosen_color", "creature_type" => "chosen_type", "card_type" => "chosen_card_type",
15
+ "basic_land_type" => "chosen_type", "option" => "chosen_option" }.freeze
16
+ # CR 105.4: one of the five colors.
17
+ COLORS = %w[W U B R G].freeze
18
+ # CR 205.2a: the card types of traditional Magic cards.
19
+ CARD_TYPES = %w[artifact battle creature enchantment instant kindred land planeswalker sorcery].freeze
20
+ # CR 305.6: the basic land types.
21
+ BASIC_LAND_TYPES = %w[plains island swamp mountain forest].freeze
22
+ WORD = /\A[a-z][a-z0-9_]*\z/
23
+
24
+ # The values a player may choose: every color, creature type (CR 205.3m) or card type, or the named options.
25
+ def self.values(kind, words)
26
+ case kind
27
+ when "color" then COLORS
28
+ when "creature_type" then Keywords::Changeling::CREATURE_TYPES.map(&:to_s)
29
+ when "card_type" then CARD_TYPES
30
+ when "basic_land_type" then BASIC_LAND_TYPES
31
+ else words
32
+ end
33
+ end
34
+
35
+ # `"options": ["khans", "dragons"]` for "option": two or more lowercase words (CR 607.2f).
36
+ def self.words(kind, params, errors, path)
37
+ return [].freeze unless kind == "option"
38
+
39
+ list = Mechanics.fetch_param(params, :options)
40
+ ok = list.is_a?(Array) && list.size >= 2 && list.all? { WORD.match?(it.to_s) } && list.uniq.size == list.size
41
+ errors << "#{path}.options: must list two or more different lowercase words" unless ok
42
+ ok ? list.map(&:to_s).freeze : [].freeze
43
+ end
44
+
45
+ def self.memory(kind, value) = { MEMORY.fetch(kind) => value }
46
+ def self.words_text(kind, words) = kind == "option" ? words.join(" or ") : "a #{kind.tr("_", " ")}"
47
+ end
48
+
49
+ module Abilities
50
+ # "As this enters, choose a color / creature type / card type / one of the words" (CR 614.12, 607.2d).
51
+ class EnteringChosen < EnteringChoice
52
+ def self.kind_name = raise(NotImplementedError)
53
+
54
+ def self.build(params, errors, path, **)
55
+ Mechanics.check_keys(params, kind_name == "option" ? %w[options] : [], errors, path)
56
+ new(Chosen.words(kind_name, params, errors, path))
57
+ end
58
+
59
+ def initialize(words)
60
+ super()
61
+ @words = words
62
+ freeze
63
+ end
64
+
65
+ def to_s = "as this enters, choose #{Chosen.words_text(self.class.kind_name, @words)}"
66
+
67
+ def options(_engine, _controller)
68
+ kind = self.class.kind_name
69
+ Chosen.values(kind, @words).map { { type: kind, kind.to_sym => it } }
70
+ end
71
+
72
+ def enters_for(answer) = { "memory" => Chosen.memory(self.class.kind_name, answer[self.class.kind_name.to_sym]) }
73
+ end
74
+
75
+ # "As this enters, choose a color."
76
+ class ChooseColor < EnteringChosen
77
+ primitive :ability, :choose_color,
78
+ description: "As this permanent enters, its controller chooses a color; filters with \"colors\": " \
79
+ "\"chosen\" (and other abilities of it) use it.",
80
+ rules: %w[614.12 105.4 607.2d], example: { "choose_color" => {} }
81
+ def self.kind_name = "color"
82
+ end
83
+
84
+ # "As this enters, choose a creature type."
85
+ class ChooseCreatureType < EnteringChosen
86
+ primitive :ability, :choose_creature_type,
87
+ description: "As this permanent enters, its controller chooses a creature type; filters with " \
88
+ "\"subtypes\": \"chosen\" use it.",
89
+ rules: %w[614.12 205.3m 607.2d], example: { "choose_creature_type" => {} }
90
+ def self.kind_name = "creature_type"
91
+ end
92
+
93
+ # "As this enters, choose a card type."
94
+ class ChooseCardType < EnteringChosen
95
+ primitive :ability, :choose_card_type,
96
+ description: "As this permanent enters, its controller chooses a card type; filters with \"types\": " \
97
+ "\"chosen\" (or \"not\": \"chosen\") use it.",
98
+ rules: %w[614.12 205.2a 607.2d], example: { "choose_card_type" => {} }
99
+ def self.kind_name = "card_type"
100
+ end
101
+
102
+ # "As this enters, choose a basic land type." (Multiversal Passage: "This land is the chosen type.")
103
+ class ChooseBasicLandType < EnteringChosen
104
+ primitive :ability, :choose_basic_land_type,
105
+ description: "As this permanent enters, its controller chooses a basic land type; filters with " \
106
+ "\"subtypes\": \"chosen\" and a static ability's `\"subtypes\": \"chosen\"` use it " \
107
+ "(\"this land is the chosen type\").",
108
+ rules: %w[614.12 305.6 607.2d], example: { "choose_basic_land_type" => {} }
109
+ def self.kind_name = "basic_land_type"
110
+ end
111
+
112
+ # "As this enters, choose Khans or Dragons." Abilities of it check the word with {"chosen": word}.
113
+ class ChooseOption < EnteringChosen
114
+ primitive :ability, :choose_option,
115
+ description: "As this permanent enters, its controller chooses one of the `options` (words with no " \
116
+ "rules meaning); a condition {\"chosen\": word} is true when that word was chosen.",
117
+ rules: %w[614.12 607.2f 614.12b],
118
+ params: { "options" => "list of two or more lowercase words" },
119
+ example: { "choose_option" => { "options" => %w[khans dragons] } }
120
+ def self.kind_name = "option"
121
+ end
122
+ end
123
+
124
+ module Effects
125
+ # "Choose a creature type." while resolving: the choice is remembered on the resolving spell or
126
+ # ability, so its later effects' filters ("of the chosen type") and conditions read it (CR 607.2d).
127
+ class ResolvingChosen < ChoiceEffect
128
+ def self.kind_name = raise(NotImplementedError)
129
+ def self.default_to = "you"
130
+ def self.keys = kind_name == "option" ? %w[options to if] : %w[to if]
131
+
132
+ def setup(params, errors, path) = @words = Chosen.words(self.class.kind_name, params, errors, path)
133
+
134
+ def to_s = "choose #{Chosen.words_text(self.class.kind_name, @words)}"
135
+
136
+ def run(engine, ctx, locals)
137
+ return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
138
+
139
+ kind = self.class.kind_name
140
+ answer = answers(locals).first
141
+ unless answer
142
+ chooser = players(engine, ctx).first || ctx.controller
143
+ return ask(engine, locals, chooser, "Choose #{Chosen.words_text(kind, @words)}",
144
+ Chosen.values(kind, @words).map { { "type" => kind, kind => it } })
145
+ end
146
+ engine.state.objects[ctx.object]&.memory&.merge!(Chosen.memory(kind, answer[kind]))
147
+ :done
148
+ end
149
+ end
150
+
151
+ # "Choose a color."
152
+ class ChooseColorEffect < ResolvingChosen
153
+ primitive :effect, :choose_color,
154
+ description: "The player (`to`, default you) chooses a color; later effects of this spell or ability " \
155
+ "read it with \"colors\": \"chosen\".",
156
+ rules: %w[105.4 607.2d 608.2c], params: { "to" => "player selector (default you)" },
157
+ example: { "choose_color" => {} }
158
+ def self.kind_name = "color"
159
+ end
160
+
161
+ # "Choose a creature type."
162
+ class ChooseCreatureTypeEffect < ResolvingChosen
163
+ primitive :effect, :choose_creature_type,
164
+ description: "The player (`to`, default you) chooses a creature type; later effects of this spell or " \
165
+ "ability read it with \"subtypes\": \"chosen\".",
166
+ rules: %w[205.3m 607.2d 608.2c], params: { "to" => "player selector (default you)" },
167
+ example: { "choose_creature_type" => {} }
168
+ def self.kind_name = "creature_type"
169
+ end
170
+
171
+ # "Choose a card type."
172
+ class ChooseCardTypeEffect < ResolvingChosen
173
+ primitive :effect, :choose_card_type,
174
+ description: "The player (`to`, default you) chooses a card type; later effects of this spell or " \
175
+ "ability read it with \"types\": \"chosen\".",
176
+ rules: %w[205.2a 607.2d 608.2c], params: { "to" => "player selector (default you)" },
177
+ example: { "choose_card_type" => {} }
178
+ def self.kind_name = "card_type"
179
+ end
180
+
181
+ # "Choose odd or even." Later effects check the word with {"if": {"chosen": word}}.
182
+ class ChooseOptionEffect < ResolvingChosen
183
+ primitive :effect, :choose_option,
184
+ description: "The player (`to`, default you) chooses one of the `options` (words); later effects of this " \
185
+ "spell or ability check it with the condition {\"chosen\": word}.",
186
+ rules: %w[607.2f 608.2c],
187
+ params: { "options" => "list of two or more lowercase words", "to" => "player selector (default you)" },
188
+ example: { "choose_option" => { "options" => %w[odd even] } }
189
+ def self.kind_name = "option"
190
+ end
191
+ end
192
+
193
+ class Condition
194
+ # {"chosen": "khans"}: that word was chosen for this object (CR 607.2f), by its "as this enters"
195
+ # choice or earlier while this spell or ability resolved.
196
+ class ChosenIs
197
+ def self.build(data, errors, path)
198
+ Mechanics.check_keys(data, %w[chosen], errors, path)
199
+ errors << "#{path}.chosen: must be a lowercase word" unless Chosen::WORD.match?(data["chosen"].to_s)
200
+ new(data["chosen"].to_s)
201
+ end
202
+
203
+ def initialize(word)
204
+ @word = word
205
+ freeze
206
+ end
207
+
208
+ def true?(engine, ctx) = Filters.chosen_memory(engine, ctx.source, ctx)["chosen_option"] == @word
209
+ def to_s = "#{@word} was chosen"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "costs {N} less" (CR 601.2f, 118.7a): an amount of generic mana, fixed or computed (Amount),
7
+ # optionally only while a condition holds. Shared by the spell and activated-ability forms.
8
+ class Reduction
9
+ attr_reader :amount, :condition
10
+
11
+ def self.build(data, errors, path)
12
+ unless data.is_a?(Hash)
13
+ errors << "#{path}: must be {\"amount\": n, \"if\": condition}"
14
+ return new(Amount::Fixed.new(0), nil)
15
+ end
16
+ data = data.transform_keys(&:to_s)
17
+ Mechanics.check_keys(data, %w[amount if], errors, path)
18
+ amount = if data.key?("amount")
19
+ Amount.build(data["amount"], errors, "#{path}.amount", targets_count: 0)
20
+ else
21
+ (errors << "#{path}.amount: required") && Amount::Fixed.new(0)
22
+ end
23
+ condition = data["if"] && Condition.build(data["if"], errors, "#{path}.if")
24
+ new(amount, condition)
25
+ end
26
+
27
+ def initialize(amount, condition)
28
+ @amount = amount
29
+ @condition = condition
30
+ freeze
31
+ end
32
+
33
+ # The generic mana it takes off now (0 when the condition doesn't hold).
34
+ def reduction(engine, ctx) = condition.nil? || condition.true?(engine, ctx) ? amount.value(engine, ctx) : 0
35
+
36
+ def to_s = "costs {#{amount}} less#{" if #{condition}" if condition}"
37
+ end
38
+
39
+ # "This spell costs {N} less to cast [if ... / for each ...]" (CR 601.2f). It functions while
40
+ # the card is being cast; the condition sees the chosen targets ("if it targets ...").
41
+ class CostReduction < Primitive
42
+ primitive :ability, :cost_reduction,
43
+ description: "This spell costs `amount` generic mana less to cast, if the optional `if` condition " \
44
+ "holds when its total cost is determined (after targets are chosen). `amount` may be " \
45
+ "computed, e.g. {\"count\": {\"each\": \"artifact\", \"controller\": \"you\"}} for " \
46
+ "affinity for artifacts (CR 702.41a). It can't reduce colored mana or go below {0}.",
47
+ rules: %w[601.2f 118.7a 702.41a],
48
+ params: { "amount" => "amount (integer or computed)", "if" => "condition (optional)" },
49
+ example: { "cost_reduction" => { "amount" => 1, "if" => { "attacked" => true } } }
50
+
51
+ attr_reader :rule
52
+
53
+ def self.build(params, errors, path, **)
54
+ new(Reduction.build(params, errors, path))
55
+ end
56
+
57
+ def initialize(rule)
58
+ super()
59
+ @rule = rule
60
+ freeze
61
+ end
62
+
63
+ def reduction(engine, ctx) = rule.reduction(engine, ctx)
64
+ def kind = :cost_reduction
65
+ def mana_ability? = false
66
+ def allowed_types = nil
67
+ def try_effects = []
68
+ def to_s = "This spell #{rule}."
69
+ end
70
+
71
+ # "Spells you cast cost {1} less" / "Noncreature spells your opponents cast cost {1} more"
72
+ # (CR 601.2f): a static ability of a permanent that changes the total cost of matching spells.
73
+ class CostModifier < Primitive
74
+ TYPES = %w[artifact creature enchantment land planeswalker instant sorcery].freeze
75
+ CASTERS = %w[you opponent any].freeze
76
+
77
+ primitive :ability, :cost_modifier,
78
+ description: "While on the battlefield: spells cast by `caster` (you, opponent or any; default you) " \
79
+ "whose card types match `spells` ({\"any_of\": [types], \"not\": [types]}, default all " \
80
+ "spells) cost `reduce` less or `increase` more generic mana. `from` / `not_from`: only " \
81
+ "spells cast from (not from) that zone (hand | graveyard | exile | library | outside).",
82
+ rules: %w[601.2f 118.7a 604.1 601.2a],
83
+ params: { "spells" => "{\"any_of\": [types], \"not\": [types]} (optional)",
84
+ "caster" => "you | opponent | any", "reduce" => "amount", "increase" => "amount",
85
+ "from" => "zone", "not_from" => "zone" },
86
+ example: { "cost_modifier" => { "spells" => { "not" => ["creature"] }, "reduce" => 1 } }
87
+
88
+ attr_reader :caster, :any_of, :not_types, :amount, :sign
89
+
90
+ def self.build(params, errors, path, **)
91
+ Mechanics.check_keys(params, %w[spells caster reduce increase] + CastFrom::KEYS, errors, path)
92
+ spells = Mechanics.fetch_param(params, :spells) || {}
93
+ unless spells.is_a?(Hash)
94
+ errors << "#{path}.spells: must be an object"
95
+ spells = {}
96
+ end
97
+ Mechanics.check_keys(spells, %w[any_of not], errors, "#{path}.spells")
98
+ lists = %w[any_of not].to_h do |key|
99
+ list = Array(spells.transform_keys(&:to_s)[key]).map(&:to_s)
100
+ errors << "#{path}.spells.#{key}: must be card types (#{TYPES.join(", ")})" unless (list - TYPES).empty?
101
+ [key, list.map(&:to_sym)]
102
+ end
103
+ caster = Mechanics.fetch_param(params, :caster) || "you"
104
+ errors << "#{path}.caster: must be one of #{CASTERS.join(", ")}" unless CASTERS.include?(caster)
105
+ new(caster, lists["any_of"], lists["not"], *build_amount(params, errors, path),
106
+ zones: CastFrom.build(params, errors, path))
107
+ end
108
+
109
+ def self.build_amount(params, errors, path)
110
+ keys = %w[reduce increase].select { params.key?(it) }
111
+ unless keys.size == 1
112
+ errors << "#{path}: needs exactly one of reduce, increase"
113
+ return [Amount::Fixed.new(0), -1]
114
+ end
115
+ [Amount.build(params[keys.first], errors, "#{path}.#{keys.first}", targets_count: 0), keys.first == "reduce" ? -1 : 1]
116
+ end
117
+
118
+ def initialize(caster, any_of, not_types, amount, sign, zones: CastFrom.new(nil, nil))
119
+ super()
120
+ @caster = caster
121
+ @any_of = any_of.freeze
122
+ @not_types = not_types.freeze
123
+ @amount = amount
124
+ @sign = sign
125
+ @zones = zones
126
+ freeze
127
+ end
128
+
129
+ def kind = :cost_modifier
130
+ def mana_ability? = false
131
+ def allowed_types = nil
132
+ def try_effects = []
133
+
134
+ # The generic change this permanent makes to `spell`, cast by player `caster_index`.
135
+ def change_for(engine, permanent, spell, caster_index)
136
+ return 0 unless caster == "any" || (caster == "you") == (caster_index == permanent.controller)
137
+
138
+ types = engine.chars(spell).types
139
+ return 0 if types.intersect?(not_types) || !(any_of.empty? || types.intersect?(any_of))
140
+ return 0 unless @zones.match?(engine.cast_origin(spell))
141
+
142
+ sign * amount.value(engine, EffectContext.new(permanent.controller, permanent.id, permanent.id, []))
143
+ end
144
+
145
+ def to_s
146
+ kinds = [not_types.map { "non#{it}" }, any_of.join(" or ")].flatten.reject(&:empty?).join(" ")
147
+ whose = { "you" => "you cast", "opponent" => "your opponents cast", "any" => "" }[caster]
148
+ "#{[kinds, "spells", whose].reject(&:empty?).join(" ")}#{@zones} cost {#{amount}} " \
149
+ "#{sign.negative? ? "less" : "more"}"
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end