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,60 @@
1
+ # Adding a mechanic
2
+
3
+ A mechanic is a **primitive**: a small class that registers itself under a name
4
+ that card data can reference. The categories are `type`, `supertype`, `subtype`,
5
+ `keyword`, `ability`, `cost`, `effect` and `filter`.
6
+
7
+ ## Checklist
8
+
9
+ 1. **Read the rules.** Find the CR section (pinned version in `lib/enjine/version.rb`)
10
+ and decide which primitive category the mechanic belongs to. Check
11
+ `docs/MECHANICS_COVERAGE.md` for its milestone and priority.
12
+ 2. **Write the primitive** in `lib/enjine/mechanics/<category>/<mechanic>.rb`. A mechanic's file holds
13
+ its helper primitives too (crew's triggers sit in `keywords/crew.rb`); the shared bases are in
14
+ `<category>/base.rb` (abilities: `stack_abilities.rb`), and it `require_relative`s any other mechanic it extends:
15
+
16
+ ```ruby
17
+ module Enjine::Mechanics::Effects
18
+ class Mill < Effect
19
+ primitive :effect, :mill,
20
+ description: "Each referenced player mills `amount` cards.",
21
+ rules: %w[701.17a],
22
+ params: { "amount" => "integer", "to" => "selector" },
23
+ example: { "mill" => { "amount" => 2, "to" => "each_opponent" } }
24
+
25
+ def apply(engine, ctx)
26
+ players(engine, ctx).each do |i|
27
+ amount.times { (id = engine.player(i).library.first) && engine.move!(id, :graveyard, reason: "mill") }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ ```
33
+
34
+ - `build(params, errors, path, **)` validates the data and pushes path-prefixed
35
+ errors (the `Effect` base class already handles `amount` and `to`).
36
+ - Change state **only** through engine methods that emit events (`move!`,
37
+ `deal_damage!`, `add_counters!`, `change_life!`, `tap!`, …).
38
+ - If the mechanic needs new state, add it to `State`, `State#to_h`,
39
+ `Snapshot.build` and `Snapshot.validate`, plus `View` if players can see it.
40
+ The fuzzer will catch anything you forget.
41
+ 3. **Regenerate the docs:** `bundle exec rake docs`. The docs spec fails until you do.
42
+ 4. **Test at three levels:**
43
+ - **Unit** (`spec/unit`): data validation and error paths.
44
+ - **Integration** (`spec/integration`): a `build_game` scenario, then legal
45
+ options, then a decision, then the resulting state and events. Cite the rule
46
+ numbers in the example names.
47
+ - **End-to-end:** add a card using the mechanic to the fitting family file in
48
+ `spec/fixtures/cards/` so the fuzzer (`rake fuzz`) plays it in thousands of random positions.
49
+ 5. `bundle exec rake` (specs, coverage gate and RuboCop) must pass.
50
+
51
+ ## Where things live
52
+
53
+ | You need | Look at |
54
+ | --- | --- |
55
+ | A new card type's timing or placement rules | `mechanics/types/` (`permanent?`, `instant_speed?`, `validate`) |
56
+ | A new kind of thing to do while holding priority | `lib/enjine/actions/` + `Actions.register` |
57
+ | A new kind of question for a player | `lib/enjine/decisions/` + `Decisions.register` |
58
+ | Turn-structure hooks | `lib/enjine/steps.rb` |
59
+ | P/T and other characteristics (layers) | `lib/enjine/engine/characteristics.rb` |
60
+ | State-based actions | `lib/enjine/engine/state_based_actions.rb` (`check_permanent_state_based_actions!`) |
@@ -0,0 +1,614 @@
1
+ # Using Enjine from Ruby
2
+
3
+ This guide is for code that *calls* Enjine: a game client, a bot, a simulator, a
4
+ reinforcement-learning environment, a test harness. It covers everything public:
5
+ cards and decks, starting games, the decision loop, what players may see, events,
6
+ saving and replaying, the deck builder and the testing tools.
7
+
8
+ - [1. The model in one minute](#1-the-model-in-one-minute)
9
+ - [2. Cards](#2-cards)
10
+ - [3. Decks and players](#3-decks-and-players)
11
+ - [4. Setups and games](#4-setups-and-games)
12
+ - [5. The decision loop](#5-the-decision-loop)
13
+ - [6. Decision kinds and options](#6-decision-kinds-and-options)
14
+ - [7. Ids and references](#7-ids-and-references)
15
+ - [8. Observing a game: views](#8-observing-a-game-views)
16
+ - [9. History and events](#9-history-and-events)
17
+ - [10. Saving, replaying, branching](#10-saving-replaying-branching)
18
+ - [11. Writing a bot or an RL environment](#11-writing-a-bot-or-an-rl-environment)
19
+ - [12. The deck builder](#12-the-deck-builder)
20
+ - [13. Testing tools: RulesMonitor, Fuzz and Verify](#13-testing-tools-rulesmonitor-fuzz-and-verify)
21
+ - [14. The command line](#14-the-command-line)
22
+ - [15. Errors](#15-errors)
23
+ - [16. Guarantees and limits](#16-guarantees-and-limits)
24
+
25
+ ```sh
26
+ gem install enjine
27
+ ```
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "enjine"
32
+ ```
33
+
34
+ ```ruby
35
+ require "enjine" # the engine
36
+ require "enjine/deck_builder" # optional: random decks from a card pool
37
+ ```
38
+
39
+ Ruby 4.0, no runtime dependencies.
40
+
41
+ ## 1. The model in one minute
42
+
43
+ ```ruby
44
+ alice = Enjine::Player.new(name: "alice", deck: alice_deck) # a deck document (Hash), see §3
45
+ bob = Enjine::Player.new(name: "bob", deck: bob_deck)
46
+ setup = Enjine::Setup.new(alice, bob) # validate and compile once
47
+ game = setup.new_game(seed: 42) # cheap: make as many as you like
48
+
49
+ until game.over?
50
+ decision = game.pending_decision # who must decide, what kind, every legal option
51
+ game.submit(decision.options.sample) # answer with one of the options, exactly as given
52
+ end
53
+
54
+ game.result # => {"winner" => "bob", "reason" => "life"}
55
+ ```
56
+
57
+ Four ideas carry the whole API:
58
+
59
+ 1. **The engine never decides for a player.** Every choice the rules give a
60
+ player — keeping a hand, passing priority, each target, each mana payment,
61
+ each attacker — is one *decision* with a complete list of legal *options*.
62
+ There is no auto-pass and no hidden default.
63
+ 2. **Options are plain hashes.** `{type: "cast", object: "lightning_bolt#12"}`.
64
+ You never construct actions; you pick one from the list. Anything not in the
65
+ list raises `Enjine::IllegalDecision` and leaves the game untouched.
66
+ 3. **Deterministic.** All randomness (shuffles, die rolls, random choices) comes
67
+ from the game's seeded RNG. Same cards, same seed, same decisions → the same
68
+ game, bit for bit.
69
+ 4. **Everything is data.** Cards, decks, decisions, views, events and saved games
70
+ are JSON-compatible hashes.
71
+
72
+ ## 2. Cards
73
+
74
+ Enjine ships no cards and never reads card text. Every card is a JSON object
75
+ written in the card DSL, describing what the card *does*:
76
+
77
+ ```json
78
+ { "name": "Lightning Bolt", "mana_cost": "{R}", "types": ["instant"],
79
+ "abilities": [ { "spell": { "targets": ["any_target"],
80
+ "effects": [ { "deal_damage": { "amount": 3, "to": { "target": 0 } } } ] } } ],
81
+ "rarity": "common", "printing": { "set": "fdn", "number": "1" } }
82
+ ```
83
+
84
+ `rarity` and `printing` are optional and ignored by the rules. The full language
85
+ (fields, abilities, effects, filters, costs, multi-face cards, keywords) is in
86
+ the [card DSL guide](card-data-format.md); the exhaustive list of primitives is
87
+ generated in [docs/reference/](../reference).
88
+
89
+ Validate a card without starting a game:
90
+
91
+ ```ruby
92
+ definition = Enjine::CardDefinition.from_h(card_hash) # frozen, validated
93
+ definition.id # => "lightning_bolt" (slug of the name; the card's id everywhere)
94
+ definition.name # => "Lightning Bolt"
95
+ definition.to_h # => the card data, round-trippable
96
+ ```
97
+
98
+ A bad card raises `Enjine::InvalidCardDefinition`; `#errors` lists every problem
99
+ with its path:
100
+
101
+ ```ruby
102
+ rescue Enjine::InvalidCardDefinition => e
103
+ e.errors
104
+ # => ["abilities[0].spell.effects[0].deal_damage.to: required",
105
+ # "toughness: creatures need an integer toughness (CR 302.4)", ...]
106
+ ```
107
+
108
+ A `Catalog` is a set of definitions keyed by id. You rarely build one yourself
109
+ (decks and setups do), but it's what `Game.import` needs:
110
+
111
+ ```ruby
112
+ catalog = Enjine::Catalog.from_definitions(cards) # Hashes or CardDefinitions
113
+ catalog.fetch("lightning_bolt") # by id ...
114
+ catalog.fetch("Lightning Bolt") # ... or by name
115
+ ```
116
+
117
+ Two different cards with the same name raise `Enjine::CatalogConflict`.
118
+
119
+ ## 3. Decks and players
120
+
121
+ A **deck document** carries the cards it uses, so it is self-contained:
122
+
123
+ ```json
124
+ {
125
+ "name": "gruul_beats",
126
+ "description": "optional, free text",
127
+ "cards": [ { "name": "Forest", "types": ["land"], "supertypes": ["basic"], "subtypes": ["forest"] },
128
+ { "name": "Grizzly Bears", "mana_cost": "{1}{G}", "types": ["creature"], "power": 2, "toughness": 2 } ],
129
+ "main": [ { "card": "Forest", "count": 24 }, { "card": "Grizzly Bears", "count": 2 }, "..." ],
130
+ "outside": [ { "card": "Grizzly Bears", "count": 2 } ]
131
+ }
132
+ ```
133
+
134
+ - `cards`: card data blocks (§2). `main` and `outside` refer to them by name or id.
135
+ - `main`: the decklist. Constructed rules apply (CR 100.2a): at least 60 cards,
136
+ at most 4 copies of a card unless it is a basic land or says otherwise
137
+ (`"deck_limit"` in the card data).
138
+ - `outside` (optional): cards the player owns outside the game — the sideboard
139
+ that "from outside the game" effects use (CR 400.11). At most 15 (CR 100.4a);
140
+ the copy limit counts both lists.
141
+
142
+ ```ruby
143
+ player = Enjine::Player.new(name: "alice", deck: doc) # doc: Hash or Enjine::Deck
144
+ player.ready? # => true when the deck is legal
145
+ player.deck.errors # => ["deck has 10 cards; at least 60 are required (CR 100.2a)", ...]
146
+ player.deck.size # => 60
147
+ player.deck.entries # => {"forest" => 24, "grizzly_bears" => 4, ...}
148
+ ```
149
+
150
+ An illegal deck can still be built and inspected; it just isn't `ready?`, and
151
+ `Setup.new` refuses it with `Enjine::PlayerNotReady` (whose `#errors` are the
152
+ deck's). A malformed document (not a Hash, `main` not a list, unknown card
153
+ reference) raises `Enjine::InvalidDeck`.
154
+
155
+ While a player is seated in an unfinished game, editing their deck raises
156
+ `Enjine::DeckLocked`.
157
+
158
+ ## 4. Setups and games
159
+
160
+ ```ruby
161
+ setup = Enjine::Setup.new(alice, bob)
162
+ ```
163
+
164
+ A `Setup` is a frozen, compiled pairing of two ready players: their cards are
165
+ validated and merged into one catalog once. Player names must differ. Spawning
166
+ games from it is cheap, so keep one `Setup` per matchup and create games from it:
167
+
168
+ ```ruby
169
+ game = setup.new_game(seed: 42) # Integer seed; drives every random event of the game
170
+ ```
171
+
172
+ The game starts at its very first decision: the die-roll winner chooses to play
173
+ or draw (CR 103.1), then each player decides on a mulligan.
174
+
175
+ | `Game` method | Returns |
176
+ | --- | --- |
177
+ | `pending_decision` | the `Decision` the engine waits for, or `nil` when the game is over |
178
+ | `legal_options` | `pending_decision.options` (or `[]`) |
179
+ | `submit(option)` | applies one option, returns the `HistoryEntry` it produced |
180
+ | `apply([options])` | submits several, all or nothing; returns the game |
181
+ | `concede(player)` | the player concedes (CR 104.3a), at any time |
182
+ | `over?`, `result` | `result` is `{"winner" => name or nil, "reason" => "..."}` once over |
183
+ | `turn`, `step`, `names` | turn number, current step as a Symbol (`:precombat_main`), the two names |
184
+ | `view_for(viewer)` | what a player (by name) or `:observer` may see (§8) |
185
+ | `history`, `history_for(viewer)`, `decision_log` | everything that happened (§9) |
186
+ | `subscribe { \|event\| }` | live typed events (§9) |
187
+ | `export`, `to_h` | a JSON snapshot / its Hash (§10) |
188
+ | `fork` | an independent copy for search and lookahead (§10) |
189
+ | `restart(seed: nil)` | back to the opening, same or new seed |
190
+ | `catalog` | the cards of this game |
191
+
192
+ `result["reason"]` is why the loser lost: `life` (CR 704.5a), `empty_library`
193
+ (drew from an empty library, 704.5b), `poison` (704.5c), `concede` (104.3a),
194
+ `effect` ("you lose the game") or `opponent_won` ("you win the game"). When both
195
+ players lose at once the game is a draw: `"winner" => nil` and the reasons are
196
+ joined, e.g. `"life,life"` (CR 104.4a).
197
+
198
+ ## 5. The decision loop
199
+
200
+ ```ruby
201
+ decision = game.pending_decision
202
+ decision.player # => "alice" who must decide
203
+ decision.kind # => "choose_target" what kind of decision (§6)
204
+ decision.options # => [{type: "target", target: "player:bob"}, {type: "cancel"}, ...]
205
+ decision.details # => {"index" => 0} extra context for this kind
206
+ decision.to_h # => {"player" => ..., "kind" => ..., "options" => [...], "index" => 0}
207
+ ```
208
+
209
+ Real examples (`decision.to_h`, from an actual game):
210
+
211
+ ```json
212
+ {"player":"alice","kind":"play_or_draw","options":[{"type":"play"},{"type":"draw"}]}
213
+ {"player":"bob","kind":"mulligan","options":[{"type":"keep"},{"type":"mulligan"}]}
214
+ {"player":"alice","kind":"choose_target","index":0,
215
+ "options":[{"type":"target","target":"player:alice"},{"type":"target","target":"player:bob"},{"type":"cancel"}]}
216
+ {"player":"alice","kind":"pay_cost","remaining":"{R}",
217
+ "options":[{"type":"activate_mana","object":"mountain#137","ability":"intrinsic:mountain"},{"type":"cancel"}]}
218
+ {"player":"bob","kind":"declare_attackers",
219
+ "options":[{"type":"done"},{"type":"attack","object":"horned_turtle#149","target":"player:alice"}]}
220
+ ```
221
+
222
+ **Submitting.** Pass one of the options back. Symbol or string keys both work, so
223
+ options that went through JSON can be submitted as they are:
224
+
225
+ ```ruby
226
+ game.submit(decision.options.first)
227
+ game.submit({"type" => "pass"}) # same as {type: "pass"}
228
+ ```
229
+
230
+ An option that isn't legal raises `Enjine::IllegalDecision` (`#option`,
231
+ `#legal_options`) and changes nothing. Submitting after the game ended raises too.
232
+
233
+ **Priority.** Most decisions are `priority`: the player holding priority picks an
234
+ action (`play_land`, `cast`, `activate`, `activate_mana`) or `{type: "pass"}`.
235
+ When both players pass in succession with an empty stack, the step ends; with
236
+ something on the stack, its top resolves (CR 117.4). The engine never passes
237
+ for anyone: a client that wants auto-pass submits `pass` itself when it is the
238
+ only option (the CLI does this for human seats).
239
+
240
+ ### Casting is several small decisions
241
+
242
+ Casting a spell (CR 601.2) is a short conversation, each step its own decision.
243
+ Lightning Bolt with a Mountain in play:
244
+
245
+ ```ruby
246
+ game.submit(type: "cast", object: "lightning_bolt#12") # priority: the card moves to the stack
247
+ game.submit(type: "target", target: "player:bob") # choose_target
248
+ game.submit(type: "activate_mana", object: "mountain#3",
249
+ ability: "intrinsic:mountain") # pay_cost: tap the Mountain, {R} in pool
250
+ game.submit(type: "pay_mana", mana: "R") # pay_cost: spend it -> the spell is cast
251
+ ```
252
+
253
+ Depending on the card, the engine also asks for modes (`choose_mode`), X
254
+ (`choose_x`), kicker (`choose_kicker`), the number of targets
255
+ (`choose_target_count`), how to divide damage (`distribute`), or which permanent
256
+ pays a cost (`choose_cost`). Abilities (`activate`) work the same way.
257
+
258
+ **Cancelling.** Until the last payment, `{type: "cancel"}` is offered and rewinds
259
+ the whole cast: the card returns to its place in hand, sources tapped for it
260
+ untap and the mana pool is restored (CR 734). It stops being offered once
261
+ something irreversible is paid (a Treasure was sacrificed for mana).
262
+
263
+ ### Combat
264
+
265
+ `declare_attackers` and `declare_blockers` collect one creature at a time:
266
+ `{type: "attack", object:, target:}` / `{type: "block", object:, attacker:}`,
267
+ then `{type: "done"}`. `done` is only offered when the declaration is legal
268
+ (menace, "attacks each combat if able", "can't attack alone"); `withdraw` /
269
+ `unblock` undo a pick. When a blocked creature's damage has to be split,
270
+ `assign_damage` asks one point at a time.
271
+
272
+ ## 6. Decision kinds and options
273
+
274
+ | Kind | When | Options |
275
+ | --- | --- | --- |
276
+ | `play_or_draw` | die-roll winner, game start | `play`, `draw` |
277
+ | `mulligan` | each player, in turn order | `keep`, `mulligan` (London) |
278
+ | `bottom` | after a mulligan | `{type: "bottom", object:}` once per mulligan |
279
+ | `priority` | most of the game | `pass`, `play_land`, `cast`, `activate`, `activate_mana` |
280
+ | `choose_mode` | modal spell or ability | `{type: "mode", mode: i}`, `done`, `cancel` |
281
+ | `choose_x` | {X} in a cost | `{type: "x", value: n}`, `cancel` |
282
+ | `choose_kicker` | an optional cost: kicker, optional additional cost, gift, teamwork | `kick`, `no_kick`, `cancel` |
283
+ | `choose_cost_option` | a choice between costs ("behold a Goblin or pay {2}") | `{type: "cost_option", option: i}`, `cancel` |
284
+ | `choose_target_count` | "X target ...", "any number of target ..." | `{type: "count", count: n}`, `cancel` |
285
+ | `choose_target` | each target | `{type: "target", target: ref}`, `done` (for "up to"), `cancel` |
286
+ | `distribute` | dividing damage/counters | `{type: "assign", target: ref}`, `cancel` |
287
+ | `choose_cost` | a cost that picks permanents/cards (sacrifice, crew, station, ...) | `{type: "cost", object:}`, `done`, `cancel` |
288
+ | `pay_cost` | paying mana | `activate_mana`, `pay_mana`, `convoke`, `improvise`, `waterbend`, `cancel` |
289
+ | `order_triggers` | several of your triggers at once | `{type: "trigger", trigger: id}` |
290
+ | `effect_choice` | a choice while something resolves (scry, surveil, "you may", choose a card, ...) | one of the listed `choices`; `prompt` describes it |
291
+ | `enter_choice` | as a permanent enters ("choose a color", "pay 2 life or enters tapped", "behold or enters tapped") | `color`, `creature_type`, `number`, `name`, `option`, `pay_life`, `behold`, `enter_tapped`, ... |
292
+ | `untap_choice` | "you may choose not to untap" | `untap`, `keep_tapped` |
293
+ | `legend_rule` | two legends with one name | `{type: "keep", object:}` |
294
+ | `declare_attackers` | your combat | `attack`, `withdraw`, `done` |
295
+ | `declare_blockers` | opponent's combat | `block`, `unblock`, `done` |
296
+ | `assign_damage` | splitting combat damage | `{type: "assign", object:}` |
297
+ | `discard` | cleanup, over 7 cards | `{type: "discard", object:}` |
298
+
299
+ The generated [decision reference](../reference/decisions.md) documents every
300
+ kind's options and `details` exactly. Treat the option list as the source of
301
+ truth: new mechanics add kinds and option types, and a client that renders or
302
+ picks from `options` generically keeps working.
303
+
304
+ ## 7. Ids and references
305
+
306
+ | Thing | Form | Example |
307
+ | --- | --- | --- |
308
+ | card definition | slug of its name | `"lightning_bolt"` |
309
+ | game object (card or token in a zone, spell, ability on the stack) | `"<card id>#<n>"` | `"lightning_bolt#12"` |
310
+ | player | name, or `"player:<name>"` where a target is expected | `"alice"`, `"player:alice"` |
311
+ | ability of an object | ref string | `"printed:0"`, `"intrinsic:mountain"`, `"keyword:station"`, `"granted:<card>:printed:0:0"` |
312
+ | card outside the game | `"outside:<card id>"` | `"outside:negate"` |
313
+
314
+ An object's id lasts while it stays in one zone. Moving zones makes a new object
315
+ with a new id (CR 400.7); the `zone_changed` event carries `old_id` and `new_id`,
316
+ so you can follow a card.
317
+
318
+ ## 8. Observing a game: views
319
+
320
+ ```ruby
321
+ game.view_for("alice") # what alice may know
322
+ game.view_for(:observer) # everything (both hands, library order)
323
+ ```
324
+
325
+ A view is a plain Hash:
326
+
327
+ ```ruby
328
+ {
329
+ "viewer" => "alice", "turn" => 7, "active" => "bob", "step" => "declare_attackers",
330
+ "phase" => "combat", "priority" => "bob",
331
+ "players" => [ ...one per player... ],
332
+ "battlefield" => [ ...objects... ], "stack" => [...], "exile" => [...], "phased_out" => [...],
333
+ "pending" => { ...the pending decision, as this viewer may see it... },
334
+ "result" => nil,
335
+ "effects" => [ ...continuous effects in force (pumps, grants, control changes)... ],
336
+ "delayed_triggers" => [ ...waiting delayed triggers... ]
337
+ }
338
+ ```
339
+
340
+ A player entry:
341
+
342
+ ```json
343
+ {"name":"alice","life":18,"poison":0,"mana_pool":{"R":1},"restricted_mana":[],"lands_played":1,
344
+ "mulligans":0,"hand_count":6,"library_count":43,"outside_count":0,"graveyard":[ ... ],
345
+ "hand":[ ... ], "outside":[ ... ]}
346
+ ```
347
+
348
+ `hand` and `outside` appear only for their owner (and the observer); `library`
349
+ (in order, top first) only for the observer; `speed` once the player has one.
350
+
351
+ A permanent shows its **current** characteristics, after counters and continuous
352
+ effects — what the rules use, not what's printed:
353
+
354
+ ```json
355
+ {"id":"savannah_lions#187","card":"savannah_lions","name":"Savannah Lions","type_line":"Creature — Cat",
356
+ "owner":"bob","controller":"bob","mana_cost":"{W}","keywords":["double_strike"],"tapped":true,
357
+ "power":3,"toughness":2,"damage":0,"summoning_sick":false,"counters":{"+1/+1":1},
358
+ "attacking":"player:alice"}
359
+ ```
360
+
361
+ Other keys appear when they apply: `loyalty`, `blocking`, `attached_to`,
362
+ `transformed`, `prepared`. Face-down exiled cards show only `{"face_down": true}`
363
+ to players who may not look at them (CR 406.3).
364
+
365
+ ## 9. History and events
366
+
367
+ Every `submit` returns a `HistoryEntry`: the decision and every event it caused.
368
+
369
+ ```json
370
+ {"seq":33,"turn":2,"step":"declare_attackers","player":"alice","kind":"pay_cost",
371
+ "option":{"type":"pay_mana","mana":"R"},
372
+ "events":[{"type":"mana_paid","player":"alice","mana":"{R}","object":"lightning_bolt#138"},
373
+ {"type":"spell_cast","player":"alice","object":"lightning_bolt#138","card":"lightning_bolt",
374
+ "targets":["player:bob"],"during_resolution":false}]}
375
+ ```
376
+
377
+ ```ruby
378
+ game.history # all HistoryEntry objects (entry.to_h = observer form)
379
+ game.history_for("bob") # Hashes, hidden information redacted for bob
380
+ game.subscribe { |event| } # called with each typed event as it happens
381
+ ```
382
+
383
+ Redaction follows the rules on hidden information: bob doesn't learn which card
384
+ alice drew or put on the bottom, but he does see that she drew one, and every
385
+ public move in full.
386
+
387
+ **Events** are `Data` objects (`event.type`, `event.to_h`, `event.for_viewer(name)`),
388
+ all listed in `Enjine::Events::REGISTRY`. The common ones:
389
+
390
+ | Event | Fields |
391
+ | --- | --- |
392
+ | `game_started`, `die_rolled`, `starting_player_chosen` | `players`, `seed` / `winner` / `chooser`, `choice`, `starting_player` |
393
+ | `mulligan_declared`, `mulligan_taken`, `hand_kept` | `player`, `choice` / `mulligans` |
394
+ | `turn_began`, `step_began` | `turn`, `active` / `turn`, `step`, `phase` |
395
+ | `priority_passed` | `player` |
396
+ | `zone_changed` | `card`, `old_id`, `new_id`, `owner`, `from`, `to`, `reason` (`draw`, `play_land`, `cast`, `resolve`, `destroy`, `sacrifice`, `countered`, `sba_lethal_damage`, ...) |
397
+ | `spell_cast`, `ability_activated`, `cast_cancelled`, `resolved`, `fizzled` | `player`, `object`, `card`, `targets`, ... |
398
+ | `ability_triggered`, `trigger_put_on_stack` | `player`, `source`, `card`, `ability` |
399
+ | `tapped`, `untapped`, `mana_added`, `mana_paid`, `mana_emptied` | `object` / `player`, `mana` |
400
+ | `attacker_declared`, `blocker_declared`, `became_blocked`, `damage_assigned` | `object`, `target` / `attacker` / `blockers` |
401
+ | `damage_dealt` | `source`, `target`, `amount`, `combat` |
402
+ | `life_changed` | `player`, `from`, `to` |
403
+ | `counters_changed` | `object`, `counter`, `from`, `to` |
404
+ | `effect_started`, `effects_ended` | `kind`, `affected`, `details` / `count` |
405
+ | `control_changed`, `attached`, `transformed`, ... | mechanic-specific |
406
+ | `player_lost`, `game_ended` | `player`, `reason` / `winner`, `reason` |
407
+
408
+ ## 10. Saving, replaying, branching
409
+
410
+ ```ruby
411
+ json = game.export # any moment, even halfway through a cast
412
+ game = setup.import(json) # or Enjine::Game.import(json, catalog: setup.catalog)
413
+ ```
414
+
415
+ The snapshot holds the full state and history. Import checks it for
416
+ consistency and raises `Enjine::InvalidSnapshot` (with `#errors`) if it's
417
+ malformed. A snapshot needs the same cards (`catalog`) to load.
418
+
419
+ **Replay.** The seed plus the decision log reproduces a game exactly:
420
+
421
+ ```ruby
422
+ log = game.decision_log # every submitted option
423
+ replay = setup.new_game(seed: 42).apply(log) # identical game
424
+ replay.export == game.export # => true
425
+ ```
426
+
427
+ `apply` is all or nothing: if option *i* is illegal it raises
428
+ `IllegalDecision` with `#index == i` and the game is unchanged.
429
+
430
+ **Branching.** `game.fork` returns an independent copy (state and history; not
431
+ subscribers). It's the tool for search: fork, try a line, look at the result,
432
+ throw it away. A fork takes well under a millisecond on a mid-game board.
433
+
434
+ ```ruby
435
+ best = game.legal_options.max_by do |option|
436
+ trial = game.fork
437
+ trial.submit(option)
438
+ evaluate(trial.view_for(game.pending_decision.player))
439
+ end
440
+ ```
441
+
442
+ `game.restart(seed:)` resets to the opening with the same decks.
443
+
444
+ ## 11. Writing a bot or an RL environment
445
+
446
+ An agent is anything that turns a `Decision` into one of its options:
447
+
448
+ ```ruby
449
+ class MyAgent
450
+ def choose(decision) = decision.options.first # your policy here
451
+ end
452
+
453
+ agents = { "alice" => MyAgent.new, "bob" => Enjine::Agents::Random.new(seed: 7) }
454
+ game = setup.new_game(seed: 1)
455
+ game.submit(agents.fetch(game.pending_decision.player).choose(game.pending_decision)) until game.over?
456
+ ```
457
+
458
+ `Enjine::Agents::Random.new(seed:, cancel_rate: 0.02)` picks uniformly (rarely
459
+ cancelling a cast), and is the baseline opponent.
460
+
461
+ For reinforcement learning:
462
+
463
+ - **Action space**: the current decision's `options`. They are already the
464
+ legal-action mask; encode them (e.g. by `type` + referenced object features)
465
+ and choose among them. Small decisions keep the branching factor low: a cast
466
+ is several picks rather than one huge combined action.
467
+ - **Observation**: `view_for(player)` gives exactly the information that player
468
+ is entitled to (no peeking at hands or library order). `view_for(:observer)` is
469
+ there for privileged critics or debugging.
470
+ - **Reward**: `game.result` when `over?`; intermediate signals from events
471
+ (`life_changed`, `zone_changed` with reason `sba_lethal_damage`, ...) via
472
+ `subscribe` or the returned history entries.
473
+ - **Episodes**: compile the `Setup` once per matchup; `new_game(seed:)` per
474
+ episode. Seeds make every episode reproducible.
475
+ - **Throughput**: about 4,000 decisions per second single-threaded with the
476
+ random agent on the example decks (`bundle exec rake bench` measures it on
477
+ your machine). Games share nothing mutable, so parallel rollouts can run one
478
+ game per process (or per thread).
479
+
480
+ ## 12. The deck builder
481
+
482
+ `require "enjine/deck_builder"`. One builder holds a card pool and builds as many
483
+ random, legal, plausibly shaped decks from it as you ask. It knows nothing about
484
+ sets: the pool is whatever cards you give it.
485
+
486
+ ```ruby
487
+ builder = Enjine::DeckBuilder.new(cards) # Array of card Hashes / CardDefinitions
488
+ deck = builder.build(colors: %w[W U], seed: 7) # chosen colors
489
+ deck = builder.build(colors: 2, seed: 8) # two random colors
490
+ deck = builder.build(seed: 9, size: 80) # :any colors (1, 2 or 3, weighted)
491
+ deck = builder.build(seed: 10, profile: { lands: { range: 16..18, center: 17, outliers: 16..18 } })
492
+ player = Enjine::Player.new(name: "bob", deck: deck)
493
+ ```
494
+
495
+ - A pool entry may be `{"card" => card, "count" => n}`: you own n copies, so no
496
+ deck holds more (a personal collection).
497
+ - Each count (lands 22–26, creatures 14–24, the rest other spells; mana curve;
498
+ copies per card; nonbasic lands; color balance) is drawn from a range, with
499
+ occasional outliers; all of them can be overridden with `profile:` (see
500
+ `DeckBuilder::DEFAULT_PROFILE`). Counts scale with `size:`.
501
+ - Only cards the chosen colors can cast are used; basic lands always exist.
502
+ - Same pool + arguments + seed → same deck, whatever order the pool is in.
503
+ - `build` returns a deck document with a `description` like
504
+ `"random deck: colors=WG seed=3 — 23 lands, 25 creatures, 12 other spells, 14 distinct cards"`.
505
+ - If the pool can't fill the deck: `DeckBuilder::NotEnoughCards`, with a message
506
+ and `#details` (`{pool:, colors:, creatures_available:, missing:, ...}`).
507
+
508
+ More in the [deck builder guide](deck-builder.md).
509
+
510
+ ## 13. Testing tools: RulesMonitor, Fuzz and Verify
511
+
512
+ `Enjine::RulesMonitor` is an independent rules checker: it re-derives from raw
513
+ state and events what must be true after each decision (life totals, mana
514
+ emptying, lethal damage, summoning sickness, land plays, P/T, ...), without using
515
+ the engine's rules code.
516
+
517
+ ```ruby
518
+ monitor = Enjine::RulesMonitor.new(game)
519
+ monitor.check!(game.submit(option)) # after every submit
520
+ monitor.findings # => [] or [Finding(rule: "704.5g", message:, turn:, step:, seq:)]
521
+ monitor.stats # mechanics seen, e.g. {"spell_cast" => 12, "damage_dealt" => 30}
522
+ ```
523
+
524
+ `Enjine::Fuzz` plays random games with invariant checks, the monitor and
525
+ export/import round-trips, and raises `Fuzz::Failure` (with `seed` and
526
+ `decisions`) on any problem:
527
+
528
+ ```ruby
529
+ result = Enjine::Fuzz.run(setup, games: 50, seed: 1, roundtrip_every: 25)
530
+ result.games; result.decisions; result.outcomes; result.casts; result.seconds
531
+ Enjine::Fuzz.random_deck(cards, rng: Enjine::Rng.from_seed(3)) # a random legal deck, any colors
532
+ ```
533
+
534
+ Use them when you write your own cards: a game that breaks names the seed to
535
+ reproduce it.
536
+
537
+ `Enjine::Verify` (`require "enjine/verify"`) does all of this for a folder of
538
+ card files at once. It reads every `.json` file below the folder (each holds one
539
+ card or an array of cards), validates every card, builds decks from the valid
540
+ ones so that every card is in some deck, and plays games watched by the rules
541
+ monitor. It records which cards were actually used (cast, played, activated,
542
+ triggered or resolved), and gives cards that were never used extra decks of their
543
+ own:
544
+
545
+ ```ruby
546
+ require "enjine/verify"
547
+ report = Enjine::Verify.new("my_cards/", games: 20, seed: 1).run
548
+ report["ok"] # false if any card is invalid, the monitor found a rules error, or the engine raised
549
+ report["invalid"] # [{"file", "index", "name", "errors" => ["types[0]: unknown type ..."]}]
550
+ report["unexercised"] # valid cards no game ever used: write a scenario for them
551
+ report["findings"] # rules-monitor findings, with the seed and both deck descriptions
552
+ report["types"] # per card type: total / valid / exercised
553
+ puts Enjine::Verify.text(report)
554
+ ```
555
+
556
+ The same folder, `games` and `seed` give the same report. Two cards with the same
557
+ name but different rules data count as invalid; an identical reprint counts once.
558
+
559
+ ## 14. The command line
560
+
561
+ With the gem installed the command is `enjine`; from a checkout, `bundle exec exe/enjine`.
562
+
563
+ ```sh
564
+ exe/enjine --deck1 a.json --deck2 b.json # hot-seat: you play both sides
565
+ exe/enjine --deck1 a.json --deck2 b.json --p2 random # against the random agent
566
+ exe/enjine --deck1 a.json --deck2 b.json --p1 random --p2 random --seed 7 [--quiet] [--observer]
567
+ exe/enjine --pool cards.json --deck1 random:colors=RG --deck2 random # random decks from a pool
568
+ exe/enjine --pool cards.json --random-deck colors=WU seed=7 # print a random deck document
569
+ exe/enjine --validate cards.json # JSON report; exit 1 if any card is invalid
570
+ exe/enjine --verify my_cards/ [--games N] [--seed S] [--json] # validate and play-test a folder of cards
571
+ exe/enjine --load game.json # resume a game saved with `s`
572
+ ```
573
+
574
+ In a game: a human seat whose only option is to pass priority passes
575
+ automatically. A number picks an option, `b` shows the board, `o` toggles the observer view, `h [n]` shows history, `u` undoes,
576
+ `s FILE` saves, `c` concedes, `q` quits, `?` helps.
577
+
578
+ ## 15. Errors
579
+
580
+ All inherit from `Enjine::Error`.
581
+
582
+ | Error | Raised by | Carries |
583
+ | --- | --- | --- |
584
+ | `IllegalDecision` | `submit`, `apply`, `concede` | `option`, `legal_options`, `index` (for `apply`) |
585
+ | `InvalidCardDefinition` | `CardDefinition.from_h`, decks, catalogs | `errors` (path-addressed) |
586
+ | `InvalidDeck` | `Player.new` / `Deck.from_h` on a malformed document | `errors` |
587
+ | `PlayerNotReady` | `Setup.new` with an illegal deck | `errors` |
588
+ | `InvalidSnapshot` | `Game.import` / `Setup#import` | `errors` |
589
+ | `CatalogConflict` | two different cards with the same id | message |
590
+ | `DeckLocked` | editing a deck while its player is in a game | message |
591
+ | `DeckBuilder::NotEnoughCards` | `DeckBuilder#build` | `details` |
592
+ | `Fuzz::Failure` | `Fuzz.run` | `seed`, `decisions` |
593
+
594
+ A failed call never changes a game: `submit` and `apply` validate first, and
595
+ `apply`/`import` are all or nothing.
596
+
597
+ ## 16. Guarantees and limits
598
+
599
+ - **Rules**: two-player games by the Comprehensive Rules pinned in
600
+ `Enjine::RULES_VERSION` (2026-11-06). Code and tests cite rule numbers.
601
+ - **Scope**: the mechanics of the Standard-legal sets (see the README's Scope
602
+ section). Mechanics found only outside Standard (morph, madness, infect, ...)
603
+ are not available.
604
+ - **Stable surface**: everything in this guide. `game.state` and the engine's
605
+ internals are for tests and debugging and may change.
606
+ - **Determinism**: the RNG is a seeded PCG32 (`Enjine::Rng`); nothing reads the
607
+ clock or global randomness.
608
+ - **Threading**: a game is not thread-safe; use one game per thread. Games and
609
+ setups share only frozen data.
610
+ - **Not included**: matches (best of three), sideboarding between games,
611
+ multiplayer, and any card data. Cards you write are validated: a mechanic the
612
+ engine doesn't have is rejected with an error naming the unknown key and
613
+ listing the supported ones (`abilities[0].spell.effects[0]: unknown effect
614
+ "teleport" (supported: add_counters, ...)`).