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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Objects and zones (CR 400): creating objects, moving them between zones through replacement
6
+ # effects (CR 614), tokens, drawing, shuffling and tapping; looking at and revealing cards
7
+ # (CR 701.20), putting cards on the bottom of a library (CR 401.4), cards "exiled with" a source
8
+ # (linked abilities, CR 607.2a, 406.6) and "until" exiles (CR 610.3).
9
+ module Zones
10
+ # Zone changes inside the block aren't results of the running effect (CR 400.7j names only
11
+ # what the effect's instruction moved, e.g. not "the rest" put into a graveyard).
12
+ def unrecorded
13
+ saved = @recorded
14
+ @recorded = nil
15
+ yield
16
+ ensure
17
+ @recorded = saved
18
+ end
19
+
20
+ # CR 701.20a: every player sees the card; it stays where it is (701.20b).
21
+ def reveal!(index, ids)
22
+ ids.each { emit Events::CardRevealed.new(player: name(index), object: it, card: object(it).card) }
23
+ end
24
+
25
+ # CR 701.20e: only the looking player (and the cards' owner) learn which cards they are.
26
+ def look_at!(index, owner, ids)
27
+ return if ids.empty?
28
+
29
+ emit Events::LookedAt.new(player: name(index), owner: name(owner), objects: ids.dup,
30
+ cards: ids.map { object(it).card })
31
+ end
32
+
33
+ # Puts library cards (already in `index`'s library) on its bottom, the first id highest (CR 401.4).
34
+ # `random`: the order is randomized with the game's RNG, so replays stay deterministic.
35
+ def put_on_bottom!(index, ids, random: false)
36
+ return if ids.empty?
37
+
38
+ ids = ids.dup
39
+ state.rng.shuffle!(ids) if random
40
+ library = player(index).library
41
+ library.replace((library - ids) + ids)
42
+ emit Events::LibraryReordered.new(player: name(index), top: 0, bottom: ids.size)
43
+ end
44
+
45
+ # CR 607.2a: cards an ability of `source` exiled, for its linked abilities ("exiled with it").
46
+ def link_exiled!(source, id)
47
+ obj = state.objects[source] or return
48
+ (obj.memory[Mechanics::Linked::EXILED] ||= []) << id
49
+ end
50
+
51
+ # CR 610.3, 610.3c: once the source of an "exile ... until this leaves the battlefield" effect has
52
+ # left, the cards it exiled return to the battlefield under their owners' control. A token that left
53
+ # can't come back (CR 111.8).
54
+ def return_exiled_until_leaves!(old)
55
+ ids = old.memory[Mechanics::Linked::UNTIL_LEAVES] or return
56
+ unrecorded do
57
+ ids.each do |id|
58
+ card = state.objects[id]
59
+ next unless card && card.zone == :exile && !token?(card)
60
+
61
+ move!(id, :battlefield, reason: "return")
62
+ end
63
+ end
64
+ end
65
+
66
+ # ---- objects & zones (CR 400) ------------------------------------------
67
+
68
+ # A card exiled face down gets an id that doesn't name it (CR 406.3); a card's id names its front face.
69
+ def create_object(card, owner, zone, hidden: false)
70
+ id = "#{hidden ? FACE_DOWN_ID : card.split(CardDefinition::FACE_SEPARATOR).first}##{state.next_id}"
71
+ state.next_id += 1
72
+ state.objects[id] = GameObject.new(id: id, card: card, owner: owner, controller: owner, zone: zone,
73
+ tapped: false, controlled_since: nil, kind: "card", counters: {},
74
+ damage: 0, source: nil, ability: nil, targets: nil, loyalty_turn: nil,
75
+ deathtouched: false, modes: nil, memory: {}, attached_to: nil)
76
+ end
77
+
78
+ # Moves an object to a zone as a new object (CR 400.7). Returns the new object.
79
+ # Proposes a state change, lets replacement effects modify it (CR 614), and returns
80
+ # the final proposal for the caller to execute.
81
+ # `applied`: replacement effects that already modified the event this one replaces (CR 614.5).
82
+ def propose(kind, data, applied = []) = Replacements.apply(self, Proposal.new(kind, data, applied.dup))
83
+
84
+ # `face_down`: exile it face down (CR 406.3); the list of player indices who may look at it.
85
+ def move!(id, to, reason:, position: :top, controller: nil, enters: nil, face_down: nil)
86
+ data = plan_move(id, to, reason: reason, position: position, controller: controller, enters: enters,
87
+ face_down: face_down)
88
+ data && execute_zone_change!(data)
89
+ end
90
+
91
+ # The zone change `move!` would make, after replacement effects (CR 614), not yet executed; nil if a
92
+ # replacement cancelled it. Planning several before executing any lets them happen at once (CR 614.12).
93
+ def plan_move(id, to, reason:, position: :top, controller: nil, enters: nil, face_down: nil)
94
+ data = { "object" => id, "to" => to, "position" => position, "reason" => reason, "controller" => controller }
95
+ data["face_down"] = face_down if face_down && to == :exile
96
+ data["enters"] = { "tapped" => enters&.fetch("tapped", false) == true, "counters" => {} } if to == :battlefield
97
+ data["enters"]["face"] = enters["face"] if data["enters"] && enters&.key?("face") # seen by its own replacements
98
+ data = propose("zone_change", data).data
99
+ return nil if data["cancelled"] # e.g. a shield counter instead of being destroyed (CR 122.1c)
100
+
101
+ merge_enters!(data["enters"], enters.except("tapped")) if enters && data["enters"] # tapped: replaceable (614.12)
102
+ data
103
+ end
104
+
105
+ # Takes the object out of its zone; returns its last known information if it was a permanent.
106
+ def depart!(old)
107
+ lki = old.zone == :battlefield ? last_known_info(old) : nil # before it leaves (CR 603.10a)
108
+ remember_last_known!(old.id, lki) if lki # CR 608.2h (see Engine::TurnHistory)
109
+ remember_spell!(old) if old.zone == :stack && !old.ability? # a countered spell's mana spent (Engine::TurnHistory)
110
+ state.zone_list(old.zone, old.owner).delete(old.id)
111
+ state.objects.delete(old.id)
112
+ lki
113
+ end
114
+
115
+ def execute_zone_change!(data)
116
+ id = data["object"]
117
+ to = data["to"]
118
+ old = object(id)
119
+ lki = depart!(old)
120
+ face_down = to == :exile ? data["face_down"] : nil
121
+ fresh = create_object(card_after_move(old, to), old.owner, to, hidden: !face_down.nil?) # front face up (Engine::Faces)
122
+ carry_copy_status!(fresh, old, to) # a token or copy stays one (Engine::Copies)
123
+ if old.zone == :battlefield
124
+ leave_combat!(id)
125
+ end_while_source_effects!(id) # CR 611.2b
126
+ end
127
+ arrive!(fresh, old, data)
128
+ fresh.memory.merge!("face_down" => true, "may_look" => face_down.uniq.sort) if face_down
129
+ list = state.zone_list(to, old.owner)
130
+ data["position"] == :bottom ? list.push(fresh.id) : list.unshift(fresh.id)
131
+ record_result!(fresh.id)
132
+ event = Events::ZoneChanged.new(card: old.card, old_id: id, new_id: fresh.id, owner: name(old.owner),
133
+ from: old.zone.to_s, to: to.to_s, reason: data["reason"],
134
+ lookers: face_down&.map { name(it) })
135
+ lki ? with_last_known(id, lki) { emit event } : emit(event)
136
+ return_exiled_until_leaves!(old) if lki # CR 610.3
137
+ unmeld!(old, to, data) if old.memory["melded"] # CR 713.8 (Engine::Faces)
138
+ prepare!(fresh) if to == :battlefield && data["enters"]["prepared"] # "enters prepared" (CR 723.3a)
139
+ run_follow_ups!(data, "object" => fresh.id) # "exile it instead. When you do, ..." (Engine::FollowUps)
140
+ fresh
141
+ end
142
+
143
+ # Status of the new object in its new zone (CR 110.2, 614.12 entering status).
144
+ def arrive!(fresh, old, data)
145
+ case data["to"]
146
+ when :battlefield
147
+ fresh.controller = data["controller"] || old.owner
148
+ fresh.controlled_since = state.turn
149
+ fresh.memory["entered"] = state.turn # "if it entered this turn" (power-up, CR 702.193a)
150
+ fresh.memory["cast"] = true if data["enters"]["cast"] # CR 400.7d: it remembers it was cast
151
+ fresh.memory.merge!(data["enters"]["memory"]) if data["enters"]["memory"] # CR 614.12: an "as this enters" choice
152
+ enter_face!(fresh, data["enters"]["face"]) if data["enters"]["face"] # CR 712.9, 712.11a (Engine::Faces)
153
+ enter_as_copy!(fresh, data["enters"]["copy"]) if data["enters"]["copy"] # CR 707.5 (Engine::Copies)
154
+ fresh.tapped = data["enters"]["tapped"]
155
+ fresh.counters.merge!(data["enters"]["counters"])
156
+ carry_from_stack!(fresh, old) if old.zone == :stack
157
+ fresh.attached_to = data["enters"]["attached_to"] # CR 608.3c: an Aura enters attached
158
+ enter_attacking!(fresh, data["enters"]["attacking"]) if data["enters"]["attacking"] # CR 508.4, 506.3a
159
+ when :stack
160
+ fresh.controller = data["controller"] || old.owner
161
+ end
162
+ end
163
+
164
+ # A permanent spell becoming a permanent remembers how it was cast (CR 400.7d): its X (its ETB abilities
165
+ # use the spell's X, CR 107.3m), that it was kicked (CR 702.33d) and the mana spent to cast it (CR 601.2h).
166
+ def carry_from_stack!(fresh, old)
167
+ fresh.x = old.x
168
+ fresh.memory.merge!(old.memory.slice("kicked", "kicked_with", "gift")) # gift: CR 702.174b
169
+ fresh.mana_spent = old.mana_spent&.dup
170
+ end
171
+
172
+ # CR 111.1: a token enters the battlefield under its creator's control. Entering replacement
173
+ # effects apply to it like to any permanent (CR 614.1c, 614.12).
174
+ # `memory`: a token that's a copy (Engine::Copies#create_token_copy!).
175
+ # A replacement may create several instead ("twice that many", CR 614.1a): the extra ones are part of the
176
+ # replaced event (`replaced`), so the replacement effects don't apply to them again (CR 614.5).
177
+ def create_token!(definition, controller, tapped: false, memory: nil, enters: nil, replaced: false)
178
+ definition = replace_token_creation!(definition, controller, tapped:, memory:, enters:) unless replaced
179
+ obj = create_object(definition.id, controller, :battlefield)
180
+ obj.memory.merge!(memory) if memory
181
+ entering = enters # an "as this enters" choice made before it's created (CR 614.12a)
182
+ tapped ||= entering&.dig("tapped") == true
183
+ enters = propose("zone_change", { "object" => obj.id, "to" => :battlefield, "controller" => controller,
184
+ "reason" => "create_token",
185
+ "enters" => { "tapped" => tapped, "counters" => {} } }).data["enters"]
186
+ merge_enters!(enters, entering.except("tapped")) if entering
187
+ obj.memory.merge!(enters["memory"]) if enters["memory"]
188
+ obj.controlled_since = state.turn
189
+ obj.memory["entered"] = state.turn
190
+ obj.tapped = enters["tapped"]
191
+ obj.counters.merge!(enters["counters"])
192
+ enter_as_copy!(obj, enters["copy"]) if enters["copy"] # CR 707.5 (Engine::Copies)
193
+ state.battlefield.unshift(obj.id)
194
+ enter_attacking!(obj) if enters["attacking"] # CR 508.4
195
+ record_result!(obj.id)
196
+ emit Events::ZoneChanged.new(card: definition.id, old_id: nil, new_id: obj.id, owner: name(controller),
197
+ from: "none", to: "battlefield", reason: "create_token")
198
+ prepare!(obj) if enters["prepared"] # CR 723.3a (Engine::Faces)
199
+ obj
200
+ end
201
+
202
+ # The create_token replacements (CR 614.1a): the token definition to create, after creating the extra
203
+ # ones a "twice that many" replacement adds.
204
+ def replace_token_creation!(definition, controller, memory:, **)
205
+ data = propose("create_token", { "player" => controller, "token" => definition.id, "count" => 1 }).data
206
+ definition = catalog.fetch(data["token"])
207
+ (data["count"] - 1).times do
208
+ create_token!(definition, controller, memory: memory && Snapshot.deep_copy(memory), replaced: true, **)
209
+ end
210
+ definition
211
+ end
212
+
213
+ # CR 121.2: one card at a time. A replacement may turn one draw into several (CR 614.11); those
214
+ # draws are the modified event, so the replacement that made them doesn't apply again (CR 614.5).
215
+ def draw!(index, applied: [])
216
+ proposal = propose("draw", { "player" => index, "count" => 1 }, applied)
217
+ return unless proposal.data["player"]
218
+ return proposal.data["count"].times { draw!(index, applied: proposal.applied) } if proposal.data["count"] != 1
219
+
220
+ pl = player(index)
221
+ if pl.library.empty?
222
+ pl.drew_from_empty = true # CR 704.5b, checked as a state-based action
223
+ else
224
+ move!(pl.library.first, :hand, reason: "draw")
225
+ end
226
+ end
227
+
228
+ def shuffle_library!(index)
229
+ state.rng.shuffle!(player(index).library)
230
+ emit Events::LibraryShuffled.new(player: name(index))
231
+ end
232
+
233
+ def tap!(object)
234
+ object.tapped = true
235
+ emit Events::Tapped.new(object: object.id)
236
+ end
237
+
238
+ # CR 701.26b: only tapped permanents can be untapped. Replacement effects (a stun counter,
239
+ # CR 122.1d) may stop it.
240
+ def untap!(object)
241
+ return unless object.tapped
242
+ return if propose("untap", { "object" => object.id }).data["cancelled"]
243
+
244
+ object.tapped = false
245
+ emit Events::Untapped.new(object: object.id)
246
+ end_while_source_effects!(object.id, %w[while_source_tapped]) # CR 611.2b: "for as long as it remains tapped" ends
247
+ end
248
+
249
+ # CR 502.3: the active player's permanents untap, except those effects keep from untapping and those
250
+ # they chose to keep tapped (`keep`, see Engine::TurnStructure).
251
+ # An exerted permanent skips this one untap step (CR 701.43a).
252
+ def untap_all!(index, keep: [])
253
+ state.battlefield.each do |id|
254
+ obj = object(id)
255
+ next unless obj.controller == index
256
+
257
+ exerted = obj.memory.delete("exerted")
258
+ untap!(obj) unless keep.include?(id) || exerted || chars(obj).keyword?(:doesnt_untap)
259
+ end
260
+ end
261
+
262
+ # CR 701.43a-b: exerting it (again) means it won't untap during its controller's next untap step.
263
+ def exert!(obj) = obj.memory["exerted"] = true
264
+
265
+ # CR 302.6: a creature's {T} abilities need it under continuous control since
266
+ # the start of its controller's most recent turn.
267
+ def summoning_sick_for_tap?(object)
268
+ current = chars(object)
269
+ return false if !current.creature? || current.keyword?(:haste)
270
+
271
+ most_recent_turn = state.active == object.controller ? state.turn : state.turn - 1
272
+ object.controlled_since.nil? || object.controlled_since >= most_recent_turn
273
+ end
274
+ end
275
+
276
+ include Zones
277
+ end
278
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # The rules state machine.
5
+ #
6
+ # No Ruby call stack ever holds game progress: everything lives in State, so a
7
+ # game can be exported at any decision and resumed elsewhere. #advance! runs the
8
+ # game forward until a player must decide something (state.pending) or the game ends.
9
+ class Engine
10
+ HAND_SIZE = 7
11
+ MAX_MULLIGANS = 7
12
+
13
+ attr_accessor :state
14
+ attr_reader :catalog
15
+
16
+ def initialize(catalog, state, &emit)
17
+ @catalog = catalog
18
+ @state = state
19
+ @emit = emit
20
+ end
21
+
22
+ def emit(event)
23
+ touch! if volatile_layers? || Characteristics::LAYER_EVENTS.include?(event.class)
24
+ record_history!(event) # before triggers look at it (see Engine::TurnHistory)
25
+ @emit.call(event)
26
+ watch_triggers!(event)
27
+ end
28
+
29
+ def name(index) = state.players[index].name
30
+ def names = state.players.map(&:name)
31
+ def player(index) = state.players[index]
32
+ def active_player = state.players[state.active]
33
+ def definition(object) = catalog.fetch(object.card)
34
+ def object(id) = state.objects.fetch(id)
35
+ def ask!(pending) = state.pending = pending
36
+ def step = Steps.fetch(state.step)
37
+ def attackers = state.attackers
38
+
39
+ # ---- decisions --------------------------------------------------------------
40
+
41
+ def options
42
+ return [] unless state.pending
43
+
44
+ @options_memo = {}
45
+ Decisions.fetch(state.pending["kind"]).options(self, state.pending)
46
+ ensure
47
+ @options_memo = nil
48
+ end
49
+
50
+ def submit!(option)
51
+ Decisions.fetch(state.pending["kind"]).submit(self, state.pending, option)
52
+ advance!
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Base class of every error raised by Enjine.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when an error carries a list of individual problems.
8
+ class ErrorList < Error
9
+ attr_reader :errors
10
+
11
+ def initialize(errors, prefix)
12
+ @errors = errors.freeze
13
+ super("#{prefix}:\n" + errors.map { |e| " - #{e}" }.join("\n"))
14
+ end
15
+ end
16
+
17
+ # A submitted option is not legal for the pending decision.
18
+ class IllegalDecision < Error
19
+ attr_reader :option, :legal_options, :index
20
+
21
+ def initialize(message, option: nil, legal_options: [], index: nil)
22
+ @option = option
23
+ @legal_options = legal_options
24
+ @index = index
25
+ super(index.nil? ? message : "decision ##{index}: #{message}")
26
+ end
27
+ end
28
+
29
+ # A card data block failed validation. #errors lists every problem with its path.
30
+ class InvalidCardDefinition < ErrorList
31
+ def initialize(errors, name: nil)
32
+ super(errors, "invalid card definition#{" (#{name})" if name}")
33
+ end
34
+ end
35
+
36
+ # A deck document is malformed or references unknown cards.
37
+ class InvalidDeck < ErrorList
38
+ def initialize(errors)
39
+ super(errors, "invalid deck")
40
+ end
41
+ end
42
+
43
+ # A player whose deck is not valid tried to take a seat.
44
+ class PlayerNotReady < ErrorList
45
+ def initialize(errors, name:)
46
+ super(errors, "player #{name} is not ready")
47
+ end
48
+ end
49
+
50
+ # A snapshot could not be imported because it is inconsistent.
51
+ class InvalidSnapshot < ErrorList
52
+ def initialize(errors)
53
+ super(errors, "invalid snapshot")
54
+ end
55
+ end
56
+
57
+ # Two definitions with the same id but different contents.
58
+ class CatalogConflict < Error; end
59
+
60
+ # A deck was edited while its player is seated in an unfinished game.
61
+ class DeckLocked < Error; end
62
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Typed, serializable game events. Every state change emits one.
5
+ #
6
+ # Players are referenced by name. Each event knows how to present itself to a
7
+ # viewer (a player name or :observer); hidden information is redacted.
8
+ module Events
9
+ PUBLIC_ZONES = %w[battlefield graveyard stack exile].freeze
10
+ REGISTRY = {} # rubocop:disable Style/MutableConstant -- extension registry
11
+
12
+ # Behavior shared by every event class.
13
+ module Base
14
+ def type = self.class.event_type
15
+
16
+ def to_h = { "type" => type }.merge(super.transform_keys(&:to_s))
17
+
18
+ # Hash form for one viewer. Defaults to the full event.
19
+ def for_viewer(_viewer) = to_h
20
+ end
21
+
22
+ def self.define(name, *fields, &)
23
+ klass = Data.define(*fields, &)
24
+ klass.include(Base)
25
+ klass.define_singleton_method(:event_type) { name.to_s }
26
+ REGISTRY[name.to_s] = klass
27
+ klass
28
+ end
29
+
30
+ def self.from_h(hash)
31
+ hash = hash.transform_keys(&:to_s)
32
+ klass = REGISTRY.fetch(hash["type"]) { raise ArgumentError, "unknown event #{hash["type"].inspect}" }
33
+ klass.new(**klass.members.to_h { |m| [m, hash[m.to_s]] })
34
+ end
35
+
36
+ def self.zone_visible?(zone, owner, viewer)
37
+ viewer == :observer || PUBLIC_ZONES.include?(zone) || (zone == "hand" && viewer == owner)
38
+ end
39
+
40
+ GameStarted = define(:game_started, :players, :seed)
41
+ DieRolled = define(:die_rolled, :winner)
42
+ StartingPlayerChosen = define(:starting_player_chosen, :chooser, :choice, :starting_player)
43
+ LibraryShuffled = define(:library_shuffled, :player)
44
+ MulliganDeclared = define(:mulligan_declared, :player, :choice)
45
+ MulliganTaken = define(:mulligan_taken, :player, :mulligans)
46
+ HandKept = define(:hand_kept, :player, :mulligans)
47
+ TurnBegan = define(:turn_began, :turn, :active)
48
+ StepBegan = define(:step_began, :turn, :step, :phase)
49
+ PriorityPassed = define(:priority_passed, :player)
50
+ Tapped = define(:tapped, :object)
51
+ Untapped = define(:untapped, :object)
52
+ # CR 702.26: phasing isn't a zone change; `indirectly`: it phased out attached to another permanent.
53
+ PhasedOut = define(:phased_out, :object, :indirectly)
54
+ PhasedIn = define(:phased_in, :object)
55
+ ManaAdded = define(:mana_added, :player, :mana, :source)
56
+ ManaEmptied = define(:mana_emptied, :player, :mana)
57
+ PlayerLost = define(:player_lost, :player, :reason)
58
+ GameEnded = define(:game_ended, :winner, :reason)
59
+ TargetChosen = define(:target_chosen, :player, :object, :target)
60
+ XAnnounced = define(:x_announced, :player, :object, :value)
61
+ ManaPaid = define(:mana_paid, :player, :mana, :object)
62
+ # `during_resolution`: cast while another spell or ability resolves (CR 608.2g), ignoring timing.
63
+ SpellCast = define(:spell_cast, :player, :object, :card, :targets, :during_resolution) do
64
+ def initialize(player:, object:, card:, targets:, during_resolution: false) = super
65
+ end
66
+ AbilityActivated = define(:ability_activated, :player, :object, :card, :source, :ability, :targets)
67
+ CastCancelled = define(:cast_cancelled, :player, :object, :card)
68
+ Resolved = define(:resolved, :object, :card, :kind)
69
+ Fizzled = define(:fizzled, :object, :card)
70
+ # `combat`: dealt as combat damage (CR 510.2); triggers such as "deals combat damage to a player" read it.
71
+ DamageDealt = define(:damage_dealt, :source, :target, :amount, :combat) do
72
+ def initialize(source:, target:, amount:, combat: false) = super
73
+ end
74
+ # A life total changed. CR 119.9: a gain is a life gain event, a loss a life loss event.
75
+ LifeChanged = define(:life_changed, :player, :from, :to) do
76
+ def gain = [to - from, 0].max
77
+ def loss = [from - to, 0].max
78
+ end
79
+ CountersChanged = define(:counters_changed, :object, :counter, :from, :to)
80
+ PlayerCountersChanged = define(:player_counters_changed, :player, :counter, :from, :to) # poison, rad, ... (CR 122.1)
81
+ EmblemCreated = define(:emblem_created, :player, :object, :card) # CR 114.2
82
+ # CR 705: a coin flip; `won` is nil for a flip no one wins or loses (CR 705.2).
83
+ CoinFlipped = define(:coin_flipped, :player, :heads, :won)
84
+ # CR 706.2: a die with `sides` sides rolled by an effect, and its natural result.
85
+ RolledDie = define(:rolled_die, :player, :sides, :result)
86
+ # CR 707.10: a copy of a spell put on the stack (not cast).
87
+ SpellCopied = define(:spell_copied, :player, :object, :card, :original, :targets)
88
+ EffectStarted = define(:effect_started, :kind, :affected, :details)
89
+ EffectsEnded = define(:effects_ended, :count)
90
+ AttackerDeclared = define(:attacker_declared, :object, :target)
91
+ BlockerDeclared = define(:blocker_declared, :object, :attacker)
92
+ DamageAssigned = define(:damage_assigned, :attacker, :blocker)
93
+ # `trigger`: the id of the pending triggered ability ("copy that ability", see Engine#stack_object_for).
94
+ AbilityTriggered = define(:ability_triggered, :player, :source, :card, :ability, :trigger) do
95
+ def initialize(player:, source:, card:, ability:, trigger: nil) = super
96
+ end
97
+ TriggerPutOnStack = define(:trigger_put_on_stack, :player, :object, :source, :card, :ability, :targets)
98
+ TriggerRemoved = define(:trigger_removed, :player, :source, :card, :reason)
99
+ TokenCeased = define(:token_ceased, :object, :card, :zone)
100
+ LibraryReordered = define(:library_reordered, :player, :top, :bottom)
101
+ Attached = define(:attached, :object, :to) # CR 701.3
102
+ Unattached = define(:unattached, :object, :from) # CR 701.3d
103
+ # A card in a hidden zone is shown to every player (CR 701.20a); it doesn't leave its zone (701.20b).
104
+ CardRevealed = define(:card_revealed, :player, :object, :card)
105
+ # A player looks at cards in a hidden zone (CR 701.20e): only they (and the cards' owner) see which.
106
+ LookedAt = define(:looked_at, :player, :owner, :objects, :cards) do
107
+ def for_viewer(viewer)
108
+ return to_h if viewer == :observer || viewer == player || viewer == owner
109
+
110
+ { "type" => type, "player" => player, "owner" => owner, "count" => objects.size }
111
+ end
112
+ end
113
+ ControlChanged = define(:control_changed, :object, :controller)
114
+ ExtraTurnAdded = define(:extra_turn_added, :player) # CR 500.7
115
+ TurnSkipped = define(:turn_skipped, :player) # CR 614.10
116
+ DilemmaFaced = define(:dilemma_faced, :player, :object, :modes) # CR 700.17
117
+ # Combat declarations once final (CR 508.3d "whenever [a player] attacks", 509.3c "becomes blocked").
118
+ AttacksDeclared = define(:attacks_declared, :player, :attackers)
119
+ BecameBlocked = define(:became_blocked, :object, :blockers)
120
+ # Crew (CR 702.122): a creature tapped to pay a crew cost crews that Vehicle (702.122b); the Vehicle
121
+ # becomes crewed as its crew ability resolves (702.122e), by the creatures in `crew`.
122
+ CrewedVehicle = define(:crewed_vehicle, :object, :vehicle)
123
+ # A permanent tapped to pay a `cost` (e.g. "teamwork", CR 702.194a) of the spell `spell`, once the spell is cast.
124
+ TappedForCost = define(:tapped_for_cost, :object, :cost, :spell)
125
+ BecameCrewed = define(:became_crewed, :object, :crew)
126
+ # Saddle (CR 702.171): a creature tapped to pay a saddle cost saddles that Mount (702.171c); the Mount
127
+ # becomes saddled (702.171b), by the creatures in `riders` when its saddle ability did it.
128
+ SaddledMount = define(:saddled_mount, :object, :mount)
129
+ BecameSaddled = define(:became_saddled, :object, :riders)
130
+ # A player's speed changed (CR 702.179); `from` nil: they had no speed.
131
+ SpeedChanged = define(:speed_changed, :player, :from, :to)
132
+ # A player got a designation for the rest of the game, e.g. an enduring story (CR 702.195b).
133
+ DesignationGained = define(:designation_gained, :player, :designation)
134
+ # A permanent's class level became `level` (CR 717.2a).
135
+ ClassLevelChanged = define(:class_level_changed, :object, :level)
136
+ # CR 701.27a: a double-faced permanent turned over; `card` names the face now up (CR 712).
137
+ Transformed = define(:transformed, :object, :card)
138
+ # CR 723.3a-c: a permanent gained (`prepared` true) or lost the prepared designation.
139
+ PreparedChanged = define(:prepared_changed, :object, :prepared)
140
+ # A keyword action that abilities trigger on, once it's complete: "scry" / "surveil" N (CR 701.22, 701.25);
141
+ # earthbend, waterbend, airbend and firebend (CR 701.66b, 701.67c, 701.65b, 702.189b).
142
+ KeywordAction = define(:keyword_action, :player, :action, :amount)
143
+
144
+ # A card moved between zones; old_id stops existing and new_id is born (CR 400.7). `lookers`: the card
145
+ # was exiled face down, and only these players (by name) see which card it is (CR 406.3).
146
+ ZoneChanged = define(:zone_changed, :card, :old_id, :new_id, :owner, :from, :to, :reason, :lookers) do
147
+ def initialize(card:, old_id:, new_id:, owner:, from:, to:, reason:, lookers: nil) = super
148
+
149
+ def for_viewer(viewer)
150
+ if lookers && viewer != :observer && !lookers.include?(viewer)
151
+ { "type" => type, "owner" => owner, "new_id" => new_id, "from" => from, "to" => to, "reason" => reason,
152
+ "face_down" => true }
153
+ elsif Events.zone_visible?(from, owner, viewer) || Events.zone_visible?(to, owner, viewer)
154
+ to_h
155
+ else
156
+ { "type" => type, "owner" => owner, "from" => from, "to" => to, "reason" => reason }
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # In-progress processes, kept on a serializable stack (state.frames, innermost last).
5
+ #
6
+ # Anything that may need to stop and ask a player something in the middle of the
7
+ # rules (casting, resolving a spell, assigning combat damage, ...) is a frame:
8
+ # plain JSON data plus a registered frame class. Engine#advance! steps the top
9
+ # frame until it is done or waiting for a decision, so a game can be exported at
10
+ # any point, including halfway through resolving an effect list.
11
+ #
12
+ # A frame class implements:
13
+ # .step(engine, frame) -> :done (pop it), :wait (state.pending is set), or :push (stepped child next)
14
+ # .decisions -> decision kinds that may be pending while it is on top
15
+ # .validate(frame, data, errors) -> snapshot consistency checks
16
+ module Frames
17
+ KINDS = {} # rubocop:disable Style/MutableConstant -- extension registry
18
+
19
+ def self.register(type, klass)
20
+ KINDS[type] = klass
21
+ end
22
+
23
+ def self.fetch(type) = KINDS.fetch(type) { raise ArgumentError, "unknown frame type #{type.inspect}" }
24
+
25
+ # Base frame.
26
+ class Base
27
+ def self.decisions = []
28
+ def self.validate(_frame, _data, _errors) = nil
29
+ end
30
+
31
+ # Casting a spell or activating an ability (CR 601.2, 602.2).
32
+ class Cast < Base
33
+ def self.step(engine, _frame) = engine.continue_casting!
34
+
35
+ def self.decisions
36
+ %w[choose_kicker choose_cost_option choose_mode choose_x choose_target_count choose_target distribute choose_cost pay_cost]
37
+ end
38
+
39
+ def self.validate(frame, data, errors)
40
+ errors << "frames: cast object #{frame["object"]} is not on the stack" unless data["stack"].include?(frame["object"])
41
+ end
42
+ end
43
+ register("cast", Cast)
44
+
45
+ # Resolving the top object of the stack, one effect at a time (CR 608).
46
+ class Resolve < Base
47
+ def self.step(engine, frame) = engine.step_resolution!(frame)
48
+ def self.decisions = %w[effect_choice enter_choice]
49
+
50
+ def self.validate(frame, data, errors)
51
+ errors << "frames: resolving object #{frame["object"]} is not on the stack" unless data["stack"].include?(frame["object"])
52
+ end
53
+ end
54
+ register("resolve", Resolve)
55
+
56
+ # Assigning and dealing combat damage (CR 510).
57
+ class CombatDamage < Base
58
+ def self.step(engine, frame) = engine.step_combat_damage!(frame)
59
+ def self.decisions = %w[assign_damage]
60
+ end
61
+ register("combat_damage", CombatDamage)
62
+
63
+ # Putting pending triggered abilities on the stack (CR 603.3), then giving priority.
64
+ class PutTriggers < Base
65
+ def self.step(engine, frame) = engine.step_put_triggers!(frame)
66
+ def self.decisions = %w[order_triggers]
67
+ end
68
+ register("put_triggers", PutTriggers)
69
+
70
+ # The legend rule's choice (CR 704.5j).
71
+ class LegendRule < Base
72
+ def self.step(engine, frame) = engine.step_legend_rule!(frame)
73
+ def self.decisions = %w[legend_rule]
74
+ end
75
+ register("legend_rule", LegendRule)
76
+
77
+ # Playing a land that asks an "as this enters" choice first (CR 614.12).
78
+ class PlayLand < Base
79
+ def self.step(engine, frame) = Actions::PlayLand.step(engine, frame)
80
+ def self.decisions = %w[enter_choice]
81
+ end
82
+ register("play_land", PlayLand)
83
+ end
84
+ end