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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ CANCEL = { type: "cancel" }.freeze
6
+
7
+ # CR 734.1: a cast or activation can be rewound ("cancel") until something irreversible has been
8
+ # paid (Engine::Payment#cancellable?); a triggered ability being put on the stack never can.
9
+ def self.with_cancel(engine, options) = engine.cancellable? ? options + [CANCEL] : options
10
+
11
+ # CR 601.2b / 603.3c: choose a mode of a modal spell or ability, one per decision.
12
+ class ChooseMode < Base
13
+ def self.doc
14
+ 'Choose a mode of a modal ("choose one") spell or ability: `{type: "mode", mode: index}` ' \
15
+ '(0-based, in printed order); `{type: "done"}` once enough are chosen when more are allowed; ' \
16
+ '`{type: "cancel"}` rewinds a cast. `choose` modes in total (at least `min`).'
17
+ end
18
+
19
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.mode_options)
20
+
21
+ def self.submit(engine, _pending, option)
22
+ case option[:type]
23
+ when "cancel" then engine.cancel_casting!
24
+ when "done" then engine.modes_done!
25
+ else engine.choose_mode!(option[:mode])
26
+ end
27
+ end
28
+ end
29
+ register("choose_mode", ChooseMode)
30
+
31
+ # CR 601.2b / 702.33a: announce whether an optional additional cost (kicker) will be paid.
32
+ class ChooseKicker < Base
33
+ def self.doc
34
+ 'While casting a spell with kicker: `{type: "kick"}` pays the kicker cost too, `{type: "no_kick"}` ' \
35
+ "doesn't; `{type: \"cancel\"}` rewinds. `cost` describes the kicker cost."
36
+ end
37
+
38
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.kicker_options)
39
+
40
+ def self.submit(engine, _pending, option)
41
+ option[:type] == "cancel" ? engine.cancel_casting! : engine.announce_kicker!(option[:type] == "kick")
42
+ end
43
+ end
44
+ register("choose_kicker", ChooseKicker)
45
+
46
+ # CR 601.2b: announce which of two or more costs ("behold a Goblin or pay {2}") will be paid.
47
+ class ChooseCostOption < Base
48
+ def self.doc
49
+ 'While casting/activating with a choice between costs (cost `one_of`): `{type: "cost_option", option: i}` ' \
50
+ "(0-based, in the order of `costs`) for one that could be paid; `{type: \"cancel\"}` rewinds."
51
+ end
52
+
53
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.cost_option_options)
54
+
55
+ def self.submit(engine, _pending, option)
56
+ option[:type] == "cancel" ? engine.cancel_casting! : engine.choose_cost_option!(option[:option])
57
+ end
58
+ end
59
+ register("choose_cost_option", ChooseCostOption)
60
+
61
+ # CR 601.2b / 107.3a: announce the value of X for a cost with {X}.
62
+ class ChooseX < Base
63
+ def self.doc
64
+ 'While casting/activating a spell or ability with {X} in its cost: `{type: "x", value: n}` ' \
65
+ 'announces X (0 up to `max`, the most the available mana could pay for); `{type: "cancel"}` rewinds.'
66
+ end
67
+
68
+ def self.options(engine, pending) = Decisions.with_cancel(engine, (0..pending["max"]).map { { type: "x", value: it } })
69
+
70
+ def self.submit(engine, _pending, option)
71
+ option[:type] == "cancel" ? engine.cancel_casting! : engine.announce_x!(option[:value])
72
+ end
73
+ end
74
+ register("choose_x", ChooseX)
75
+
76
+ # CR 601.2c: a variable number of targets ("X target", "any number of target", "up to N") is announced
77
+ # before the targets are chosen.
78
+ class ChooseTargetCount < Base
79
+ def self.doc
80
+ 'While casting/activating: `{type: "count", count: n}` announces how many targets (from `min` to ' \
81
+ '`max`, at most as many as there are legal ones) target ##`index` + 1 will have; `{type: "cancel"}` rewinds.'
82
+ end
83
+
84
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.target_count_options)
85
+
86
+ def self.submit(engine, _pending, option)
87
+ option[:type] == "cancel" ? engine.cancel_casting! : engine.choose_target_count!(option[:count])
88
+ end
89
+ end
90
+ register("choose_target_count", ChooseTargetCount)
91
+
92
+ # CR 601.2c: choose the next target of the spell or ability being cast.
93
+ class ChooseTarget < Base
94
+ def self.doc
95
+ 'While casting/activating: `{type: "target", target: ref}` for the next target (ref is an ' \
96
+ 'object id or "player:<name>"); `{type: "done"}` chooses no more for an "up to" target; ' \
97
+ '`{type: "cancel"}` rewinds the whole cast.'
98
+ end
99
+
100
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.target_options)
101
+
102
+ def self.submit(engine, _pending, option)
103
+ case option[:type]
104
+ when "cancel" then engine.cancel_casting!
105
+ when "done" then engine.choose_target!(nil)
106
+ else engine.choose_target!(option[:target])
107
+ end
108
+ end
109
+ end
110
+ register("choose_target", ChooseTarget)
111
+
112
+ # CR 601.2d: divide an amount (counters, damage) among the chosen targets, one unit at a time
113
+ # beyond the one each target must get.
114
+ class Distribute < Base
115
+ def self.doc
116
+ 'While casting: `{type: "assign", target: ref}` gives one more of the divided amount to that target ' \
117
+ '(each target already has one); `{type: "cancel"}` rewinds. `left` is what remains to divide.'
118
+ end
119
+
120
+ def self.options(engine, _pending) = Decisions.with_cancel(engine, engine.distribute_options)
121
+
122
+ def self.submit(engine, _pending, option)
123
+ option[:type] == "cancel" ? engine.cancel_casting! : engine.distribute!(option[:target])
124
+ end
125
+ end
126
+ register("distribute", Distribute)
127
+
128
+ # CR 601.2h / 602.2b: a cost that needs a choice, e.g. which other creature to tap (CR 702.184a)
129
+ # or which creature to sacrifice. Tapping happens at once; other choices are paid as it becomes
130
+ # cast. Cancelling rewinds it.
131
+ class ChooseCost < Base
132
+ def self.doc
133
+ 'While casting/activating: `{type: "cost", object: id}` picks the permanent (or card) a cost uses ' \
134
+ '(the creature to tap for station, the creature to sacrifice, the creature beamed up, ...); `{type: "done"}` ' \
135
+ "stops choosing for a cost of any number of permanents once enough are chosen (crew: total power N or " \
136
+ 'more); `{type: "cancel"}` rewinds. `cost` describes the cost.'
137
+ end
138
+
139
+ def self.options(engine, _pending)
140
+ options = engine.cost_choice_candidates.map { { type: "cost", **it } }
141
+ options << { type: "done" } if engine.cost_choice_finishable?
142
+ Decisions.with_cancel(engine, options)
143
+ end
144
+
145
+ def self.submit(engine, _pending, option)
146
+ case option[:type]
147
+ when "cancel" then engine.cancel_casting!
148
+ when "done" then engine.finish_cost_choice!
149
+ else engine.choose_cost!(option)
150
+ end
151
+ end
152
+ end
153
+ register("choose_cost", ChooseCost)
154
+
155
+ # CR 601.2g-h: pay the mana cost one mana at a time. Mana abilities may be activated
156
+ # now; mana in the pool is spent one symbol per decision. Also used for a cost paid while a
157
+ # spell or ability resolves (CR 118.12), where "cancel" declines to pay.
158
+ class PayCost < Base
159
+ def self.doc
160
+ 'While casting/activating or paying a cost: `{type: "pay_mana", mana: "R"}` spends one mana from ' \
161
+ "the pool (`restricted: i`: restricted mana, CR 106.6; `from: i`: the pool mana an artifact made that " \
162
+ 'pool_sources[i] names), `{type: "activate_mana", object: id, ability: ref}` ' \
163
+ '(plus `mana: "{G}{G}"` when the mana is chosen) taps a source, `{type: "cancel"}` ' \
164
+ "rewinds (or declines a payment asked for while resolving). Cancel isn't offered once a mana ability " \
165
+ "with a cost other than {T} (a Treasure) has been activated. `{type: \"convoke\", object: id}` taps a " \
166
+ "creature to pay for {1} or one mana of its color (a spell with convoke); `{type: \"improvise\", object: id}` " \
167
+ "taps an artifact to pay for {1} (a spell with improvise); `{type: \"waterbend\", object: id}` taps " \
168
+ "an artifact or creature to pay {1} of a waterbend cost. `remaining` shows what is left to pay."
169
+ end
170
+
171
+ def self.options(engine, _pending)
172
+ Decisions.with_cancel(engine, engine.payable_mana_options + engine.payment_mana_options + engine.convoke_options +
173
+ engine.waterbend_options)
174
+ end
175
+
176
+ def self.submit(engine, _pending, option)
177
+ case option[:type]
178
+ when "cancel" then engine.cancel_payment!
179
+ when "pay_mana" then engine.pay_mana!(option[:mana], option[:restricted], option[:from])
180
+ when "convoke", "improvise" then engine.convoke!(option[:object], option[:type]) # CR 702.51a, 702.126a
181
+ when "waterbend" then engine.waterbend!(option[:object]) # CR 701.67a
182
+ else engine.tap_for_payment!(option[:object], option[:ability], option[:mana])
183
+ end
184
+ end
185
+ end
186
+ register("pay_cost", PayCost)
187
+ end
188
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ DONE = { type: "done" }.freeze
6
+
7
+ # CR 508.1: the active player declares attackers one at a time, then `done`.
8
+ class DeclareAttackers < Base
9
+ def self.doc
10
+ 'Declare one attacker: `{type: "attack", object: id, target: ref}` (the defending player ' \
11
+ 'or one of their planeswalkers), or `{type: "done"}` (withheld while a creature that attacks ' \
12
+ "each combat if able could still attack, CR 508.1d, or while a creature that can't attack alone " \
13
+ 'attacks alone, CR 506.5; then `{type: "withdraw", object: id}` undoes an attack).'
14
+ end
15
+
16
+ def self.options(engine, _pending)
17
+ targets = engine.attack_targets
18
+ legal = engine.attack_declaration_legal?
19
+ done = engine.unmet_attack_requirements.empty? && legal ? [DONE] : [] # CR 508.1c-d
20
+ withdraw = legal ? [] : engine.attackers.keys.map { { type: "withdraw", object: it } }
21
+ done + engine.attack_candidates.flat_map { |id| targets.map { { type: "attack", object: id, target: it } } } + withdraw
22
+ end
23
+
24
+ def self.submit(engine, _pending, option)
25
+ case option[:type]
26
+ when "done" then engine.finish_attackers!
27
+ when "withdraw" then engine.withdraw_attacker!(option[:object])
28
+ else engine.declare_attacker!(option[:object], option[:target])
29
+ end
30
+ end
31
+ end
32
+ register("declare_attackers", DeclareAttackers)
33
+
34
+ # CR 509.1: the defending player declares blockers one at a time, then `done`.
35
+ class DeclareBlockers < Base
36
+ def self.doc
37
+ 'Declare one blocker: `{type: "block", object: id, attacker: id}`, `{type: "done"}` (only when the ' \
38
+ 'blocks are legal), or `{type: "unblock", object: id}` to fix an illegal menace block or free a ' \
39
+ "creature for an attacker that must be blocked if able (CR 509.1c)."
40
+ end
41
+
42
+ # "done" is offered only when the blocks are legal (menace, requirements); otherwise blocks can be removed.
43
+ def self.options(engine, _pending)
44
+ blocks = engine.block_options.map { |id, attacker| { type: "block", object: id, attacker: attacker } }
45
+ unblock = engine.menace_violations.flat_map { engine.blockers_of(it) } + engine.requirement_unblocks
46
+ removals = unblock.uniq.map { { type: "unblock", object: it } }
47
+ (engine.blocks_legal? ? [DONE] : []) + blocks + removals
48
+ end
49
+
50
+ def self.submit(engine, _pending, option)
51
+ case option[:type]
52
+ when "done" then engine.finish_blockers!
53
+ when "unblock" then engine.remove_blocker!(option[:object])
54
+ else engine.declare_blocker!(option[:object], option[:attacker])
55
+ end
56
+ end
57
+ end
58
+ register("declare_blockers", DeclareBlockers)
59
+
60
+ # CR 510.1c: assign one point of a multiply-blocked attacker's damage to one of its blockers.
61
+ class AssignDamage < Base
62
+ def self.doc
63
+ 'Assign one point of combat damage of `attacker`: `{type: "assign", object: blocker_id}`. ' \
64
+ "`remaining` points are left."
65
+ end
66
+
67
+ def self.options(engine, pending)
68
+ engine.assignment_targets(pending["attacker"]).map { { type: "assign", object: it } }
69
+ end
70
+
71
+ def self.submit(engine, pending, option) = engine.assign_damage!(pending["attacker"], option[:object])
72
+ end
73
+ register("assign_damage", AssignDamage)
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 514.1: during cleanup the active player discards down to maximum hand size,
6
+ # one card per decision.
7
+ class Discard < Base
8
+ def self.doc
9
+ 'Cleanup step: discard one card, `{type: "discard", object: id}`, until the hand ' \
10
+ "has 7 cards. `count` is how many remain (CR 514.1)."
11
+ end
12
+
13
+ def self.options(engine, pending)
14
+ engine.player(pending["player"]).hand.map { |id| { type: "discard", object: id } }
15
+ end
16
+
17
+ def self.submit(engine, pending, option)
18
+ engine.move!(option[:object], :graveyard, reason: "discard")
19
+ remaining = pending["count"] - 1
20
+ if remaining.positive?
21
+ engine.ask!(pending.merge("count" => remaining))
22
+ else
23
+ engine.turn_based_actions_done!
24
+ end
25
+ end
26
+ end
27
+ register("discard", Discard)
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # A choice an effect asks for while it resolves (scry, surveil, discard, ...). The pending
6
+ # decision carries a human-readable `prompt` and the exact `choices`.
7
+ class EffectChoice < Base
8
+ def self.doc
9
+ "A choice made while an effect resolves (scry, surveil, discard...): submit one of the " \
10
+ "`choices` exactly as listed; `prompt` describes it."
11
+ end
12
+
13
+ def self.options(_engine, pending) = pending["choices"].map { it.transform_keys(&:to_sym) }
14
+ def self.submit(engine, _pending, option) = engine.effect_answer!(option)
15
+
16
+ # Which library card went where is hidden from other players; discards are public anyway.
17
+ def self.redact(option, viewer_is_decider) = viewer_is_decider ? option : option.except(:object)
18
+ end
19
+ register("effect_choice", EffectChoice)
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # An "as this enters" choice (CR 614.12), e.g. pay life or enter tapped, a number or a card name.
6
+ class EnterChoice < Base
7
+ def self.doc
8
+ 'Before `object` enters: `{type: "pay_life", life: n}` (only with enough life) or ' \
9
+ '`{type: "enter_tapped"}` (CR 614.12, 119.4), or `{type: "behold", object: id}` (CR 701.4a); ' \
10
+ '`{type: "number", number: n}` for "choose a number"; ' \
11
+ '`{type: "name", name: "..."}` for "choose a card name" (CR 201.4); `{type: "color", color: "R"}`, ' \
12
+ '`{type: "creature_type", creature_type: "elf"}`, `{type: "card_type", card_type: "artifact"}`, ' \
13
+ '`{type: "basic_land_type", basic_land_type: "forest"}` or ' \
14
+ '`{type: "option", option: "word"}` for the other "choose a ..." abilities (CR 607.2d); devour picks permanents ' \
15
+ 'to sacrifice one at a time, `{type: "devour", object: id}` (`chosen` lists them), then `{type: "done"}` ' \
16
+ "(CR 702.82a). With `effect: true` the permanent " \
17
+ "is being put onto the battlefield by a resolving effect (a token about to be created has `card`); " \
18
+ "with several entering at once, each is asked in turn before any enters (CR 614.12a-b)."
19
+ end
20
+
21
+ def self.options(engine, pending) = engine.entering_options(pending)
22
+ def self.submit(engine, _pending, option) = engine.enter_choice!(option)
23
+ end
24
+ register("enter_choice", EnterChoice)
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 704.5j: choose which of your same-named legendary permanents to keep.
6
+ class LegendRule < Base
7
+ def self.doc
8
+ 'Keep one of your legendary permanents named `name`: `{type: "keep", object: id}`; the others go ' \
9
+ "to the graveyard (CR 704.5j)."
10
+ end
11
+
12
+ def self.options(engine, _pending)
13
+ frame = engine.state.frames.rfind { it["type"] == "legend_rule" }
14
+ engine.legend_candidates(frame).map { { type: "keep", object: it } }
15
+ end
16
+
17
+ def self.submit(engine, _pending, option) = engine.keep_legend!(option[:object])
18
+ end
19
+ register("legend_rule", LegendRule)
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 103.5: keep the opening hand or take a (London) mulligan.
6
+ class Mulligan < Base
7
+ def self.doc
8
+ 'Keep the hand (`{type: "keep"}`) or take a London mulligan (`{type: ' \
9
+ '"mulligan"}`). Players declare in turn order; mulligans then happen ' \
10
+ "simultaneously (CR 103.5)."
11
+ end
12
+
13
+ KEEP = { type: "keep" }.freeze
14
+ MULLIGAN = { type: "mulligan" }.freeze
15
+
16
+ def self.options(engine, pending)
17
+ engine.player(pending["player"]).mulligans < Engine::MAX_MULLIGANS ? [KEEP, MULLIGAN] : [KEEP]
18
+ end
19
+
20
+ def self.submit(engine, pending, option)
21
+ engine.declare_mulligan!(pending["player"], option[:type])
22
+ end
23
+ end
24
+ register("mulligan", Mulligan)
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 603.3b: a player with several triggered abilities to put on the stack chooses the
6
+ # order, one per decision: the chosen trigger goes on the stack next (so it resolves later).
7
+ class OrderTriggers < Base
8
+ def self.doc
9
+ 'Put one of your pending triggered abilities on the stack next: `{type: "trigger", trigger: id}` ' \
10
+ "(CR 603.3b). The last one put on the stack resolves first."
11
+ end
12
+
13
+ def self.options(engine, pending)
14
+ engine.pending_triggers_for(pending["player"]).map { { type: "trigger", trigger: it["id"] } }
15
+ end
16
+
17
+ def self.submit(engine, _pending, option) = engine.choose_trigger!(option[:trigger])
18
+ end
19
+ register("order_triggers", OrderTriggers)
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 103.1: the winner of the die roll chooses to play first or draw first.
6
+ class PlayOrDraw < Base
7
+ def self.doc = 'The die-roll winner chooses `{type: "play"}` or `{type: "draw"}` (CR 103.1).'
8
+
9
+ OPTIONS = [{ type: "play" }.freeze, { type: "draw" }.freeze].freeze
10
+
11
+ def self.options(_engine, _pending) = OPTIONS
12
+
13
+ def self.submit(engine, pending, option)
14
+ engine.choose_starting_player!(pending["player"], option[:type] == "play")
15
+ end
16
+ end
17
+ register("play_or_draw", PlayOrDraw)
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 117: the player with priority may take any legal action or pass.
6
+ # Options are the union of every registered priority action (Enjine::Actions).
7
+ class Priority < Base
8
+ def self.doc
9
+ "The player holding priority takes an action or passes. Options come from the priority actions below (CR 117)."
10
+ end
11
+
12
+ def self.options(engine, pending)
13
+ Actions::PRIORITY.flat_map { |action| action.options(engine, pending["player"]) }
14
+ end
15
+
16
+ def self.submit(engine, pending, option)
17
+ Actions.fetch(option[:type]).perform(engine, pending["player"], option)
18
+ end
19
+ end
20
+ register("priority", Priority)
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 502.3: in their untap step, the active player decides whether a permanent they "may choose not
6
+ # to untap" untaps. One decision per such tapped permanent (Engine::TurnStructure).
7
+ class UntapChoice < Base
8
+ def self.doc
9
+ 'Untap step: `{type: "untap", object: id}` or `{type: "keep_tapped", object: id}` for a permanent you may ' \
10
+ "choose not to untap (CR 502.3)."
11
+ end
12
+
13
+ def self.options(_engine, pending)
14
+ [{ type: "untap", object: pending["object"] }, { type: "keep_tapped", object: pending["object"] }]
15
+ end
16
+
17
+ def self.submit(engine, _pending, option) = engine.choose_untap!(option[:object], option[:type] == "untap")
18
+ end
19
+ register("untap_choice", UntapChoice)
20
+ end
21
+
22
+ module Frames
23
+ # The active player's untap-step choices, then the untap itself (CR 502.3).
24
+ class UntapChoice < Base
25
+ def self.step(engine, frame) = engine.step_untap_choice!(frame)
26
+ def self.decisions = %w[untap_choice]
27
+
28
+ def self.validate(frame, data, errors)
29
+ ids = Array(frame["pending"]) + Array(frame["keep"])
30
+ errors << "frames: untap_choice permanents must be on the battlefield" unless ids.all? { data["battlefield"].include?(it) }
31
+ end
32
+ end
33
+ register("untap_choice", UntapChoice)
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Every kind of pending decision. A decision kind is a class with:
5
+ # .options(engine, pending) -> Array of option Hashes (symbol keys, JSON-safe values)
6
+ # .submit(engine, pending, option)
7
+ # .redact(option, viewer_is_decider) -> what another viewer may see of the chosen option
8
+ module Decisions
9
+ KINDS = {} # rubocop:disable Style/MutableConstant -- extension registry
10
+
11
+ def self.register(kind, klass)
12
+ KINDS[kind] = klass
13
+ end
14
+
15
+ def self.fetch(kind) = KINDS.fetch(kind) { raise ArgumentError, "unknown decision kind #{kind.inspect}" }
16
+
17
+ # Base class: options are public unless a subclass says otherwise.
18
+ class Base
19
+ def self.redact(option, _viewer_is_decider) = option
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A player's deck: the card definitions it uses plus the main decklist, and optionally the cards the
5
+ # player owns outside the game.
6
+ #
7
+ # Deck.from_h({"cards" => [{...Forest...}], "main" => [{"card" => "Forest", "count" => 60}],
8
+ # "outside" => [{"card" => "Forest", "count" => 2}]})
9
+ #
10
+ # Constructed rules (CR 100.2a): at least 60 cards, at most 4 copies of any card
11
+ # that is not exempt (basic lands). An invalid deck can still be built and edited;
12
+ # it is simply not #valid?, so its player is not ready.
13
+ #
14
+ # Outside the game (CR 400.11, 400.11a): the engine doesn't track a match, so a deck carries the cards
15
+ # its player owns outside the game as `outside`: a constructed sideboard of at most 15 cards (CR 100.4a).
16
+ # The four-copy limit applies to the deck and the outside list together, and outside cards don't count
17
+ # toward the 60. Effects that bring a card into the game from outside it take the card from this list.
18
+ class Deck
19
+ MIN_SIZE = 60
20
+ MAX_COPIES = 4
21
+ MAX_OUTSIDE = 15
22
+
23
+ attr_reader :catalog
24
+ attr_writer :guard
25
+
26
+ def self.from_h(hash)
27
+ return hash if hash.is_a?(Deck)
28
+ raise InvalidDeck.new(["deck must be an object"]) unless hash.is_a?(Hash)
29
+
30
+ doc = hash.transform_keys(&:to_s).except("name", "description")
31
+ lists = %w[cards main outside].to_h { [it, doc.fetch(it, [])] }
32
+ lists.each { |key, list| raise InvalidDeck.new(["#{key}: must be a list"]) unless list.is_a?(Array) }
33
+
34
+ new(cards: lists["cards"], main: lists["main"], outside: lists["outside"])
35
+ end
36
+
37
+ def initialize(cards: [], main: [], outside: [])
38
+ @catalog = Catalog.new
39
+ cards.each { @catalog.add(it) }
40
+ @entries = {}
41
+ @outside = {}
42
+ @reference_errors = []
43
+ main.each_with_index { |entry, i| load_entry(entry, i, @entries, "main") }
44
+ outside.each_with_index { |entry, i| load_entry(entry, i, @outside, "outside") }
45
+ end
46
+
47
+ # Ordered {id => count}.
48
+ def entries = @entries.dup.freeze
49
+ def size = @entries.values.sum
50
+ def count(card) = @entries.fetch(CardDefinition.slug(card), 0)
51
+
52
+ # Flat list of definition ids, one per physical card.
53
+ def card_ids = @entries.flat_map { |id, n| [id] * n }
54
+
55
+ # The cards outside the game: ordered {id => count}, their number, and one definition id per card.
56
+ def outside_entries = @outside.dup.freeze
57
+ def outside_size = @outside.values.sum
58
+ def outside_ids = @outside.flat_map { |id, n| [id] * n }
59
+
60
+ def errors
61
+ errs = @reference_errors.dup
62
+ errs << "deck has #{size} cards; at least #{MIN_SIZE} are required (CR 100.2a)" if size < MIN_SIZE
63
+ errs << "outside: #{outside_size} cards; at most #{MAX_OUTSIDE} are allowed (CR 100.4a)" if outside_size > MAX_OUTSIDE
64
+ @entries.merge(@outside) { |_, a, b| a + b }.each do |id, n|
65
+ limit = @catalog.fetch(id).deck_limit # nil: any number (basic lands, "deck_limit": "any")
66
+ next if limit.nil? || n <= limit
67
+
68
+ errs << "#{@catalog.fetch(id).name}: #{n} copies; at most #{limit} are allowed (CR 100.2a#{", 100.4a" if @outside.key?(id)})"
69
+ end
70
+ errs
71
+ end
72
+
73
+ def valid? = errors.empty?
74
+
75
+ # Adds a card definition this deck can then use.
76
+ def define(definition)
77
+ check_guard!
78
+ @catalog.add(definition)
79
+ end
80
+
81
+ def add(card, count: 1)
82
+ check_guard!
83
+ id = resolve!(card)
84
+ @entries[id] = @entries.fetch(id, 0) + positive!(count)
85
+ self
86
+ end
87
+
88
+ # Adds cards the player owns outside the game (CR 400.11a).
89
+ def add_outside(card, count: 1)
90
+ check_guard!
91
+ id = resolve!(card)
92
+ @outside[id] = @outside.fetch(id, 0) + positive!(count)
93
+ self
94
+ end
95
+
96
+ def remove(card, count: 1)
97
+ check_guard!
98
+ id = resolve!(card)
99
+ have = @entries.fetch(id, 0)
100
+ raise InvalidDeck.new(["cannot remove #{count} #{card}: deck has #{have}"]) if have < positive!(count)
101
+
102
+ have == count ? @entries.delete(id) : @entries[id] = have - count
103
+ self
104
+ end
105
+
106
+ # Swaps cards between games; no limit on how many.
107
+ def swap(out:, in:, count: 1)
108
+ incoming = binding.local_variable_get(:in)
109
+ check_guard!
110
+ resolve!(incoming)
111
+ remove(out, count: count)
112
+ add(incoming, count: count)
113
+ end
114
+
115
+ def to_h
116
+ # Tokens aren't listed: the cards that create them bring them back (two tokens may share a name).
117
+ doc = { "cards" => @catalog.reject(&:token?).map(&:to_h), "main" => @entries.map { |id, n| { "card" => id, "count" => n } } }
118
+ doc["outside"] = @outside.map { |id, n| { "card" => id, "count" => n } } unless @outside.empty?
119
+ doc
120
+ end
121
+
122
+ private
123
+
124
+ def load_entry(entry, index, into, list)
125
+ card, count = entry.is_a?(Hash) ? entry.transform_keys(&:to_s).values_at("card", "count") : []
126
+ unless card.is_a?(String) && count.is_a?(Integer) && count.positive?
127
+ return @reference_errors << "#{list}[#{index}]: must be {\"card\": name, \"count\": positive integer}"
128
+ end
129
+ return @reference_errors << "#{list}[#{index}].card: #{card.inspect} is not defined in cards" unless @catalog.include?(card)
130
+
131
+ id = CardDefinition.slug(card)
132
+ into[id] = into.fetch(id, 0) + count
133
+ end
134
+
135
+ def resolve!(card)
136
+ raise InvalidDeck.new(["card #{card.inspect} is not defined in this deck"]) unless @catalog.include?(card)
137
+
138
+ CardDefinition.slug(card)
139
+ end
140
+
141
+ def positive!(count)
142
+ raise ArgumentError, "count must be a positive integer" unless count.is_a?(Integer) && count.positive?
143
+
144
+ count
145
+ end
146
+
147
+ def check_guard!
148
+ @guard&.call
149
+ end
150
+ end
151
+ end