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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "card_faces"
4
+
5
+ module Enjine
6
+ # Immutable, validated blueprint of a card, built from a data block.
7
+ #
8
+ # CardDefinition.from_h({"name" => "Forest", "types" => ["land"], ...})
9
+ #
10
+ # Every type, supertype, subtype with rules, ability and cost is looked up in
11
+ # Enjine.registry. Anything unknown is reported (with its path) in a single
12
+ # InvalidCardDefinition error.
13
+ class CardDefinition
14
+ KEYS = %w[name mana_cost colors types supertypes subtypes keywords power toughness loyalty abilities rules_text deck_limit].freeze
15
+ # Optional catalogue data beside the rules data, never read by the rules: rarity, and the set code and
16
+ # collector number printed on the card ({"set" => "trk", "number" => "12"}).
17
+ PRINT_KEYS = %w[rarity printing].freeze
18
+ SUBTYPE_FORMAT = /\A[a-z][a-z0-9_]*\z/
19
+ include Faces
20
+
21
+ attr_reader :id, :name, :mana_cost, :types, :supertypes, :subtypes, :keywords, :power, :toughness, :loyalty,
22
+ :abilities, :rules_text, :type_modules, :source, :colors, :power_amount, :toughness_amount, :rarity, :printing
23
+
24
+ # Canonical id derived from a card name: "Jace, the Mind Sculptor" -> "jace_the_mind_sculptor".
25
+ # A multi-face card is named by its front face: "Alfa // Bravo" -> "alfa" (see CardDefinition::Faces).
26
+ def self.slug(name)
27
+ name.to_s.split(" // ").first.to_s.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.delete("'’")
28
+ .gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
29
+ end
30
+
31
+ # A token definition (CR 111.1). Its id is derived from its data so two different tokens
32
+ # with the same name ("Soldier" 1/1 and "Soldier" 2/2) never collide in a catalog.
33
+ def self.token(data)
34
+ card = data.transform_keys(&:to_s)
35
+ digest = Digest::SHA256.hexdigest(JSON.generate(card))[0, 8]
36
+ errors = []
37
+ new(card, errors, token_id: "#{slug(card["name"])}_token_#{digest}").tap do
38
+ raise InvalidCardDefinition.new(errors, name: card["name"]) unless errors.empty?
39
+ end
40
+ end
41
+
42
+ def self.from_h(hash)
43
+ return hash if hash.is_a?(CardDefinition)
44
+
45
+ errors = []
46
+ raise InvalidCardDefinition.new(["card definition must be an object, got #{hash.class}"]) unless hash.is_a?(Hash)
47
+
48
+ card = hash.transform_keys(&:to_s)
49
+ new(card, errors).tap do
50
+ raise InvalidCardDefinition.new(errors, name: card["name"]) unless errors.empty?
51
+ end
52
+ end
53
+
54
+ # `face`: [front definition, index] when building a face other than the front (CardDefinition::Faces).
55
+ def initialize(card, errors, token_id: nil, face: nil)
56
+ card = read_print(card, errors) if face.nil?
57
+ if face.nil? && card.key?("faces")
58
+ read_faces(card, errors, token_id)
59
+ else
60
+ read_card(card, errors, token_id, face)
61
+ @source = normalize(card)
62
+ end
63
+ @rules_data = @source
64
+ @source = deep_freeze(@source.merge("rarity" => @rarity, "printing" => @printing).compact) if @rarity || @printing
65
+ @printed_characteristics = Characteristics.printed(self) if errors.empty?
66
+ freeze
67
+ end
68
+
69
+ def read_card(card, errors, token_id, face = nil)
70
+ (card.keys - KEYS).each { |k| errors << "#{k}: unknown field (allowed: #{(KEYS + PRINT_KEYS).join(", ")})" }
71
+ @name = read_name(card, errors)
72
+ @id = face ? "#{face[0].id}#{FACE_SEPARATOR}#{face[1]}" : token_id || self.class.slug(@name)
73
+ @front, @face_index = face
74
+ @token = !token_id.nil?
75
+ @types, @type_modules = read_names(card, "types", :type, errors, required: true)
76
+ @supertypes, = read_names(card, "supertypes", :supertype, errors)
77
+ @subtypes = read_subtypes(card, errors)
78
+ @mana_cost = card.key?("mana_cost") ? Mana.parse_cost(card["mana_cost"], errors) : nil
79
+ @keywords, keyword_modules = read_names(card, "keywords", :keyword, errors)
80
+ @colors = read_colors(card, errors)
81
+ @deck_limit = read_deck_limit(card, errors)
82
+ @power, @power_amount = read_pt(card, "power", errors)
83
+ @toughness, @toughness_amount = read_pt(card, "toughness", errors)
84
+ @loyalty = read_stat(card, "loyalty", errors)
85
+ @rules_text = card["rules_text"]
86
+ errors << "rules_text: must be a string" unless @rules_text.nil? || @rules_text.is_a?(String)
87
+ @abilities = read_abilities(card, errors)
88
+ @type_modules.each { |mod| mod.validate(card, errors) }
89
+ keyword_modules.each { |mod| mod.validate(card, errors) if mod.respond_to?(:validate) }
90
+ precompute_predicates
91
+ validate_type_specific(errors)
92
+ end
93
+ private :read_card
94
+
95
+ def type?(type) = @types.include?(type.to_sym)
96
+ def supertype?(type) = @supertypes.include?(type.to_sym)
97
+ def keyword?(keyword) = @keywords.include?(keyword.to_sym)
98
+
99
+ # Predicates below are computed once at construction: they sit on the engine's hot path.
100
+ attr_reader :spell_ability, :ability_instances
101
+
102
+ # Characteristics as printed: the starting point of the layer system (CR 613).
103
+ attr_reader :printed_characteristics
104
+
105
+ # Any ability of the card by reference: printed or intrinsic, one a station symbol grants only
106
+ # while it applies (CR 722.2a), or a delayed triggered ability one of its effects creates
107
+ # ("delayed:<path>", see Effects::DelayedTrigger).
108
+ def ability(ref)
109
+ (@ability_instances.find { it.ref == ref } || @nested_instances.find { it.ref == ref })&.ability ||
110
+ @delayed_abilities[ref] || @effect_granted.values.flatten.find { it.ref == ref }&.ability ||
111
+ (ref.to_s.start_with?("keyword:") && CardDefinition.keyword_instances(ref.delete_prefix("keyword:")).first&.ability) ||
112
+ CardDefinition.keyword_delayed(ref)
113
+ end
114
+
115
+ # A delayed triggered ability created by a keyword's ability (decayed's "sacrifice it at end of combat"):
116
+ # it's part of the keyword's data, referenced "delayed:keyword:<name>...".
117
+ def self.keyword_delayed(ref)
118
+ keyword = ref.to_s[/\Adelayed:keyword:([a-z_]+)/, 1] or return nil
119
+ every = ->(effects) { effects.flat_map { [it, *every.call(it.nested_effects)] } }
120
+ every.call(keyword_instances(keyword).flat_map { it.ability.try_effects })
121
+ .find { it.respond_to?(:delayed_ability) && it.ref == ref }&.delayed_ability
122
+ end
123
+
124
+ # The abilities a keyword stands for ("Prowess" is a triggered ability, CR 702.108a), as instances
125
+ # referenced "keyword:<name>"; the same for a printed keyword and one an effect grants (CR 613.1f).
126
+ def self.keyword_instances(keyword)
127
+ rule = Enjine.registry.fetch(:keyword, keyword.to_s)
128
+ abilities = rule.respond_to?(:intrinsic_abilities) ? rule.intrinsic_abilities : []
129
+ abilities.map { AbilityInstance.new(ref: "keyword:#{keyword}", ability: it) }
130
+ end
131
+
132
+ # The abilities a subtype grants ("Forest" has "{T}: Add {G}.", CR 305.6), referenced "intrinsic:<subtype>";
133
+ # the same for a printed subtype and one an effect adds.
134
+ def self.intrinsic_instances(subtype)
135
+ rule = Enjine.registry.fetch(:subtype, subtype.to_s)
136
+ abilities = rule.respond_to?(:intrinsic_abilities) ? rule.intrinsic_abilities : []
137
+ abilities.each_with_index.map { |a, i| AbilityInstance.new(ref: "intrinsic:#{subtype}#{":#{i}" if i.positive?}", ability: a) }
138
+ end
139
+
140
+ # Abilities an effect of this card grants for a while ("<effect ref>:<j>", see Effects::Become).
141
+ def effect_granted(ref) = @effect_granted.fetch(ref, EMPTY)
142
+
143
+ # Abilities the ability at `ref` grants while it applies (a station symbol's, CR 722.2).
144
+ def nested_instances(ref) = @nested_by_ref.fetch(ref, EMPTY)
145
+ EMPTY = [].freeze
146
+
147
+ def permanent? = @permanent
148
+
149
+ # CR 604.3: power and/or toughness defined by a characteristic-defining ability ("*" printed).
150
+ def defined_pt? = !(@power_amount.nil? && @toughness_amount.nil?)
151
+ def token? = @token
152
+
153
+ # Token definitions this card's effects can create; catalogs register them with the card.
154
+ def token_definitions
155
+ (@all_effects.filter_map { it.definition if it.is_a?(Mechanics::Effects::CreateToken) } +
156
+ @all_effects.flat_map { it.respond_to?(:created_tokens) ? it.created_tokens : [] } + # recruit, amass, ...
157
+ @abilities.filter_map { it.token_definition if it.respond_to?(:token_definition) } +
158
+ other_faces.flat_map(&:token_definitions)).compact.uniq(&:id)
159
+ end
160
+
161
+ # Every effect of every ability, including effects nested in other effects ("may", delayed triggers).
162
+ attr_reader :all_effects
163
+
164
+ def triggered? = @triggered
165
+ def land? = @land
166
+ def creature? = @creature
167
+ def planeswalker? = @planeswalker
168
+ def deck_limit_exempt? = @deck_limit_exempt
169
+
170
+ # Most copies a constructed deck may hold (CR 100.2a): nil for any number (basic lands, or a card that
171
+ # says "a deck can have any number of cards named ~").
172
+ def deck_limit = @deck_limit_exempt ? nil : (@deck_limit || Deck::MAX_COPIES)
173
+
174
+ # Whether this card can be cast at instant speed (CR 304.1, 702.8a).
175
+ def instant_speed? = @instant_speed
176
+
177
+ # Lands are played, not cast; a card without a mana cost can't be cast normally (CR 202.1b).
178
+ def castable? = @castable
179
+
180
+ def mana_abilities = @ability_instances.map(&:ability).select(&:mana_ability?)
181
+
182
+ def type_line
183
+ left = (@supertypes + @types).map { |t| t.to_s.capitalize }.join(" ")
184
+ right = @subtypes.map { |t| t.to_s.split("_").map(&:capitalize).join(" ") }.join(" ")
185
+ right.empty? ? left : "#{left} — #{right}"
186
+ end
187
+
188
+ # Canonical data form; CardDefinition.from_h(defn.to_h) == defn.
189
+ def to_h = @source
190
+
191
+ # The canonical data form without the PRINT_KEYS: what the rules see.
192
+ attr_reader :rules_data
193
+
194
+ # The same card: equal rules data, whatever its rarity or printing (a reprint is the same card).
195
+ def ==(other) = other.is_a?(CardDefinition) && other.rules_data == rules_data
196
+ alias eql? ==
197
+
198
+ def hash = @rules_data.hash
199
+
200
+ private
201
+
202
+ def precompute_predicates
203
+ @land = @types.include?(:land)
204
+ @creature = @types.include?(:creature)
205
+ @planeswalker = @types.include?(:planeswalker)
206
+ @permanent = @type_modules.any?(&:permanent?)
207
+ @instant_speed = @type_modules.any?(&:instant_speed?) || @keywords.include?(:flash)
208
+ @castable = !@land && !@mana_cost.nil?
209
+ @deck_limit_exempt = @deck_limit == "any" || @supertypes.any? { Enjine.registry.fetch(:supertype, it).deck_limit_exempt? }
210
+ @spell_ability = @abilities.find { it.kind == :spell } || aura_spell_ability
211
+ every = @abilities + @nested_instances.map(&:ability)
212
+ @triggered = every.any? { it.kind == :triggered }
213
+ @all_effects = nested(every.flat_map do |ability|
214
+ ability.respond_to?(:modes) && ability.modal? ? ability.modes.flat_map(&:effects) : ability.try_effects
215
+ end).freeze
216
+ @delayed_abilities = @all_effects.select { it.respond_to?(:delayed_ability) }.to_h { [it.ref, it.delayed_ability] }
217
+ .merge(granted_by_effects).freeze
218
+ @effect_granted = @all_effects.select { it.respond_to?(:granted_abilities) }.to_h do |effect|
219
+ [effect.ref, effect.granted_abilities.each_with_index.map do |a, j|
220
+ AbilityInstance.new(ref: "#{effect.ref}:#{j}", ability: a)
221
+ end.freeze]
222
+ end.freeze
223
+ end
224
+
225
+ def nested(effects) = effects.flat_map { [it, *nested(it.nested_effects)] }
226
+
227
+ # Abilities effects grant ("that card gains ...", Effects::GrantAbility), referenced by the effect's path.
228
+ def granted_by_effects
229
+ @all_effects.select { it.respond_to?(:granted_ability) && it.granted_ability }.to_h { [it.ref, it.granted_ability] }
230
+ end
231
+
232
+ def validate_type_specific(errors)
233
+ if !creature? && !@subtypes.include?(:vehicle) && (@power || @toughness) # CR 301.7a: a Vehicle has them printed
234
+ errors << "power/toughness: only creatures and Vehicles have power and toughness"
235
+ end
236
+ errors << "loyalty: only planeswalkers have loyalty" if !planeswalker? && @loyalty
237
+ @abilities.each_with_index do |ability, i|
238
+ allowed = ability.allowed_types
239
+ next if allowed.nil? || allowed.any? { type?(it) }
240
+
241
+ errors << "abilities[#{i}]: #{ability.class.primitive_name} abilities need a #{allowed.join("/")} card"
242
+ end
243
+ validate_definition_hooks(errors)
244
+ end
245
+
246
+ # Rules that depend on the whole card, e.g. "an Aura has an enchant ability" (CR 303.4a).
247
+ def validate_definition_hooks(errors)
248
+ @subtypes.each do |sub|
249
+ rule = Enjine.registry.fetch(:subtype, sub)
250
+ rule.validate_definition(self, errors) if rule.respond_to?(:validate_definition)
251
+ end
252
+ @abilities.each_with_index do |ability, i|
253
+ ability.validate_definition(self, errors, "abilities[#{i}]") if ability.respond_to?(:validate_definition)
254
+ end
255
+ end
256
+
257
+ # CR 115.1b / 303.4a: an Aura spell targets what its enchant ability allows.
258
+ def aura_spell_ability
259
+ enchant = @subtypes.include?(:aura) && @abilities.find { it.kind == :enchant }
260
+ Mechanics::Abilities::AuraSpell.for(enchant) if enchant
261
+ end
262
+
263
+ def read_name(card, errors)
264
+ name = card["name"]
265
+ return name if name.is_a?(String) && !self.class.slug(name).empty?
266
+
267
+ errors << "name: required, must be a non-empty string"
268
+ "?"
269
+ end
270
+
271
+ def read_names(card, key, category, errors, required: false)
272
+ list = card.fetch(key, [])
273
+ if !list.is_a?(Array) || (required && list.empty?)
274
+ errors << "#{key}: must be a #{"non-empty " if required}list"
275
+ return [[].freeze, [].freeze]
276
+ end
277
+
278
+ modules = list.each_with_index.filter_map do |raw, i|
279
+ Enjine.registry.fetch(category, raw.to_s.downcase.tr(" -", "__")).tap do |mod|
280
+ unless mod
281
+ errors << "#{key}[#{i}]: unknown #{category} #{raw.inspect} " \
282
+ "(supported: #{Enjine.registry.names(category).join(", ")})"
283
+ end
284
+ end
285
+ end
286
+ [modules.map { |m| m.primitive_name.to_sym }.uniq.freeze, modules.uniq.freeze]
287
+ end
288
+
289
+ def read_subtypes(card, errors)
290
+ list = card.fetch("subtypes", [])
291
+ unless list.is_a?(Array)
292
+ errors << "subtypes: must be a list"
293
+ return [].freeze
294
+ end
295
+
296
+ list.each_with_index.filter_map do |raw, i|
297
+ sub = raw.to_s.downcase.tr(" -", "__")
298
+ unless SUBTYPE_FORMAT.match?(sub)
299
+ errors << "subtypes[#{i}]: invalid subtype #{raw.inspect}"
300
+ next
301
+ end
302
+
303
+ rule = Enjine.registry.fetch(:subtype, sub)
304
+ if rule && !rule.applies_to.intersect?(@types)
305
+ errors << "subtypes[#{i}]: #{sub} is only valid for #{rule.applies_to.join("/")} cards (CR 205.3d)"
306
+ end
307
+ sub.to_sym
308
+ end.uniq.freeze
309
+ end
310
+
311
+ # CR 202.2: a card's colors come from its mana cost, unless it has a color indicator (CR 204) or is a
312
+ # token whose creating effect gives its colors (CR 111.4): `"colors": ["G"]` (`[]` for colorless).
313
+ def read_colors(card, errors)
314
+ return (@mana_cost.is_a?(Mana::Cost) ? Mana::COLORS.select { @mana_cost.colored.key?(it) } : []).freeze unless card.key?("colors")
315
+
316
+ list = card["colors"]
317
+ ok = list.is_a?(Array) && list.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
318
+ errors << "colors: must be a list of W, U, B, R, G" unless ok
319
+ ok ? Mana::COLORS.select { |c| list.map { it.to_s.upcase }.include?(c.to_s) }.freeze : [].freeze
320
+ end
321
+
322
+ # CR 100.2a: "a deck can have any number of cards named ~" is `"deck_limit": "any"`; "up to seven" is 7.
323
+ # Reads the PRINT_KEYS of a whole card; returns the card without them.
324
+ def read_print(card, errors)
325
+ @rarity = card["rarity"]
326
+ errors << "rarity: must be a string" unless @rarity.nil? || @rarity.is_a?(String)
327
+ printing = card["printing"]
328
+ printing = printing.transform_keys(&:to_s) if printing.is_a?(Hash)
329
+ if printing.is_a?(Hash) && printing.keys.sort == %w[number set] && printing.values.all? { it.is_a?(String) || it.is_a?(Integer) }
330
+ @printing = printing.transform_values(&:to_s).freeze
331
+ elsif printing
332
+ errors << "printing: must be {\"set\": code, \"number\": collector number}"
333
+ end
334
+ card.except(*PRINT_KEYS)
335
+ end
336
+
337
+ def read_deck_limit(card, errors)
338
+ value = card["deck_limit"]
339
+ return value if value.nil? || value == "any" || (value.is_a?(Integer) && value >= 1)
340
+
341
+ errors << "deck_limit: must be \"any\" or an integer >= 1"
342
+ nil
343
+ end
344
+
345
+ # Power/toughness: an integer, or an amount for a characteristic-defining ability ("*", CR 604.3,
346
+ # 208.2a), e.g. {"count": {"each": {"creature": {"types": ["artifact"]}}, "controller": "you"}}.
347
+ # Returns [printed value, amount]; the defined value is computed in layer 7a (CR 613.4a).
348
+ def read_pt(card, key, errors)
349
+ value = card[key]
350
+ return [read_stat(card, key, errors), nil] unless value.is_a?(Hash)
351
+
352
+ amount = Mechanics::Amount.build(value, errors, key, targets_count: 0)
353
+ unless Mechanics::Amount.layer_safe?(amount)
354
+ errors << "#{key}: a characteristic-defining amount can't read power or toughness (CR 613.8)"
355
+ end
356
+ [0, amount]
357
+ end
358
+
359
+ def read_stat(card, key, errors)
360
+ return nil unless card.key?(key)
361
+
362
+ value = card[key]
363
+ errors << "#{key}: must be an integer" unless value.is_a?(Integer)
364
+ value
365
+ end
366
+
367
+ def read_abilities(card, errors)
368
+ list = card.fetch("abilities", [])
369
+ unless list.is_a?(Array)
370
+ errors << "abilities: must be a list"
371
+ list = []
372
+ end
373
+
374
+ explicit = list.each_with_index.filter_map do |entry, i|
375
+ ability = Mechanics.build_entry(:ability, entry, errors, "abilities[#{i}]")
376
+ AbilityInstance.new(ref: "printed:#{i}", ability: ability) if ability
377
+ end
378
+ intrinsic = @subtypes.flat_map { CardDefinition.intrinsic_instances(it) }
379
+ @ability_instances = (explicit + linked_instances(explicit) + intrinsic + keyword_abilities).freeze
380
+ read_nested_abilities(explicit)
381
+ @ability_instances.map(&:ability).freeze
382
+ end
383
+
384
+ # Abilities that come with another one ("When this permanent enters, if its evoke cost was paid, ...",
385
+ # Abilities::Evoke), referenced as "<ref>:linked:<j>".
386
+ def linked_instances(explicit)
387
+ explicit.flat_map do |inst|
388
+ linked = inst.ability.respond_to?(:linked_abilities) ? inst.ability.linked_abilities : EMPTY
389
+ linked.each_with_index.map { |a, j| AbilityInstance.new(ref: "#{inst.ref}:linked:#{j}", ability: a) }
390
+ end
391
+ end
392
+
393
+ # Keyword abilities that stand for an ability ("Station" is an activated ability, CR 702.184a).
394
+ def keyword_abilities = @keywords.flat_map { CardDefinition.keyword_instances(it) }
395
+
396
+ # Abilities nested in another ability (a station symbol's, CR 722.2), referenced as
397
+ # "<parent ref>:<j>". They're part of the object's characteristics only while the parent applies.
398
+ def read_nested_abilities(explicit)
399
+ @nested_by_ref = explicit.to_h do |inst|
400
+ nested = inst.ability.respond_to?(:nested_abilities) ? inst.ability.nested_abilities : EMPTY
401
+ [inst.ref, nested.each_with_index.map { |a, j| AbilityInstance.new(ref: "#{inst.ref}:#{j}", ability: a) }.freeze]
402
+ end.freeze
403
+ @nested_instances = @nested_by_ref.values.flatten.freeze
404
+ end
405
+
406
+ def normalize(card)
407
+ out = { "name" => @name }
408
+ out["mana_cost"] = @mana_cost.to_s if @mana_cost
409
+ out["colors"] = @colors.map(&:to_s) if card.key?("colors")
410
+ out["types"] = @types.map(&:to_s)
411
+ out["supertypes"] = @supertypes.map(&:to_s) unless @supertypes.empty?
412
+ out["subtypes"] = @subtypes.map(&:to_s) unless @subtypes.empty?
413
+ out["keywords"] = @keywords.map(&:to_s) unless @keywords.empty?
414
+ %w[power toughness].each { |key| out[key] = normalized_stat(card, key) unless instance_variable_get("@#{key}").nil? }
415
+ out["loyalty"] = @loyalty unless @loyalty.nil?
416
+ out["abilities"] = Snapshot.deep_stringify(card["abilities"]) if card["abilities"].is_a?(Array) && !card["abilities"].empty?
417
+ out["rules_text"] = @rules_text if @rules_text
418
+ out["deck_limit"] = @deck_limit if @deck_limit
419
+ deep_freeze(out)
420
+ end
421
+
422
+ # An integer, or the amount of a characteristic-defining ability as given.
423
+ def normalized_stat(card, key) = card[key].is_a?(Hash) ? Snapshot.deep_stringify(card[key]) : instance_variable_get("@#{key}")
424
+
425
+ def deep_freeze(value)
426
+ case value
427
+ when Hash then value.each_value { deep_freeze(it) }
428
+ when Array then value.each { deep_freeze(it) }
429
+ end
430
+ value.freeze
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class CardDefinition
5
+ # Cards with more than one set of characteristics, written as
6
+ #
7
+ # {"name": "Alfa // Bravo", "layout": "transform", "faces": [{...front...}, {...back...}]}
8
+ #
9
+ # where each face is a complete card block (name, mana_cost, types, abilities, ...). `name` is optional
10
+ # and, when given, must be "<front name> // <back name>". The layouts:
11
+ #
12
+ # transform a nonmodal double-faced card (CR 712.2): cast and played front face up, it can transform
13
+ # (CR 701.27) or enter transformed (CR 712.11a)
14
+ # modal a modal double-faced card (CR 712.3): its owner chooses the face to cast or play (CR 712.8b,
15
+ # 712.9); a modal card may also transform (CR 712.3)
16
+ # adventure an adventurer card (CR 716): faces[1] is the Adventure, an instant or sorcery
17
+ # omen an omen card (CR 721): faces[1] is the Omen, an instant or sorcery
18
+ # prepare a preparation card (CR 723): faces[1] is the prepare spell, an instant or sorcery
19
+ # meld a meld card (CR 713): faces[1] is the combined back face of its meld pair, the same on both
20
+ # cards; it's up only on the melded permanent (CR 713.1b, 713.5c; effect `meld`)
21
+ #
22
+ # The definition itself has the front face's (faces[0]'s) characteristics, which are the card's in
23
+ # every zone where no other face is up (CR 712.6a, 716.4, 721.4, 723.4). Every other face is a
24
+ # CardDefinition with the id "<card id>/<index>"; a game object whose `card` names a face has that
25
+ # face up (see Engine::Faces).
26
+ module Faces
27
+ LAYOUTS = %w[transform modal adventure omen prepare meld].freeze
28
+ DOUBLE_FACED = %w[transform modal].freeze
29
+ # Layouts whose faces[1] is a spell the card can be (CR 716.2, 721.2, 723.2).
30
+ SPELL_FACE = { "adventure" => "adventure", "omen" => "omen", "prepare" => nil }.freeze
31
+ FACED_KEYS = %w[name layout faces].freeze
32
+ FACE_SEPARATOR = "/"
33
+
34
+ # A face's error list: it holds the face's own messages (so validation reading them back sees the
35
+ # same paths as for a single-faced card) and adds each to the card's errors under the face's path
36
+ # ("faces[1].types: ...").
37
+ class PathErrors < Array
38
+ def initialize(errors, path)
39
+ super()
40
+ @errors = errors
41
+ @path = path
42
+ end
43
+
44
+ def <<(message)
45
+ @errors << "#{@path}.#{message}"
46
+ super
47
+ end
48
+ end
49
+
50
+ # The layout of the card this face belongs to (nil for a single-faced card).
51
+ def layout = @front ? @front.layout : @layout
52
+
53
+ # Every face, front first ([self] for a single-faced card).
54
+ def faces = @front ? @front.faces : (@faces || [self])
55
+ def face(index) = faces.fetch(index)
56
+ def other_faces = @front || @faces.nil? ? [] : @faces.drop(1)
57
+
58
+ # 0 for the front face (and a single-faced card).
59
+ def face_index = @face_index || 0
60
+
61
+ # The card's own definition: the front face.
62
+ def card = @front || self
63
+
64
+ # A double-faced card (CR 712.1), whose faces can transform (CR 712.7).
65
+ def double_faced? = DOUBLE_FACED.include?(layout)
66
+
67
+ # The face to turn to when this face transforms (CR 701.27a).
68
+ def other_face = faces[1 - face_index]
69
+
70
+ # "Alfa // Bravo" for a card with faces.
71
+ def full_name = faces.map(&:name).join(" // ")
72
+
73
+ private
74
+
75
+ def read_faces(card, errors, token_id)
76
+ (card.keys - FACED_KEYS).each do |key|
77
+ errors << "#{key}: a card with faces keeps its characteristics in each face (allowed beside faces: " \
78
+ "#{(FACED_KEYS + PRINT_KEYS).join(", ")})"
79
+ end
80
+ @layout = card["layout"]
81
+ errors << "layout: required with faces, one of #{LAYOUTS.join(", ")}" unless LAYOUTS.include?(@layout)
82
+ list = card["faces"]
83
+ unless list.is_a?(Array) && list.size == 2 && list.all?(Hash)
84
+ errors << "faces: must be a list of two faces, each a card object"
85
+ list = Array(list).grep(Hash).first(2)
86
+ list << {} while list.size < 2
87
+ end
88
+ front, back = list.map { it.transform_keys(&:to_s) }
89
+ read_card(front, prefixed(errors, "faces[0]"), token_id)
90
+ @faces = [self, CardDefinition.new(back, prefixed(errors, "faces[1]"), face: [self, 1])].freeze
91
+ validate_faces(card, errors)
92
+ @source = deep_freeze({ "name" => full_name, "layout" => @layout,
93
+ "faces" => [normalize(front), @faces[1].to_h] })
94
+ end
95
+
96
+ def prefixed(errors, path) = PathErrors.new(errors, path)
97
+
98
+ def validate_faces(card, errors)
99
+ name = card["name"]
100
+ errors << "name: must be #{full_name.inspect} (the faces' names joined by \" // \")" unless name.nil? || name == full_name
101
+ errors << "faces[1].types: a melded permanent is a permanent (CR 713.1a)" if @layout == "meld" && !@faces[1].permanent?
102
+ return unless SPELL_FACE.key?(@layout)
103
+
104
+ spell = @faces[1]
105
+ errors << "faces[0].types: the main face of a #{@layout} card is a permanent card" unless permanent?
106
+ unless spell.type?(:instant) || spell.type?(:sorcery)
107
+ errors << "faces[1].types: the #{@layout} face is an instant or sorcery (CR 716.2, 721.2, 723.2)"
108
+ end
109
+ subtype = SPELL_FACE[@layout]
110
+ return if subtype.nil? || spell.subtypes.include?(subtype.to_sym)
111
+
112
+ errors << "faces[1].subtypes: an #{@layout} face has the spell type #{subtype} (CR 205.3k)"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A set of card definitions keyed by id. Built per game from the players' decks;
5
+ # there is no global card registry.
6
+ class Catalog
7
+ include Enumerable
8
+
9
+ def self.from_definitions(list)
10
+ new.tap { |c| list.each { c.add(it) } }
11
+ end
12
+
13
+ def initialize
14
+ @definitions = {}
15
+ @faces = {} # a card's other faces, by face id ("alfa/1", see CardDefinition::Faces)
16
+ end
17
+
18
+ # Adds a definition (CardDefinition or data Hash). Raises CatalogConflict when a
19
+ # different definition with the same id is already present.
20
+ def add(definition)
21
+ defn = CardDefinition.from_h(definition)
22
+ existing = @definitions[defn.id]
23
+ raise CatalogConflict, "two different definitions for #{defn.name.inspect} (#{defn.id})" if existing && existing != defn
24
+
25
+ @definitions[defn.id] ||= defn
26
+ defn.other_faces.each { @faces[it.id] = it }
27
+ defn.token_definitions.each { add(it) unless @definitions.key?(it.id) }
28
+ defn
29
+ end
30
+
31
+ def merge(other)
32
+ other.each { add(it) }
33
+ self
34
+ end
35
+
36
+ # Accepts an id ("lightning_bolt") or a display name ("Lightning Bolt"). Ids hit an exact
37
+ # lookup; only names pay for slugging (Unicode normalization), so the engine's hot path stays cheap.
38
+ def fetch(ref)
39
+ @definitions[ref] || @faces[ref] ||
40
+ @definitions.fetch(CardDefinition.slug(ref)) { raise KeyError, "unknown card #{ref.inspect}" }
41
+ end
42
+
43
+ def [](ref) = @definitions[ref] || @faces[ref] || @definitions[CardDefinition.slug(ref)]
44
+ def include?(ref) = @definitions.key?(ref) || @faces.key?(ref) || @definitions.key?(CardDefinition.slug(ref))
45
+ def ids = @definitions.keys
46
+ def size = @definitions.size
47
+ def each(&) = @definitions.each_value(&)
48
+ def to_a = @definitions.values.map(&:to_h)
49
+
50
+ def freeze
51
+ @definitions.freeze
52
+ @faces.freeze
53
+ super
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # An ability as an object currently has it, with a stable reference:
5
+ # "printed:<i>" the i-th ability in the card data
6
+ # "intrinsic:<subtype>" granted by a subtype rule (e.g. a basic land type, CR 305.6)
7
+ # Later: "keyword:<name>", "granted:<effect>:<i>". References are what options and
8
+ # stack objects carry, so they stay valid when abilities are granted or removed.
9
+ AbilityInstance = Data.define(:ref, :ability)
10
+
11
+ # An object's characteristics after continuous effects (CR 109.3, 613).
12
+ # Rules code asks these questions via Engine#chars, never the printed CardDefinition.
13
+ Characteristics = Data.define(:name, :mana_cost, :colors, :types, :supertypes, :subtypes, :keywords, :abilities,
14
+ :power, :toughness, :loyalty) do
15
+ def self.printed(defn)
16
+ new(name: defn.name, mana_cost: defn.mana_cost, colors: defn.colors, types: defn.types, supertypes: defn.supertypes,
17
+ subtypes: defn.subtypes, keywords: defn.keywords, abilities: defn.ability_instances,
18
+ power: defn.power, toughness: defn.toughness, loyalty: defn.loyalty)
19
+ end
20
+
21
+ def type?(type) = types.include?(type)
22
+ def legendary? = supertypes.include?(:legendary)
23
+ def creature? = types.include?(:creature)
24
+ def planeswalker? = types.include?(:planeswalker)
25
+ def land? = types.include?(:land)
26
+ def keyword?(keyword) = keywords.include?(keyword)
27
+
28
+ def ability(ref) = abilities.find { it.ref == ref }&.ability
29
+ def mana_abilities = abilities.select { it.ability.mana_ability? }
30
+ def activated_abilities = abilities.select { %i[activated loyalty].include?(it.ability.kind) }
31
+ def triggered_abilities = abilities.select { it.ability.kind == :triggered }
32
+ end
33
+
34
+ # A card exiled face down has no characteristics (CR 406.3a).
35
+ Characteristics::FACE_DOWN = Characteristics.new(
36
+ name: "", mana_cost: nil, colors: [].freeze, types: [].freeze, supertypes: [].freeze, subtypes: [].freeze,
37
+ keywords: [].freeze, abilities: [].freeze, power: nil, toughness: nil, loyalty: nil
38
+ )
39
+ end