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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "enjine/deck_builder"
4
+
5
+ module Enjine
6
+ # Checks a folder of card data (require "enjine/verify"): every JSON file below `dir`, each a card or an
7
+ # array of cards. It validates every card, builds random decks from the valid ones (one per color and color
8
+ # pair the pool supports, plus decks that each include a handful of cards, so every card is in some deck),
9
+ # plays rules-monitored games between them (Agents::Busy and Agents::Random seats, export/import round
10
+ # trips), and records which cards were used: cast, played, activated, or had an ability trigger or resolve.
11
+ # Cards still unused get decks of their own, each seated twice, for up to ROUNDS rounds in all.
12
+ #
13
+ # report = Enjine::Verify.new("cards/", games: 20, seed: 1).run
14
+ # report["ok"] # => false if a card is invalid, a rules finding occurred or the engine raised
15
+ # puts Enjine::Verify.text(report)
16
+ #
17
+ # Same folder, games and seed: same report.
18
+ class Verify
19
+ TYPES = %w[creature instant sorcery artifact enchantment land planeswalker battle kindred].freeze
20
+ SPELLS_PER_DECK = 6
21
+ LANDS_PER_DECK = 3
22
+ ROUNDS = 4
23
+ ROUNDTRIP_EVERY = 97
24
+ # Tiny pools still make decks: no outlier land counts, but basics may fill every slot the cards can't.
25
+ PROFILE = { outlier_chance: 0, lands: { outliers: 20..Deck::MIN_SIZE } }.freeze
26
+ PAIRINGS = [%i[busy busy], %i[busy random], %i[random busy]].freeze
27
+
28
+ # One distinct card: where it was first seen, its type names, its definition (nil if invalid).
29
+ Card = Data.define(:name, :file, :index, :types, :definition, :errors)
30
+
31
+ def initialize(dir, games: 10, seed: 0, max_decisions: 20_000)
32
+ raise ArgumentError, "#{dir}: not a directory" unless File.directory?(dir)
33
+
34
+ @dir = dir
35
+ @games = games
36
+ @seed = seed
37
+ @max_decisions = max_decisions
38
+ end
39
+
40
+ def run
41
+ @cards = {}
42
+ @invalid = []
43
+ @used = Hash.new(0)
44
+ @results = { findings: [], errors: [], unfinished: [], games: 0, decisions: 0 }
45
+ Dir.glob("**/*.json", base: @dir).sort.each { load_file(it) }
46
+ play_rounds unless valid.empty?
47
+ report
48
+ end
49
+
50
+ # A readable report.
51
+ def self.text(report)
52
+ out = ["Enjine #{report["engine_version"]} (Comprehensive Rules #{report["rules_version"]}) — seed #{report["seed"]}",
53
+ "#{report["cards"]} valid cards, #{report["games"]} games, #{report["decisions"]} decisions", "",
54
+ "type total valid exercised"]
55
+ report["types"].each { |type, n| out << format("%-14s %6d %6d %10d", type, n["total"], n["valid"], n["exercised"]) }
56
+ section(out, "Invalid cards", report["invalid"]) do
57
+ "#{it["file"]}#{"[#{it["index"]}]" if it["index"]} #{it["name"]}: #{it["errors"].join("; ")}"
58
+ end
59
+ section(out, "Never exercised", report["unexercised"]) { "#{it["name"]} (#{it["file"]})" }
60
+ section(out, "Rules monitor findings", report["findings"]) { "seed #{it["seed"]}: #{it["message"]} — #{it["decks"].join(" vs ")}" }
61
+ section(out, "Engine errors", report["errors"]) { "seed #{it["seed"]}: #{it["message"]}" }
62
+ section(out, "Unfinished games (decision cap)", report["unfinished"]) { "seed #{it["seed"]}" }
63
+ out << "" << (report["ok"] ? "OK" : "FAILED")
64
+ out.join("\n")
65
+ end
66
+
67
+ def self.section(out, title, rows, &)
68
+ return if rows.empty?
69
+
70
+ out << "" << "#{title} (#{rows.size}):"
71
+ rows.each { out << " #{yield it}" }
72
+ end
73
+ private_class_method :section
74
+
75
+ private
76
+
77
+ def valid = @cards.values.select(&:definition)
78
+
79
+ # --- loading --------------------------------------------------------------------------------------
80
+
81
+ def load_file(file)
82
+ data = JSON.parse(File.read(File.join(@dir, file)))
83
+ data.is_a?(Array) ? data.each_with_index { |card, i| add(file, i, card) } : add(file, nil, data)
84
+ rescue JSON::ParserError => e
85
+ @invalid << invalid(file, nil, nil, [], ["not valid JSON: #{e.message.lines.first.strip}"])
86
+ end
87
+
88
+ def add(file, index, data)
89
+ return @invalid << invalid(file, index, nil, [], ["a card must be a JSON object"]) unless data.is_a?(Hash)
90
+
91
+ defn = CardDefinition.from_h(data)
92
+ held = @cards[defn.id]
93
+ return @cards[defn.id] = Card.new(defn.name, file, index, type_names(data), defn, []) if held.nil?
94
+ return if held.definition == defn # a reprint: the same card
95
+
96
+ @invalid << invalid(file, index, defn.name, type_names(data), ["differs from #{defn.name} in #{held.file}"])
97
+ rescue InvalidCardDefinition => e
98
+ @invalid << invalid(file, index, data["name"], type_names(data), e.errors)
99
+ end
100
+
101
+ def invalid(file, index, name, types, errors) = Card.new(name, file, index, types, nil, errors)
102
+
103
+ # Type names from the raw data, so an invalid card still counts: its own, or all its faces'.
104
+ def type_names(data)
105
+ types = [data, *(data["faces"].is_a?(Array) ? data["faces"] : [])].flat_map { it.is_a?(Hash) ? Array(it["types"]) : [] }
106
+ types.grep(String).map(&:downcase).uniq
107
+ end
108
+
109
+ # --- decks and games ------------------------------------------------------------------------------
110
+
111
+ def play_rounds
112
+ @rng = Rng.from_seed(@seed)
113
+ @builder = DeckBuilder.new(valid.map(&:definition))
114
+ decks = coverage_decks(valid) + variety_decks
115
+ ROUNDS.times do |round|
116
+ play(decks, round.zero? ? @games : decks.size)
117
+ rest = valid.reject { @used.key?(it.definition.id) }
118
+ break if rest.empty?
119
+
120
+ decks = coverage_decks(rest)
121
+ end
122
+ end
123
+
124
+ # One deck per color and color pair the pool can fill.
125
+ def variety_decks
126
+ combos = Mana::COLORS.combination(1).to_a + Mana::COLORS.combination(2).to_a
127
+ combos.filter_map do |colors|
128
+ @builder.build(colors: colors, seed: @rng.rand(2**31), profile: PROFILE)
129
+ rescue DeckBuilder::NotEnoughCards
130
+ nil
131
+ end
132
+ end
133
+
134
+ # Decks that together include every card in `cards`: grouped by the colors each needs (a land: the colors it
135
+ # makes), a few spells and lands per deck. Colorless groups get random colors.
136
+ def coverage_decks(cards)
137
+ defns = cards.map(&:definition).reject { DeckBuilder.basic?(it) } # every deck has them
138
+ groups = defns.group_by { it.land? ? DeckBuilder.produced_colors(it) : DeckBuilder.required_colors(it) }
139
+ groups.sort_by { |colors, _| colors.join }.flat_map do |colors, defns|
140
+ lands, spells = defns.partition(&:land?)
141
+ spell_chunks = spells.each_slice(SPELLS_PER_DECK).to_a
142
+ land_chunks = lands.each_slice(LANDS_PER_DECK).to_a
143
+ Array.new([spell_chunks.size, land_chunks.size].max) do |i|
144
+ including = (spell_chunks[i] || []) + (land_chunks[i] || [])
145
+ coverage_deck(colors.empty? ? :any : colors, including.map(&:id))
146
+ end
147
+ end.compact
148
+ end
149
+
150
+ def coverage_deck(colors, including)
151
+ @builder.build(colors: colors, seed: @rng.rand(2**31), profile: PROFILE, including: including)
152
+ rescue Error, ArgumentError => e
153
+ @results[:errors] << { "seed" => nil, "message" => "building a deck with #{including.join(", ")}: #{e.message}" }
154
+ nil
155
+ end
156
+
157
+ # Seats every deck at least once, and plays at least `minimum` games.
158
+ def play(decks, minimum)
159
+ return if decks.empty?
160
+
161
+ [minimum, (decks.size + 1) / 2].max.times do |i|
162
+ game_seed = @seed + @results[:games]
163
+ @results[:games] += 1
164
+ play_game([decks[(2 * i) % decks.size], decks[((2 * i) + 1) % decks.size]], game_seed)
165
+ end
166
+ end
167
+
168
+ # The rules monitor checks every decision; Snapshot invariants are the fuzz task's (they nearly halve the speed).
169
+ def play_game(decks, seed)
170
+ players = decks.each_with_index.map { |deck, i| Player.new(name: "p#{i + 1}", deck: deck) }
171
+ game = Setup.new(*players).new_game(seed: seed)
172
+ game.subscribe { use(it) }
173
+ @results[:decisions] += Fuzz.play(game, seats(game, seed), seed, @max_decisions, ROUNDTRIP_EVERY, false, RulesMonitor.new(game))
174
+ rescue Fuzz::Failure => e
175
+ @results[:decisions] += e.decisions
176
+ failure(e, decks)
177
+ rescue StandardError => e # setting the game up, before Fuzz.play wraps errors
178
+ @results[:errors] << { "seed" => seed, "message" => "#{e.class}: #{e.message}" }
179
+ end
180
+
181
+ def failure(error, decks)
182
+ row = { "seed" => error.seed, "decisions" => error.decisions, "message" => error.message,
183
+ "decks" => decks.map { it["description"] } }
184
+ case error.kind
185
+ when :finding then @results[:findings] << row
186
+ when :stall then @results[:unfinished] << row
187
+ else @results[:errors] << row
188
+ end
189
+ end
190
+
191
+ # An agent for each seat, by the game's place in PAIRINGS.
192
+ def seats(game, seed)
193
+ agents = game.names.zip(PAIRINGS[seed % PAIRINGS.size]).to_h do |name, kind|
194
+ [name, (kind == :busy ? Agents::Busy : Agents::Random).new(seed: seed + name.sum)]
195
+ end
196
+ Seats.new(agents)
197
+ end
198
+
199
+ Seats = Data.define(:agents) do
200
+ def choose(decision) = agents.fetch(decision.player).choose(decision)
201
+ end
202
+
203
+ def use(event)
204
+ card = case event
205
+ when Events::SpellCast, Events::AbilityActivated, Events::AbilityTriggered, Events::Resolved then event.card
206
+ when Events::ZoneChanged then event.card if event.reason == "play_land"
207
+ end
208
+ @used[card] += 1 if card
209
+ end
210
+
211
+ # --- report ---------------------------------------------------------------------------------------
212
+
213
+ def report
214
+ unused = valid.reject { @used.key?(it.definition.id) }
215
+ { "engine_version" => VERSION, "rules_version" => RULES_VERSION, "seed" => @seed, "cards" => valid.size,
216
+ "games" => @results[:games], "decisions" => @results[:decisions], "types" => type_counts,
217
+ "invalid" => @invalid.map { { "file" => it.file, "index" => it.index, "name" => it.name, "errors" => it.errors } },
218
+ "unexercised" => unused.sort_by(&:name).map { { "name" => it.name, "file" => it.file } },
219
+ "findings" => @results[:findings], "errors" => @results[:errors], "unfinished" => @results[:unfinished],
220
+ "ok" => @invalid.empty? && @results[:findings].empty? && @results[:errors].empty? }
221
+ end
222
+
223
+ def type_counts
224
+ all = @cards.values + @invalid
225
+ types = (TYPES & all.flat_map(&:types)) + (all.flat_map(&:types).uniq - TYPES).sort
226
+ types.to_h do |type|
227
+ cards = all.select { it.types.include?(type) }
228
+ exercised = cards.count { it.definition && @used.key?(it.definition.id) }
229
+ [type, { "total" => cards.size, "valid" => cards.count(&:definition), "exercised" => exercised }]
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ VERSION = "0.1.0"
5
+ # Comprehensive Rules document this engine follows.
6
+ RULES_VERSION = "2026-11-06"
7
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Builds what a viewer may see of a game. A viewer is a player name or :observer.
5
+ #
6
+ # A player sees public zones, their own hand, and counts of hidden zones.
7
+ # The observer sees everything, including both hands and library order.
8
+ module View
9
+ module_function
10
+
11
+ def build(engine, viewer)
12
+ state = engine.state
13
+ observer = viewer == :observer
14
+ {
15
+ "viewer" => observer ? "observer" : viewer,
16
+ "turn" => state.turn,
17
+ "active" => state.active && engine.name(state.active),
18
+ "step" => state.step&.to_s,
19
+ "phase" => state.step && Steps.fetch(state.step).phase.to_s,
20
+ "priority" => state.priority && engine.name(state.priority),
21
+ "players" => state.players.map { |pl| player_view(engine, pl, observer || pl.name == viewer, observer) },
22
+ "battlefield" => state.battlefield.map { object_view(engine, it) },
23
+ "stack" => state.stack.map { object_view(engine, it) },
24
+ "exile" => state.exile.map { exiled_view(engine, it, viewer) },
25
+ "phased_out" => state.phased_out.map { object_view(engine, it) },
26
+ "command" => engine.emblems.map { emblem_view(engine, it) },
27
+ "pending" => pending_view(engine, viewer),
28
+ "result" => state.result&.dup,
29
+ "effects" => state.effects.map { it.except("controller").merge("controller" => engine.name(it["controller"])) },
30
+ "delayed_triggers" => state.delayed_triggers.map { delayed_view(engine, it) }
31
+ }
32
+ end
33
+
34
+ # An emblem in the command zone (CR 114): public, with its owner and abilities.
35
+ def emblem_view(engine, obj)
36
+ { "id" => obj.id, "kind" => "emblem", "owner" => engine.name(obj.owner),
37
+ "abilities" => engine.chars(obj).abilities.map { it.ability.to_s } }
38
+ end
39
+
40
+ # A delayed triggered ability waiting for its event (CR 603.7): public, like the spell that made it.
41
+ def delayed_view(engine, entry)
42
+ defn = engine.catalog.fetch(entry["card"])
43
+ { "controller" => engine.name(entry["controller"]), "card" => defn.name, "ability" => defn.ability(entry["ability"]).to_s }
44
+ end
45
+
46
+ # How a mana kept apart in the pool may be spent and how long it stays (CR 106.6, 702.189a).
47
+ def pool_entry_text(entry)
48
+ parts = [entry["spend_only"] && Mechanics::SpendOnly.from_data(entry["spend_only"]).to_s,
49
+ entry["until"] && "you don't lose this mana until end of combat"]
50
+ parts.compact.join("; ")
51
+ end
52
+
53
+ def player_view(engine, player, sees_hand, sees_library)
54
+ view = {
55
+ "name" => player.name, "life" => player.life, "poison" => player.poison,
56
+ "mana_pool" => player.mana_pool.reject { |_, n| n.zero? }.transform_keys(&:to_s),
57
+ "restricted_mana" => player.restricted_mana.map { it.merge("text" => View.pool_entry_text(it)) },
58
+ "lands_played" => player.lands_played, "mulligans" => player.mulligans,
59
+ "hand_count" => player.hand.size, "library_count" => player.library.size, "outside_count" => player.outside.size,
60
+ "graveyard" => player.graveyard.map { object_view(engine, it) }
61
+ }
62
+ view.merge!(public_extras(player))
63
+ view["hand"] = player.hand.map { object_view(engine, it) } if sees_hand
64
+ view["outside"] = outside_view(engine, player) if sees_hand # CR 400.11a: only its owner knows their sideboard
65
+ view["library"] = player.library.map { object_view(engine, it) } if sees_library
66
+ view["library_top"] = object_view(engine, player.library.first) if sees_hand && looks_at_top?(engine, player)
67
+ view
68
+ end
69
+
70
+ # Public player state present only when there is some: speed (CR 702.179), designations (CR 702.195b), player
71
+ # counters other than poison (CR 122.1) and the artifact sources of pool mana (Engine::ManaSources#add_mana!).
72
+ def public_extras(player)
73
+ { "speed" => player.speed, "designations" => player.designations&.dup, "counters" => player.counters&.dup,
74
+ "pool_sources" => Snapshot.deep_copy(player.pool_sources) }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
75
+ end
76
+
77
+ # The cards a player owns outside the game (CR 400.11a): not objects, so no ids (CR 400.11).
78
+ def outside_view(engine, player)
79
+ player.outside.map do |card|
80
+ defn = engine.catalog.fetch(card)
81
+ { "card" => card, "name" => defn.name, "type_line" => defn.type_line, "mana_cost" => defn.mana_cost&.to_s }.compact
82
+ end
83
+ end
84
+
85
+ # CR 401.5: an effect letting a player look at the top card of their library shows it to that player only.
86
+ def looks_at_top?(engine, player)
87
+ !player.library.empty? && engine.may_look_at_top?(engine.state.players.index(player))
88
+ end
89
+
90
+ # CR 406.3: a card exiled face down shows only that it's face down, except to an observer and to the
91
+ # players who may look at it.
92
+ def exiled_view(engine, id, viewer)
93
+ obj = engine.object(id)
94
+ return object_view(engine, id) unless engine.face_down?(obj)
95
+
96
+ hidden = { "id" => id, "face_down" => true, "owner" => engine.name(obj.owner) }
97
+ looks = viewer == :observer || obj.memory.fetch("may_look", []).map { engine.name(it) }.include?(viewer)
98
+ looks ? object_view(engine, id).merge(hidden) : hidden
99
+ end
100
+
101
+ def object_view(engine, id)
102
+ obj = engine.object(id)
103
+ defn = engine.definition(obj)
104
+ return ability_view(engine, obj, defn) if obj.ability?
105
+
106
+ view = { "id" => id, "card" => obj.card, "name" => defn.name, "type_line" => defn.type_line,
107
+ "owner" => engine.name(obj.owner) }
108
+ view["mana_cost"] = defn.mana_cost.to_s if defn.mana_cost
109
+ view["keywords"] = defn.keywords.map(&:to_s) unless defn.keywords.empty?
110
+ view["rules_text"] = defn.rules_text if defn.rules_text
111
+ case obj.zone
112
+ when :battlefield then permanent_view(engine, obj, defn, view)
113
+ when :stack then view.merge!("controller" => engine.name(obj.controller), "targets" => (obj.targets || []).flatten)
114
+ when :phased_out # public: still on the battlefield, treated as though it doesn't exist (CR 702.26b)
115
+ view.merge!("controller" => engine.name(obj.controller), "tapped" => obj.tapped, "phased_out" => true)
116
+ view["attached_to"] = obj.attached_to if obj.attached_to
117
+ end
118
+ view
119
+ end
120
+
121
+ def permanent_view(engine, obj, defn, view)
122
+ view["controller"] = engine.name(obj.controller)
123
+ view["tapped"] = obj.tapped
124
+ view["name"] = engine.chars(obj).name # "named Humble Merchant" (CR 612.8)
125
+ keywords = engine.chars(obj).keywords # printed plus granted (CR 613.1f), minus removed
126
+ keywords.empty? ? view.delete("keywords") : view["keywords"] = keywords.map(&:to_s)
127
+ if engine.chars(obj).creature? # e.g. a Spacecraft with enough charge counters (CR 722.2b)
128
+ power, toughness = engine.power_toughness(obj)
129
+ view.merge!("power" => power, "toughness" => toughness, "damage" => obj.damage,
130
+ "summoning_sick" => engine.summoning_sick_for_tap?(obj))
131
+ end
132
+ view["loyalty"] = engine.loyalty(obj) if defn.planeswalker?
133
+ view["counters"] = obj.counters.reject { |_, n| n.zero? } unless obj.counters.values.all?(&:zero?)
134
+ view["attached_to"] = obj.attached_to if obj.attached_to
135
+ view["transformed"] = true if engine.transformed?(obj) # CR 701.27h
136
+ view["prepared"] = true if engine.prepared?(obj) # CR 723.3a
137
+ if (attack = engine.attackers[obj.id])
138
+ view["attacking"] = attack["target"]
139
+ end
140
+ view["blocking"] = engine.state.blockers[obj.id] if engine.state.blockers.key?(obj.id)
141
+ end
142
+
143
+ def ability_view(engine, obj, defn)
144
+ ability = defn.ability(obj.ability)
145
+ { "id" => obj.id, "card" => obj.card, "name" => "#{defn.name} ability", "kind" => "ability",
146
+ "ability" => obj.ability, "loyalty_cost" => ability.loyalty_cost, "source" => obj.source,
147
+ "controller" => engine.name(obj.controller), "targets" => (obj.targets || []).flatten }.compact
148
+ end
149
+
150
+ def pending_view(engine, viewer)
151
+ pending = engine.state.pending
152
+ return nil unless pending
153
+
154
+ decider = engine.name(pending["player"])
155
+ view = pending.merge("player" => decider)
156
+ return view.except("choices") unless viewer == :observer || viewer == decider # e.g. the cards a search sees
157
+
158
+ view["options"] = engine.options.map { it.transform_keys(&:to_s) }
159
+ view
160
+ end
161
+ end
162
+ end
data/lib/enjine.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "enjine/version"
4
+ require_relative "enjine/errors"
5
+ require_relative "enjine/rng"
6
+ require_relative "enjine/mana"
7
+ require_relative "enjine/primitive"
8
+ require_relative "enjine/mechanics"
9
+ # The shared bases load first; a mechanic file requires any other mechanic it builds on.
10
+ %w[selector values effects/base triggers/base filters/base costs/base abilities/stack_abilities].each do |f|
11
+ require_relative "enjine/mechanics/#{f}"
12
+ end
13
+ Dir[File.join(__dir__, "enjine/mechanics/**/*.rb")].each { |f| require f }
14
+ require_relative "enjine/characteristics"
15
+ require "json"
16
+ require "digest"
17
+ require_relative "enjine/tokens"
18
+ require_relative "enjine/card_definition"
19
+ require_relative "enjine/catalog"
20
+ require_relative "enjine/deck"
21
+ require_relative "enjine/player"
22
+ require_relative "enjine/state"
23
+ require_relative "enjine/events"
24
+ require_relative "enjine/steps"
25
+ require_relative "enjine/frames"
26
+ require_relative "enjine/decisions"
27
+ Dir[File.join(__dir__, "enjine/decisions/*.rb")].each { |f| require f }
28
+ require_relative "enjine/actions"
29
+ %w[pass play_land activate_mana_ability cast special_action].each { |f| require_relative "enjine/actions/#{f}" }
30
+ require_relative "enjine/replacements"
31
+ require_relative "enjine/engine"
32
+ Dir[File.join(__dir__, "enjine/engine/**/*.rb")].each { |f| require f }
33
+ require_relative "enjine/snapshot"
34
+ require_relative "enjine/history"
35
+ require_relative "enjine/view"
36
+ require_relative "enjine/game"
37
+ require_relative "enjine/setup"
38
+
39
+ # Enjine: a deterministic, rules-accurate Magic: The Gathering engine.
40
+ # It knows no cards: every card arrives as data, in a deck or a DeckBuilder pool.
41
+ module Enjine
42
+ end
43
+ require_relative "enjine/agents/random"
44
+ require_relative "enjine/agents/busy"
45
+ require_relative "enjine/fuzz"
46
+ require_relative "enjine/rules_monitor"