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,542 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # One target of a spell or ability with options (CR 601.2c, 115.1d): {"filter": f, "optional": true}
7
+ # is "up to one target f" (the player may choose none), and "distinct_from": [i, ...] keeps it
8
+ # different from earlier targets of the same "target" word ("one or two target creatures").
9
+ # "shares_type_with": i keeps it sharing a card type with earlier target i ("two target permanents that
10
+ # share a card type", CR 115.1, 205.2a).
11
+ # "count" is a variable number of targets for one "target" word (CR 601.2c, 115.3; Engine::TargetCounts):
12
+ # an amount ("x": "X target creatures", 2: "two target creatures"; with "optional": "up to") or "any"
13
+ # ("any number of target ..."). Its slot then holds a list, and {"target": n} means each of them.
14
+ class TargetSpec
15
+ KEYS = %w[filter optional distinct_from count shares_type_with].freeze
16
+
17
+ attr_reader :filter, :distinct_from, :count, :shares_type_with
18
+
19
+ def self.build(entry, errors, path, index)
20
+ Mechanics.check_keys(entry, KEYS, errors, path)
21
+ filter = Mechanics.build_entry(:filter, entry["filter"], errors, "#{path}.filter")
22
+ distinct = Array(entry["distinct_from"])
23
+ errors << "#{path}.distinct_from: must list earlier targets" unless distinct.all? { it.is_a?(Integer) && it >= 0 && it < index }
24
+ optional = Mechanics.flag_param(entry, :optional, errors, path)
25
+ shares = entry["shares_type_with"]
26
+ unless shares.nil? || (shares.is_a?(Integer) && shares >= 0 && shares < index)
27
+ errors << "#{path}.shares_type_with: must name an earlier target"
28
+ end
29
+ filter && new(filter, optional, distinct, build_count(entry, errors, path), shares_type_with: shares)
30
+ end
31
+
32
+ def self.build_count(entry, errors, path)
33
+ return nil unless entry.key?("count")
34
+ return :any if entry["count"] == "any"
35
+
36
+ Amount.build(entry["count"], errors, "#{path}.count", targets_count: 0, min: 1)
37
+ end
38
+
39
+ def initialize(filter, optional, distinct_from, count = nil, shares_type_with: nil)
40
+ @filter = filter
41
+ @optional = optional
42
+ @distinct_from = distinct_from.freeze
43
+ @count = count
44
+ @shares_type_with = shares_type_with
45
+ freeze
46
+ end
47
+
48
+ def optional? = @optional
49
+ def counted? = !@count.nil?
50
+
51
+ # [min, max] objects to choose (max nil: any number).
52
+ def count_range(engine, ctx)
53
+ return [0, nil] if @count == :any
54
+
55
+ n = [@count.value(engine, ctx), 0].max
56
+ [optional? ? 0 : n, n]
57
+ end
58
+
59
+ def candidates_for(...) = filter.candidates_for(...)
60
+ def match_for?(...) = filter.match_for?(...)
61
+ def candidates(...) = filter.candidates(...)
62
+ def match?(...) = filter.match?(...)
63
+ def primitive_name = filter.primitive_name
64
+ def to_s = "#{count_words}#{filter.is_a?(Class) ? primitive_name : filter}"
65
+
66
+ private
67
+
68
+ def count_words
69
+ return "any number of " if @count == :any
70
+ return "#{"up to " if optional?}#{@count.is_a?(Amount::Fixed) ? @count.n : "X"} " if counted?
71
+
72
+ "up to one " if optional?
73
+ end
74
+ end
75
+
76
+ # Shared parsing for abilities that use the stack: targets and effects.
77
+ class StackAbility < Primitive
78
+ # One mode of a modal ability ("Choose one —", CR 700.2): its own targets and effects, and for a spell
79
+ # the additional mana cost paid when it's chosen (tiered, CR 702.183a), or nil.
80
+ Mode = Data.define(:targets, :effects, :cost) do
81
+ def initialize(targets:, effects:, cost: nil) = super
82
+ end
83
+
84
+ # Optional parts of the body: how many modes may be chosen and when more may (CR 700.2), modes
85
+ # not chosen before for the same object, targets of a kicked spell (CR 702.33g), and an amount
86
+ # divided among the targets as it's cast (CR 601.2d).
87
+ Extras = Data.define(:choose_max, :choose_if, :unchosen, :kicked_targets, :divide) do
88
+ def initialize(choose_max: nil, choose_if: nil, unchosen: false, kicked_targets: nil, divide: nil) = super
89
+ end
90
+ NO_EXTRAS = Extras.new
91
+
92
+ attr_reader :targets, :effects, :costs, :modes, :choose, :extras
93
+
94
+ # Parses either plain targets/effects or {"choose": n, "modes": [{targets, effects}, ...]}.
95
+ # `choose` may be {"min": a, "max": b} ("choose one or more"), and `choose_if` {"if": condition,
96
+ # "choose": ...} replaces it while the condition holds.
97
+ # `mode_costs`: modes may have a `cost` (a spell's tiered modes, CR 702.183a).
98
+ def self.parse_body(params, errors, path, mode_costs: false)
99
+ modes_data = Mechanics.fetch_param(params, :modes)
100
+ extras = parse_extras(params, errors, path)
101
+ if modes_data.nil?
102
+ targets = parse_targets(params, errors, path)
103
+ check_kicked_targets(extras, targets, errors, path)
104
+ check_divide(extras, targets, errors, path)
105
+ return { targets: targets, effects: parse_effects(params, errors, path, targets.size), modes: [].freeze,
106
+ choose: 0, extras: extras }
107
+ end
108
+ unless modes_data.is_a?(Array) && modes_data.size >= 2
109
+ errors << "#{path}.modes: must be a list of at least two modes"
110
+ modes_data = []
111
+ end
112
+ errors << "#{path}: use either modes or targets/effects, not both" if params.key?("targets") || params.key?("effects")
113
+ modes = modes_data.each_with_index.map do |mode, i|
114
+ mode_path = "#{path}.modes[#{i}]"
115
+ mode = mode.is_a?(Hash) ? mode.transform_keys(&:to_s) : {}
116
+ Mechanics.check_keys(mode, %w[targets effects] + (mode_costs ? %w[cost] : []), errors, mode_path)
117
+ mode_targets = parse_targets(mode, errors, mode_path)
118
+ cost = mode.key?("cost") ? Mana.parse_cost(mode["cost"], errors, "#{mode_path}.cost") : nil
119
+ Mode.new(targets: mode_targets, effects: parse_effects(mode, errors, mode_path, mode_targets.size), cost: cost)
120
+ end
121
+ min, max = parse_choose(Mechanics.fetch_param(params, :choose), errors, "#{path}.choose", modes.size)
122
+ extras = extras.with(choose_max: max) if max != min
123
+ { targets: [].freeze, effects: [].freeze, modes: modes.freeze, choose: min, extras: extras }
124
+ end
125
+
126
+ BODY_KEYS = %w[targets effects modes choose choose_if unchosen kicked_targets divide].freeze
127
+
128
+ # [min, max] modes from 1, n or {"min": a, "max": b}.
129
+ def self.parse_choose(raw, errors, path, mode_count)
130
+ raw = 1 if raw.nil?
131
+ min, max = raw.is_a?(Hash) ? [raw["min"], raw["max"]] : [raw, raw]
132
+ unless min.is_a?(Integer) && max.is_a?(Integer) && min >= 1 && max >= min && (raw.is_a?(Integer) || raw.size == 2)
133
+ errors << "#{path}: must be an integer >= 1 or {\"min\": a, \"max\": b}"
134
+ return [1, 1]
135
+ end
136
+ errors << "#{path}: can't exceed the number of modes" if max > mode_count && mode_count.positive?
137
+ [min, max]
138
+ end
139
+
140
+ def self.parse_extras(params, errors, path)
141
+ extras = {}
142
+ if (raw = Mechanics.fetch_param(params, :choose_if))
143
+ extras[:choose_if] = parse_choose_if(raw, errors, "#{path}.choose_if", params)
144
+ end
145
+ extras[:unchosen] = Mechanics.flag_param(params, :unchosen, errors, path)
146
+ raw = Mechanics.fetch_param(params, :kicked_targets)
147
+ extras[:kicked_targets] = parse_targets(params, errors, path, key: :kicked_targets) unless raw.nil?
148
+ extras[:divide] = Amount.build(params["divide"], errors, "#{path}.divide", targets_count: 0, min: 1) if params.key?("divide")
149
+ Extras.new(**extras)
150
+ end
151
+
152
+ def self.parse_choose_if(raw, errors, path, params)
153
+ unless raw.is_a?(Hash) && raw.key?("if") && raw.key?("choose")
154
+ errors << "#{path}: must be {\"if\": condition, \"choose\": n or {\"min\": a, \"max\": b}}"
155
+ return nil
156
+ end
157
+ Mechanics.check_keys(raw, %w[if choose], errors, path)
158
+ count = Array(Mechanics.fetch_param(params, :modes)).size
159
+ [Condition.build(raw["if"], errors, "#{path}.if"), *parse_choose(raw["choose"], errors, "#{path}.choose", count)]
160
+ end
161
+
162
+ # CR 601.2d: each target gets at least one, so there must be a first, required target. A counted one
163
+ # ("one, two, or three targets": count 3, optional) needs at least one target when dividing.
164
+ def self.check_divide(extras, targets, errors, path)
165
+ first = targets.first
166
+ return if extras.divide.nil? || (first && (!first.respond_to?(:optional?) || !first.optional? || first.counted?))
167
+
168
+ errors << "#{path}.divide: needs a first target that isn't optional"
169
+ end
170
+
171
+ # CR 702.33g: the kicked spell's targets replace the normal ones, one for one.
172
+ def self.check_kicked_targets(extras, targets, errors, path)
173
+ return if extras.kicked_targets.nil? || extras.kicked_targets.size == targets.size
174
+
175
+ errors << "#{path}.kicked_targets: must list as many targets as targets"
176
+ end
177
+
178
+ def self.parse_targets(params, errors, path, key: :targets)
179
+ Mechanics.list_param(params, key, errors, path).each_with_index.filter_map do |entry, i|
180
+ entry_path = "#{path}.#{key}[#{i}]"
181
+ if entry.is_a?(Hash) && entry.key?("filter")
182
+ TargetSpec.build(entry, errors, entry_path, i)
183
+ else
184
+ Mechanics.build_entry(:filter, entry, errors, entry_path)
185
+ end
186
+ end.freeze
187
+ end
188
+
189
+ # Effect i may name the results of effects 0...i ({"result": n}). A nested list (`results_count`
190
+ # given) sees only its parent's earlier results.
191
+ def self.parse_effects(params, errors, path, targets_count, key: :effects, results_count: nil)
192
+ list = Mechanics.list_param(params, key, errors, path)
193
+ list.each_with_index.filter_map do |entry, i|
194
+ Mechanics.build_entry(:effect, entry, errors, "#{path}.#{key}[#{i}]", targets_count: targets_count,
195
+ results_count: results_count || i)
196
+ end.freeze
197
+ end
198
+
199
+ def self.parse_costs(params, errors, path)
200
+ Mechanics.list_param(params, :cost, errors, path).each_with_index.filter_map do |entry, i|
201
+ Mechanics.build_entry(:cost, entry, errors, "#{path}.cost[#{i}]")
202
+ end.freeze
203
+ end
204
+
205
+ def initialize(targets:, effects:, costs: [].freeze, modes: [].freeze, choose: 0, extras: NO_EXTRAS)
206
+ super()
207
+ @targets = targets
208
+ @effects = effects
209
+ @costs = costs
210
+ @modes = modes
211
+ @choose = choose
212
+ @extras = extras
213
+ freeze
214
+ end
215
+
216
+ def modal? = !modes.empty?
217
+ def try_effects = effects
218
+
219
+ # The additional mana costs of the chosen modes (a spell's tiered modes, CR 702.183a).
220
+ def mode_costs(chosen) = modal? ? chosen.filter_map { modes[it].cost } : []
221
+
222
+ # Targets in order for the chosen modes (or the ability's own targets); a kicked spell uses its
223
+ # kicked targets (CR 702.33g).
224
+ def targets_for(chosen, kicked: false)
225
+ return chosen.sort.flat_map { modes[it].targets } if modal?
226
+
227
+ kicked && extras.kicked_targets ? extras.kicked_targets : targets
228
+ end
229
+
230
+ # [min, max] modes to choose now (CR 700.2): `choose_if` applies while its condition holds.
231
+ def choice_range(engine, ctx)
232
+ cond, min, max = extras.choose_if
233
+ return [min, max] if cond&.true?(engine, ctx)
234
+
235
+ [choose, extras.choose_max || choose]
236
+ end
237
+
238
+ # [[effect, target offset, target count, index in its list], ...] in resolution order (modes in
239
+ # printed order, CR 700.2a). The index lets {"result": n} find results within the same list.
240
+ def effect_plan(chosen)
241
+ return effects.each_with_index.map { |e, i| [e, 0, targets.size, i] } unless modal?
242
+
243
+ offset = 0
244
+ chosen.sort.flat_map do |index|
245
+ mode = modes[index]
246
+ plan = mode.effects.each_with_index.map { |e, i| [e, offset, mode.targets.size, i] }
247
+ offset += mode.targets.size
248
+ plan
249
+ end
250
+ end
251
+
252
+ def mana_ability? = false
253
+ def loyalty_cost = nil
254
+
255
+ # The zone the ability functions in (CR 113.6): a cost that moves the card out of a zone
256
+ # (e.g. discarding it from hand) says where it can be activated (CR 113.6m).
257
+ def zone = costs.find { it.respond_to?(:zone) }&.zone || :battlefield
258
+
259
+ def effects_text
260
+ return effects.join("; ") unless modal?
261
+
262
+ "choose #{choose} — " + modes.map { it.effects.join("; ") }.join(" • ")
263
+ end
264
+
265
+ def to_s = costs.empty? ? effects_text : "#{costs.join(", ")}: #{effects_text}"
266
+ def sorcery_speed? = false
267
+ def allowed_types = nil
268
+
269
+ # Total mana cost to pay during activation.
270
+ def mana_cost
271
+ Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
272
+ end
273
+ end
274
+
275
+ # The spell ability of an instant or sorcery (CR 113.3a): what happens when it resolves.
276
+ class SpellAbility < StackAbility
277
+ primitive :ability, :spell,
278
+ description: "The spell ability of an instant or sorcery: its targets are chosen while casting " \
279
+ "and its effects happen in order when it resolves. If every target is illegal on " \
280
+ "resolution, it doesn't resolve. A mode of a modal spell may have a `cost`: a mana " \
281
+ "cost paid in addition when that mode is chosen (tiered: \"Choose one additional " \
282
+ "cost\").",
283
+ rules: %w[113.3a 601.2c 608.2b 608.2c 702.183a 601.2f],
284
+ params: { "targets" => "list of target filters", "effects" => "list of effects",
285
+ "modes" => "list of {targets, effects, cost} (with `choose`)" },
286
+ example: { "spell" => { "targets" => ["any_target"],
287
+ "effects" => [{ "deal_damage" => { "amount" => 3, "to" => { "target" => 0 } } }] } }
288
+
289
+ def self.build(params, errors, path, **)
290
+ Mechanics.check_keys(params, BODY_KEYS, errors, path)
291
+ new(**parse_body(params, errors, path, mode_costs: true))
292
+ end
293
+
294
+ def kind = :spell
295
+ def allowed_types = %i[instant sorcery]
296
+ end
297
+
298
+ # A non-mana activated ability "[Cost]: [Effect]" (CR 602).
299
+ class ActivatedAbility < StackAbility
300
+ primitive :ability, :activated,
301
+ description: "An activated ability: announce it, choose targets, pay its costs, then it goes on " \
302
+ "the stack. `timing: \"sorcery\"` restricts it to sorcery speed, `\"your_turn\"` to your " \
303
+ "turns; `limit: \"once_each_turn\"` to one activation per turn, `\"once\"` to one " \
304
+ "activation for as long as the object exists; `only_if` to when a " \
305
+ "condition holds. `zone` (\"exile\", \"graveyard\" or \"hand\") makes it function only " \
306
+ "while its card is in that zone (\"activate only if this card is exiled\"). `exhaust: " \
307
+ "true` makes it an exhaust ability: it can be activated only once (CR 702.177a). " \
308
+ "`power_up: true` makes it a power-up ability: activate only once, and if the " \
309
+ "permanent entered this turn its cost is reduced by the permanent's mana cost " \
310
+ "(CR 702.193). `boast: true`: activate only if this creature attacked this turn and " \
311
+ "only once each turn (CR 702.142a).",
312
+ rules: %w[602.1 602.2 602.5b 602.5d 702.177a 702.193a 702.193b 702.142a],
313
+ params: { "cost" => "list of costs (tap, {\"mana\": \"{1}\"}, {\"mana\": \"{X}\"})",
314
+ "targets" => "list of target filters", "effects" => "list of effects",
315
+ "timing" => "\"instant\" (default), \"sorcery\" or \"your_turn\"",
316
+ "limit" => "\"once_each_turn\" | \"once\" (CR 602.5b)", "only_if" => "condition (\"activate only if ...\")",
317
+ "exhaust" => "true: \"Exhaust — [cost]: [effect]\", activate only once (CR 702.177a)",
318
+ "power_up" => "true: \"Power-up — [cost]: [effect]\" (CR 702.193a)",
319
+ "boast" => "true: \"Boast — [cost]: [effect]\" (CR 702.142a)",
320
+ "zone" => "\"exile\" | \"graveyard\" | \"hand\" (CR 113.6b; default: the battlefield, or the " \
321
+ "zone a cost moves the card from)",
322
+ "cost_reduction" => "{\"amount\": amount, \"if\": condition}: \"this ability costs {N} less " \
323
+ "to activate\" (CR 601.2f, 602.2b)" },
324
+ example: { "activated" => { "cost" => ["tap"], "targets" => ["any_target"],
325
+ "effects" => [{ "deal_damage" => { "amount" => 1, "to" => { "target" => 0 } } }] } }
326
+
327
+ def self.build(params, errors, path, **)
328
+ Mechanics.check_keys(params, %w[cost timing cost_reduction limit only_if zone exhaust power_up boast] + BODY_KEYS, errors, path)
329
+ timing = Mechanics.fetch_param(params, :timing) || "instant"
330
+ errors << "#{path}.timing: must be one of #{TIMINGS.join(", ")}" unless TIMINGS.include?(timing)
331
+ restriction = parse_restriction(params, errors, path)
332
+ costs = parse_costs(params, errors, path)
333
+ errors << "#{path}.cost: must be a non-empty list" if costs.empty? && errors.none? { it.start_with?("#{path}.cost") }
334
+ raw = Mechanics.fetch_param(params, :cost_reduction)
335
+ reduction = raw && Reduction.build(raw, errors, "#{path}.cost_reduction")
336
+ check_cost_zones(costs, errors, path)
337
+ zone = parse_zone(params, costs, errors, path)
338
+ body = parse_body(params, errors, path)
339
+ check_mana_ability(body, errors, path)
340
+ new(**body, costs: costs, sorcery: timing == "sorcery", cost_reduction: reduction, zone: zone,
341
+ restriction: restriction.merge(your_turn: timing == "your_turn"))
342
+ end
343
+
344
+ ZONES = %w[exile graveyard hand].freeze
345
+
346
+ # CR 113.6b: "activate only if this card is exiled" (an ability that functions only from a zone).
347
+ def self.parse_zone(params, costs, errors, path)
348
+ zone = Mechanics.fetch_param(params, :zone) or return nil
349
+ return (errors << "#{path}.zone: must be one of #{ZONES.join(", ")}") && nil unless ZONES.include?(zone)
350
+
351
+ own = costs.filter_map { it.zone if it.respond_to?(:zone) }.first
352
+ errors << "#{path}.zone: its costs are paid from the #{own}" if own && own.to_s != zone
353
+ if costs.any? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
354
+ errors << "#{path}.zone: an ability of a card in the #{zone} can't tap or sacrifice it"
355
+ end
356
+ zone.to_sym
357
+ end
358
+
359
+ TIMINGS = %w[instant sorcery your_turn].freeze
360
+ LIMITS = { "once_each_turn" => 1, "once" => nil }.freeze
361
+
362
+ # CR 602.5b: "Activate only once each turn" and "Activate only if [condition]".
363
+ def self.parse_restriction(params, errors, path)
364
+ limit = Mechanics.fetch_param(params, :limit)
365
+ errors << "#{path}.limit: must be one of #{LIMITS.keys.join(", ")}" unless limit.nil? || LIMITS.key?(limit)
366
+ raw = Mechanics.fetch_param(params, :only_if)
367
+ flags = %i[exhaust power_up boast].to_h { [it, Mechanics.flag_param(params, it, errors, path)] }
368
+ errors << "#{path}: exhaust and power_up can't be used together" if flags[:exhaust] && flags[:power_up]
369
+ errors << "#{path}.boast: can't be combined with limit" if flags[:boast] && limit
370
+ { per_turn: flags[:boast] ? 1 : LIMITS[limit], only_if: raw && Condition.build(raw, errors, "#{path}.only_if"),
371
+ once: limit == "once", **flags }
372
+ end
373
+
374
+ # CR 605.1a: an activated ability without targets that could add mana is a mana ability,
375
+ # which doesn't use the stack.
376
+ def self.check_mana_ability(body, errors, path)
377
+ plans = body[:modes].empty? ? [body] : body[:modes].map(&:to_h)
378
+ return unless plans.any? { it[:targets].empty? && it[:effects].any?(Effects::AddMana) }
379
+
380
+ errors << "#{path}: an activated ability without targets that adds mana is a mana ability (CR 605.1a); " \
381
+ "use a \"mana\" ability"
382
+ end
383
+
384
+ # CR 113.6m: an ability can't both function from the hand (or graveyard) and tap or sacrifice its
385
+ # permanent, nor function from two zones.
386
+ def self.check_cost_zones(costs, errors, path)
387
+ zones = costs.filter_map { it.zone if it.respond_to?(:zone) }.uniq
388
+ errors << "#{path}.cost: costs can't be paid from both #{zones.join(" and ")}" if zones.size > 1
389
+ return if zones.empty? || costs.none? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
390
+
391
+ errors << "#{path}.cost: a cost paid from the #{zones.first} can't be combined with tap or sacrifice"
392
+ end
393
+
394
+ attr_reader :cost_reduction
395
+
396
+ def initialize(sorcery:, cost_reduction: nil, restriction: {}, zone: nil, **)
397
+ @sorcery = sorcery
398
+ @cost_reduction = cost_reduction
399
+ @restriction = restriction
400
+ @zone = zone
401
+ super(**)
402
+ end
403
+
404
+ def kind = :activated
405
+ def zone = @zone || super
406
+ def sorcery_speed? = @sorcery
407
+
408
+ # How many times it may be activated each turn (nil: no limit, CR 602.5b).
409
+ def per_turn = @restriction[:per_turn]
410
+
411
+ # An exhaust ability: "Activate only once" (CR 702.177a), see Engine::CastChoices#activation_allowed?.
412
+ def exhaust? = @restriction[:exhaust] == true
413
+
414
+ # A power-up ability (CR 702.193a): activate only once; cheaper the turn its permanent entered
415
+ # (Keywords::PowerUp).
416
+ def power_up? = @restriction[:power_up] == true
417
+
418
+ # A boast ability (CR 702.142a): only if this creature attacked this turn, and only once each turn.
419
+ def boast? = @restriction[:boast] == true
420
+
421
+ # "Activate only once" for as long as the object exists (CR 602.5b, per object, CR 400.7): exhaust,
422
+ # power-up, or `limit: "once"`.
423
+ def once? = exhaust? || power_up? || @restriction[:once] == true
424
+
425
+ KEYWORD_WORDS = { exhaust?: "Exhaust", power_up?: "Power-up", boast?: "Boast" }.freeze
426
+
427
+ def to_s
428
+ word = KEYWORD_WORDS.find { |flag, _| public_send(flag) }&.last
429
+ text = word ? "#{word} — #{super}" : super
430
+ @restriction[:once] ? "#{text} (activate only once)" : text
431
+ end
432
+
433
+ # CR 602.5b: restrictions on when it may be activated, besides timing and costs.
434
+ def restriction_met?(engine, index, source)
435
+ return false if boast? && !engine.attacked_this_turn?(source) # CR 702.142a
436
+ return false if @restriction[:your_turn] && engine.state.active != index
437
+ return false if @restriction[:only_if] && !@restriction[:only_if].true?(engine, EffectContext.new(index, source, nil, nil))
438
+
439
+ true
440
+ end
441
+ end
442
+
443
+ # A triggered ability "When/Whenever/At [trigger], [effect]" (CR 603).
444
+ class TriggeredAbility < StackAbility
445
+ primitive :ability, :triggered,
446
+ description: "A triggered ability: when its trigger condition happens, it is put on the stack the " \
447
+ "next time a player would receive priority (in APNAP order), with targets chosen then.",
448
+ rules: %w[603.2 603.3 603.3b 603.3d],
449
+ params: { "when" => "a trigger condition, e.g. {\"enters\": {}}", "targets" => "list of target filters",
450
+ "effects" => "list of effects",
451
+ "if" => "condition: an intervening \"if\", checked when it triggers and on resolution (CR 603.4)",
452
+ "while" => "condition checked only when it triggers (\"whenever you attack while ...\", CR 603.2)",
453
+ "limit" => "\"once_each_turn\": it triggers at most once each turn (per object)",
454
+ "zone" => "\"graveyard\": the ability functions only while its card is in a graveyard (CR 113.6k)" },
455
+ example: { "triggered" => { "when" => { "enters" => {} }, "effects" => [{ "draw" => { "amount" => 1 } }] } }
456
+
457
+ attr_reader :trigger, :condition, :while_condition, :limit
458
+
459
+ LIMITS = %w[once_each_turn].freeze
460
+ ZONES = %w[battlefield graveyard].freeze
461
+
462
+ def self.build(params, errors, path, **)
463
+ Mechanics.check_keys(params, %w[when if while limit zone] + BODY_KEYS, errors, path)
464
+ raw = Mechanics.fetch_param(params, :when)
465
+ trigger = raw.nil? ? ((errors << "#{path}.when: required") && nil) : Mechanics.build_entry(:trigger, raw, errors, "#{path}.when")
466
+ raw_if = Mechanics.fetch_param(params, :if)
467
+ condition = raw_if && Condition.build(raw_if, errors, "#{path}.if")
468
+ raw_while = Mechanics.fetch_param(params, :while)
469
+ during = raw_while && Condition.build(raw_while, errors, "#{path}.while")
470
+ limit = Mechanics.fetch_param(params, :limit)
471
+ errors << "#{path}.limit: must be one of #{LIMITS.join(", ")}" unless limit.nil? || LIMITS.include?(limit)
472
+ zone = Mechanics.fetch_param(params, :zone) || "battlefield"
473
+ errors << "#{path}.zone: must be one of #{ZONES.join(", ")}" unless ZONES.include?(zone)
474
+ new(**parse_body(params, errors, path), trigger: trigger, condition: condition, while_condition: during,
475
+ limit: limit, zone: zone.to_sym)
476
+ end
477
+
478
+ def initialize(trigger:, condition: nil, while_condition: nil, limit: nil, zone: :battlefield, **)
479
+ @trigger = trigger
480
+ @condition = condition
481
+ @while_condition = while_condition
482
+ @limit = limit
483
+ @zone = zone
484
+ super(**)
485
+ end
486
+
487
+ # CR 113.6k: a trigger condition that can't trigger from the battlefield functions where it can.
488
+ attr_reader :zone
489
+
490
+ # CR 603.4: an intervening "if" must be true when the trigger event happens and on resolution.
491
+ # A "while" condition is part of the trigger event (CR 603.2): only checked as it triggers, which is
492
+ # when no resolving `object` is given.
493
+ # `facts`: the trigger event being checked (no ability object exists yet), see Selector.memory.
494
+ def condition_met?(engine, controller, source, object = nil, facts: nil)
495
+ ctx = EffectContext.new(controller, source, object, [], nil, nil, facts && { "triggering" => facts })
496
+ return false if object.nil? && while_condition && !while_condition.true?(engine, ctx)
497
+
498
+ condition.nil? || condition.true?(engine, ctx)
499
+ end
500
+
501
+ def kind = :triggered
502
+
503
+ def to_s
504
+ "#{trigger}#{" while #{while_condition}" if while_condition}, #{"if #{condition}, " if condition}#{effects_text}" \
505
+ "#{" (only once each turn)" if limit}#{" (from a graveyard)" if zone == :graveyard}"
506
+ end
507
+ end
508
+
509
+ # A planeswalker loyalty ability "+N: ..." / "-N: ..." (CR 606).
510
+ class LoyaltyAbility < StackAbility
511
+ primitive :ability, :loyalty,
512
+ description: "A loyalty ability. Its cost adds (positive) or removes (negative) loyalty counters " \
513
+ "and is paid on activation. Sorcery speed, and only one loyalty ability per " \
514
+ "planeswalker per turn.",
515
+ rules: %w[606.3 606.4 606.5 602.2b],
516
+ params: { "cost" => "integer, e.g. 1 for +1 or -2", "targets" => "list of target filters",
517
+ "effects" => "list of effects" },
518
+ example: { "loyalty" => { "cost" => -3, "targets" => ["creature"],
519
+ "effects" => [{ "deal_damage" => { "amount" => 3, "to" => { "target" => 0 } } }] } }
520
+
521
+ def self.build(params, errors, path, **)
522
+ Mechanics.check_keys(params, %w[cost] + BODY_KEYS, errors, path)
523
+ cost = Mechanics.fetch_param(params, :cost)
524
+ errors << "#{path}.cost: must be an integer loyalty change" unless cost.is_a?(Integer)
525
+ new(**parse_body(params, errors, path), loyalty: cost.to_i)
526
+ end
527
+
528
+ def initialize(loyalty:, **)
529
+ @loyalty = loyalty
530
+ super(**)
531
+ end
532
+
533
+ def kind = :loyalty
534
+ def loyalty_cost = @loyalty
535
+ def sorcery_speed? = true
536
+ def allowed_types = [:planeswalker]
537
+
538
+ def to_s = "#{"+" if @loyalty.positive?}#{@loyalty}: #{effects_text}"
539
+ end
540
+ end
541
+ end
542
+ end