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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # A static ability that modifies permanents while its source is on the battlefield (CR 604,
7
+ # 611.3, 613): "Creatures you control get +1/+1", "Other Elves you control have reach",
8
+ # a keyword only during your turn, an Aura that sets creature types, creatures that gain a mana
9
+ # ability. Unlike a resolved effect, the set of affected
10
+ # objects is re-evaluated continuously (CR 611.3a).
11
+ class StaticAbility < Primitive
12
+ primitive :ability, :static,
13
+ description: "While its source is on the battlefield (and its `if` condition holds), every permanent " \
14
+ "it `affects` gets `pump` (fixed or computed amounts, layer 7c), the `keywords` and " \
15
+ "granted `abilities` (layer 6, triggered, activated or mana abilities), and with " \
16
+ "`creature_types` has exactly those creature types (layer 4); `loses_abilities: true` " \
17
+ "removes the abilities it had before (layer 6, in timestamp order: later grants and " \
18
+ "keyword counters stay, CR 613.7), `colors` sets its colors (layer 5) and " \
19
+ "`base_power_toughness` its base power and toughness (layer 7b); `types` are card " \
20
+ "types it has in addition " \
21
+ "to its other types (\"this Vehicle is an artifact creature\", layer 4) and `subtypes` subtypes it has in " \
22
+ "addition to its other types (\"is a Wizard in addition to its other types\", layer 4). " \
23
+ "`affects` is " \
24
+ "{\"each\": filter, " \
25
+ "\"controller\": you|opponent|any, \"other\": true} (relative to the source's " \
26
+ "controller), \"self\", or \"attached\" (\"equipped/enchanted creature\").",
27
+ rules: %w[604.1 604.2 611.3a 612.8 613.1c 613.1d 613.1e 613.1f 613.4b 613.4c 613.7a 613.8a 205.1a 205.1b 301.5f
28
+ 303.4m],
29
+ params: { "affects" => "{\"each\": filter, \"controller\": ..., \"other\": bool}, \"self\" or \"attached\"",
30
+ "if" => "condition (\"as long as ...\", \"during your turn\")",
31
+ "pump" => "{\"power\": amount, \"toughness\": amount} (\"gets +1/+1 for each ...\")",
32
+ "keywords" => "list of keywords", "creature_types" => "list of creature types (replaces the others)",
33
+ "types" => "list of card types it also has (in addition to its other types)",
34
+ "subtypes" => "list of subtypes it also has (in addition to its other types), or \"chosen\": " \
35
+ "the type chosen as its source entered",
36
+ "abilities" => "list of triggered, activated or mana abilities the affected permanents have",
37
+ "loses_abilities" => "true: it loses all abilities (\"loses all abilities\")",
38
+ "colors" => "list of W, U, B, R, G: its colors are exactly these ([] for colorless)",
39
+ "base_power_toughness" => "{\"power\": n, \"toughness\": n}",
40
+ "set_types" => "list of card types: its card types are exactly these",
41
+ "name" => "its name (\"named Humble Merchant\")" },
42
+ example: { "static" => { "affects" => { "each" => "creature", "controller" => "you" },
43
+ "pump" => { "power" => 1, "toughness" => 1 } } }
44
+
45
+ GRANTABLE = %i[triggered mana activated hexproof_from protection_from tapping_power].freeze
46
+ # Data that would make the layer pass depend on its own result (CR 613.8).
47
+ LIVE = %w[power toughness greatest_power attacking blocking tapped].freeze
48
+ KEYS = %w[affects if pump keywords creature_types abilities types subtypes loses_abilities colors
49
+ base_power_toughness set_types name].freeze
50
+
51
+ attr_reader :filter, :controller, :other, :keywords, :creature_types, :condition, :granted, :card_types, :subtypes,
52
+ :colors, :base_power_toughness, :set_types, :name
53
+
54
+ def self.build(params, errors, path, **)
55
+ Mechanics.check_keys(params, KEYS, errors, path)
56
+ affects = Mechanics.fetch_param(params, :affects)
57
+ filter, controller, other = parse_affects(affects, errors, path)
58
+ raw_if = Mechanics.fetch_param(params, :if)
59
+ errors << "#{path}.if: can't depend on power, toughness or combat status (CR 613.8)" if live?(raw_if)
60
+ condition = raw_if && Condition.build(raw_if, errors, "#{path}.if")
61
+ pump = parse_pump(params, errors, path)
62
+ keywords = parse_keywords(params, errors, path)
63
+ types = parse_subtypes(params, :creature_types, errors, path)
64
+ granted = parse_granted(params, errors, path)
65
+ card_types = Effects::Become.names_param(params, :types, :type, errors, path, allowed: Effects::Become::ADDABLE_TYPES)
66
+ chosen = Mechanics.fetch_param(params, :subtypes) == "chosen" # "is the chosen type in addition" (CR 607.2d)
67
+ subtypes = chosen ? [] : parse_subtypes(params, :subtypes, errors, path) || []
68
+ sets = parse_sets(params, errors, path).merge(chosen_subtype: chosen)
69
+ if pump.empty? && keywords.empty? && types.nil? && granted.empty? && card_types.empty? && subtypes.empty? &&
70
+ sets.values.none?
71
+ errors << "#{path}: needs at least one of #{(KEYS - %w[affects if]).join(", ")}"
72
+ end
73
+ new(filter: filter, controller: controller, other: other, pump: pump, keywords: keywords, creature_types: types,
74
+ condition: condition, granted: granted, card_types: card_types, subtypes: subtypes, **sets)
75
+ end
76
+
77
+ # "loses all abilities and is a black 1/1" (layers 5, 6 and 7b): the same shapes as Effects::Become.
78
+ def self.parse_sets(params, errors, path)
79
+ loses = Mechanics.fetch_param(params, :loses_abilities)
80
+ errors << "#{path}.loses_abilities: must be true (or absent)" unless [nil, true].include?(loses)
81
+ { loses_abilities: loses == true,
82
+ colors: Effects::Become.parse_colors(Mechanics.fetch_param(params, :colors), errors, path),
83
+ set_types: parse_set_types(params, errors, path),
84
+ name: parse_name(params, errors, path),
85
+ base_power_toughness: Effects::Become.parse_power_toughness(Mechanics.fetch_param(params, :base_power_toughness),
86
+ errors, path) }
87
+ end
88
+
89
+ # "is a black Skeleton creature" (CR 205.1a): exactly these card types, or nil.
90
+ def self.parse_set_types(params, errors, path)
91
+ return nil unless params.key?("set_types")
92
+
93
+ Effects::Become.names_param(params, :set_types, :type, errors, path, allowed: Effects::Become::ADDABLE_TYPES)
94
+ end
95
+
96
+ # "named Legitimate Businessperson" (CR 612.8): a non-empty string.
97
+ def self.parse_name(params, errors, path)
98
+ name = Mechanics.fetch_param(params, :name)
99
+ return nil if name.nil?
100
+
101
+ errors << "#{path}.name: must be a non-empty string" unless name.is_a?(String) && !name.strip.empty?
102
+ name.is_a?(String) ? name.freeze : nil
103
+ end
104
+
105
+ def self.parse_affects(affects, errors, path)
106
+ return [affects.to_sym, "any", false] if %w[attached self].include?(affects)
107
+
108
+ unless affects.is_a?(Hash) && affects["each"]
109
+ errors << "#{path}.affects: must be {\"each\": filter, ...}, \"self\" or \"attached\""
110
+ affects = { "each" => "creature" }
111
+ end
112
+ Mechanics.check_keys(affects, %w[each controller other], errors, "#{path}.affects")
113
+ filter = Mechanics.build_entry(:filter, affects["each"], errors, "#{path}.affects.each")
114
+ live = filter ? filter.live_keys : []
115
+ # CR 613.8: these would make the layer pass depend on its own result.
116
+ errors << "#{path}.affects.each: #{live.join(", ")} can't be used in a static ability yet" unless live.empty?
117
+ [filter, affects.fetch("controller", "any"), affects["other"] == true]
118
+ end
119
+
120
+ def self.live?(data)
121
+ case data
122
+ when Hash then data.any? { |k, v| LIVE.include?(k.to_s) || live?(v) }
123
+ when Array then data.any? { live?(it) }
124
+ else false
125
+ end
126
+ end
127
+
128
+ # {"power": amount, "toughness": amount}; an amount is counted during the layer pass, so it can't read
129
+ # power or toughness itself (CR 613.8).
130
+ def self.parse_pump(params, errors, path)
131
+ raw = Mechanics.fetch_param(params, :pump) || {}
132
+ return {} if raw.empty?
133
+
134
+ Mechanics.check_keys(raw, %w[power toughness], errors, "#{path}.pump")
135
+ %w[power toughness].to_h do |key|
136
+ amount = Effects::Pump.signed(raw, key, errors, "#{path}.pump", targets_count: 0)
137
+ errors << "#{path}.pump.#{key}: can't read power or toughness (CR 613.8)" unless Amount.layer_safe?(amount)
138
+ [key, amount]
139
+ end
140
+ end
141
+
142
+ def self.parse_keywords(params, errors, path)
143
+ keywords = Array(Mechanics.fetch_param(params, :keywords)).map { it.to_s.downcase.tr(" -", "__") }
144
+ keywords.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
145
+ keywords.map(&:to_sym)
146
+ end
147
+
148
+ def self.parse_subtypes(params, key, errors, path)
149
+ raw = Mechanics.fetch_param(params, key)
150
+ return nil if raw.nil?
151
+
152
+ ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
153
+ errors << "#{path}.#{key}: must be a non-empty list of lowercase subtypes" unless ok
154
+ ok ? raw.map(&:to_sym).freeze : nil
155
+ end
156
+
157
+ # Granted abilities are part of this card's data, referenced as "<this ability's ref>:<j>" (like a
158
+ # station symbol's, see CardDefinition#nested_instances).
159
+ def self.parse_granted(params, errors, path)
160
+ Mechanics.list_param(params, :abilities, errors, path).each_with_index.filter_map do |entry, j|
161
+ ability = Mechanics.build_entry(:ability, entry, errors, "#{path}.abilities[#{j}]")
162
+ next ability if ability.nil? || GRANTABLE.include?(ability.kind)
163
+
164
+ errors << "#{path}.abilities[#{j}]: only #{GRANTABLE.join(", ")} abilities can be granted yet"
165
+ nil
166
+ end.freeze
167
+ end
168
+
169
+ def initialize(filter:, controller:, other:, pump:, keywords:, creature_types:, condition:, granted:, card_types: [],
170
+ subtypes: [], loses_abilities: false, colors: nil, base_power_toughness: nil, chosen_subtype: false,
171
+ set_types: nil, name: nil)
172
+ super()
173
+ @set_types = set_types
174
+ @name = name
175
+ @chosen_subtype = chosen_subtype
176
+ @card_types = card_types.freeze
177
+ @subtypes = subtypes.freeze
178
+ @loses_abilities = loses_abilities
179
+ @colors = colors
180
+ @base_power_toughness = base_power_toughness
181
+ @filter = filter
182
+ @controller = controller
183
+ @other = other
184
+ @pump = pump.freeze
185
+ @keywords = keywords.freeze
186
+ @creature_types = creature_types
187
+ @condition = condition
188
+ @granted = granted
189
+ freeze
190
+ end
191
+
192
+ def kind = :static
193
+ def attached? = @filter == :attached
194
+ def mana_ability? = false
195
+ def allowed_types = nil
196
+ def try_effects = []
197
+ def nested_abilities = @granted
198
+ def pump? = !@pump.empty?
199
+ def loses_abilities? = @loses_abilities
200
+ def adds_subtypes? = @chosen_subtype || !@subtypes.empty?
201
+ # Whether it changes layers 3-4: the name, card types or subtypes.
202
+ def type_changes? = !@creature_types.nil? || !@card_types.empty? || adds_subtypes? || !@set_types.nil? || !@name.nil?
203
+
204
+ # The subtypes it adds: its own, or the type its source chose as it entered (CR 607.2d, 614.12).
205
+ def added_subtypes(source) = @chosen_subtype ? [source.memory["chosen_type"]].compact.map(&:to_sym) : @subtypes
206
+ # Whether it changes layer 6: abilities removed or added.
207
+ def ability_changes? = @loses_abilities || !@keywords.empty? || !@granted.empty?
208
+ def computed? = @pump.values.any? { !it.is_a?(Amount::Fixed) }
209
+
210
+ # The fixed bonus, for display and for data readers (computed bonuses are Amounts).
211
+ def power = fixed(@pump["power"])
212
+ def toughness = fixed(@pump["toughness"])
213
+
214
+ # The bonus for this layer pass: [power, toughness] (CR 608.2h doesn't apply: statics aren't locked in).
215
+ def bonus(engine, source)
216
+ ctx = context(source)
217
+ [@pump.fetch("power", ZERO).value(engine, ctx), @pump.fetch("toughness", ZERO).value(engine, ctx)]
218
+ end
219
+
220
+ ZERO = Amount::Fixed.new(0)
221
+
222
+ # Whether the ability applies now: its "as long as" / "during your turn" condition (CR 604.2).
223
+ def active?(engine, source) = @condition.nil? || @condition.true?(engine, context(source))
224
+
225
+ # Matches on characteristics and counters (Filters::Facts), so the layer pass never depends on its
226
+ # own result.
227
+ def affects?(object, source, facts)
228
+ return source.attached_to == object.id if attached?
229
+ return object.id == source.id if @filter == :self
230
+ return false if @other && object.id == source.id
231
+ return false unless @controller == "any" || (@controller == "you") == (object.controller == source.controller)
232
+
233
+ @filter.matches_facts?(@filter.uses_chosen? ? facts.with(chosen: source.memory) : facts) # CR 607.2d
234
+ end
235
+
236
+ def to_s
237
+ bits = []
238
+ bits << "loses all abilities" if loses_abilities?
239
+ bits.concat(set_bits)
240
+ bits << "base #{base_power_toughness.values.join("/")}" if base_power_toughness
241
+ bits << "#{Effects::Pump.signed_text(@pump["power"] || ZERO)}/#{Effects::Pump.signed_text(@pump["toughness"] || ZERO)}" if pump?
242
+ bits << keywords.join(", ") unless keywords.empty?
243
+ bits << "creature type #{creature_types.join(" ")}" if creature_types
244
+ bits << "types #{card_types.join(" ")}" unless card_types.empty?
245
+ bits << "subtypes #{@chosen_subtype ? "the chosen type" : subtypes.join(" ")}" if adds_subtypes?
246
+ bits << "\"#{granted.join("; ")}\"" unless granted.empty?
247
+ "#{who} get#{"s" if %i[attached self].include?(@filter)} #{bits.join(" and ")}#{" as long as #{condition}" if condition}"
248
+ end
249
+
250
+ private
251
+
252
+ def fixed(amount) = amount.is_a?(Amount::Fixed) ? amount.n : 0
253
+
254
+ # What it sets: name (layer 3), card types (layer 4) and colors (layer 5).
255
+ def set_bits
256
+ [("named #{name}" if name), ("card types exactly #{set_types.join(" ")}" if set_types),
257
+ ("colors #{colors.empty? ? "colorless" : colors.join}" if colors)].compact
258
+ end
259
+
260
+ def context(source) = EffectContext.new(source.controller, source.id, source.id, [], [])
261
+
262
+ def who
263
+ return "the attached permanent" if attached?
264
+ return "it" if @filter == :self
265
+
266
+ "#{"other " if other}#{filter.respond_to?(:primitive_name) ? filter.primitive_name : filter}s " \
267
+ "#{{ "you" => "you control ", "opponent" => "opponents control " }[controller]}".strip
268
+ end
269
+ end
270
+ end
271
+
272
+ module Effects
273
+ # "gains flying until end of turn" (CR 611.2, 613.1f): grant keywords to a locked set.
274
+ class Grant < Effect
275
+ primitive :effect, :grant,
276
+ description: "Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn.",
277
+ rules: %w[611.2a 611.2c 613.1f 514.2],
278
+ params: { "keywords" => "list of keywords", "to" => "selector" },
279
+ example: { "grant" => { "keywords" => ["flying"], "to" => { "target" => 0 } } }
280
+
281
+ attr_reader :keywords
282
+
283
+ def self.keys = %w[keywords to if]
284
+
285
+ def setup(params, errors, path)
286
+ @keywords = Array(Mechanics.fetch_param(params, :keywords)).map { it.to_s.downcase.tr(" -", "__") }
287
+ errors << "#{path}.keywords: needs at least one keyword" if @keywords.empty?
288
+ @keywords.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
289
+ end
290
+
291
+ # A spell on the stack can gain keywords too ("those spells gain wither", Engine::SpellGrants).
292
+ def apply(engine, ctx)
293
+ spells = refs(engine, ctx).select { (obj = engine.state.objects[it]) && obj.zone == :stack && !obj.ability? }
294
+ engine.add_continuous_effect!("grant", ctx, objects(engine, ctx) + spells, "keywords" => keywords)
295
+ end
296
+
297
+ def to_s = "gains #{keywords.join(", ")} until end of turn → #{to}"
298
+ end
299
+
300
+ # Setting a creature's creature types until end of turn; its other creature types are lost (CR 205.1a, 613.1d).
301
+ class SetCreatureTypes < Effect
302
+ primitive :effect, :set_creature_types,
303
+ description: "Until end of turn, each referenced creature's creature types are exactly `types` (its " \
304
+ "other subtypes, such as artifact types, stay). The affected set is locked in.",
305
+ rules: %w[205.1a 613.1d 611.2c 514.2],
306
+ params: { "types" => "list of creature types", "to" => "selector" },
307
+ example: { "set_creature_types" => { "types" => ["doctor"], "to" => { "target" => 0 } } }
308
+
309
+ attr_reader :types
310
+
311
+ def self.keys = %w[types to if]
312
+
313
+ def setup(params, errors, path)
314
+ raw = Mechanics.fetch_param(params, :types)
315
+ ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
316
+ errors << "#{path}.types: must be a non-empty list of lowercase creature types" unless ok
317
+ @types = ok ? raw.map(&:to_s).freeze : []
318
+ end
319
+
320
+ def apply(engine, ctx)
321
+ affected = objects(engine, ctx).select { engine.chars(it).creature? }
322
+ engine.add_continuous_effect!("creature_types", ctx, affected, "types" => types)
323
+ end
324
+
325
+ def to_s = "becomes #{types.join(" ")} until end of turn → #{to}"
326
+ end
327
+
328
+ # Setting base power and toughness to fixed numbers (CR 613.4b): a setting effect, until end of turn or for
329
+ # as long as the permanent stays on the battlefield (`duration: "indefinite"`, CR 611.2a-b).
330
+ class SetBasePowerToughness < Effect
331
+ DURATIONS = %w[end_of_turn indefinite].freeze
332
+
333
+ primitive :effect, :set_base_power_toughness,
334
+ description: "Each referenced creature's base power and toughness become `power`/`toughness` " \
335
+ "(layer 7b; counters and pumps still apply), until end of turn or, with `duration: " \
336
+ "\"indefinite\"`, for as long as it stays on the battlefield.",
337
+ rules: %w[613.4b 611.2a 611.2b 611.2c],
338
+ params: { "power" => "integer", "toughness" => "integer", "duration" => DURATIONS.join(" | "),
339
+ "to" => "selector" },
340
+ example: { "set_base_power_toughness" => { "power" => 7, "toughness" => 9, "duration" => "indefinite",
341
+ "to" => "self" } }
342
+
343
+ attr_reader :power, :toughness, :duration
344
+
345
+ def self.keys = %w[power toughness duration to if]
346
+
347
+ def setup(params, errors, path)
348
+ @power = Mechanics.integer_param(params, :power, errors, path, min: 0)
349
+ @toughness = Mechanics.integer_param(params, :toughness, errors, path, min: 0)
350
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
351
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
352
+ end
353
+
354
+ def apply(engine, ctx)
355
+ affected = objects(engine, ctx).select { engine.chars(it).creature? }
356
+ engine.add_continuous_effect!("base_power_toughness", ctx, affected,
357
+ { "power" => power, "toughness" => toughness,
358
+ "duration" => @duration == "indefinite" ? "permanent" : "end_of_turn" })
359
+ end
360
+
361
+ def to_s = "base power and toughness become #{power}/#{toughness}#{" until end of turn" if duration == "end_of_turn"} → #{to}"
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "This creature saddles Mounts and crews Vehicles as though its power were 2 greater", "... using its
7
+ # toughness rather than its power", "stations permanents using its toughness rather than its power" (CR
8
+ # 702.122a, 702.171a, 702.184c): a static ability of the creature changing the number it counts when it's
9
+ # tapped to pay a crew or saddle cost or to station a permanent. The costs ask TappingPower.of.
10
+ class TappingPower < Primitive
11
+ ACTIONS = %w[crew saddle station].freeze
12
+ USES = %w[power toughness toughness_if_greater].freeze
13
+
14
+ primitive :ability, :tapping_power,
15
+ description: "When this creature is tapped to pay the cost of the abilities in `for` (crew, saddle, " \
16
+ "station), it counts its `use` (power, default; toughness; toughness_if_greater: its " \
17
+ "toughness when that's greater than its power) plus `plus`. Several of them add up their " \
18
+ "`plus`. A `static` can grant it.",
19
+ rules: %w[702.122a 702.171a 702.184a 702.184c],
20
+ params: { "for" => "list of #{ACTIONS.join(", ")}", "use" => USES.join(" | "), "plus" => "integer (default 0)" },
21
+ example: { "tapping_power" => { "for" => %w[crew saddle], "plus" => 2 } }
22
+
23
+ attr_reader :actions, :use, :plus
24
+
25
+ def self.build(params, errors, path, **)
26
+ Mechanics.check_keys(params, %w[for use plus], errors, path)
27
+ actions = Mechanics.fetch_param(params, :for)
28
+ unless actions.is_a?(Array) && !actions.empty? && (actions - ACTIONS).empty?
29
+ errors << "#{path}.for: must be a non-empty list of #{ACTIONS.join(", ")}"
30
+ actions = []
31
+ end
32
+ use = Mechanics.fetch_param(params, :use) || "power"
33
+ errors << "#{path}.use: must be one of #{USES.join(", ")}" unless USES.include?(use)
34
+ plus = params.key?("plus") ? Mechanics.integer_param(params, :plus, errors, path) : 0
35
+ errors << "#{path}: needs `use` or `plus`" if use == "power" && plus.to_i.zero?
36
+ new(actions.map(&:to_sym), use, plus.to_i)
37
+ end
38
+
39
+ # The number `creature` counts when tapped for `action` (:crew, :saddle or :station): its power unless an
40
+ # ability of it says otherwise.
41
+ def self.of(engine, creature, action)
42
+ mods = engine.chars(creature).abilities.map(&:ability).select { it.kind == :tapping_power && it.actions.include?(action) }
43
+ power, toughness = engine.power_toughness(creature)
44
+ base = if mods.any? { it.use == "toughness" } then toughness
45
+ elsif mods.any? { it.use == "toughness_if_greater" } then [power, toughness].max
46
+ else power
47
+ end
48
+ base + mods.sum(&:plus)
49
+ end
50
+
51
+ def initialize(actions, use, plus)
52
+ super()
53
+ @actions = actions.freeze
54
+ @use = use
55
+ @plus = plus
56
+ freeze
57
+ end
58
+
59
+ def kind = :tapping_power
60
+ def mana_ability? = false
61
+ def allowed_types = nil
62
+ def try_effects = []
63
+
64
+ def to_s
65
+ counted = { "power" => "power", "toughness" => "toughness", "toughness_if_greater" => "toughness if greater" }[use]
66
+ "#{actions.join(", ")} using its #{counted}#{" + #{plus}" unless plus.zero?}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # Static abilities that change the rules of a turn for their controller (CR 305.2, 502.3).
7
+ # They take no parameters and do nothing on their own: the engine looks for them by kind
8
+ # (Engine::TurnStructure).
9
+ class TurnRule < Primitive
10
+ def self.build(params, errors, path, **)
11
+ Mechanics.check_keys(params, [], errors, path)
12
+ self::INSTANCE
13
+ end
14
+
15
+ def mana_ability? = false
16
+ def allowed_types = nil
17
+ def try_effects = []
18
+ end
19
+
20
+ # "You may play an additional land on each of your turns" (CR 305.2): each instance adds one to the
21
+ # number of lands its controller can play each turn (Engine#land_plays_allowed).
22
+ class AdditionalLandPlays < TurnRule
23
+ primitive :ability, :additional_land_plays,
24
+ description: "Its controller may play an additional land on each of their turns. Each instance " \
25
+ "adds one land play (CR 305.2); lands put onto the battlefield by effects don't count.",
26
+ rules: %w[305.2 305.2a 305.2b 604.1], example: "additional_land_plays"
27
+
28
+ INSTANCE = new.freeze
29
+
30
+ def kind = :additional_land_plays
31
+ def to_s = "You may play an additional land on each of your turns"
32
+ end
33
+
34
+ # "You may choose not to untap [this permanent] during your untap step" (CR 502.3): its controller is
35
+ # asked in their untap step, while it's tapped (decision `untap_choice`).
36
+ class MayNotUntap < TurnRule
37
+ primitive :ability, :may_not_untap,
38
+ description: "Its controller may choose not to untap it during their untap step (decision " \
39
+ "`untap_choice`, asked only while it's tapped).",
40
+ rules: %w[502.3 604.1], example: "may_not_untap"
41
+
42
+ INSTANCE = new.freeze
43
+
44
+ def kind = :may_not_untap
45
+ def to_s = "You may choose not to untap it during your untap step"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # A description of a card by its characteristics, for effects that look at cards in any
6
+ # zone ("a basic land card", "a Swamp card", "an Aura or Equipment card"; CR 701.23a,
7
+ # 702.29e). Data forms:
8
+ #
9
+ # "card" any card
10
+ # {"types": ["land"], "supertypes": ["basic"]} every listed quality must match
11
+ # {"subtypes": ["swamp"]} / {"name": "Card Name"}
12
+ # {"not_types": ["creature", "land"]} "noncreature, nonland card"
13
+ # {"permanent": true} "permanent card": any permanent card type (CR 110.4a)
14
+ # {"mana_value": {"at_most": 3}} also at_least / equal; each bound an amount (CR 202.3)
15
+ # {"colors": ["B"]} a black card: it has any of the listed colors (CR 105.2)
16
+ # {"color_count": {"equal": 1}} monocolored (1), multicolored (at least 2), colorless (0)
17
+ # {"any_of": [spec, spec, ...]} at least one spec must match
18
+ class CardSpec
19
+ KEYS = %w[types not_types subtypes supertypes name any_of permanent mana_value colors color_count].freeze
20
+ SUBTYPE = /\A[a-z][a-z0-9_]*\z/
21
+ # CR 110.4a: the permanent card types (battles have no primitive yet, so none matches that way).
22
+ PERMANENT_TYPES = %i[artifact battle creature enchantment land planeswalker].freeze
23
+ BOUNDS = %w[at_least at_most equal].freeze
24
+
25
+ attr_reader :types, :not_types, :subtypes, :supertypes, :name, :any_of, :permanent, :mana_value, :colors, :color_count
26
+
27
+ def self.build(data, errors, path)
28
+ return ANY if data.nil? || data == "card"
29
+ return (errors << "#{path}: must be \"card\" or an object (#{KEYS.join(", ")})") && ANY unless data.is_a?(Hash)
30
+
31
+ data = data.transform_keys(&:to_s)
32
+ Mechanics.check_keys(data, KEYS, errors, path)
33
+ errors << "#{path}: needs at least one of #{KEYS.join(", ")}" if data.empty?
34
+ new(types: names(data, "types", :type, errors, path), supertypes: names(data, "supertypes", :supertype, errors, path),
35
+ subtypes: subtype_list(data, errors, path), name: name_param(data, errors, path),
36
+ any_of: alternatives(data, errors, path), not_types: names(data, "not_types", :type, errors, path),
37
+ permanent: permanent_param(data, errors, path), mana_value: bounds_param(data, "mana_value", errors, path),
38
+ colors: colors_param(data, errors, path), color_count: bounds_param(data, "color_count", errors, path))
39
+ end
40
+
41
+ def self.colors_param(data, errors, path)
42
+ list(data, "colors", errors, path).filter_map do |raw|
43
+ color = raw.to_s.upcase.to_sym
44
+ next color if Mana::COLORS.include?(color)
45
+
46
+ errors << "#{path}.colors: unknown color #{raw.inspect} (W, U, B, R, G)"
47
+ nil
48
+ end
49
+ end
50
+
51
+ def self.names(data, key, category, errors, path)
52
+ list(data, key, errors, path).filter_map do |raw|
53
+ name = raw.to_s.downcase.tr(" -", "__")
54
+ next name.to_sym if Enjine.registry.fetch(category, name)
55
+
56
+ errors << "#{path}.#{key}: unknown #{category} #{raw.inspect}"
57
+ nil
58
+ end
59
+ end
60
+
61
+ def self.subtype_list(data, errors, path)
62
+ list(data, "subtypes", errors, path).filter_map do |raw|
63
+ sub = raw.to_s.downcase.tr(" -", "__")
64
+ next sub.to_sym if SUBTYPE.match?(sub)
65
+
66
+ errors << "#{path}.subtypes: invalid subtype #{raw.inspect}"
67
+ nil
68
+ end
69
+ end
70
+
71
+ def self.list(data, key, errors, path)
72
+ value = data.fetch(key, [])
73
+ return value if value.is_a?(Array)
74
+
75
+ errors << "#{path}.#{key}: must be a list"
76
+ []
77
+ end
78
+
79
+ def self.name_param(data, errors, path)
80
+ value = data["name"]
81
+ errors << "#{path}.name: must be a card name" unless value.nil? || (value.is_a?(String) && !value.empty?)
82
+ value.is_a?(String) ? value : nil
83
+ end
84
+
85
+ def self.alternatives(data, errors, path)
86
+ return [] unless data.key?("any_of")
87
+
88
+ specs = list(data, "any_of", errors, path)
89
+ errors << "#{path}.any_of: must list at least two specs" if specs.size < 2
90
+ specs.each_with_index.map { |spec, i| build(spec, errors, "#{path}.any_of[#{i}]") }
91
+ end
92
+
93
+ def self.permanent_param(data, errors, path)
94
+ value = data["permanent"]
95
+ errors << "#{path}.permanent: must be true" unless value.nil? || value == true
96
+ value == true
97
+ end
98
+
99
+ # {"at_least"|"at_most"|"equal": amount}: each bound is an Amount, computed as the card is looked at.
100
+ def self.bounds_param(data, key, errors, path)
101
+ raw = data[key]
102
+ return {} if raw.nil?
103
+
104
+ unless raw.is_a?(Hash) && !raw.empty? && (raw.keys.map(&:to_s) - BOUNDS).empty?
105
+ errors << "#{path}.#{key}: must be {\"at_least\"|\"at_most\"|\"equal\": amount}"
106
+ return {}
107
+ end
108
+ raw.to_h { |bound, n| [bound.to_s, Amount.build(n, errors, "#{path}.#{key}.#{bound}", targets_count: 0)] }
109
+ end
110
+
111
+ def initialize(types: [], subtypes: [], supertypes: [], name: nil, any_of: [], not_types: [], permanent: false,
112
+ mana_value: {}, colors: [], color_count: {})
113
+ @colors = colors.freeze
114
+ @color_count = color_count.freeze
115
+ @types = types.freeze
116
+ @not_types = not_types.freeze
117
+ @subtypes = subtypes.freeze
118
+ @supertypes = supertypes.freeze
119
+ @name = name
120
+ @any_of = any_of.freeze
121
+ @permanent = permanent
122
+ @mana_value = mana_value.freeze
123
+ freeze
124
+ end
125
+
126
+ # True when the spec states no quality: "a card" (CR 701.23d).
127
+ def any?
128
+ types.empty? && not_types.empty? && subtypes.empty? && supertypes.empty? && name.nil? && any_of.empty? &&
129
+ !permanent && mana_value.empty? && colors.empty? && color_count.empty?
130
+ end
131
+
132
+ # Whether a card object matches, by its current characteristics (CR 109.3). `ctx` (an EffectContext)
133
+ # computes mana value bounds given as amounts; without one, only fixed bounds can be met.
134
+ def match?(engine, object, ctx = nil) = object.kind == "card" && chars_match?(engine, engine.chars(object), ctx)
135
+
136
+ # Whether characteristics match (an object's, or remembered ones such as a mana source's, CR 106.6).
137
+ def chars_match?(engine, chars, ctx = nil)
138
+ types.all? { chars.type?(it) } && not_types.none? { chars.type?(it) } &&
139
+ subtypes.all? { chars.subtypes.include?(it) } && supertypes.all? { chars.supertypes.include?(it) } &&
140
+ (name.nil? || chars.name == name) && (!permanent || chars.types.intersect?(PERMANENT_TYPES)) &&
141
+ (colors.empty? || colors.intersect?(chars.colors)) && bounded?(mana_value, chars.mana_cost&.mana_value.to_i, engine, ctx) &&
142
+ bounded?(color_count, chars.colors.size, engine, ctx) && (any_of.empty? || any_of.any? { it.chars_match?(engine, chars, ctx) })
143
+ end
144
+
145
+ def to_s
146
+ return "#{any_of.map { it.to_s.delete_suffix(" card") }.join(" or ")} card" unless any_of.empty?
147
+ return "card named #{name}" if name
148
+
149
+ words = not_types.map { "non#{it}" } + colors + supertypes + subtypes.map { it.to_s.capitalize } + types
150
+ words << "permanent" if permanent
151
+ text = (words.map { it.to_s.tr("_", " ") } + ["card"]).join(" ")
152
+ bounds = { "mana value" => mana_value, "number of colors" => color_count }.reject { |_, b| b.empty? }
153
+ return text if bounds.empty?
154
+
155
+ "#{text} with #{bounds.map { |what, b| "#{what} #{b.map { |bound, n| "#{bound.tr("_", " ")} #{n}" }.join(" and ")}" }.join(", ")}"
156
+ end
157
+
158
+ private
159
+
160
+ # CR 202.3: a card's mana value (0 without a mana cost, CR 202.3a), or its number of colors (CR 105.2), within
161
+ # the bounds.
162
+ def bounded?(bounds, value, engine, ctx)
163
+ bounds.all? do |bound, amount|
164
+ n = ctx ? amount.value(engine, ctx) : fixed(amount)
165
+ next false if n.nil?
166
+
167
+ { "at_least" => value >= n, "at_most" => value <= n, "equal" => value == n }.fetch(bound)
168
+ end
169
+ end
170
+
171
+ def fixed(amount) = amount.is_a?(Amount::Fixed) ? amount.n : nil
172
+ end
173
+
174
+ # "card": any card.
175
+ CardSpec::ANY = CardSpec.new
176
+ end
177
+ end