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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # The exceptions of a copy effect (CR 707.9a-b), e.g. {"legendary": false} ("except it isn't
6
+ # legendary") or {"keywords": ["menace"]} ("except it has menace"). They become part of the copy's
7
+ # copiable values, so a copy of the copy has them too (CR 707.2). See Engine::Copies.
8
+ module CopyExcept
9
+ KEYS = %w[legendary keywords types subtypes supertypes name power toughness colors].freeze
10
+ LISTS = { "keywords" => :keyword, "types" => :type, "supertypes" => :supertype }.freeze
11
+ PARAM = "{\"legendary\": false, \"keywords\": [...], \"types\": [...], \"subtypes\": [...], " \
12
+ "\"supertypes\": [...], \"name\": ..., \"power\": n, \"toughness\": n, \"colors\": [...]}"
13
+
14
+ module_function
15
+
16
+ def build(raw, errors, path)
17
+ return {}.freeze if raw.nil?
18
+ return (errors << "#{path}: must be an object #{PARAM}") && {}.freeze unless raw.is_a?(Hash)
19
+
20
+ raw = raw.transform_keys(&:to_s)
21
+ Mechanics.check_keys(raw, KEYS, errors, path)
22
+ out = {}
23
+ errors << "#{path}.legendary: must be false (\"it isn't legendary\")" if raw.key?("legendary") && raw["legendary"] != false
24
+ out["legendary"] = false if raw.key?("legendary")
25
+ LISTS.each { |key, category| out[key] = names(raw[key], category, errors, "#{path}.#{key}") if raw.key?(key) }
26
+ out["subtypes"] = subtypes(raw["subtypes"], errors, "#{path}.subtypes") if raw.key?("subtypes")
27
+ values(raw, out, errors, path)
28
+ out["colors"] = colors(raw["colors"], errors, "#{path}.colors") if raw.key?("colors")
29
+ out.freeze
30
+ end
31
+
32
+ # "name", "power", "toughness": values the copy has instead of the copied ones.
33
+ def values(raw, out, errors, path)
34
+ out["name"] = raw["name"] if raw.key?("name")
35
+ errors << "#{path}.name: must be a non-empty string" if raw.key?("name") && !(raw["name"].is_a?(String) && !raw["name"].empty?)
36
+ %w[power toughness].each do |key|
37
+ next unless raw.key?(key)
38
+
39
+ errors << "#{path}.#{key}: must be an integer" unless raw[key].is_a?(Integer)
40
+ out[key] = raw[key]
41
+ end
42
+ end
43
+
44
+ def names(list, category, errors, path)
45
+ ok = list.is_a?(Array) && !list.empty? && list.all? { Enjine.registry.fetch(category, it.to_s.downcase.tr(" -", "__")) }
46
+ errors << "#{path}: must be a non-empty list of #{category}s (#{Enjine.registry.names(category).join(", ")})" unless ok
47
+ ok ? list.map { it.to_s.downcase.tr(" -", "__") }.freeze : [].freeze
48
+ end
49
+
50
+ def subtypes(list, errors, path)
51
+ ok = list.is_a?(Array) && !list.empty? && list.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
52
+ errors << "#{path}: must be a non-empty list of lowercase subtypes" unless ok
53
+ ok ? list.map(&:to_s).freeze : [].freeze
54
+ end
55
+
56
+ def colors(list, errors, path)
57
+ ok = list.is_a?(Array) && list.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
58
+ errors << "#{path}: must be a list of W, U, B, R, G ([] for colorless)" unless ok
59
+ ok ? list.map { it.to_s.upcase }.freeze : [].freeze
60
+ end
61
+
62
+ def to_s(except)
63
+ return "" if except.empty?
64
+
65
+ bits = []
66
+ bits << "not legendary" if except["legendary"] == false
67
+ %w[name power toughness].each { bits << "#{it} #{except[it]}" if except.key?(it) }
68
+ %w[keywords types subtypes supertypes colors].each { bits << "#{it} #{except[it].join("/")}" if except[it] }
69
+ " (except #{bits.join(", ")})"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # Costs that need choices while casting or activating (CR 601.2h, 602.2b): which permanents to tap
7
+ # or sacrifice, which counters to remove. Each choice is a `choose_cost` decision recorded in the
8
+ # cast frame's "paid" list ({"cost": index, "object": id, ...}); the spell or ability keeps that list
9
+ # (GameObject#paid) so effects can ask about "the sacrificed creature". Tapping happens at once
10
+ # (cancelling untaps); sacrificing and removing counters happen as the spell or ability becomes cast.
11
+ class Choosing < Primitive
12
+ attr_reader :count, :filter
13
+
14
+ def self.build(params, errors, path, **)
15
+ Mechanics.check_keys(params, keys, errors, path)
16
+ raw = Mechanics.fetch_param(params, :filter) || default_filter
17
+ filter = Mechanics.build_entry(:filter, raw, errors, "#{path}.filter")
18
+ count = Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
19
+ new(filter, count, Mechanics.flag_param(params, :other, errors, path), params, errors, path)
20
+ end
21
+
22
+ def self.keys = %w[filter count other]
23
+ def self.default_filter = "permanent"
24
+
25
+ def initialize(filter, count, other, _params, _errors, _path)
26
+ super()
27
+ @filter = filter
28
+ @count = count
29
+ @other = other
30
+ freeze
31
+ end
32
+
33
+ def mana? = false
34
+ def choices_needed = count
35
+
36
+ # Permanents the controller of `object` controls that match (not `object` itself for "another").
37
+ def matching(engine, object)
38
+ engine.state.battlefield.select do |id|
39
+ perm = engine.object(id)
40
+ perm.controller == object.controller && !(@other && id == object.id) &&
41
+ @filter.match_for?(engine, id, object.controller, object.id)
42
+ end
43
+ end
44
+
45
+ # The options still open, given the choices already made for this cost (`chosen`: paid entries).
46
+ def choice_options(engine, object, chosen)
47
+ taken = chosen.map { it["object"] }
48
+ candidates(engine, object).reject { taken.include?(it) }.map { { object: it } }
49
+ end
50
+
51
+ def candidates(engine, object) = matching(engine, object)
52
+ def payable?(engine, object) = candidates(engine, object).size >= count
53
+
54
+ # Called when a choice is made: the paid entry to record (and any immediate payment).
55
+ def choose!(_engine, option) = { "object" => option[:object] }
56
+
57
+ # Called as the spell or ability becomes cast, with this cost's paid entries.
58
+ def pay_chosen!(_engine, _object, _entries) = nil
59
+
60
+ # Costs without choices of their own are paid by ActivatedAbility's finishing step.
61
+ def pay!(_engine, _object) = nil
62
+
63
+ def what = @filter.is_a?(Class) ? @filter.primitive_name.tr("_", " ") : @filter.to_s
64
+
65
+ def amount_words
66
+ if count == 1
67
+ @other ? "another" : "a"
68
+ else
69
+ count.to_s
70
+ end
71
+ end
72
+ end
73
+
74
+ # `count` cards from one of your zones (`zone`) matching the card spec `card`, never the card the spell
75
+ # or ability belongs to: "discard a card", "exile two cards from your graveyard" (CR 601.2h, 602.2b).
76
+ class CardsCost < Choosing
77
+ def self.keys = %w[count card]
78
+ def self.zone = raise(NotImplementedError)
79
+
80
+ # `count` "X": as many as the X announced for the spell or ability (CR 601.2b, 107.3a).
81
+ def self.build(params, errors, path, **)
82
+ Mechanics.check_keys(params, keys, errors, path)
83
+ count = if Mechanics.fetch_param(params, :count) == "X" then "X"
84
+ else Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
85
+ end
86
+ raw = Mechanics.fetch_param(params, :card)
87
+ spec = raw && CardSpec.build(raw, errors, "#{path}.card")
88
+ new(spec, count, false, params, errors, path)
89
+ end
90
+
91
+ def candidates(engine, object)
92
+ engine.player(object.controller).public_send(self.class.zone).select do |id|
93
+ id != object.id && (filter.nil? || filter.match?(engine, engine.object(id)))
94
+ end
95
+ end
96
+
97
+ # "Exile X cards": the engine asks for as many as X (Engine::CastChoices#cost_chosen?); X can be at
98
+ # most the number of matching cards (Engine::Costs#x_limit).
99
+ def x? = count == "X"
100
+ def choices_needed = x? ? 0 : super
101
+ def payable?(engine, object) = x? || super
102
+ def x_limit(engine, object) = candidates(engine, object).size
103
+
104
+ # As it becomes cast, the chosen cards still where they were chosen are paid (exiled by default).
105
+ def pay_chosen!(engine, _object, entries)
106
+ entries.map { it["object"] }.select { engine.state.objects[it]&.zone == self.class.zone }.each { pay_card!(engine, it) }
107
+ end
108
+
109
+ def pay_card!(engine, id) = engine.move!(id, :exile, reason: "cost")
110
+
111
+ def cards_text = "#{count == 1 ? "a" : count} #{filter || "card"}#{"s" unless count == 1}"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # Behold (CR 701.4a): "behold a [quality]" means reveal a [quality] card from your hand or choose a
6
+ # [quality] permanent you control. Whether a [quality] was beheld looks at the object as it was beheld
7
+ # (CR 701.4b).
8
+ module Beholding
9
+ # The cards in `index`'s hand and the permanents they control matching the card spec.
10
+ def self.candidates(engine, index, spec)
11
+ perms = engine.state.battlefield.select { engine.object(it).controller == index }
12
+ (perms + engine.player(index).hand).select { spec.match?(engine, engine.object(it)) }
13
+ end
14
+
15
+ # Reveals a card from the hand (CR 701.4a); the entry remembers what was beheld (CR 701.4b).
16
+ def self.behold!(engine, index, id)
17
+ obj = engine.object(id)
18
+ engine.reveal!(index, [id]) if obj.zone == :hand
19
+ facts = engine.chars(obj)
20
+ { "object" => id, "zone" => obj.zone.to_s, "types" => facts.types.map(&:to_s),
21
+ "subtypes" => facts.subtypes.map(&:to_s) }
22
+ end
23
+ end
24
+
25
+ module Costs
26
+ # "Behold a Goblin" / "Behold three Elementals" / "Behold a Kithkin and exile it" as a cost (CR 701.4a,
27
+ # 601.2h). Each choice is a card in your hand or a permanent you control matching `card`; with `exile`
28
+ # they are exiled as the spell or ability becomes cast, linked to it ("the exiled card", CR 607.2a). A
29
+ # spell's linked cards stay linked to the permanent it becomes.
30
+ class Behold < Choosing
31
+ primitive :cost, :behold,
32
+ description: "Behold `count` (default 1) objects matching the card spec `card`: each a card " \
33
+ "revealed from your hand or a permanent you control, chosen while casting or " \
34
+ "activating. `exile: true` also exiles them as it becomes cast; they are then " \
35
+ "\"exiled with\" it (selector `exiled_with`).",
36
+ rules: %w[701.4a 701.4b 601.2h 602.2b 118.3 607.2a],
37
+ params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"goblin\"]}",
38
+ "count" => "integer (default 1)", "exile" => "true: exile what was beheld" },
39
+ example: { "behold" => { "card" => { "subtypes" => ["goblin"] }, "exile" => true } }
40
+
41
+ def self.keys = %w[card count exile]
42
+
43
+ def self.build(params, errors, path, **)
44
+ Mechanics.check_keys(params, keys, errors, path)
45
+ count = Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
46
+ raw = Mechanics.fetch_param(params, :card)
47
+ errors << "#{path}.card: required" if raw.nil?
48
+ new(CardSpec.build(raw, errors, "#{path}.card"), count, false, params, errors, path)
49
+ end
50
+
51
+ def initialize(filter, count, other, params, errors, path)
52
+ @exile = Mechanics.flag_param(params, :exile, errors, path)
53
+ super
54
+ end
55
+
56
+ # Not the card being cast (it is on the stack once casting begins).
57
+ def candidates(engine, object) = Beholding.candidates(engine, object.controller, filter) - [object.id]
58
+ def choose!(engine, option) = Beholding.behold!(engine, engine.object(option[:object]).controller, option[:object])
59
+
60
+ def pay_chosen!(engine, object, entries)
61
+ return unless @exile
62
+
63
+ entries.each do |entry|
64
+ card = engine.state.objects[entry["object"]]
65
+ next unless card&.zone.to_s == entry["zone"]
66
+
67
+ fresh = engine.move!(card.id, :exile, reason: "cost")
68
+ engine.link_exiled!(object.id, fresh.id) if fresh
69
+ end
70
+ end
71
+
72
+ def to_s = "Behold #{count == 1 ? "a" : count} #{filter.to_s.delete_suffix(" card")}#{" and exile it" if @exile}"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Discard this card" as a cost (CR 701.9a, 602.2b). It moves the card out of its owner's
7
+ # hand, so an ability with this cost functions only from the hand (CR 113.6m).
8
+ class DiscardSelf < Primitive
9
+ primitive :cost, :discard_self,
10
+ description: "Discard the card the ability belongs to (it goes from your hand to your graveyard). " \
11
+ "An ability with this cost is activated from your hand.",
12
+ rules: %w[701.9a 602.2b 113.6m 118.3],
13
+ example: "discard_self"
14
+
15
+ INSTANCE = new.freeze
16
+
17
+ def self.build(params, errors, path, **)
18
+ Mechanics.check_keys(params, [], errors, path)
19
+ INSTANCE
20
+ end
21
+
22
+ def mana? = false
23
+ def zone = :hand
24
+ def payable?(engine, object) = engine.player(object.owner).hand.include?(object.id)
25
+ def pay!(engine, object) = engine.move!(object.id, :graveyard, reason: "discard")
26
+ def to_s = "Discard this card"
27
+ end
28
+
29
+ # "Discard a card" / "Discard two cards" / "Discard a creature card" / "Discard a card at random" as a
30
+ # cost (CR 701.9a-b, 601.2h): cards from your hand other than this one, chosen while casting or activating
31
+ # and discarded as the spell or ability becomes cast. Random discards are paid last (CR 601.2h).
32
+ class Discard < CardsCost
33
+ primitive :cost, :discard,
34
+ description: "Discard `count` cards (default 1) from your hand, never this card, matching the card " \
35
+ "spec `card` if given: chosen while casting or activating and discarded as the spell " \
36
+ "or ability becomes cast. `random: true` discards them at random instead.",
37
+ rules: %w[701.9a 701.9b 601.2h 602.2b 118.3],
38
+ params: { "count" => "integer (default 1), or \"X\": the X announced",
39
+ "card" => "card spec, e.g. {\"types\": [\"land\"]}",
40
+ "random" => "true: at random (CR 701.9b)" },
41
+ example: { "discard" => { "count" => 1 } }
42
+
43
+ def self.keys = %w[count card random]
44
+ def self.zone = :hand
45
+
46
+ def initialize(filter, count, other, params, errors, path)
47
+ @random = Mechanics.flag_param(params, :random, errors, path)
48
+ super
49
+ end
50
+
51
+ def choices_needed = @random ? 0 : count
52
+
53
+ def pay_chosen!(engine, object, entries)
54
+ return super unless @random
55
+
56
+ left = candidates(engine, object)
57
+ [count, left.size].min.times { pay_card!(engine, left.delete_at(engine.state.rng.rand(left.size))) }
58
+ end
59
+
60
+ def pay_card!(engine, id) = engine.move!(id, :graveyard, reason: "discard")
61
+ def to_s = "Discard #{cards_text}#{" at random" if @random}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Exert [this permanent]" as a cost (CR 701.43a): it won't untap during your next untap step. It can be
7
+ # exerted tapped or untapped, and again before that untap step (one untap step is skipped, CR 701.43b);
8
+ # only a permanent can be (CR 701.43c).
9
+ class Exert < Primitive
10
+ primitive :cost, :exert,
11
+ description: "Exert this permanent: it doesn't untap during your next untap step.",
12
+ rules: %w[701.43a 701.43b 701.43c],
13
+ example: "exert"
14
+
15
+ INSTANCE = new.freeze
16
+
17
+ def self.build(params, errors, path, **)
18
+ Mechanics.check_keys(params, [], errors, path)
19
+ INSTANCE
20
+ end
21
+
22
+ def payable?(_engine, object) = object.zone == :battlefield
23
+ def pay!(engine, object) = engine.exert!(object)
24
+ def mana? = false
25
+ def to_s = "Exert this"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Exile a card from your hand" / "Exile two cards from your hand" as a cost (CR 406.1, 601.2h,
7
+ # 602.2b). The cards are chosen while casting or activating and exiled as it becomes cast; never the
8
+ # card the ability belongs to.
9
+ class ExileFromHand < CardsCost
10
+ primitive :cost, :exile_from_hand,
11
+ description: "Exile `count` cards from your hand (default 1), chosen while casting or activating, " \
12
+ "exiled as the spell or ability becomes cast. `card` (a card spec) restricts which.",
13
+ rules: %w[406.1 601.2h 602.2b 118.3],
14
+ params: { "count" => "integer (default 1), or \"X\": the X announced",
15
+ "card" => "card spec, e.g. {\"types\": [\"creature\"]}" },
16
+ example: { "exile_from_hand" => { "count" => 1 } }
17
+
18
+ def self.zone = :hand
19
+ def to_s = "Exile #{cards_text} from your hand"
20
+ end
21
+
22
+ # "Exile a creature card from your graveyard" / "Exile two cards from your graveyard" as a cost (CR 406.1,
23
+ # 601.2h): cards other than the one the ability belongs to, chosen while casting or activating.
24
+ class ExileFromGraveyard < CardsCost
25
+ primitive :cost, :exile_from_graveyard,
26
+ description: "Exile `count` cards from your graveyard (default 1), never this card, chosen while " \
27
+ "casting or activating and exiled as the spell or ability becomes cast. `card` (a card " \
28
+ "spec) restricts which.",
29
+ rules: %w[406.1 601.2h 602.2b 118.3],
30
+ params: { "count" => "integer (default 1), or \"X\": the X announced",
31
+ "card" => "card spec, e.g. {\"types\": [\"creature\"]}" },
32
+ example: { "exile_from_graveyard" => { "count" => 2 } }
33
+
34
+ def self.zone = :graveyard
35
+ def to_s = "Exile #{cards_text} from your graveyard"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Exile this card from your graveyard" / "Put this card from your graveyard on the bottom of your
7
+ # library" as a cost: the ability functions only from the graveyard (CR 113.6m).
8
+ class FromGraveyard < Primitive
9
+ def self.build(params, errors, path, **)
10
+ Mechanics.check_keys(params, [], errors, path)
11
+ new
12
+ end
13
+
14
+ def mana? = false
15
+ def zone = :graveyard
16
+ def uses_source? = true
17
+ def payable?(engine, object) = engine.player(object.owner).graveyard.include?(object.id)
18
+ end
19
+
20
+ # Exile this card from your graveyard (CR 406, 113.6m).
21
+ class ExileSelfFromGraveyard < FromGraveyard
22
+ primitive :cost, :exile_self_from_graveyard,
23
+ description: "Exile this card from your graveyard. An ability with this cost is activated from " \
24
+ "your graveyard.",
25
+ rules: %w[406.1 113.6m 602.2b 118.3],
26
+ example: "exile_self_from_graveyard"
27
+
28
+ def pay!(engine, object) = engine.move!(object.id, :exile, reason: "cost")
29
+ def to_s = "Exile this card from your graveyard"
30
+ end
31
+
32
+ # Put this card from your graveyard on the bottom of your library (CR 401, 113.6m).
33
+ class BottomSelfFromGraveyard < FromGraveyard
34
+ primitive :cost, :bottom_self_from_graveyard,
35
+ description: "Put this card from your graveyard on the bottom of your library. An ability with " \
36
+ "this cost is activated from your graveyard.",
37
+ rules: %w[401.1 113.6m 602.2b 118.3],
38
+ example: "bottom_self_from_graveyard"
39
+
40
+ def pay!(engine, object) = engine.move!(object.id, :library, position: :bottom, reason: "cost")
41
+ def to_s = "Put this card from your graveyard on the bottom of your library"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Get five poison counters" as a cost (CR 118.1, 122.1): the payer gets that many counters of a kind
7
+ # (a player with ten or more poison counters loses the game, CR 704.5c). It can always be paid.
8
+ class GetCounters < Primitive
9
+ primitive :cost, :get_counters,
10
+ description: "You get `amount` counters of the kind `counter` (e.g. poison). It can always be paid.",
11
+ rules: %w[118.1 122.1 704.5c],
12
+ params: { "counter" => "player counter kind, e.g. \"poison\"", "amount" => "integer >= 1" },
13
+ example: { "get_counters" => { "counter" => "poison", "amount" => 5 } }
14
+
15
+ attr_reader :counter, :amount
16
+
17
+ def self.build(params, errors, path, **)
18
+ Mechanics.check_keys(params, %w[counter amount], errors, path)
19
+ counter = Mechanics.fetch_param(params, :counter)
20
+ errors << "#{path}.counter: must be a counter kind, e.g. \"poison\"" unless counter.is_a?(String) && !counter.empty?
21
+ new(counter.to_s, Mechanics.integer_param(params, :amount, errors, path, min: 1))
22
+ end
23
+
24
+ def initialize(counter, amount)
25
+ super()
26
+ @counter = counter
27
+ @amount = amount
28
+ freeze
29
+ end
30
+
31
+ def mana? = false
32
+ def spell_cost? = true
33
+ def payable?(_engine, _object) = true
34
+ def pay!(engine, object) = engine.add_player_counters!(object.controller, counter, amount)
35
+ def to_s = "Get #{amount} #{counter} counter#{"s" unless amount == 1}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # A mana cost for an activated ability, e.g. {"mana": "{1}{R}"} (CR 602.2b, 601.2f).
7
+ class ManaPayment < Primitive
8
+ primitive :cost, :mana,
9
+ description: "Pay mana as part of an activation. Paid during the activation by spending mana from " \
10
+ "your pool, tapping sources one at a time.",
11
+ rules: %w[601.2g 601.2h 602.2b],
12
+ params: { "(value)" => "mana cost string, e.g. \"{1}{R}\"" },
13
+ example: { "mana" => "{1}{R}" }
14
+
15
+ attr_reader :mana_cost
16
+
17
+ def self.scalar_params? = true
18
+
19
+ # Accepts {"mana": "{1}{R}"}; the params object here is the cost string itself.
20
+ def self.build(params, errors, path, **)
21
+ new(Mana.parse_cost(params, errors, path) || Mana::Cost.new(generic: 0, colored: {}))
22
+ end
23
+
24
+ def initialize(mana_cost)
25
+ super()
26
+ @mana_cost = mana_cost
27
+ freeze
28
+ end
29
+
30
+ def mana? = true
31
+ def payable?(_engine, _object) = true
32
+ def pay!(_engine, _object) = nil
33
+ def to_s = mana_cost.to_s
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # A choice between costs: "behold a Goblin or pay {2}", "sacrifice an artifact or discard a nonland card"
7
+ # (CR 118.3, 601.2b). Which one will be paid is announced while casting or activating, before X and the
8
+ # total cost (CR 601.2b, 601.2f, 602.2b): the `choose_cost_option` decision. From then on the chosen cost
9
+ # stands in its place. While resolving (`pay`, ward) the payer chooses as they pay (CR 118.12).
10
+ class OneOf < Primitive
11
+ primitive :cost, :one_of,
12
+ description: "Pay one of the listed costs (two or more), chosen as the spell or ability is cast or " \
13
+ "activated (the `choose_cost_option` decision) or, for `pay` and ward, as it is paid. " \
14
+ "A cost that can't be paid can't be chosen.",
15
+ rules: %w[118.3 601.2b 601.2f 602.2b 118.12],
16
+ params: { "(list)" => "two or more costs" },
17
+ example: { "one_of" => [{ "behold" => { "card" => { "subtypes" => ["goblin"] } } }, { "mana" => "{2}" }] }
18
+
19
+ attr_reader :options
20
+
21
+ def self.scalar_params? = true
22
+
23
+ def self.build(params, errors, path, **)
24
+ unless params.is_a?(Array) && params.size >= 2
25
+ errors << "#{path}: must be a list of at least two costs"
26
+ return new([])
27
+ end
28
+ options = params.each_with_index.filter_map { |c, i| Mechanics.build_entry(:cost, c, errors, "#{path}[#{i}]") }
29
+ errors << "#{path}: a one_of can't contain another one_of" if options.any?(OneOf)
30
+ new(options)
31
+ end
32
+
33
+ def initialize(options)
34
+ super()
35
+ @options = options.freeze
36
+ freeze
37
+ end
38
+
39
+ def mana? = false
40
+
41
+ # Some option could be paid (a mana option is checked against the total cost by the engine).
42
+ def payable?(engine, object) = options.any? { it.mana? || it.payable?(engine, object) }
43
+ # Each option as text: "Behold a Goblin", "Pay {2}".
44
+ def option_texts = options.map { it.mana? ? "Pay #{it}" : it.to_s }
45
+ def to_s = option_texts.each_with_index.map { |text, i| i.zero? ? text : text.sub(/\A\w/, &:downcase) }.join(" or ")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Pay 2 life" / "Pay X life" as a cost (CR 119.4, 118.3b): {"pay_life": 2} or {"pay_life": "X"}. A
7
+ # player can pay life only if their life total is at least that much; 0 life can always be paid
8
+ # (CR 119.4b). X is announced with the spell or ability (CR 601.2b, 107.3a), at most the life total.
9
+ class PayLife < Primitive
10
+ primitive :cost, :pay_life,
11
+ description: "Pay that much life (you lose it). Only possible while your life total is at least " \
12
+ "that much. `\"X\"` pays the X announced for the spell or ability (at most your life " \
13
+ "total); it may be a spell's additional cost.",
14
+ rules: %w[119.4 119.4b 118.3b 601.2b 107.3a],
15
+ params: { "(value)" => "integer >= 1, or \"X\"" },
16
+ example: { "pay_life" => 2 }
17
+
18
+ attr_reader :amount
19
+
20
+ def self.scalar_params? = true
21
+
22
+ def self.build(params, errors, path, **)
23
+ errors << "#{path}: must be an integer >= 1 or \"X\"" unless params == "X" || (params.is_a?(Integer) && params >= 1)
24
+ new(params)
25
+ end
26
+
27
+ def initialize(amount)
28
+ super()
29
+ @amount = amount
30
+ freeze
31
+ end
32
+
33
+ def mana? = false
34
+ def spell_cost? = true
35
+ def x? = @amount == "X"
36
+ def x_limit(engine, object) = [engine.player(object.controller).life, 0].max
37
+
38
+ # X is bounded by the life total as it's announced (Engine::Costs#x_limit).
39
+ def payable?(engine, object) = x? || engine.player(object.controller).life >= @amount.to_i
40
+
41
+ def pay!(engine, object)
42
+ life = x? ? engine.casting&.fetch("x", 0).to_i : @amount.to_i
43
+ engine.change_life!(object.controller, -life) if life.positive?
44
+ end
45
+
46
+ def to_s = "Pay #{@amount} life"
47
+ end
48
+ end
49
+ end
50
+ end