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,141 @@
1
+ # Enjine — Architecture
2
+
3
+ Companion to [DESIGN.md](DESIGN.md). Describes how the code is laid out and why.
4
+ Rules reference: Comprehensive Rules effective **2026-11-06**.
5
+
6
+ ## Folder structure
7
+
8
+ ```
9
+ lib/enjine.rb # entry point; requires everything except card pools
10
+ lib/enjine/
11
+ version.rb # VERSION, RULES_VERSION (pinned CR date)
12
+ errors.rb # Error hierarchy
13
+ rng.rb # PCG32, integer state -> JSON-exportable, deterministic
14
+ mana.rb # mana costs, symbols, pools
15
+
16
+ # ---- static world: cards & decks (no game state) ----
17
+ primitive.rb # Primitive base class + Registry (every mechanic registers here)
18
+ mechanics.rb # helpers to build primitives from data entries
19
+ card_definition.rb # immutable, validated blueprint built from a data Hash
20
+ catalog.rb # id -> CardDefinition, conflict detection
21
+ deck.rb player.rb setup.rb # decklists, players, compiled pairings
22
+
23
+ mechanics/ # ONE PLACE PER MECHANIC: <category>/<mechanic>.rb, all registered primitives
24
+ types/ supertypes/ subtypes/ card types, supertypes, subtypes (permanent_types spell_types land saga auras_and_equipment ...)
25
+ keywords/ keyword abilities, CR 702 (evergreen flags crew kicker ward station alternative_costs ...)
26
+ abilities/ other abilities: stack_abilities (spell activated triggered loyalty), static, mana_ability,
27
+ replacement_effects, play_permissions, cost_changes, enters_tapped ...
28
+ triggers/ trigger events grouped by what happens (zone_changes combat casting life ...)
29
+ costs/ effects/ filters/ counters/ one file per cost, effect (damage exile counters library ...), filter, counter kind
30
+ */base.rb shared bases of effects, triggers, filters, costs (abilities: stack_abilities.rb),
31
+ loaded first; a file requires any other mechanic it extends
32
+ selector.rb values.rb who an effect applies to (target / self / you / each ...); amounts and conditions
33
+ linked.rb card_spec.rb ... shared helpers: linked abilities, token/copy card specs, mana spent
34
+
35
+ # ---- dynamic world: a running game ----
36
+ game.rb # public facade: pending_decision / submit / apply / export / views
37
+ state.rb # State, PlayerState, GameObject (plain mutable structs)
38
+ engine.rb # the state machine: accessors, emit, options/submit!; one module per area below
39
+ engine/
40
+ pregame.rb priority.rb # CR 103 start and mulligans; CR 117 priority
41
+ turn_structure.rb # CR 500-502: advance!, steps, untap step, land plays
42
+ zones.rb # CR 400: create/move objects (through replacements), tokens, draw, tap
43
+ casting/ # CR 601/602: casting.rb (announce, targets, cancel/rewind), payment.rb,
44
+ # cast_choices.rb, costs.rb, target_counts.rb, alt_casting.rb, permissions.rb
45
+ mana_sources.rb # CR 106/605: affordability, mana abilities, mana pools
46
+ resolution.rb entering.rb # CR 608: resolve top of stack, fizzle; "as this enters" choices
47
+ triggers.rb # CR 603: matching events, pending triggers
48
+ combat.rb # CR 506-511: attackers, blockers, damage assignment
49
+ damage.rb # damage, life, counters
50
+ state_based_actions.rb # CR 704 and game end (CR 104)
51
+ characteristics.rb # CR 613 layer pipeline for P/T, continuous effects, cleanup
52
+ rules.rb copies.rb faces.rb ... # player rules and control; copies (CR 707); faces; sagas, phasing, speed ...
53
+ steps.rb # 12 step objects with turn-based actions
54
+ events.rb # typed, serializable events + per-viewer redaction
55
+ decisions/ # one class per decision kind
56
+ priority play_or_draw mulligan bottom discard casting(choose_target, pay_cost) combat(...)
57
+ actions/ # options offered at priority
58
+ pass play_land activate_mana_ability cast(cast, activate)
59
+ snapshot.rb # export/import + full consistency validation (also the fuzz invariant)
60
+ view.rb history.rb # per-viewer views and histories
61
+
62
+ # ---- tooling (no game logic) ----
63
+ docs.rb # generates docs/reference/*.md + SUPPORTED.md from the Registry
64
+ agents/random.rb fuzz.rb # random legal player, invariant-checking fuzzer
65
+ cli.rb cli/text.rb # interactive hot-seat CLI + text renderer, --validate
66
+ deck_builder.rb # OPT-IN random constructed decks from a card pool
67
+ exe/enjine
68
+ examples/ # ready-to-play decks and a card pool (not in the gem)
69
+ spec/ unit/ integration/ e2e/ support/scenario.rb fixtures/cards/ (the test and fuzz cards)
70
+ docs/ DESIGN.md ARCHITECTURE.md MECHANICS_COVERAGE.md SUPPORTED.md(root) reference/ guides/
71
+ ```
72
+
73
+ ## Key design decisions
74
+
75
+ ### 1. Two worlds: static vs dynamic
76
+ - **Static** (`CardDefinition`, `Catalog`, `Deck`, `Player`, `Setup`) is validated
77
+ once and frozen. Shared by every game spawned from a `Setup` — no per-game cost.
78
+ - **Dynamic** (`State`, `GameObject`, ...) is plain mutable data. Objects refer to
79
+ definitions **by id** (`"forest"`), never by Ruby reference, so state is small,
80
+ Marshal-copyable, and serializable.
81
+
82
+ ### 2. The engine is an explicit state machine
83
+ No Ruby call stack ever holds game progress. Turn, step, priority holder, pass
84
+ count, stack and the pending decision are fields on `State`. `submit(option)`
85
+ mutates the state one tick and `Engine#advance!` runs forward until the next
86
+ decision is needed. This is what makes export (including the pending decision),
87
+ import, transactional `apply`, `restart`, and replay possible.
88
+
89
+ ### 3. Mechanics are registered primitives (composition, not inheritance)
90
+ ```
91
+ Primitive (base: name, category, description, rules[], params, example, validate/build)
92
+ ├─ category :type -> Mechanics::Types::Land, (Creature, Instant, ... later)
93
+ ├─ category :supertype -> Basic
94
+ ├─ category :subtype -> Plains/Island/Swamp/Mountain/Forest
95
+ ├─ category :ability -> ManaAbility (Triggered, Static, Activated ... later)
96
+ ├─ category :cost -> Tap (Mana, Sacrifice ... later)
97
+ └─ category :effect -> (DealDamage, AddCounters ... milestone 2)
98
+ ```
99
+ A card is a *set* of type primitives plus a list of ability primitives. A card
100
+ data block naming anything not in the Registry fails with
101
+ `InvalidCardDefinition` and a full list of path-addressed errors. Docs and
102
+ `SUPPORTED.md` are generated from the same Registry, so they cannot drift.
103
+
104
+ ### 4. Decisions and actions are small classes with one job each
105
+ - `Decisions::*`: a kind of pending decision (`priority`, `mulligan`, `bottom`,
106
+ `discard`, `play_or_draw`). Each one has `options(engine, pending)` and
107
+ `submit(engine, pending, option)`.
108
+ - `Actions::*`: what you can do at priority (`pass`, `play_land`,
109
+ `activate_mana`, and later `cast` / `activate`). Priority options are the union
110
+ of every registered action's options. Adding casting means adding one Action class.
111
+ - Options are plain frozen hashes (`{type: "play_land", object: "forest#12"}`), so
112
+ they are JSON-able and comparable with `==`.
113
+
114
+ ### 5. Events are the single source of change
115
+ Every mutation emits a typed event (`Data` classes). Events feed history, the CLI
116
+ log, subscribers and test assertions, and triggered abilities later. Each event
117
+ knows how to redact itself for a viewer: a library→hand move shows only "drew
118
+ a card" to the opponent.
119
+
120
+ ### 6. Determinism and IDs
121
+ - PCG32 RNG, with its state stored as an integer in the snapshot.
122
+ - Object id = `<definition_id>#<n>` from a per-game counter. A card gets a new id
123
+ on every zone change (CR 400.7).
124
+ - `Setup` + seed reproduces a game exactly, and `restart` uses the same path.
125
+
126
+ ### 6a. Cards outside the game
127
+ A game doesn't know about a match, so a deck's optional `outside` list stands for the
128
+ cards its player owns outside the game (a constructed sideboard, CR 400.11a, 100.4a).
129
+ At setup each player gets `PlayerState#outside`, a list of definition ids. These are not
130
+ objects, because outside the game isn't a zone and the cards have no ids yet
131
+ (CR 400.11). `State#outside_lists` keeps the starting lists for `restart`. Both
132
+ round-trip through snapshots, which check every id against the catalog. Legal actions
133
+ name such a card `outside:<card id>`, and legality checks use a detached stand-in
134
+ object that is never stored. The real object is created only as the card enters
135
+ the stack or the battlefield (`Engine#bring_in_from_outside!`), owned by that player
136
+ (CR 108.3). A cancelled cast puts the id back in the list.
137
+
138
+ ### 7. Atomicity
139
+ `submit` validates against the legal options before touching anything. `apply`
140
+ and `import` work on a Marshal copy and swap it in only on success. Events are
141
+ buffered and delivered to subscribers only after success.
data/docs/DESIGN.md ADDED
@@ -0,0 +1,139 @@
1
+ # Enjine — Design
2
+
3
+ Enjine is a Ruby gem that runs two-player Magic: The Gathering games with strict
4
+ Comprehensive Rules (CR) fidelity. It is pure gameplay infrastructure: no AI, no
5
+ heuristics, no "smartness". Callers (a text CLI today, reinforcement-learning
6
+ agents in a separate project later) drive it through an atomic decision API.
7
+
8
+ ## 1. Scope
9
+
10
+ - **Standard.** The target is every mechanic on the cards of the current
11
+ Standard-legal sets, plus the core rules (CR 1–6) every game needs. Mechanics
12
+ found only on cards outside Standard, multiplayer and casual variants are out of
13
+ scope until a Standard card needs them. New Standard sets add mechanics; rotated
14
+ sets keep theirs.
15
+ - Two-player constructed: 20 life, deck ≥ 60 cards, max 4 copies of a card
16
+ (basic lands and "any number" cards exempt). No sideboard tracking.
17
+ - Single games, start to finish. No best-of-3 / match object.
18
+ - The engine knows **no cards**: every card arrives as data, in a deck document or a
19
+ `DeckBuilder` pool. The test and fuzz cards live in `spec/fixtures/cards/`.
20
+ - Pinned to a dated CR version. Code and tests cite rule numbers (`# CR 704.5a`).
21
+
22
+ ## 2. Cards
23
+
24
+ - **Data-first.** Card definitions are pure data (JSON / Hash). No Ruby card code.
25
+ - **Primitive registry.** Abilities are composed of named primitives: effects,
26
+ costs, targets/filters, triggers, conditions, amounts, keywords, continuous
27
+ effects, replacements. Each primitive declares: name, parameter schema,
28
+ description, JSON example, CR references.
29
+ - **Validation.** Loading validates against primitive schemas. Unknown/invalid
30
+ pieces raise `InvalidCardDefinition` listing *all* errors with paths
31
+ (`abilities[0].effect[1].deal_damage.amount`).
32
+ - **Composition for card types.** A card has a *set* of types; each type is a
33
+ rule module (timing, destination zone, characteristics). Keywords are modules.
34
+ Inheritance only for engine abstractions (`Ability`, `Effect`, `Cost`,
35
+ `Decision`, `Event`, `Zone`).
36
+ - **Extension.** A new mechanic = a new primitive class registered by name.
37
+ - **IDs.** Definition id = canonical slug of the unique card name
38
+ (`"Lightning Bolt"` → `lightning_bolt`).
39
+
40
+ ## 3. Players, Setup, Game
41
+
42
+ - `Player` = name + deck document (card definitions + decklist). `ready?` only
43
+ when the deck is valid. Between games the deck can be edited freely
44
+ (swap/add/remove, any number of cards) and is re-validated. Editing while
45
+ seated in an unfinished game raises `DeckLocked`.
46
+ - `Setup` = frozen, compiled once from two ready players: validated catalog
47
+ (union of both players' definitions; differing definitions under the same
48
+ name → `CatalogConflict`), compiled decks. All games spawned from a Setup
49
+ share compiled definitions in memory.
50
+ - `setup.new_game(seed:)` — cheap new game (new shuffle, die roll, mulligans).
51
+ - `game.restart(seed: nil)` — back to the opening; same seed = identical game.
52
+ - `Setup + seed` fully reproduces a game; training stores seeds, not states.
53
+ - The engine runs setup itself: shuffle, die roll, play/draw choice, London
54
+ mulligan, opening hands.
55
+
56
+ ## 4. Decision API
57
+
58
+ - `game.pending_decision` → `{player, kind, options}`; `game.submit(option)`.
59
+ - **No auto-skip.** Every priority point is an explicit decision.
60
+ - **Atomic.** Each decision is one small choice: cast/activate, choose target,
61
+ tap a mana source (manual mana — one source at a time), declare attackers,
62
+ declare blockers, order/assign damage, choose X, choose mode, mulligan, etc.
63
+ - **Cancel** is allowed until all costs are paid; it rolls back everything done
64
+ during that cast/activation (card back to hand, sources tapped during the
65
+ payment untap; mana that was in the pool before stays). Once costs are paid
66
+ the spell is cast and cannot be undone.
67
+ - Invalid submission raises `IllegalDecision` (with the legal options); state is
68
+ never partially mutated.
69
+
70
+ ## 5. State, Events, History, Views
71
+
72
+ - Mutable state of compact structs indexed by id; seeded RNG; fully deterministic.
73
+ - **Object ids**: `<definition_id>#<n>` from a per-game counter. A new id on every
74
+ zone change (CR 400.7).
75
+ - **Events**: every change is a typed, serializable event (`DamageDealt`,
76
+ `ZoneChanged`, `LifeChanged`, `CounterAdded`, `StepBegan`, ...). Events drive
77
+ triggers, history, the CLI log and test assertions.
78
+ - **History**: ordered log of (turn, step, player, decision, resulting events).
79
+ `history_for(player)` hides what that player cannot know; the observer history
80
+ shows everything.
81
+ - **Views**:
82
+ - `view_for(player)` — that player's legal knowledge (opponent hand and
83
+ libraries as counts).
84
+ - `view_for(:observer)` — omniscient: hands, face-down cards, library order.
85
+ - `state` — raw internals, for tests/debugging.
86
+
87
+ ## 6. Export / Import / Fast-forward
88
+
89
+ - JSON snapshot: full state, RNG state, pending decision; versioned schema.
90
+ - Replay: seed + decision log.
91
+ - `game.apply([decisions])` and snapshot import are **transactional**: all or
92
+ nothing; on failure the game is restored and the error names the failing
93
+ index and reason.
94
+ - Deck rules apply to new games only; an imported state must be internally
95
+ consistent (valid zones, real objects, legal step, matching pending decision).
96
+
97
+ ## 7. Errors
98
+
99
+ `Enjine::Error` → `IllegalDecision`, `InvalidCardDefinition`, `InvalidDeck`,
100
+ `InvalidSnapshot`, `CatalogConflict`, `DeckLocked`.
101
+
102
+ ## 8. Testing
103
+
104
+ Every mechanic ships with three levels:
105
+
106
+ 1. **Unit** — the primitive in isolation (schema, validation, effect on a
107
+ minimal state).
108
+ 2. **Integration** — scenario builder imports a board position, asserts legal
109
+ options, submits a decision, asserts resulting state and events.
110
+ 3. **End-to-end** — full games from two 60-card decks via `Setup`, scripted and
111
+ fuzzed (random legal options), with invariant checks after every step.
112
+
113
+ RSpec. CI (GitHub Actions): RSpec, RuboCop, SimpleCov ≥ 95% line + branch,
114
+ docs drift check, short fuzz run.
115
+
116
+ ## 9. Docs
117
+
118
+ - Generated from the primitive registry: `docs/reference/*.md`, `SUPPORTED.md`.
119
+ CI fails on drift.
120
+ - Hand-written guides in `docs/guides/`: game lifecycle, decision API, card data
121
+ format, adding a mechanic.
122
+
123
+ ## 10. Tooling & Performance
124
+
125
+ - Ruby 4.0.x with YJIT. Zero runtime dependencies.
126
+ - Dev: rspec, rubocop(-rspec), simplecov, benchmark-ips, stackprof,
127
+ memory_profiler. Benchmarks/profiling are rake tasks, not CI gates.
128
+ - `exe/enjine`: text CLI, random decks from the pool, seats `human` / `random`.
129
+
130
+ ## 11. Milestones
131
+
132
+ 1. **Skeleton** — setup, zones, turn structure, priority, mulligan, lands, manual
133
+ mana, vanilla creatures, combat, SBAs, win/lose, export/import, CLI, fuzzer.
134
+ 2. **Stack** — instants/sorceries, targeting, countering, activated & triggered
135
+ abilities, cancel.
136
+ 3. **Continuous effects** — layers (CR 613), static abilities,
137
+ replacement/prevention, counters, auras, equipment.
138
+ 4. **Keywords & planeswalkers**.
139
+ 5. **Long tail** — copy, control change, exotic mechanics, larger pool.