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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # A spell on the stack.
7
+ class Spell < Filter
8
+ primitive :filter, :spell, description: "A spell on the stack.", rules: %w[112.1 115.1], example: "spell"
9
+ def self.zone = :stack
10
+ def self.match_object?(_engine, object, _controller) = object.kind == "card"
11
+ end
12
+
13
+ # An activated or triggered ability on the stack (CR 113.1b, 115.1): "counter target activated or
14
+ # triggered ability", "copy target triggered ability you control from a creature source". Mana
15
+ # abilities never use the stack (CR 605.3b), so they can't be targeted.
16
+ class StackAbility < Filter
17
+ KINDS = %w[activated triggered].freeze
18
+ CONTROLLERS = %w[you opponent].freeze
19
+
20
+ primitive :filter, :stack_ability,
21
+ description: "An activated or triggered ability on the stack. `kind`: \"activated\" or \"triggered\" " \
22
+ "for only one of them; `controller`: you | opponent; `source`: a filter its source " \
23
+ "matches (e.g. \"creature\", \"artifact\").",
24
+ rules: %w[113.1b 115.1 605.3b 113.7],
25
+ params: { "kind" => KINDS.join(" | "), "controller" => CONTROLLERS.join(" | "), "source" => "filter" },
26
+ example: { "stack_ability" => { "controller" => "you", "source" => "creature" } }
27
+
28
+ def self.zone = :stack
29
+ def self.match_object?(_engine, object, _controller) = object.ability?
30
+
31
+ def self.build(params, errors, path, **)
32
+ return self if params.empty?
33
+
34
+ Mechanics.check_keys(params, %w[kind controller source], errors, path)
35
+ kind, controller, source = params.values_at("kind", "controller", "source")
36
+ errors << "#{path}.kind: must be one of #{KINDS.join(", ")}" unless kind.nil? || KINDS.include?(kind)
37
+ unless controller.nil? || CONTROLLERS.include?(controller)
38
+ errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}"
39
+ end
40
+ source &&= Mechanics.build_entry(:filter, source, errors, "#{path}.source")
41
+ Narrowed.new(kind, controller, source)
42
+ end
43
+
44
+ # Only activated (loyalty included) or only triggered abilities, of a controller, from a kind of source.
45
+ class Narrowed
46
+ def initialize(kind, controller, source)
47
+ @kind = kind
48
+ @controller = controller
49
+ @source = source
50
+ freeze
51
+ end
52
+
53
+ def primitive_name = "stack_ability"
54
+
55
+ def to_s
56
+ whose = { "you" => " you control", "opponent" => " an opponent controls" }[@controller]
57
+ "#{@kind || "activated or triggered"} ability#{whose}#{" from a source: #{Triggers.filter_words(@source)}" if @source}"
58
+ end
59
+
60
+ def candidates(engine, controller) = StackAbility.candidates(engine, controller).select { narrow?(engine, it, controller) }
61
+
62
+ def candidates_for(engine, controller, _source, **) = candidates(engine, controller)
63
+
64
+ def match?(engine, ref, controller) = StackAbility.match?(engine, ref, controller) && narrow?(engine, ref, controller)
65
+
66
+ def match_for?(engine, ref, controller, _source, **) = match?(engine, ref, controller)
67
+
68
+ def matches_types?(_types) = false
69
+ def matches_facts?(_facts) = false
70
+ def live_keys = []
71
+ def uses_chosen? = false
72
+
73
+ private
74
+
75
+ def narrow?(engine, ref, controller)
76
+ obj = engine.object(ref)
77
+ return false if @kind && (engine.stack_ability(obj).kind == :triggered) != (@kind == "triggered")
78
+ return false if @controller && (@controller == "you") != (obj.controller == controller)
79
+
80
+ @source.nil? || source_ok?(engine, obj)
81
+ end
82
+
83
+ # CR 113.7: the source of an ability is the object that created it; one that has left the
84
+ # battlefield is matched as it last existed (its card's characteristics).
85
+ def source_ok?(engine, obj)
86
+ source = engine.state.objects[obj.source]
87
+ return @source.match_for?(engine, source.id, obj.controller, nil) if source&.zone == :battlefield
88
+
89
+ card = source&.card || obj.card
90
+ defn = engine.catalog.fetch(card)
91
+ @source.matches_facts?(Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # Effects that happen right after a replacement or prevention effect applies: "prevent that damage and
6
+ # put that many +1/+1 counters on him" (CR 615.5), "instead exile it and you gain 2 life" (CR 614.1a), and
7
+ # "exile it instead. When you do, ..." (a reflexive_trigger among them, CR 603.12). A `then` list of
8
+ # effects that need no choice. They see {"triggering": "amount"} (the damage prevented), "object" (the
9
+ # damage source, or the card exiled instead) and "player" (that object's controller).
10
+ #
11
+ # The FollowUp is one of its card's nested effects, so CardDefinition#ability finds its effects by `ref`;
12
+ # a replacement names it in the event it modifies ("follow_ups") and the engine runs it once the event
13
+ # is done (Engine::FollowUps).
14
+ class FollowUp
15
+ Effects = Data.define(:effects) # what CardDefinition#ability(ref) returns
16
+
17
+ attr_reader :ref, :delayed_ability
18
+
19
+ def self.build(params, errors, path)
20
+ raw = Mechanics.fetch_param(params, :then)
21
+ return nil if raw.nil?
22
+
23
+ effects = Abilities::StackAbility.parse_effects({ "then" => raw }, errors, path, 0, key: :then)
24
+ errors << "#{path}.then: needs at least one effect" if raw.is_a?(Array) && raw.empty?
25
+ new("then:#{path}", effects)
26
+ end
27
+
28
+ def initialize(ref, effects)
29
+ @ref = ref
30
+ @delayed_ability = Effects.new(effects: effects)
31
+ freeze
32
+ end
33
+
34
+ def effects = delayed_ability.effects
35
+ def nested_effects = effects
36
+ def try_effects = []
37
+ def to_s = effects.join(", ")
38
+
39
+ # The entry a replacement adds to the event's "follow_ups": what runs it later, as plain data.
40
+ def entry(card, controller, source, facts)
41
+ { "card" => card, "ref" => ref, "controller" => controller,
42
+ "source" => source, "facts" => facts.compact }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../effects/delayed_triggers"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Abilities
8
+ # Keyword abilities that are alternative costs (CR 118.9, 601.2b, 601.2f-h): "you may cast this card
9
+ # [from a zone] by paying [cost] rather than paying its mana cost". Each is offered as its own cast
10
+ # option, `{type: "cast", object: id, via: "alt:<ability ref>"}`, while the ability functions (the card
11
+ # is in the right zone, and any other condition holds). `cost` is a mana cost string, or a list of
12
+ # costs like kicker's (mana and/or chosen costs such as `tap_permanents`). How a spell was cast is
13
+ # remembered (the `cast_with` / `cast_from` conditions, CR 400.7d). See Engine::AltCasting.
14
+ class AlternativeCost < Primitive
15
+ attr_reader :costs, :mana_cost, :ref_path
16
+
17
+ # `{"cost": "{2}{R}"}` or `{"cost": [{"mana": "{2}{R}"}, {"tap_permanents": {...}}]}`.
18
+ def self.build(params, errors, path, **)
19
+ Mechanics.check_keys(params, %w[cost], errors, path)
20
+ raw = Mechanics.fetch_param(params, :cost)
21
+ costs = read_costs(raw, errors, path)
22
+ new(costs, path, errors)
23
+ end
24
+
25
+ def self.read_costs(raw, errors, path)
26
+ return [] if raw.nil? && optional_cost?
27
+
28
+ if raw.nil?
29
+ errors << "#{path}.cost: required"
30
+ return []
31
+ end
32
+ return [Costs::ManaPayment.build(raw, errors, "#{path}.cost")] if raw.is_a?(String)
33
+
34
+ costs = StackAbility.parse_costs({ "cost" => raw }, errors, path)
35
+ errors << "#{path}.cost: must be a mana cost string or a non-empty list of costs" if raw.is_a?(Array) && raw.empty?
36
+ costs.each_with_index do |cost, i|
37
+ next if cost.mana? || cost.respond_to?(:choices_needed) || cost.respond_to?(:spell_cost?)
38
+
39
+ errors << "#{path}.cost[#{i}]: an alternative cost must be mana, life or a chosen cost (tap, sacrifice, ...)"
40
+ end
41
+ costs
42
+ end
43
+
44
+ def self.optional_cost? = false
45
+
46
+ def initialize(costs, path, _errors)
47
+ super()
48
+ @costs = costs.freeze
49
+ @mana_cost = costs.empty? ? nil : Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
50
+ @ref_path = path
51
+ freeze
52
+ end
53
+
54
+ def kind = :alternative_cost
55
+ def keyword = self.class.primitive_name
56
+ def mana_ability? = false
57
+ def allowed_types = nil
58
+ def try_effects = []
59
+ def choosing_costs = costs.reject(&:mana?)
60
+
61
+ # The zone the ability lets the card be cast from (CR 113.6).
62
+ def zone = :hand
63
+
64
+ # Whether the card `obj` (owned by `index`) may be cast this way now, before timing and cost checks.
65
+ def functions?(_engine, obj, _index) = obj.zone == zone
66
+
67
+ # Paid instead of the mana cost? (Mayhem without a cost only allows playing it.)
68
+ def alternative? = !mana_cost.nil?
69
+
70
+ # "If the [keyword] cost was paid, exile this card instead of putting it anywhere else any time it would
71
+ # leave the stack."
72
+ def exiles? = false
73
+
74
+ # What creature, if any, is returned to its owner's hand as part of the cost: nil, "tapped" (web-slinging)
75
+ # or "unblocked" (sneak).
76
+ def returns = nil
77
+
78
+ # Cast "any time you could cast an instant" (sneak).
79
+ def instant_speed? = false
80
+
81
+ # "play" also lets a land be played (mayhem without a cost).
82
+ def play = "cast"
83
+
84
+ def cost_text = costs.empty? ? "" : " #{costs.join(", ")}"
85
+ def to_s = "#{keyword.split("_").map(&:capitalize).join("-")}#{cost_text}"
86
+
87
+ # The cast method (Engine::Permissions) this ability gives.
88
+ def method_for(ref)
89
+ method = { "via" => "alt:#{ref}", "play" => play, "keyword" => keyword }
90
+ return method unless alternative?
91
+
92
+ alt = { "cost" => mana_cost.to_s, "ref" => ref }
93
+ alt["exile"] = true if exiles?
94
+ alt["returns"] = returns if returns
95
+ alt["harmonize"] = true if is_a?(Harmonize)
96
+ method["instant"] = true if instant_speed?
97
+ method.merge("alt" => alt)
98
+ end
99
+ end
100
+
101
+ # CR 702.34a: flashback, from the graveyard for an instant or sorcery; exiled whenever it would leave the stack.
102
+ class Flashback < AlternativeCost
103
+ primitive :ability, :flashback,
104
+ description: "Flashback: you may cast this instant or sorcery card from your graveyard by paying " \
105
+ "`cost` rather than its mana cost; then it's exiled whenever it would leave the stack. " \
106
+ "`cost` is a mana cost string or a list of costs (`[{\"mana\": \"{1}{U}\"}, " \
107
+ "{\"tap_permanents\": {\"count\": 3}}]`). Cast option `via: \"alt:<ref>\"`.",
108
+ rules: %w[702.34a 118.9 601.2b 601.2f 601.2h],
109
+ params: { "cost" => "mana cost string, or a list of costs" },
110
+ example: { "flashback" => { "cost" => "{2}{R}" } }
111
+
112
+ def zone = :graveyard
113
+ def exiles? = true
114
+ def allowed_types = %i[instant sorcery]
115
+ end
116
+
117
+ # CR 702.180a-b: harmonize, from the graveyard, tapping up to one untapped creature you control to reduce
118
+ # the total cost by its power; exiled whenever it would leave the stack.
119
+ class Harmonize < AlternativeCost
120
+ primitive :ability, :harmonize,
121
+ description: "Harmonize: you may cast this card from your graveyard by paying `cost` and tapping up " \
122
+ "to one untapped creature you control rather than its mana cost; the total cost is " \
123
+ "reduced by generic mana equal to that creature's power. Then it's exiled whenever it " \
124
+ "would leave the stack. The creature is a `choose_cost` decision (`{type: \"cost\"}` " \
125
+ "without an object taps none). Cast option `via: \"alt:<ref>\"`.",
126
+ rules: %w[702.180a 702.180b 118.9 601.2b 601.2f 601.2h],
127
+ params: { "cost" => "mana cost string, or a list of costs" },
128
+ example: { "harmonize" => { "cost" => "{4}{G}" } }
129
+
130
+ def zone = :graveyard
131
+ def exiles? = true
132
+ end
133
+
134
+ # CR 702.187a-c: mayhem, from the graveyard if you discarded the card this turn; without a cost, the card
135
+ # may be played (a land too) for its usual costs.
136
+ class Mayhem < AlternativeCost
137
+ primitive :ability, :mayhem,
138
+ description: "Mayhem: as long as you discarded this card this turn, you may cast it from your " \
139
+ "graveyard by paying `cost` rather than its mana cost. Without `cost` (`\"mayhem\"`), " \
140
+ "you may play it from your graveyard (a land too) if you discarded it this turn. Cast " \
141
+ "option `via: \"alt:<ref>\"`.",
142
+ rules: %w[702.187a 702.187b 702.187c 118.9 601.2b 601.2f 601.2h 305.1],
143
+ params: { "cost" => "mana cost string, or a list of costs (optional)" },
144
+ example: { "mayhem" => { "cost" => "{1}{R}" } }
145
+
146
+ def self.optional_cost? = true
147
+
148
+ def zone = :graveyard
149
+ def play = alternative? ? "cast" : "play"
150
+ def functions?(engine, obj, _index) = obj.zone == zone && engine.discarded_this_turn?(obj)
151
+ end
152
+
153
+ # CR 702.185a-c: warp, from the hand. The permanent is exiled at the beginning of the next end step; its
154
+ # owner may cast it from exile after this turn for as long as it remains exiled.
155
+ class Warp < AlternativeCost
156
+ primitive :ability, :warp,
157
+ description: "Warp: you may cast this card from your hand by paying `cost` rather than its mana " \
158
+ "cost. If you do, the permanent it becomes is exiled at the beginning of the next end " \
159
+ "step (a delayed triggered ability), and its owner may cast it from exile after this " \
160
+ "turn has ended for as long as it remains exiled (cast option `via: \"warped\"`). " \
161
+ "Conditions `{\"cast_with\": \"warp\"}` and `{\"cast_with_this_turn\": \"warp\"}` read it.",
162
+ rules: %w[702.185a 702.185b 702.185c 603.7 118.9 601.2b 601.2f 601.2h],
163
+ params: { "cost" => "mana cost string, or a list of costs" },
164
+ example: { "warp" => { "cost" => "{1}{R}" } }
165
+
166
+ attr_reader :delayed
167
+
168
+ def initialize(costs, path, errors)
169
+ @delayed = Effects::WarpDelayed.build({}, errors, path)
170
+ super
171
+ end
172
+
173
+ def try_effects = [delayed]
174
+ end
175
+
176
+ # CR 702.74a: evoke, from the hand; "When this permanent enters, if its evoke cost was paid, its controller
177
+ # sacrifices it" is a linked triggered ability.
178
+ class Evoke < AlternativeCost
179
+ primitive :ability, :evoke,
180
+ description: "Evoke: you may cast this card by paying `cost` rather than its mana cost. When the " \
181
+ "permanent enters, if its evoke cost was paid, its controller sacrifices it (a triggered " \
182
+ "ability that comes with the keyword). Cast option `via: \"alt:<ref>\"`.",
183
+ rules: %w[702.74a 118.9 601.2b 601.2f 601.2h 603.4],
184
+ params: { "cost" => "mana cost string, or a list of costs" },
185
+ example: { "evoke" => { "cost" => "{R/W}{R/W}" } }
186
+
187
+ SACRIFICE = { "triggered" => { "when" => { "enters" => {} }, "if" => { "cast_with" => "evoke" },
188
+ "effects" => [{ "sacrifice" => { "to" => "self" } }] } }.freeze
189
+
190
+ attr_reader :linked_abilities
191
+
192
+ def initialize(costs, path, errors)
193
+ @linked_abilities = [Mechanics.build_entry(:ability, SACRIFICE, errors, "#{path}.sacrifice")].compact.freeze
194
+ super
195
+ end
196
+
197
+ def allowed_types = %i[artifact creature enchantment planeswalker]
198
+ end
199
+
200
+ # CR 702.188a: web-slinging, paying `cost` and returning a tapped creature you control to its owner's hand.
201
+ class WebSlinging < AlternativeCost
202
+ primitive :ability, :web_slinging,
203
+ description: "Web-slinging: you may cast this spell by paying `cost` and returning a tapped creature " \
204
+ "you control to its owner's hand rather than its mana cost. The creature is a " \
205
+ "`choose_cost` decision, returned as the spell becomes cast. Cast option " \
206
+ "`via: \"alt:<ref>\"`.",
207
+ rules: %w[702.188a 118.9 601.2b 601.2f 601.2h],
208
+ params: { "cost" => "mana cost string, or a list of costs" },
209
+ example: { "web_slinging" => { "cost" => "{2}{G}" } }
210
+
211
+ def returns = "tapped"
212
+ end
213
+
214
+ # CR 702.190a-b: sneak, during your declare blockers step at instant speed, paying `cost` and returning an
215
+ # unblocked attacking creature you control to its owner's hand. A permanent spell enters tapped and
216
+ # attacking whatever that creature was attacking.
217
+ class Sneak < AlternativeCost
218
+ primitive :ability, :sneak,
219
+ description: "Sneak: any time you could cast an instant during your declare blockers step, you may " \
220
+ "cast this spell by paying `cost` and returning an unblocked attacking creature you " \
221
+ "control to its owner's hand rather than its mana cost. A permanent spell cast this way " \
222
+ "enters tapped and attacking what that creature was attacking. Cast option " \
223
+ "`via: \"alt:<ref>\"`.",
224
+ rules: %w[702.190a 702.190b 506.3a 118.9 601.2b 601.2f 601.2h],
225
+ params: { "cost" => "mana cost string, or a list of costs" },
226
+ example: { "sneak" => { "cost" => "{1}{B}" } }
227
+
228
+ def returns = "unblocked"
229
+ def instant_speed? = true
230
+
231
+ def functions?(engine, obj, index)
232
+ obj.zone == zone && engine.state.active == index && engine.state.step == :declare_blockers
233
+ end
234
+ end
235
+ end
236
+
237
+ module Effects
238
+ # The delayed triggered ability of warp (CR 702.185a): "exile the permanent this spell becomes at the
239
+ # beginning of the next end step". It is created as the spell becomes cast and watches the spell; the
240
+ # permanent it became remembers the spell ("warp_spell"). Not a card-data primitive.
241
+ class WarpDelayed < DelayedTrigger
242
+ END_STEP = { "step_begins" => { "step" => "end", "whose" => "each" } }.freeze
243
+
244
+ def self.keys = []
245
+
246
+ def setup(_params, errors, path)
247
+ @ref = "delayed:#{path}"
248
+ trigger = Mechanics.build_entry(:trigger, END_STEP, errors, "#{path}.when")
249
+ exile = WarpExile.build({ "to" => "watched" }, errors, "#{path}.exile")
250
+ @delayed_ability = Abilities::TriggeredAbility.new(targets: [].freeze, effects: [exile].freeze, trigger: trigger)
251
+ end
252
+
253
+ def to_s = "at the beginning of the next end step, exile the permanent this spell became"
254
+ end
255
+
256
+ # Exiles the permanent a warped spell became; the card in exile is "warped" (CR 702.185b) and its owner
257
+ # may cast it after this turn has ended (Engine::AltCasting#warped_methods).
258
+ class WarpExile < Effect
259
+ def self.keys = %w[to]
260
+
261
+ def apply(engine, ctx)
262
+ spells = refs(engine, ctx)
263
+ engine.state.battlefield.dup.each do |id|
264
+ next unless spells.include?(engine.object(id).memory["warp_spell"])
265
+
266
+ fresh = engine.move!(id, :exile, reason: "warp")
267
+ fresh.memory["warped"] = engine.state.turn if fresh&.zone == :exile
268
+ end
269
+ end
270
+
271
+ def to_s = "exile the permanent this spell became"
272
+ end
273
+ end
274
+
275
+ class Condition
276
+ # How a spell was cast (CR 601.2a, 400.7d): `{"cast_with": "flashback"}` (cast using that keyword's
277
+ # ability: "if its evoke cost was paid", "if this spell's mayhem cost was paid"), `{"cast_from":
278
+ # "graveyard"}` ("if this spell was cast from a graveyard"; `{"not": {"cast_from": "hand"}}` is "from
279
+ # anywhere other than your hand"), and `{"cast_with_this_turn": "warp"}` (any player cast a spell with
280
+ # that keyword's cost this turn, CR 702.185c). The resolving spell, or the permanent it became (and
281
+ # that permanent's triggered abilities), remembers it.
282
+ class CastHow
283
+ KEYS = %w[cast_with cast_from cast_with_this_turn].freeze
284
+ KEYWORDS = %w[flashback harmonize mayhem warp evoke web_slinging sneak foretell].freeze
285
+ # Optional additional costs announced as the spell is cast (Engine::CastChoices#announce_kicker!):
286
+ # "if this spell was cast using teamwork" (CR 702.194b), "if this spell's additional cost was paid" (CR 118.8b),
287
+ # "if the gift was promised" (CR 702.174k).
288
+ OPTIONAL_TEXT = { "teamwork" => "it was cast using teamwork", "additional_cost" => "its additional cost was paid",
289
+ "gift" => "its gift was promised" }.freeze
290
+ OPTIONAL = OPTIONAL_TEXT.keys.freeze
291
+ ZONES = %w[hand graveyard exile library outside].freeze
292
+
293
+ def self.build(key, data, errors, path)
294
+ Mechanics.check_keys(data, [key], errors, path)
295
+ value = data[key]
296
+ allowed = { "cast_from" => ZONES, "cast_with" => KEYWORDS + OPTIONAL }.fetch(key, KEYWORDS)
297
+ errors << "#{path}.#{key}: must be one of #{allowed.join(", ")}" unless allowed.include?(value)
298
+ new(key, value)
299
+ end
300
+
301
+ def initialize(key, value)
302
+ @key = key
303
+ @value = value
304
+ freeze
305
+ end
306
+
307
+ def true?(engine, ctx)
308
+ return engine.cast_with_this_turn?(@value) if @key == "cast_with_this_turn"
309
+
310
+ holder = engine.state.objects[ctx.object || ctx.source]
311
+ return holder&.memory&.[]("kicked_with") == @value if OPTIONAL.include?(@value)
312
+
313
+ holder&.memory&.[](@key) == @value
314
+ end
315
+
316
+ def to_s
317
+ case @key
318
+ when "cast_with" then OPTIONAL.include?(@value) ? OPTIONAL_TEXT.fetch(@value) : "its #{@value.tr("_", "-")} cost was paid"
319
+ when "cast_from" then "it was cast from #{@value == "hand" ? "a hand" : "a #{@value}"}"
320
+ else "a spell was cast with #{@value.tr("_", "-")} this turn"
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ # Where a spell is cast from (CR 601.2a), as a filter of the `cast` trigger and of `cost_modifier`:
327
+ # `from` / `not_from` a zone ("spells you cast from anywhere other than your hand" is
328
+ # {"not_from": "hand"}). Engine#cast_origin knows the zone while the spell is being cast and after.
329
+ class CastFrom
330
+ KEYS = %w[from not_from].freeze
331
+
332
+ def self.build(params, errors, path)
333
+ from, not_from = KEYS.map { Mechanics.fetch_param(params, it) }
334
+ KEYS.zip([from, not_from]).each do |key, zone|
335
+ next if zone.nil? || Condition::CastHow::ZONES.include?(zone)
336
+
337
+ errors << "#{path}.#{key}: must be one of #{Condition::CastHow::ZONES.join(", ")}"
338
+ end
339
+ new(from, not_from)
340
+ end
341
+
342
+ def initialize(from, not_from)
343
+ @from = from
344
+ @not_from = not_from
345
+ freeze
346
+ end
347
+
348
+ def match?(zone) = (@from.nil? || zone == @from) && (@not_from.nil? || zone != @not_from)
349
+
350
+ WORDS = { "hand" => "your hand", "graveyard" => "a graveyard", "exile" => "exile", "library" => "a library",
351
+ "outside" => "outside the game" }.freeze
352
+
353
+ def to_s
354
+ return " from #{WORDS.fetch(@from)}" if @from
355
+ return " from anywhere other than #{WORDS.fetch(@not_from)}" if @not_from
356
+
357
+ ""
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # Beam me up [cost] (CR 702.196a): "You may cast this card from your graveyard if the
7
+ # resulting spell is an instant or sorcery spell by paying [cost] and returning a creature you control
8
+ # to its owner's hand rather than paying its mana cost" and "If the beam me up cost was paid, exile this
9
+ # card instead of putting it anywhere else any time it would leave the stack." The creature is chosen
10
+ # while casting (a `choose_cost` decision) and returned as the spell becomes cast (CR 601.2h).
11
+ class BeamMeUp < Primitive
12
+ primitive :ability, :beam_me_up,
13
+ description: "You may cast this instant or sorcery card from your graveyard for `cost` plus " \
14
+ "returning a creature you control to its owner's hand, instead of its mana cost. " \
15
+ "If you do, it's exiled whenever it would leave the stack. Offered as a cast option " \
16
+ "with `via: \"beam:<ref>\"`.",
17
+ rules: %w[702.196a 702.196b 118.9 601.2b 601.2f 601.2h],
18
+ params: { "cost" => "mana cost string, e.g. \"{3}{W}\"" },
19
+ example: { "beam_me_up" => { "cost" => "{3}{W}" } }
20
+
21
+ attr_reader :cost
22
+
23
+ def self.build(params, errors, path, **)
24
+ Mechanics.check_keys(params, %w[cost], errors, path)
25
+ new(Mana.parse_cost(Mechanics.fetch_param(params, :cost), errors, "#{path}.cost") || Mana::Cost.new(generic: 0, colored: {}))
26
+ end
27
+
28
+ def initialize(cost)
29
+ super()
30
+ @cost = cost
31
+ freeze
32
+ end
33
+
34
+ def kind = :beam_me_up
35
+ def mana_ability? = false
36
+ def allowed_types = %i[instant sorcery]
37
+ def try_effects = []
38
+ def to_s = "Beam me up #{cost}"
39
+ end
40
+
41
+ # A static ability of a permanent giving beam me up (CR 702.196a), during its controller's main phase,
42
+ # to the instant and sorcery cards with a mana cost in their graveyard, each card's mana cost being its
43
+ # beam me up cost. The permanent itself can't be returned to cast a spell this way (CR 702.196b).
44
+ class GrantsBeamMeUp < Primitive
45
+ primitive :ability, :grants_beam_me_up,
46
+ description: "While this permanent is on the battlefield, during your main phase each instant and " \
47
+ "sorcery card in your graveyard has beam me up with its mana cost as the beam me up " \
48
+ "cost; this permanent can't be the creature returned to cast a spell this way. " \
49
+ "Offered as a cast option with `via: \"beam_granted:<permanent>:<ref>\"`.",
50
+ rules: %w[702.196a 702.196b 604.1 611.3a],
51
+ example: "grants_beam_me_up"
52
+
53
+ INSTANCE = new.freeze
54
+
55
+ def self.build(params, errors, path, **)
56
+ Mechanics.check_keys(params, [], errors, path)
57
+ INSTANCE
58
+ end
59
+
60
+ def kind = :grants_beam_me_up
61
+ def mana_ability? = false
62
+ def allowed_types = nil
63
+ def try_effects = []
64
+ def to_s = "During your main phase, each card in your graveyard has beam me up (its mana cost)"
65
+ end
66
+ end
67
+
68
+ module Keywords
69
+ # CR 702.196b: "If a creature can't be beamed up, its controller can't return it to its owner's hand to
70
+ # pay the portion of a beam me up cost that requires them to return a creature." A flag like a keyword.
71
+ class CantBeBeamedUp < Primitive
72
+ primitive :keyword, :cant_be_beamed_up,
73
+ description: "This creature can't be returned to its owner's hand to pay a beam me up cost.",
74
+ rules: %w[702.196b], example: { "keywords" => ["cant_be_beamed_up"] }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Changeling (CR 702.73a): a characteristic-defining ability, "this object is
7
+ # every creature type". It works in every zone (CR 604.3, 113.6a), so it is applied with the object's
8
+ # copiable values (Engine::Characteristics#copy_base): the object's subtypes then include every
9
+ # creature type, and subtype filters, "creature types among" counts and type-changing effects
10
+ # (layer 4) see them like printed ones.
11
+ class Changeling < Primitive
12
+ primitive :keyword, :changeling,
13
+ description: "This object is every creature type (a characteristic-defining ability that works " \
14
+ "in all zones).",
15
+ rules: %w[702.73a 604.3 205.3m 113.6a], example: { "keywords" => ["changeling"] }
16
+
17
+ # CR 205.3m: every creature type, written as card data subtypes (lowercase, "-" and
18
+ # spaces become "_", apostrophes dropped).
19
+ CREATURE_TYPES = <<~TYPES.split.map(&:to_sym).freeze
20
+ time_lord advisor aenar aetherborn alien ally andorian android angel antelope ape archer archon armadillo
21
+ army artificer assassin assembly_worker astartes atog aurochs avatar azra badger bajoran balloon barbarian
22
+ bard basilisk bat bear beast beaver beeble beholder berserker betazoid bird bison blinkmoth boar bolian
23
+ borg brikar bringer brushwagg caitian camarid camel capybara cardassian caribou carrier cat centaur child
24
+ chimera citizen cleric clown cockatrice construct coward coyote crab crocodile ctan custodes cyberman
25
+ cyclops dalek dauthi demigod demon denobulan deserter detective devil dinosaur djinn doctor dog dragon
26
+ drake dreadnought drix drone druid dryad dwarf echidna edosian efreet egg el_aurian elder eldrazi
27
+ elemental elephant elf elk employee eternal excalbian eye faerie ferengi ferret fish flagbearer fox
28
+ fractal frog fungus gamer gamma gargoyle germ giant giraffe gith glimmer gnoll gnome goat goblin god golem
29
+ gorgon gorn graveborn gremlin griffin guest hag halfling hamster harpy hedgehog hellion hero hippo
30
+ hippogriff hirogen homarid homunculus horror horse human hydra hyena illusion illyrian imp incarnation
31
+ inhuman inkling inquisitor insect jackal jellyfish jemhadar juggernaut kangaroo kavu kelpien kirin kithkin
32
+ klingon knight kobold kor kraken kree kwejian llama lamia lammasu lanthanite leech lemur leviathan
33
+ lhurgoyf licid lizard lobster manticore masticore medusan melkotian mercenary merfolk metathran minion
34
+ minotaur mite mole monger mongoose monk monkey moogle moonfolk mount mouse mutant myr mystic nakuhl
35
+ nautilus necron nephilim nightmare nightstalker ninja noble noggle nomad nymph ocampa octopus officer ogre
36
+ ooze orb orc orgg orion otter ouphe ox oyster pangolin peasant pegasus pentavite performer pest
37
+ phelddagrif phoenix phyrexian pilot pincher pirate plant platypus porcupine possum praetor primarch prism
38
+ processor q qu rabbit raccoon ranger rat rebel reflection rhino rigger robot rogue romulan sable
39
+ salamander samurai sand saproling satyr scarecrow scientist scion scorpion scout sculpture seal serf
40
+ serpent servo shade shaman shapeshifter shark sheep shiar siren skeleton skrull skunk slith sliver sloth
41
+ slug snail snake soldier soltari sorcerer spawn specter spellshaper sphinx spider spike spirit splinter
42
+ sponge spy squid squirrel starfish surrakar survivor symbiote synth talaxian talosian tamarian tellarite
43
+ tentacle tetravite thalakos tholian thopter thrull tiefling tosk toy treefolk tribble trill trilobite
44
+ triskelavite troll turtle tyranid unicorn utrom vampire varmint vau_nakat vedalken villain volver vorta
45
+ vulcan wall walrus warlock warrior weasel weird werewolf whale wizard wolf wolverine wombat worm wraith
46
+ wurm xindi yeti zombie zubera
47
+ TYPES
48
+
49
+ # The characteristics with every creature type added, if the object has changeling.
50
+ def self.apply(chars)
51
+ return chars unless chars.keywords.include?(:changeling)
52
+
53
+ chars.with(subtypes: (chars.subtypes | CREATURE_TYPES).freeze)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end