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,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "snapshot/turn_checks"
4
+ require_relative "snapshot/player_checks"
5
+
6
+ module Enjine
7
+ # Converts State to and from plain data, with complete consistency validation.
8
+ #
9
+ # The validation doubles as the engine's invariant check: the fuzzer runs it
10
+ # after every decision of every random game.
11
+ module Snapshot
12
+ VERSION = 2
13
+ PLAYER_KEYS = %w[name life poison library hand graveyard mana_pool lands_played drew_from_empty lost
14
+ mulligans kept mulligan_choice].freeze
15
+
16
+ module_function
17
+
18
+ def validate(data, catalog)
19
+ errors = []
20
+ return ["snapshot must be an object"] unless data.is_a?(Hash)
21
+ return ["version: expected #{VERSION}, got #{data["version"].inspect}"] unless data["version"] == VERSION
22
+
23
+ validate_header(data, errors)
24
+ validate_players(data, errors)
25
+ validate_objects(data, catalog, errors)
26
+ validate_combat(data, errors) if errors.empty?
27
+ validate_turn(data, errors) if errors.empty?
28
+ validate_delayed_triggers(data, catalog, errors) if errors.empty?
29
+ validate_extra_turns(data, errors)
30
+ validate_extra_phases(data, errors)
31
+ validate_outside(data, catalog, errors)
32
+ errors
33
+ end
34
+
35
+ def load(data, catalog)
36
+ data = deep_stringify(data)
37
+ errors = validate(data, catalog)
38
+ raise InvalidSnapshot, errors unless errors.empty?
39
+
40
+ build(data)
41
+ end
42
+
43
+ def build(data)
44
+ state = State.new
45
+ state.seed = data["seed"]
46
+ state.rng = Rng.new(data["rng"])
47
+ state.decklists = data["decklists"].map { |l| l.dup.freeze }.freeze
48
+ state.players = data["players"].map { |p| build_player(p) }
49
+ state.objects = data["objects"].to_h do |o|
50
+ [o["id"], build_object(o)]
51
+ end
52
+ State::SHARED_ZONES.each { |z| state.public_send("#{z}=", data.fetch(z.to_s, []).dup) }
53
+ state.attackers = deep_copy(data.fetch("attackers", {}))
54
+ state.blockers = deep_copy(data.fetch("blockers", {}))
55
+ state.effects = deep_copy(data.fetch("effects", []))
56
+ state.frames = deep_copy(data.fetch("frames", []))
57
+ state.pending_triggers = deep_copy(data.fetch("pending_triggers", []))
58
+ state.delayed_triggers = deep_copy(data.fetch("delayed_triggers", []))
59
+ state.extra_turns = deep_copy(data.fetch("extra_turns", { "queue" => [], "begun" => [] }))
60
+ state.extra_phases = deep_copy(data.fetch("extra_phases", State.no_extra_phases))
61
+ state.outside_lists = data.fetch("outside_lists", [[], []]).map { |l| l.dup.freeze }.freeze
62
+ state.repeat_cleanup = data.fetch("repeat_cleanup", false)
63
+ state.combat_damage_pass = data["combat_damage_pass"]
64
+ state.attacks_declared = data.fetch("attacks_declared", 0)
65
+ %w[turn active step_state priority passes next_id starting_player].each do |k|
66
+ state.public_send("#{k}=", data[k])
67
+ end
68
+ state.step = data["step"]&.to_sym
69
+ state.pending = data["pending"]&.dup
70
+ state.result = data["result"]&.dup
71
+ state
72
+ end
73
+
74
+ def build_object(hash)
75
+ GameObject.new(id: hash["id"], card: hash["card"], owner: hash["owner"], controller: hash["controller"],
76
+ zone: hash["zone"].to_sym, tapped: hash["tapped"], controlled_since: hash["controlled_since"],
77
+ kind: hash.fetch("kind", "card"), counters: deep_copy(hash.fetch("counters", {})),
78
+ damage: hash.fetch("damage", 0), source: hash["source"], ability: hash["ability"],
79
+ targets: deep_copy(hash["targets"]), loyalty_turn: hash["loyalty_turn"],
80
+ deathtouched: hash.fetch("deathtouched", false), modes: hash["modes"]&.dup,
81
+ memory: deep_copy(hash.fetch("memory", {})), x: hash["x"], mana_spent: hash["mana_spent"]&.dup,
82
+ attached_to: hash["attached_to"], paid: hash["paid"] && deep_copy(hash["paid"]),
83
+ phased_out: hash["phased_out"]&.dup)
84
+ end
85
+
86
+ def deep_copy(value) = Marshal.load(Marshal.dump(value))
87
+
88
+ def build_player(hash)
89
+ PlayerState.new(**PLAYER_KEYS.to_h { |k| [k.to_sym, hash[k]] }).tap do |pl|
90
+ pl.library = pl.library.dup
91
+ pl.hand = pl.hand.dup
92
+ pl.graveyard = pl.graveyard.dup
93
+ pl.mana_pool = Mana::TYPES.to_h { |t| [t, hash["mana_pool"].fetch(t.to_s, 0)] }
94
+ pl.turn_log = deep_copy(hash.fetch("turn_log", {}))
95
+ pl.game_log = deep_copy(hash.fetch("game_log", {}))
96
+ pl.last_turn_log = deep_copy(hash.fetch("last_turn_log", {}))
97
+ pl.restricted_mana = deep_copy(hash.fetch("restricted_mana", []))
98
+ pl.outside = hash.fetch("outside", []).dup
99
+ pl.speed = hash["speed"] # CR 702.179; absent: no speed
100
+ pl.designations = hash.fetch("designations", []).dup # CR 702.195b (Engine::Designations)
101
+ pl.counters = hash.fetch("counters", {}).dup # CR 122.1: counters other than poison
102
+ pl.pool_sources = deep_copy(hash.fetch("pool_sources", [])) # Engine::ManaSources#add_mana!
103
+ end
104
+ end
105
+
106
+ def validate_header(data, errors)
107
+ errors << "rng: must be a non-negative integer" unless non_negative?(data["rng"])
108
+ errors << "next_id: must be a positive integer" unless data["next_id"].is_a?(Integer) && data["next_id"].positive?
109
+ errors << "decklists: must be a list of card id lists" unless data["decklists"].is_a?(Array) && data["decklists"].all?(Array)
110
+ %w[battlefield stack exile].each { |z| errors << "#{z}: must be a list" unless data[z].is_a?(Array) }
111
+ errors << "phased_out: must be a list" unless data.fetch("phased_out", []).is_a?(Array)
112
+ end
113
+
114
+ def validate_players(data, errors)
115
+ players = data["players"]
116
+ return errors << "players: must be a list of 2 players" unless players.is_a?(Array) && players.size == 2
117
+
118
+ players.each_with_index do |pl, i|
119
+ path = "players[#{i}]"
120
+ next errors << "#{path}: must be an object" unless pl.is_a?(Hash)
121
+
122
+ (PLAYER_KEYS - pl.keys).each { |k| errors << "#{path}.#{k}: missing" }
123
+ %w[life poison lands_played mulligans].each do |k|
124
+ errors << "#{path}.#{k}: must be an integer" unless pl[k].is_a?(Integer)
125
+ end
126
+ %w[library hand graveyard].each { |z| errors << "#{path}.#{z}: must be a list" unless pl[z].is_a?(Array) }
127
+ pool = pl["mana_pool"]
128
+ unless pool.is_a?(Hash) && pool.all? { |t, n| Mana::TYPES.include?(t.to_sym) && non_negative?(n) }
129
+ errors << "#{path}.mana_pool: must map W/U/B/R/G/C to non-negative integers"
130
+ end
131
+ validate_restricted_mana(pl.fetch("restricted_mana", []), errors, "#{path}.restricted_mana")
132
+ validate_pool_sources(pl, errors, "#{path}.pool_sources")
133
+ errors << "#{path}.speed: must be 1, 2, 3 or 4 (or absent)" unless [nil, 1, 2, 3, 4].include?(pl["speed"]) # CR 702.179
134
+ validate_designations(pl.fetch("designations", []), errors, "#{path}.designations")
135
+ end
136
+ validate_logs(players, errors)
137
+ names = players.map { it.is_a?(Hash) ? it["name"] : nil }
138
+ errors << "players: names must be distinct non-empty strings" unless names.all?(String) && names.uniq.size == 2
139
+ end
140
+
141
+ # What happened this turn and this game (Engine::TurnHistory) and counters on the players; absent in older
142
+ # snapshots.
143
+ def validate_logs(players, errors)
144
+ players.each_with_index.select { |pl, _| pl.is_a?(Hash) }.each do |pl, i|
145
+ %w[turn_log game_log last_turn_log].each do |k|
146
+ errors << "players[#{i}].#{k}: must be an object" unless pl.fetch(k, {}).is_a?(Hash)
147
+ end
148
+ counters = pl.fetch("counters", {}) # CR 122.1: counters other than poison; absent in older snapshots
149
+ next if counters.is_a?(Hash) && counters.all? { |k, n| Mechanics::Counters.valid?(k) && k != "poison" && non_negative?(n) }
150
+
151
+ errors << "players[#{i}].counters: must map counter kinds other than poison to non-negative integers"
152
+ end
153
+ end
154
+
155
+ def validate_objects(data, catalog, errors)
156
+ objects = data["objects"]
157
+ return errors << "objects: must be a list" unless objects.is_a?(Array)
158
+ return unless errors.empty?
159
+
160
+ seen = {}
161
+ objects.each_with_index do |o, i|
162
+ path = "objects[#{i}]"
163
+ next errors << "#{path}: must be an object" unless o.is_a?(Hash)
164
+
165
+ id = o["id"]
166
+ errors << "#{path}: duplicate id #{id.inspect}" if seen.key?(id)
167
+ seen[id] = o
168
+ validate_object(o, path, catalog, data["next_id"], errors)
169
+ end
170
+ validate_zone_membership(data, seen, errors)
171
+ end
172
+
173
+ def validate_object(obj, path, catalog, next_id, errors)
174
+ validate_object_id(obj, path, catalog, next_id, errors)
175
+ %w[owner controller].each { |k| errors << "#{path}.#{k}: must be 0 or 1" unless [0, 1].include?(obj[k]) }
176
+ zone = obj["zone"].to_s
177
+ errors << "#{path}.zone: unknown zone #{zone.inspect}" unless State::ZONES.map(&:to_s).include?(zone)
178
+ errors << "#{path}.tapped: must be true or false" unless [true, false].include?(obj["tapped"])
179
+ validate_object_state(obj, path, zone, errors)
180
+ validate_phasing(obj, path, zone, errors)
181
+ return if %w[battlefield stack phased_out].include?(zone)
182
+
183
+ errors << "#{path}: only permanents on the battlefield can be tapped" if obj["tapped"]
184
+ errors << "#{path}: controller must equal owner outside the battlefield and stack" if obj["controller"] != obj["owner"]
185
+ end
186
+
187
+ def validate_object_state(obj, path, zone, errors)
188
+ kind = obj.fetch("kind", "card")
189
+ errors << "#{path}.kind: must be \"card\", \"ability\" or \"emblem\"" unless %w[card ability emblem].include?(kind)
190
+ home = { "ability" => "stack", "emblem" => "command" }[kind]
191
+ if home
192
+ errors << "#{path}: #{kind}s can only exist in the #{home} zone" unless zone == home
193
+ errors << "#{path}.ability: must be an ability reference" unless obj["ability"].is_a?(String)
194
+ end
195
+ errors << "#{path}: only emblems are in the command zone (CR 114.1)" if zone == "command" && kind != "emblem"
196
+ counters = obj.fetch("counters", {})
197
+ unless counters.is_a?(Hash) && counters.all? { |k, v| Mechanics::Counters.valid?(k) && non_negative?(v) }
198
+ errors << "#{path}.counters: must map counter kinds to non-negative integers"
199
+ end
200
+ errors << "#{path}.damage: must be a non-negative integer" unless non_negative?(obj.fetch("damage", 0))
201
+ errors << "#{path}.memory: must be an object" unless obj.fetch("memory", {}).is_a?(Hash)
202
+ validate_cast_info(obj, path, errors)
203
+ validate_attachment(obj, path, zone, errors)
204
+ return if ON_BATTLEFIELD.include?(zone)
205
+
206
+ # Cards in exile may have counters (time counters of suspend, CR 702.62a); only permanents have damage.
207
+ return unless (zone != "exile" && counters.values.any?(&:positive?)) || obj["damage"].to_i.positive?
208
+
209
+ errors << "#{path}: only permanents have counters or damage"
210
+ end
211
+
212
+ # The X announced for a spell or ability and the mana spent on it (CR 107.3a, 702.44a).
213
+ def validate_cast_info(obj, path, errors)
214
+ errors << "#{path}.x: must be a non-negative integer" unless obj["x"].nil? || non_negative?(obj["x"])
215
+ spent = obj["mana_spent"]
216
+ return if spent.nil? || (spent.is_a?(Array) && spent.all? { Mana::TYPES.map(&:to_s).include?(it) })
217
+
218
+ errors << "#{path}.mana_spent: must be a list of mana types"
219
+ end
220
+
221
+ # CR 701.3: only a permanent is attached, never to itself. The object it names may already
222
+ # be gone: state-based actions (704.5m/n) clean that up before the next priority.
223
+ def validate_attachment(obj, path, zone, errors)
224
+ to = obj["attached_to"]
225
+ return if to.nil?
226
+
227
+ errors << "#{path}.attached_to: must be another object's id" unless to.is_a?(String) && to != obj["id"]
228
+ errors << "#{path}: only permanents on the battlefield can be attached" unless ON_BATTLEFIELD.include?(zone)
229
+ end
230
+
231
+ # Phased out (CR 702.26): still a permanent on the battlefield, kept in the `phased_out` list with who
232
+ # controlled it as it phased out and whether it phased out indirectly.
233
+ ON_BATTLEFIELD = %w[battlefield phased_out].freeze
234
+
235
+ def validate_phasing(obj, path, zone, errors)
236
+ info = obj["phased_out"]
237
+ return errors << "#{path}.phased_out: only phased-out permanents have it" if info && zone != "phased_out"
238
+ return if zone != "phased_out"
239
+ return if info.is_a?(Hash) && [0, 1].include?(info["by"]) && [true, false].include?(info["indirectly"])
240
+
241
+ errors << "#{path}.phased_out: must be {\"by\": 0 or 1, \"indirectly\": true or false}"
242
+ end
243
+
244
+ def validate_combat(data, errors)
245
+ battlefield = data["battlefield"]
246
+ attackers = data.fetch("attackers", {})
247
+ blockers = data.fetch("blockers", {})
248
+ return errors << "attackers/blockers: must be objects" unless attackers.is_a?(Hash) && blockers.is_a?(Hash)
249
+
250
+ attackers.each_key { |id| errors << "attackers: #{id} is not on the battlefield" unless battlefield.include?(id) }
251
+ blockers.each do |id, attacker|
252
+ errors << "blockers: #{id} is not on the battlefield" unless battlefield.include?(id)
253
+ errors << "blockers: #{id} blocks #{attacker}, which is not attacking" unless attackers.key?(attacker)
254
+ end
255
+ errors << "effects: must be a list" unless data.fetch("effects", []).is_a?(Array)
256
+ validate_frames(data, errors)
257
+ end
258
+
259
+ def validate_frames(data, errors)
260
+ frames = data.fetch("frames", [])
261
+ return errors << "frames: must be a list" unless frames.is_a?(Array)
262
+
263
+ frames.each_with_index do |frame, i|
264
+ klass = frame.is_a?(Hash) && Frames::KINDS[frame["type"]]
265
+ next errors << "frames[#{i}]: unknown frame #{frame.inspect[0, 60]}" unless klass
266
+
267
+ klass.validate(frame, data, errors)
268
+ end
269
+ top = frames.last
270
+ kind = data.dig("pending", "kind")
271
+ return unless top && kind && !Frames.fetch(top["type"]).decisions.include?(kind)
272
+
273
+ errors << "pending: #{kind} can't be pending while a #{top["type"]} frame is in progress"
274
+ end
275
+
276
+ def validate_zone_membership(data, objects, errors)
277
+ placed = Hash.new(0)
278
+ lists = State::SHARED_ZONES.map { |z| [z.to_s, nil, data.fetch(z.to_s, [])] }
279
+ data["players"].each_with_index do |pl, i|
280
+ State::PLAYER_ZONES.each { |z| lists << [z.to_s, i, pl[z.to_s]] }
281
+ end
282
+ lists.each do |zone, owner, ids|
283
+ ids.each do |id|
284
+ placed[id] += 1
285
+ obj = objects[id]
286
+ next errors << "#{zone}: unknown object #{id.inspect}" unless obj
287
+ next errors << "#{id}: listed in #{zone} but its zone is #{obj["zone"]}" unless obj["zone"].to_s == zone
288
+ next unless owner && obj["owner"] != owner
289
+
290
+ errors << "#{id}: in #{data["players"][owner]["name"]}'s #{zone} but owned by player #{obj["owner"]}"
291
+ end
292
+ end
293
+ objects.each_key { |id| errors << "#{id}: in #{placed[id]} zones, expected exactly 1" if placed[id] != 1 }
294
+ end
295
+
296
+ def non_negative?(value) = value.is_a?(Integer) && !value.negative?
297
+
298
+ def deep_stringify(value)
299
+ case value
300
+ when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
301
+ when Array then value.map { deep_stringify(it) }
302
+ when Symbol then value.to_s
303
+ else value
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # A card or token in a zone (CR 109). Refers to its definition by id only.
5
+ # A new GameObject (with a new id) is created on every zone change (CR 400.7).
6
+ GameObject = Struct.new(:id, :card, :owner, :controller, :zone, :tapped, :controlled_since, :kind, :counters,
7
+ :damage, :source, :ability, :targets, :loyalty_turn, :deathtouched, :modes, :memory,
8
+ :x, :mana_spent, :attached_to,
9
+ :paid, # paid: choices made paying costs, e.g. the creature tapped (CR 702.184a)
10
+ :phased_out) do # {"by" => controller, "indirectly" => bool} while phased out (CR 702.26)
11
+ def ability? = kind == "ability"
12
+
13
+ def to_h
14
+ { "id" => id, "card" => card, "owner" => owner, "controller" => controller, "zone" => zone.to_s,
15
+ "tapped" => tapped, "controlled_since" => controlled_since, "kind" => kind, "counters" => counters.dup,
16
+ "damage" => damage, "source" => source, "ability" => ability, "targets" => targets&.dup,
17
+ "loyalty_turn" => loyalty_turn, "deathtouched" => deathtouched,
18
+ "modes" => modes&.dup, "memory" => Marshal.load(Marshal.dump(memory || {})), "x" => x,
19
+ "mana_spent" => mana_spent&.dup, "attached_to" => attached_to, "paid" => paid&.map(&:dup),
20
+ "phased_out" => phased_out&.dup }
21
+ end
22
+ end
23
+
24
+ # Per-player game state.
25
+ PlayerState = Struct.new(:name, :life, :poison, :library, :hand, :graveyard, :mana_pool, :lands_played,
26
+ :drew_from_empty, :lost, :mulligans, :kept, :mulligan_choice,
27
+ :turn_log, :game_log, # what happened this turn / this game (see Engine::History)
28
+ :last_turn_log, # what happened during the previous turn (CR 500.1)
29
+ :restricted_mana, # mana with a spending restriction (CR 106.6), see Engine#add_mana!
30
+ :outside, # definition ids of the cards they own outside the game (CR 400.11a)
31
+ :speed, # nil (no speed) or 1-4 (CR 702.179), see Engine::Speed
32
+ :designations, # player designations, e.g. "enduring_story" (CR 702.195b), see Engine::Designations
33
+ :counters, # nil or {kind => n}: counters other than poison (CR 122.1), see Engine#add_player_counters!
34
+ :pool_sources) do # nil or [{"mana", "from"}]: sources of pool mana made by artifacts (Engine#add_mana!)
35
+ def zone(name)
36
+ case name
37
+ when :library then library
38
+ when :hand then hand
39
+ when :graveyard then graveyard
40
+ end
41
+ end
42
+
43
+ def to_h
44
+ { "name" => name, "life" => life, "poison" => poison, "library" => library.dup, "hand" => hand.dup,
45
+ "graveyard" => graveyard.dup, "mana_pool" => mana_pool.transform_keys(&:to_s),
46
+ "lands_played" => lands_played, "drew_from_empty" => drew_from_empty, "lost" => lost,
47
+ "mulligans" => mulligans, "kept" => kept, "mulligan_choice" => mulligan_choice,
48
+ "turn_log" => deep(turn_log || {}), "game_log" => deep(game_log || {}), "last_turn_log" => deep(last_turn_log || {}),
49
+ "restricted_mana" => deep(restricted_mana || []), "outside" => (outside || []).dup }.merge(optional_h)
50
+ end
51
+
52
+ private
53
+
54
+ def deep(value) = Marshal.load(Marshal.dump(value))
55
+
56
+ # Fields left out while empty.
57
+ def optional_h
58
+ { "speed" => speed, "designations" => designations.to_a.empty? ? nil : designations.dup,
59
+ "counters" => counters.nil? || counters.empty? ? nil : counters.dup,
60
+ "pool_sources" => pool_sources.nil? || pool_sources.empty? ? nil : deep(pool_sources) }.compact
61
+ end
62
+ end
63
+
64
+ # The complete, mutable state of one game. Plain data only: everything here
65
+ # round-trips through #to_h / Snapshot.load, and Marshal copies it cheaply.
66
+ class State
67
+ STARTING_LIFE = 20
68
+ PLAYER_ZONES = %i[library hand graveyard].freeze
69
+ # `phased_out` is not a zone of the rules: phased-out permanents are on the battlefield but treated as
70
+ # though they don't exist (CR 702.26b, 702.26d), so they are kept apart from `battlefield`. The command
71
+ # zone holds emblems (CR 114, 408).
72
+ SHARED_ZONES = %i[battlefield stack exile phased_out command].freeze
73
+ ZONES = (PLAYER_ZONES + SHARED_ZONES).freeze
74
+ STEP_STATES = %w[begin active done].freeze
75
+
76
+ attr_accessor :seed, :rng, :decklists, :players, :objects, :battlefield, :stack, :exile, :phased_out, :command, :turn,
77
+ :active,
78
+ :step, :step_state, :priority, :passes, :next_id, :starting_player, :pending, :result,
79
+ :attackers, :blockers, :effects, :frames, :attacks_declared,
80
+ :pending_triggers, :repeat_cleanup, :combat_damage_pass, :delayed_triggers,
81
+ :extra_turns, :extra_phases
82
+ # The definition ids each player started the game with outside it (CR 400.11a, 100.4), like
83
+ # `decklists`: a restart begins from these again. What is still outside is PlayerState#outside.
84
+ attr_accessor :outside_lists
85
+
86
+ def self.initial(names:, decklists:, seed:, outside: nil)
87
+ state = new
88
+ state.seed = seed
89
+ state.rng = Rng.from_seed(seed)
90
+ state.decklists = decklists.map { |l| l.dup.freeze }.freeze
91
+ state.outside_lists = (outside || names.map { [] }).map { |l| l.dup.freeze }.freeze
92
+ state.players = names.each_with_index.map { |name, i| new_player(name, state.outside_lists[i]) }
93
+ state.objects = {}
94
+ state.battlefield = []
95
+ state.stack = []
96
+ state.exile = []
97
+ state.phased_out = []
98
+ state.command = []
99
+ state.attackers = {}
100
+ state.blockers = {}
101
+ state.effects = []
102
+ state.frames = []
103
+ state.pending_triggers = []
104
+ state.delayed_triggers = []
105
+ state.extra_turns = { "queue" => [], "begun" => [] } # CR 500.7
106
+ state.extra_phases = no_extra_phases # CR 500.8
107
+ state.repeat_cleanup = false
108
+ state.combat_damage_pass = nil
109
+ state.attacks_declared = 0
110
+ state.turn = 0
111
+ state.passes = 0
112
+ state.next_id = 1
113
+ state.step_state = "begin"
114
+ state
115
+ end
116
+
117
+ # Phases added this turn (CR 500.8): "pending" [{"after": phase, "phases": ["combat", "main"]}] wait for
118
+ # their phase to end, "queue" lists added phases still to come and "resume" the step the turn goes on
119
+ # with after them (Engine::TurnStructure); "combats" counts combat phases begun this turn.
120
+ def self.no_extra_phases = { "pending" => [], "queue" => [], "combats" => 0 }
121
+
122
+ def self.new_player(name, outside = [])
123
+ PlayerState.new(name: name, life: STARTING_LIFE, poison: 0, library: [], hand: [], graveyard: [],
124
+ mana_pool: Mana.empty_pool, lands_played: 0, drew_from_empty: false, lost: nil,
125
+ mulligans: 0, kept: false, mulligan_choice: nil, turn_log: {}, game_log: {}, last_turn_log: {}, restricted_mana: [],
126
+ outside: outside.dup)
127
+ end
128
+
129
+ def zone_list(zone, owner)
130
+ SHARED_ZONES.include?(zone) ? public_send(zone) : players[owner].zone(zone)
131
+ end
132
+
133
+ def object(id) = objects.fetch(id)
134
+ def opponent(index) = 1 - index
135
+ def over? = !result.nil?
136
+
137
+ def to_h
138
+ { "version" => Snapshot::VERSION, "rules" => RULES_VERSION, "seed" => seed, "rng" => rng.state,
139
+ "decklists" => decklists.map(&:dup), "turn" => turn, "active" => active, "step" => step&.to_s,
140
+ "step_state" => step_state, "priority" => priority, "passes" => passes, "next_id" => next_id,
141
+ "starting_player" => starting_player, "pending" => pending&.dup, "result" => result&.dup,
142
+ "players" => players.map(&:to_h), "objects" => objects.values.map(&:to_h),
143
+ "battlefield" => battlefield.dup, "stack" => stack.dup, "exile" => exile.dup, "phased_out" => phased_out.dup,
144
+ "command" => (command || []).dup,
145
+ "attackers" => attackers.dup, "blockers" => blockers.dup, "effects" => effects.map { deep_dup(it) },
146
+ **DEEP_FIELDS.to_h { [it, deep_dup(public_send(it))] },
147
+ "repeat_cleanup" => repeat_cleanup, "combat_damage_pass" => combat_damage_pass,
148
+ "attacks_declared" => attacks_declared }.merge("outside_lists" => (outside_lists || []).map(&:dup))
149
+ end
150
+
151
+ # Nested fields #to_h deep-copies as they are.
152
+ DEEP_FIELDS = %w[frames pending_triggers delayed_triggers extra_turns extra_phases].freeze
153
+
154
+ # An independent deep copy, about 10x faster than a Marshal round trip. It walks every
155
+ # instance variable, so fields added later are copied automatically. Strings, symbols,
156
+ # numbers and frozen values are shared: state never mutates them in place.
157
+ def fork
158
+ copy = State.allocate
159
+ instance_variables.each { |ivar| copy.instance_variable_set(ivar, deep_dup(instance_variable_get(ivar))) }
160
+ copy
161
+ end
162
+
163
+ def deep_dup(value)
164
+ case value
165
+ when Hash then value.frozen? ? value : value.to_h { |k, v| [k, deep_dup(v)] }
166
+ when Array then value.frozen? ? value : value.map { deep_dup(it) }
167
+ when Struct then value.dup.tap { |copy| copy.each_pair { |k, v| copy[k] = deep_dup(v) } }
168
+ when Rng then Rng.new(value.state)
169
+ else value
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # The twelve steps of a turn (CR 500.1). Each step object owns its turn-based
5
+ # actions and whether players receive priority during it.
6
+ module Steps
7
+ # Base step: turn-based actions are a no-op and players get priority.
8
+ class Step
9
+ attr_reader :name, :phase
10
+
11
+ def initialize(name, phase)
12
+ @name = name
13
+ @phase = phase
14
+ freeze
15
+ end
16
+
17
+ def priority? = true
18
+ def skip?(_engine) = false
19
+ def finish(_engine) = nil
20
+
21
+ # Performs the step's turn-based actions. May set a pending decision; in that
22
+ # case the decision calls engine.turn_based_actions_done! when finished.
23
+ def begin(engine)
24
+ engine.turn_based_actions_done!
25
+ end
26
+ end
27
+
28
+ # CR 502: phasing (502.1), then the active player's permanents untap (502.3), some by their choice
29
+ # (Engine::TurnStructure); no priority (502.4).
30
+ class Untap < Step
31
+ def priority? = false
32
+ def begin(engine) = engine.begin_untap_step!
33
+ end
34
+
35
+ # CR 504: the active player draws. In a two-player game the starting player skips the whole
36
+ # draw step of their first turn (CR 103.8a): no draw, no priority, no "draw step" triggers.
37
+ class Draw < Step
38
+ def skip?(engine) = engine.state.then { it.turn == 1 && it.active == it.starting_player }
39
+
40
+ def begin(engine)
41
+ engine.draw!(engine.state.active)
42
+ engine.turn_based_actions_done!
43
+ end
44
+ end
45
+
46
+ # CR 505.4 / 715.3c: as the precombat main phase begins, the active player puts a
47
+ # lore counter on each Saga they control with one or more chapter abilities.
48
+ class PrecombatMain < Step
49
+ def begin(engine)
50
+ engine.add_lore_counters!(engine.state.active)
51
+ engine.turn_based_actions_done!
52
+ end
53
+ end
54
+
55
+ # CR 508.8: if no creatures were declared as attackers, declare blockers and combat damage are skipped.
56
+ class CombatStepAfterAttackers < Step
57
+ def skip?(engine) = engine.state.attacks_declared.zero?
58
+ end
59
+
60
+ # CR 508.1: the active player declares attackers (a turn-based action).
61
+ class DeclareAttackers < Step
62
+ def begin(engine) = engine.begin_declare_attackers!
63
+ end
64
+
65
+ # CR 509.1: the defending player declares blockers.
66
+ class DeclareBlockers < CombatStepAfterAttackers
67
+ def begin(engine) = engine.begin_declare_blockers!
68
+ end
69
+
70
+ # CR 510: combat damage is assigned and dealt simultaneously.
71
+ class CombatDamage < CombatStepAfterAttackers
72
+ def begin(engine) = engine.begin_combat_damage!
73
+ end
74
+
75
+ # CR 514: discard to maximum hand size, then end "until end of turn" effects; no priority.
76
+ class Cleanup < Step
77
+ def priority? = false
78
+
79
+ # CR 514.2: remove marked damage and end "until end of turn" effects (control returns first).
80
+ def finish(engine)
81
+ engine.revert_temporary_control!
82
+ engine.cleanup_turn!
83
+ end
84
+
85
+ # CR 514.1 / 402.2: discard down to the maximum hand size, if the player has one.
86
+ def begin(engine)
87
+ max = engine.max_hand_size(engine.state.active)
88
+ excess = max ? engine.active_player.hand.size - max : 0
89
+ if excess.positive?
90
+ engine.ask!("kind" => "discard", "player" => engine.state.active, "count" => excess)
91
+ else
92
+ engine.turn_based_actions_done!
93
+ end
94
+ end
95
+ end
96
+
97
+ ORDER = [
98
+ Untap.new(:untap, :beginning),
99
+ Step.new(:upkeep, :beginning),
100
+ Draw.new(:draw, :beginning),
101
+ PrecombatMain.new(:precombat_main, :precombat_main),
102
+ Step.new(:beginning_of_combat, :combat),
103
+ DeclareAttackers.new(:declare_attackers, :combat),
104
+ DeclareBlockers.new(:declare_blockers, :combat),
105
+ CombatDamage.new(:combat_damage, :combat),
106
+ Step.new(:end_of_combat, :combat),
107
+ Step.new(:postcombat_main, :postcombat_main),
108
+ Step.new(:end, :ending),
109
+ Cleanup.new(:cleanup, :ending)
110
+ ].freeze
111
+ BY_NAME = ORDER.to_h { [it.name, it] }.freeze
112
+ MAIN_PHASE_STEPS = %i[precombat_main postcombat_main].freeze
113
+
114
+ def self.fetch(name) = BY_NAME.fetch(name.to_sym)
115
+ def self.index(name) = ORDER.index(fetch(name))
116
+ end
117
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ # Predefined tokens (CR 111.10): referenced by name from card data, e.g.
5
+ # {"create_token": {"token": "Treasure"}}. Other tokens are defined inline.
6
+ module Tokens
7
+ ANY_COLOR = %w[{W} {U} {B} {R} {G}].freeze
8
+
9
+ LIBRARY = {
10
+ # CR 111.10a: "{T}, Sacrifice this token: Add one mana of any color."
11
+ "Treasure" => { "name" => "Treasure", "types" => ["artifact"], "subtypes" => ["treasure"],
12
+ "abilities" => ANY_COLOR.map { { "mana" => { "cost" => %w[tap sacrifice], "produce" => it } } } },
13
+ # CR 111.10f: "{2}, Sacrifice this token: Draw a card."
14
+ "Clue" => { "name" => "Clue", "types" => ["artifact"], "subtypes" => ["clue"],
15
+ "abilities" => [{ "activated" => { "cost" => [{ "mana" => "{2}" }, "sacrifice"],
16
+ "effects" => [{ "draw" => { "amount" => 1 } }] } }] },
17
+ # CR 111.10b: "{2}, {T}, Sacrifice this token: You gain 3 life."
18
+ "Food" => { "name" => "Food", "types" => ["artifact"], "subtypes" => ["food"],
19
+ "abilities" => [{ "activated" => { "cost" => [{ "mana" => "{2}" }, "tap", "sacrifice"],
20
+ "effects" => [{ "gain_life" => { "amount" => 3 } }] } }] }
21
+ }.freeze
22
+
23
+ def self.fetch(name) = LIBRARY[name]
24
+ end
25
+ end