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,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class CLI
5
+ # Plain-text rendering of views, decisions, options and events.
6
+ # Works only from the public API (views, history, options), never engine internals.
7
+ class Text
8
+ STEP_NAMES = {
9
+ "untap" => "Untap", "upkeep" => "Upkeep", "draw" => "Draw", "precombat_main" => "Main 1",
10
+ "beginning_of_combat" => "Beginning of Combat", "declare_attackers" => "Declare Attackers",
11
+ "declare_blockers" => "Declare Blockers", "combat_damage" => "Combat Damage", "end_of_combat" => "End of Combat",
12
+ "postcombat_main" => "Main 2", "end" => "End Step", "cleanup" => "Cleanup"
13
+ }.freeze
14
+ # CR 500.1: the phases of a turn and their steps (main phases have none), as the board's turn tracker.
15
+ PHASES = [["Beginning", { "untap" => "untap", "upkeep" => "upkeep", "draw" => "draw" }],
16
+ ["Main 1", { "precombat_main" => nil }],
17
+ ["Combat", { "beginning_of_combat" => "begin", "declare_attackers" => "attackers",
18
+ "declare_blockers" => "blockers", "combat_damage" => "damage", "end_of_combat" => "end" }],
19
+ ["Main 2", { "postcombat_main" => nil }],
20
+ ["End", { "end" => "end step", "cleanup" => "cleanup" }]].freeze
21
+
22
+ def initialize(game)
23
+ @game = game
24
+ end
25
+
26
+ # ---- board -----------------------------------------------------------------
27
+
28
+ def board(view)
29
+ lines = [rule(turn_title(view["turn"], view["active"], view["step"])), " #{tracker(view["step"].to_s)}"]
30
+ lines << " Priority: #{view["priority"]}" if view["priority"]
31
+ viewer = view["viewer"]
32
+ view["players"].sort_by { it["name"] == viewer ? 1 : 0 }.each { lines.concat(player_block(view, it)) }
33
+ lines << " Stack (top first): #{"empty" if view["stack"].empty?}"
34
+ view["stack"].each { lines << " #{stack_item(it)}" }
35
+ view["effects"].each do |e|
36
+ lines << " Effect: +#{e["power"]}/+#{e["toughness"]} until end of turn on #{e["affected"].map do
37
+ name_of(it)
38
+ end.join(", ")}"
39
+ end
40
+ lines.join("\n")
41
+ end
42
+
43
+ # "Turn 3 · alice's turn · Upkeep", or "Pregame" before the first turn.
44
+ def turn_title(turn, active, step)
45
+ return "Pregame" if step.nil? || active.nil?
46
+
47
+ "Turn #{turn} · #{active}'s turn · #{STEP_NAMES.fetch(step.to_s, step.to_s)}"
48
+ end
49
+
50
+ # The phases of the turn with the current one marked, its steps listed:
51
+ # "Beginning › ▶MAIN 1 › Combat › Main 2 › End" or
52
+ # "Beginning › Main 1 › ▶COMBAT (begin · attackers · [blockers] · damage · end) › Main 2 › End".
53
+ def tracker(step)
54
+ return "Mulligans and play/draw choices" unless STEP_NAMES.key?(step)
55
+
56
+ PHASES.map do |phase, steps|
57
+ next phase unless steps.key?(step)
58
+
59
+ inner = steps.values.compact.map { |name| steps[step] == name ? "[#{name}]" : name }
60
+ "▶#{phase.upcase}#{" (#{inner.join(" · ")})" unless inner.empty?}"
61
+ end.join(" › ")
62
+ end
63
+
64
+ # "Combat › Declare Attackers", "Main 1".
65
+ def step_line(step)
66
+ phase, steps = PHASES.find { |_, names| names.key?(step) }
67
+ name = STEP_NAMES.fetch(step, step)
68
+ phase.nil? || steps[step].nil? ? name : "#{phase} › #{name}"
69
+ end
70
+
71
+ def player_block(view, player)
72
+ pool = player["mana_pool"].map { |t, n| "{#{t}}" * n }.join + player.fetch("restricted_mana", []).map { "{#{it["mana"]}}*" }.join
73
+ poison = player["poison"].positive? ? " · poison #{player["poison"]}" : ""
74
+ lines = ["", " #{player["name"].upcase} — life #{player["life"]}#{poison} · " \
75
+ "library #{player["library_count"]} · hand #{player["hand_count"]} · " \
76
+ "graveyard #{player["graveyard"].size}#{" · mana pool #{pool}" unless pool.empty?}"]
77
+ perms = view["battlefield"].select { it["controller"] == player["name"] }
78
+ lands, others = perms.partition { it["type_line"].include?("Land") }
79
+ lines << " Lands: #{lands.empty? ? "-" : lands.map { permanent(it) }.join(", ")}"
80
+ lines << " Permanents: #{"-" if others.empty?}"
81
+ others.each { lines << " #{permanent(it)}" }
82
+ lines << " Hand: #{player["hand"].map { card(it) }.join(", ")}" if player["hand"]
83
+ if player["library"]
84
+ lines << " Library (top first): #{player["library"].first(5).map do
85
+ it["name"]
86
+ end.join(", ")}#{" …" if player["library_count"] > 5}"
87
+ end
88
+ lines << " Graveyard: #{player["graveyard"].map { it["name"] }.join(", ")}" unless player["graveyard"].empty?
89
+ lines
90
+ end
91
+
92
+ def permanent(obj)
93
+ parts = [obj["name"]]
94
+ parts << "#{obj["power"]}/#{obj["toughness"]}" if obj.key?("power")
95
+ parts << "loyalty #{obj["loyalty"]}" if obj.key?("loyalty")
96
+ parts << obj["keywords"].map { it.tr("_", " ") }.join(", ") if obj["keywords"]
97
+ parts << "damage #{obj["damage"]}" if obj["damage"].to_i.positive?
98
+ obj.fetch("counters", {}).except("loyalty").each { |kind, n| parts << "#{n}×#{kind}" }
99
+ parts << "tapped" if obj["tapped"]
100
+ parts << "sick" if obj["summoning_sick"]
101
+ parts << "attacking #{name_of(obj["attacking"])}" if obj["attacking"]
102
+ parts << "blocking #{name_of(obj["blocking"])}" if obj["blocking"]
103
+ "#{parts.join(" · ")} [#{obj["id"]}]"
104
+ end
105
+
106
+ def card(obj) = "#{obj["name"]}#{" #{obj["mana_cost"]}" if obj["mana_cost"]} [#{obj["id"]}]"
107
+
108
+ def stack_item(obj)
109
+ targets = obj["targets"].empty? ? "" : " → #{obj["targets"].map { name_of(it) }.join(", ")}"
110
+ "#{obj["name"]}#{" (#{"+" if obj["loyalty_cost"].to_i.positive?}#{obj["loyalty_cost"]})" if obj["loyalty_cost"]} " \
111
+ "controlled by #{obj["controller"]}#{targets} [#{obj["id"]}]"
112
+ end
113
+
114
+ # ---- decisions & options -------------------------------------------------
115
+
116
+ def decision(decision)
117
+ extra = case decision.kind
118
+ when "pay_cost" then " — left to pay: #{decision.details["remaining"]}"
119
+ when "discard" then " — discard #{decision.details["count"]} more"
120
+ when "bottom" then " — put #{decision.details["count"]} more on the bottom"
121
+ when "assign_damage" then " — #{name_of(decision.details["attacker"])}: #{decision.details["remaining"]} damage left"
122
+ when "choose_target" then " — target ##{decision.details["index"] + 1}"
123
+ when "choose_target_count" then " — how many for target ##{decision.details["index"] + 1}"
124
+ else ""
125
+ end
126
+ "▶ #{decision.player.upcase} must act: #{decision.kind.tr("_", " ")}#{extra} (#{turn_title(
127
+ @game.turn, @game.state.active && @game.names[@game.state.active], @game.step
128
+ )})"
129
+ end
130
+
131
+ def option(opt)
132
+ case opt[:type]
133
+ when "pass" then "Pass priority"
134
+ when "play_land" then "Play land #{name_of(opt[:object])}"
135
+ when "cast" then "Cast #{name_of(opt[:object])} #{cost_of(opt[:object])}".rstrip
136
+ when "activate" then "Activate #{name_of(opt[:object])}: #{ability_text(opt[:object], opt[:ability])}"
137
+ when "special_action" then "#{ability_text(opt[:object], opt[:ability])}: #{name_of(opt[:object])} (special action)"
138
+ when "activate_mana"
139
+ "Tap #{name_of(opt[:object])} for #{opt[:mana] || "mana"} (#{ability_text(opt[:object], opt[:ability])})"
140
+ when "pay_mana"
141
+ "Pay {#{opt[:mana]}} from mana pool#{" (restricted)" if opt[:restricted]}#{" (made by an artifact)" if opt[:from]}"
142
+ when "target" then "Target #{name_of(opt[:target])}"
143
+ when "cost" then "Use #{name_of(opt[:object])} to pay the cost"
144
+ when "behold" then "Behold #{name_of(opt[:object])}"
145
+ when "pay" then opt[:cost] ? "Pay: #{opt[:cost]}" : "Pay"
146
+ when "cost_option" then @game.pending_decision.details["costs"][opt[:option]]
147
+ when "cancel" then "Cancel (rewind this cast/activation)"
148
+ when "attack" then "Attack #{name_of(opt[:target])} with #{name_of(opt[:object])}"
149
+ when "block" then "Block #{name_of(opt[:attacker])} with #{name_of(opt[:object])}"
150
+ when "assign" then "Assign 1 damage to #{name_of(opt[:object])}"
151
+ when "done" then "Done"
152
+ when "source" then "Choose #{name_of(opt[:object])} as the source"
153
+ when "devour" then "Devour #{name_of(opt[:object])}"
154
+ when "discard", "bottom", "find" then "#{opt[:type].capitalize} #{name_of(opt[:object])}"
155
+ when "keep" then "Keep hand"
156
+ when "call" then "Call #{opt[:call]}"
157
+ when "mulligan" then "Mulligan"
158
+ when "play", "draw" then "#{opt[:type].capitalize} first"
159
+ else opt.inspect
160
+ end
161
+ end
162
+
163
+ # ---- events --------------------------------------------------------------
164
+
165
+ def events(list) = list.filter_map { event(it) }
166
+
167
+ def event(ev)
168
+ case ev["type"]
169
+ when "zone_changed" then zone_changed(ev)
170
+ when "turn_began" then "══ Turn #{ev["turn"]}: #{ev["active"]}'s turn ══"
171
+ when "step_began" then "─ #{step_line(ev["step"])}"
172
+ when "priority_passed" then "#{ev["player"]} passes"
173
+ when "tapped" then "#{name_of(ev["object"])} becomes tapped"
174
+ when "untapped" then "#{name_of(ev["object"])} untaps"
175
+ when "mana_added" then "#{ev["player"]} adds #{ev["mana"]}"
176
+ when "mana_paid" then "#{ev["player"]} pays #{ev["mana"]}"
177
+ when "mana_emptied" then "#{ev["player"]}'s unused #{ev["mana"]} empties"
178
+ when "spell_cast" then "#{ev["player"]} casts #{name_of(ev["object"])}#{targets(ev["targets"])}"
179
+ when "ability_activated" then "#{ev["player"]} activates #{name_of(ev["source"])}'s ability#{targets(ev["targets"])}"
180
+ when "target_chosen" then "#{ev["player"]} targets #{name_of(ev["target"])}"
181
+ when "cast_cancelled" then "#{ev["player"]} cancels casting #{card_name(ev["card"])}"
182
+ when "resolved" then "#{card_name(ev["card"])}#{" ability" if ev["kind"] == "ability"} resolves"
183
+ when "fizzled" then "#{card_name(ev["card"])} doesn't resolve: its targets are gone"
184
+ when "damage_dealt" then "#{name_of(ev["source"])} deals #{ev["amount"]} damage to #{name_of(ev["target"])}"
185
+ when "life_changed" then "#{ev["player"]}: life #{ev["from"]} → #{ev["to"]}"
186
+ when "counters_changed" then "#{name_of(ev["object"])}: #{ev["counter"]} counters #{ev["from"]} → #{ev["to"]}"
187
+ when "effect_started" then pumped(ev)
188
+ when "effects_ended" then "#{ev["count"]} until-end-of-turn effect(s) end"
189
+ when "attacker_declared" then "#{name_of(ev["object"])} attacks #{name_of(ev["target"])}"
190
+ when "blocker_declared" then "#{name_of(ev["object"])} blocks #{name_of(ev["attacker"])}"
191
+ when "damage_assigned" then "1 damage of #{name_of(ev["attacker"])} assigned to #{name_of(ev["blocker"])}"
192
+ when "player_lost" then "#{ev["player"]} LOSES (#{ev["reason"]})"
193
+ when "game_ended" then "GAME OVER — #{result(ev)}"
194
+ when "library_shuffled" then "#{ev["player"]} shuffles their library"
195
+ when "card_revealed" then "#{ev["player"]} reveals #{card_name(ev["card"])}"
196
+ when "die_rolled" then "#{ev["winner"]} wins the die roll"
197
+ when "rolled_die" then "#{ev["player"]} rolls a d#{ev["sides"]}: #{ev["result"]}"
198
+ when "starting_player_chosen" then "#{ev["chooser"]} chooses to #{ev["choice"]}: #{ev["starting_player"]} goes first"
199
+ when "mulligan_declared" then "#{ev["player"]} declares: #{ev["choice"]}"
200
+ when "mulligan_taken" then "#{ev["player"]} mulligans (#{ev["mulligans"]} so far)"
201
+ when "hand_kept" then "#{ev["player"]} keeps#{" after #{ev["mulligans"]} mulligan(s)" if ev["mulligans"].positive?}"
202
+ when "game_started" then "Game started: #{ev["players"].join(" vs ")} (seed #{ev["seed"]})"
203
+ else mechanic_event(ev)
204
+ end
205
+ end
206
+
207
+ # Events of particular mechanics; attacks_declared is told by its attacker_declared events.
208
+ def mechanic_event(ev)
209
+ case ev["type"]
210
+ when "attacks_declared" then nil
211
+ when "ability_triggered" then "#{card_name(ev["card"])}'s ability triggers"
212
+ when "trigger_put_on_stack" then "#{card_name(ev["card"])}'s triggered ability goes on the stack#{targets(ev["targets"])}"
213
+ when "trigger_removed" then "#{card_name(ev["card"])}'s triggered ability is removed (#{ev["reason"]})"
214
+ when "ability_countered" then "#{card_name(ev["card"])}'s ability is countered"
215
+ when "spell_copied" then "#{ev["player"]} copies #{card_name(ev["card"])}#{targets(ev["targets"])}"
216
+ when "x_announced" then "#{ev["player"]} announces X = #{ev["value"]}"
217
+ when "payment_finished" then "#{ev["player"]} #{ev["outcome"]}"
218
+ when "attached" then "#{name_of(ev["object"])} is attached to #{name_of(ev["to"])}"
219
+ when "unattached" then "#{name_of(ev["object"])} becomes unattached from #{name_of(ev["from"])}"
220
+ when "control_changed" then "#{ev["controller"]} gains control of #{name_of(ev["object"])}"
221
+ when "became_blocked" then "#{name_of(ev["object"])} is blocked"
222
+ when "crewed_vehicle" then "#{name_of(ev["object"])} crews #{name_of(ev["vehicle"])}"
223
+ when "tapped_for_cost" then "#{name_of(ev["object"])} is tapped to pay a #{ev["cost"]} cost"
224
+ when "became_crewed" then "#{name_of(ev["object"])} becomes crewed"
225
+ when "transformed" then "#{name_of(ev["object"])} transforms into #{card_name(ev["card"])}"
226
+ when "prepared_changed" then "#{name_of(ev["object"])} #{ev["prepared"] ? "becomes prepared" : "is no longer prepared"}"
227
+ when "phased_out" then "#{name_of(ev["object"])} phases out"
228
+ when "phased_in" then "#{name_of(ev["object"])} phases in"
229
+ when "token_ceased" then "#{card_name(ev["card"])} token ceases to exist"
230
+ when "library_reordered" then "#{ev["player"]} puts #{Array(ev["top"]).size} on top and #{Array(ev["bottom"]).size} on the bottom"
231
+ when "looked_at" then "#{ev["player"]} looks at #{Array(ev["objects"]).size} of #{ev["owner"]}'s cards"
232
+ when "dilemma_faced" then "#{ev["player"]} faces a dilemma"
233
+ when "extra_turn_added" then "#{ev["player"]} will take an extra turn"
234
+ when "turn_skipped" then "#{ev["player"]} skips a turn"
235
+ when "emblem_created" then "#{ev["player"]} gets an emblem from #{card_name(ev["card"])}"
236
+ when "player_counters_changed" then "#{ev["player"]}: #{ev["counter"]} counters #{ev["from"]} → #{ev["to"]}"
237
+ when "speed_changed" then "#{ev["player"]}: speed #{ev["from"]} → #{ev["to"]}"
238
+ when "coin_flipped"
239
+ "#{ev["player"]} flips #{ev["heads"] ? "heads" : "tails"}#{{ true => " and wins", false => " and loses" }[ev["won"]]}"
240
+ when "keyword_action" then "#{ev["player"]} #{ev["action"].to_s.tr("_", " ")}#{" #{ev["amount"]}" if ev["amount"]}"
241
+ else "#{ev["type"]}: #{ev.except("type")}"
242
+ end
243
+ end
244
+
245
+ def pumped(ev)
246
+ verb = ev["affected"].size == 1 ? "gets" : "get"
247
+ "#{ev["affected"].map do
248
+ name_of(it)
249
+ end.join(", ")} #{verb} +#{ev["details"]["power"]}/+#{ev["details"]["toughness"]} until end of turn"
250
+ end
251
+
252
+ def zone_changed(ev)
253
+ what = ev["card"] ? card_name(ev["card"]) : "a card"
254
+ case ev["reason"]
255
+ when "draw" then "#{ev["owner"]} draws #{what}"
256
+ when "play_land" then "#{ev["owner"]} plays #{what}"
257
+ when "cast", "mulligan" then nil
258
+ when "resolve" then ev["to"] == "battlefield" ? "#{what} enters the battlefield" : "#{what} goes to the graveyard"
259
+ when "sba_lethal_damage" then "#{what} dies (lethal damage)"
260
+ when "sba_toughness" then "#{what} dies (0 toughness)"
261
+ when "sba_loyalty" then "#{what} is put into the graveyard (0 loyalty)"
262
+ when "countered" then "#{what} is countered"
263
+ when "mulligan_bottom" then "#{ev["owner"]} puts #{what} on the bottom of their library (mulligan)"
264
+ else "#{ev["owner"]}'s #{what}: #{ev["from"]} → #{ev["to"]} (#{ev["reason"]})"
265
+ end
266
+ end
267
+
268
+ def result(res) = res["winner"] ? "#{res["winner"]} wins (#{res["reason"]})" : "draw (#{res["reason"]})"
269
+
270
+ # ---- helpers -------------------------------------------------------------
271
+
272
+ def rule(text) = "━━ #{text} #{"━" * [4, 76 - text.size].max}"
273
+
274
+ def name_of(ref)
275
+ return "-" if ref.nil?
276
+ return ref.delete_prefix("player:") if ref.start_with?("player:")
277
+
278
+ obj = @game.state.objects[ref]
279
+ obj ? "#{@game.catalog.fetch(obj.card).name}#{" ability" if obj.ability?}" : card_name(ref.split("#").first)
280
+ end
281
+
282
+ def card_name(card_id) = @game.catalog[card_id]&.name || card_id.to_s
283
+
284
+ def cost_of(id) = @game.catalog.fetch(@game.state.object(id).card).mana_cost.to_s
285
+
286
+ # A granted ability ("granted:<card>:<ref>") lives in the granting card's data.
287
+ def ability_text(id, ref)
288
+ card, inner = Engine::AbilityLayers.granted_parts(ref) || [@game.state.object(id).card, ref]
289
+ @game.catalog.fetch(card).ability(inner).to_s
290
+ end
291
+
292
+ def targets(list) = list.nil? || list.empty? ? "" : " targeting #{list.map { name_of(it) }.join(", ")}"
293
+ end
294
+ end
295
+ end
data/lib/enjine/cli.rb ADDED
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "enjine/deck_builder"
5
+ require "enjine/verify"
6
+ require_relative "cli/text"
7
+
8
+ module Enjine
9
+ # Interactive text client. Hot-seat by default: you play both players, see what
10
+ # the deciding player sees, pick numbered options, and after each choice see
11
+ # exactly what happened. Seats can also be "random" (uniform legal choices).
12
+ #
13
+ # enjine --deck1 examples/decks/gruul_beats.json --deck2 examples/decks/azorius_walkers.json
14
+ # enjine --pool examples/pool.json --deck1 random:colors=RG --deck2 random --p2 random
15
+ # enjine --verify cards/ --games 20 --seed 1 # check a folder of card JSON (see Enjine::Verify)
16
+ class CLI
17
+ SEATS = %w[human random].freeze
18
+ HELP = <<~HELP
19
+ Commands:
20
+ <number> choose that option
21
+ b show the board again o toggle observer view (see everything)
22
+ h [n] show the last n history entries (default 10)
23
+ u undo your last decision (replays the game from its seed)
24
+ s FILE save the game to FILE (JSON snapshot)
25
+ c concede for the deciding player
26
+ q quit
27
+ HELP
28
+
29
+ def initialize(argv, input: $stdin, output: $stdout)
30
+ @input = input
31
+ @out = output
32
+ @options = { p1: "human", p2: "human", quiet: false, max_decisions: 100_000,
33
+ observer: false, names: %w[alice bob] }
34
+ parser.parse!(argv)
35
+ # `--random-deck colors=WU seed=7` works unquoted: the key=value words after it belong to the spec.
36
+ @options[:random_deck] = [@options[:random_deck], *argv.grep(/=/)].compact.join(" ") if @options.key?(:random_deck)
37
+ end
38
+
39
+ def run
40
+ return verify(@options[:verify]) if @options[:verify]
41
+
42
+ @options[:seed] ||= Random.new_seed % (2**31)
43
+ return validate(@options[:validate]) if @options[:validate]
44
+ return random_deck(@options[:random_deck]) if @options.key?(:random_deck)
45
+
46
+ @game = start_game or return 1
47
+ @text = Text.new(@game)
48
+ @seats = @game.names.zip([@options[:p1], @options[:p2]]).to_h do |name, kind|
49
+ [name, kind == "human" ? :human : Agents::Random.new(seed: @options[:seed] + name.sum)]
50
+ end
51
+ intro
52
+ loop_decisions
53
+ say @text.rule("GAME OVER: #{@text.result(@game.result)} · turn #{@game.turn}") if @game.over?
54
+ 0
55
+ end
56
+
57
+ private
58
+
59
+ def parser
60
+ OptionParser.new do |o|
61
+ o.banner = "Usage: enjine --deck1 DECK --deck2 DECK [options] (default: hot-seat, you play both players)"
62
+ o.on("--seed N", Integer, "Seed for shuffling and the die roll") { @options[:seed] = it }
63
+ o.on("--p1 SEAT", SEATS, "Seat 1: human (default) or random") { @options[:p1] = it }
64
+ o.on("--p2 SEAT", SEATS, "Seat 2: human (default) or random") { @options[:p2] = it }
65
+ o.on("--deck1 DECK", "Seat 1's deck: a deck document (JSON file), or random[:SPEC] from --pool") { @options[:deck1] = it }
66
+ o.on("--deck2 DECK", "Seat 2's deck: a deck document (JSON file), or random[:SPEC] from --pool") { @options[:deck2] = it }
67
+ o.on("--pool FILE", "Cards random decks are built from: a JSON array of card data blocks") { @options[:pool] = it }
68
+ o.on("--random-deck [SPEC]", "Print a random deck from --pool as JSON, e.g. \"colors=WU seed=7\"") do
69
+ @options[:random_deck] = it
70
+ end
71
+ o.on("--names A,B", Array, "Player names (default alice,bob)") { @options[:names] = it }
72
+ o.on("--validate FILE", "Validate a JSON array of card data blocks; prints a JSON report") { @options[:validate] = it }
73
+ o.on("--verify DIR", "Validate and play-test every card in DIR's JSON files; exit 1 on any problem") do
74
+ @options[:verify] = it
75
+ end
76
+ o.on("--games N", Integer, "With --verify: play at least N games (default 10)") { @options[:games] = it }
77
+ o.on("--json", "With --verify: print the report as JSON") { @options[:json] = true }
78
+ o.on("--load FILE", "Resume a game saved with the s command") { @options[:load] = it }
79
+ o.on("--observer", "Start in observer view (see both hands and library order)") { @options[:observer] = true }
80
+ o.on("--quiet", "Only print the result (useful with random seats)") { @options[:quiet] = true }
81
+ o.on("--max-decisions N", Integer, "Concede after N decisions") { @options[:max_decisions] = it }
82
+ end
83
+ end
84
+
85
+ # Machine-readable validation for card-conversion pipelines. Exit code 1 if anything is invalid.
86
+ def validate(path)
87
+ cards = JSON.parse(File.read(path))
88
+ cards = [cards] if cards.is_a?(Hash)
89
+ report = { "rules_version" => RULES_VERSION, "engine_version" => VERSION, "valid" => [], "invalid" => [] }
90
+ cards.each_with_index do |data, i|
91
+ defn = CardDefinition.from_h(data)
92
+ report["valid"] << { "index" => i, "id" => defn.id, "name" => defn.name, "card" => defn.to_h }
93
+ rescue InvalidCardDefinition => e
94
+ report["invalid"] << { "index" => i, "name" => data.is_a?(Hash) ? data["name"] : nil, "errors" => e.errors }
95
+ end
96
+ say JSON.pretty_generate(report)
97
+ report["invalid"].empty? ? 0 : 1
98
+ rescue JSON::ParserError => e
99
+ say JSON.generate("error" => "not valid JSON: #{e.message}")
100
+ 1
101
+ end
102
+
103
+ # Checks a folder of cards (seed 0 unless --seed); exit code 1 if a card is invalid or a game went wrong.
104
+ def verify(dir)
105
+ report = Verify.new(dir, games: @options.fetch(:games, 10), seed: @options.fetch(:seed, 0)).run
106
+ say @options[:json] ? JSON.pretty_generate(report) : Verify.text(report)
107
+ report["ok"] ? 0 : 1
108
+ rescue ArgumentError => e
109
+ say "error: #{e.message}"
110
+ 1
111
+ end
112
+
113
+ # Prints a random deck document; exit code 1 with a message if it can't be built.
114
+ def random_deck(spec)
115
+ say JSON.pretty_generate(build_random_deck(spec, @options[:seed]))
116
+ 0
117
+ rescue Error, ArgumentError, SystemCallError, JSON::ParserError => e
118
+ say "error: #{e.message}"
119
+ 1
120
+ end
121
+
122
+ # SPEC: whitespace- or comma-separated key=value pairs; a bare word continues the previous
123
+ # value, so "colors=W,U,seed=7" works. Keys: colors (letters like WU, a count 1-5, or any), seed, size.
124
+ def build_random_deck(spec, default_seed)
125
+ args = parse_spec(spec.to_s)
126
+ deck = deck_builder.build(colors: args.delete("colors") || :any, seed: Integer(args.delete("seed") || default_seed),
127
+ size: Integer(args.delete("size") || DeckBuilder::BASE_SIZE))
128
+ raise ArgumentError, "unknown random deck option(s): #{args.keys.join(", ")}" unless args.empty?
129
+
130
+ deck
131
+ end
132
+
133
+ def parse_spec(spec)
134
+ key = nil
135
+ spec.split(/[\s,;]+/).reject(&:empty?).each_with_object({}) do |word, out|
136
+ if word.include?("=")
137
+ key, value = word.split("=", 2)
138
+ out[key] = value
139
+ elsif key
140
+ out[key] = "#{out[key]},#{word}"
141
+ else
142
+ raise ArgumentError, "random deck spec: expected key=value, got #{word.inspect}"
143
+ end
144
+ end
145
+ end
146
+
147
+ # One builder over the --pool, for every random deck of this run.
148
+ def deck_builder
149
+ raise ArgumentError, "random decks need --pool FILE (a JSON array of cards)" unless @options[:pool]
150
+
151
+ @deck_builder ||= DeckBuilder.new(JSON.parse(File.read(@options[:pool])))
152
+ end
153
+
154
+ def start_game
155
+ @options[:load] ? load_game : new_game
156
+ rescue Error, ArgumentError, SystemCallError, JSON::ParserError => e
157
+ say "error: #{e.message}"
158
+ nil
159
+ end
160
+
161
+ def new_game
162
+ decks = [@options[:deck1], @options[:deck2]].each_with_index.map { |deck, i| deck_for(deck, i) }
163
+ players = @options[:names].zip(decks).map { |name, deck| Player.new(name: name, deck: deck) }
164
+ @setup = Setup.new(*players)
165
+ @setup.new_game(seed: @options[:seed])
166
+ end
167
+
168
+ # A deck file, or "random[:SPEC]" for one built from --pool (seed defaults to game seed + seat).
169
+ # Remembers each deck's label for the intro.
170
+ def deck_for(deck, seat)
171
+ raise ArgumentError, "pass --deck#{seat + 1}: a deck file (see examples/decks) or random[:SPEC] with --pool" if deck.nil?
172
+
173
+ if deck.start_with?("random")
174
+ doc = build_random_deck(deck.delete_prefix("random").delete_prefix(":"), @options[:seed] + seat)
175
+ @options[:"deck#{seat + 1}"] = doc["description"]
176
+ return doc
177
+ end
178
+ JSON.parse(File.read(deck)).tap { @options[:"deck#{seat + 1}"] = it["name"] || File.basename(deck, ".json") }
179
+ end
180
+
181
+ # A saved game carries its cards (see #save), so it resumes without the decks.
182
+ def load_game
183
+ data = JSON.parse(File.read(@options[:load]))
184
+ cards = data.fetch("cards") { raise ArgumentError, "#{@options[:load]}: not a game saved with the s command" }
185
+ Game.import(data, catalog: Catalog.from_definitions(cards))
186
+ end
187
+
188
+ def intro
189
+ return if @options[:quiet]
190
+
191
+ say "Enjine #{VERSION} (Comprehensive Rules #{RULES_VERSION}) — seed #{@game.state.seed}"
192
+ say "#{@game.names[0]}: #{@options[:deck1]} #{@game.names[1]}: #{@options[:deck2]} — type ? for help"
193
+ show_events(@game.history.last) if @game.history.last
194
+ end
195
+
196
+ def loop_decisions
197
+ count = 0
198
+ until @game.over?
199
+ if count >= @options[:max_decisions]
200
+ @game.concede(@game.pending_decision.player)
201
+ break
202
+ end
203
+ decision = @game.pending_decision
204
+ seat = @seats.fetch(decision.player)
205
+ auto = seat == :human && only_pass?(decision) # nothing else to do: pass for them
206
+ option = if seat != :human then seat.choose(decision)
207
+ elsif auto then { type: "pass" }
208
+ else ask(decision)
209
+ end
210
+ break if option == :quit
211
+ next if option == :again
212
+
213
+ entry = @game.submit(option)
214
+ count += 1
215
+ show_events(entry, auto:) unless @options[:quiet]
216
+ end
217
+ end
218
+
219
+ def ask(decision)
220
+ show_board(decision)
221
+ loop do
222
+ @out.print "#{decision.player}> "
223
+ line = @input.gets
224
+ return :quit if line.nil?
225
+
226
+ result = command(line.strip, decision)
227
+ return result unless result.nil?
228
+ end
229
+ end
230
+
231
+ def show_board(decision)
232
+ say @text.board(@game.view_for(viewer(decision)))
233
+ say ""
234
+ say @text.decision(decision)
235
+ decision.options.each_with_index { |opt, i| say format(" %2d) %s", i + 1, @text.option(opt)) }
236
+ end
237
+
238
+ def viewer(decision) = @options[:observer] ? :observer : decision.player
239
+
240
+ # Returns an option, :quit, :again (decision changed), or nil to keep asking.
241
+ def command(line, decision)
242
+ case line
243
+ when /\A\d+\z/
244
+ option = decision.options[line.to_i - 1]
245
+ option || (say("Choose 1-#{decision.options.size}.") && nil)
246
+ when "b" then show_board(decision) && nil
247
+ when "o"
248
+ @options[:observer] = !@options[:observer]
249
+ say "Observer view #{@options[:observer] ? "on" : "off"}."
250
+ show_board(decision)
251
+ nil
252
+ when /\Ah(?:\s+(\d+))?\z/ then show_history(Integer(::Regexp.last_match(1) || 10)) && nil
253
+ when "u" then undo
254
+ when /\As\s+(\S+)\z/ then save(::Regexp.last_match(1)) && nil
255
+ when "c" then @game.concede(decision.player) && :again
256
+ when "q" then :quit
257
+ when "?", "help" then say(HELP) && nil
258
+ else say("Unknown command. Type ? for help.") && nil
259
+ end
260
+ end
261
+
262
+ # Priority where passing is the only real choice: a human seat passes without being asked. Mana abilities
263
+ # don't count: with nothing castable or activatable to spend it on, the mana would just empty (CR 500.4).
264
+ def only_pass?(decision)
265
+ decision.kind == "priority" && decision.options.all? { %w[pass activate_mana].include?(it[:type]) }
266
+ end
267
+
268
+ def undo
269
+ log = @game.decision_log
270
+ if log.empty? || @setup.nil?
271
+ say(@setup ? "Nothing to undo." : "Undo needs a game started in this session.")
272
+ return nil
273
+ end
274
+ @game = @setup.new_game(seed: @game.state.seed).apply(log[0...-1])
275
+ @text = Text.new(@game)
276
+ say "Undid #{@text.option(log.last.transform_keys(&:to_sym))}."
277
+ :again
278
+ end
279
+
280
+ def save(path)
281
+ File.write(path, JSON.generate(@game.to_h.merge("cards" => @game.catalog.reject(&:token?).map(&:to_h))))
282
+ say "Saved to #{path}. Resume with: enjine --load #{path}"
283
+ end
284
+
285
+ def show_history(count)
286
+ viewer = @options[:observer] ? :observer : @game.pending_decision&.player || :observer
287
+ @game.history_for(viewer).last(count).each do |entry|
288
+ who = if entry["player"]
289
+ "#{entry["player"]}: #{entry["option"] ? @text.option(entry["option"].transform_keys(&:to_sym)) : entry["kind"]}"
290
+ else
291
+ "(game)"
292
+ end
293
+ say " ##{entry["seq"]} T#{entry["turn"]} #{entry["step"] || "pregame"} — #{who}"
294
+ @text.events(entry["events"]).each { say " #{it}" }
295
+ end
296
+ end
297
+
298
+ # An automatic pass doesn't print its own "passes" line; what it caused (a resolution, the next step) does.
299
+ def show_events(entry, auto: false)
300
+ viewer = @options[:observer] || @seats.values.none?(:human) ? :observer : entry.player || :observer
301
+ events = entry.for_viewer(viewer)["events"]
302
+ events = events.reject { it["type"] == "priority_passed" && it["player"] == entry.player } if auto
303
+ lines = @text.events(events)
304
+ lines.each { say " #{it}" }
305
+ end
306
+
307
+ def say(text)
308
+ @out.puts(text) unless text.nil?
309
+ true
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Decisions
5
+ # CR 103.5: taking a mulligan ends with putting N cards from the new hand on the
6
+ # bottom of the library (asked once per card, so the order is the order chosen),
7
+ # before the next keep/mulligan declaration.
8
+ class Bottom < Base
9
+ def self.doc
10
+ "Right after a mulligan, put one card from the new hand on the bottom of the library: " \
11
+ '`{type: "bottom", object: id}`, once per mulligan taken so far, before declaring again. ' \
12
+ "Hidden from the opponent (CR 103.5)."
13
+ end
14
+
15
+ def self.options(engine, pending)
16
+ engine.player(pending["player"]).hand.map { |id| { type: "bottom", object: id } }
17
+ end
18
+
19
+ def self.submit(engine, _pending, option)
20
+ engine.move!(option[:object], :library, position: :bottom, reason: "mulligan_bottom")
21
+ engine.next_bottom_or_declaration!
22
+ end
23
+
24
+ # Which card went to the bottom is hidden from the opponent.
25
+ def self.redact(option, viewer_is_decider) = viewer_is_decider ? option : { type: "bottom" }
26
+ end
27
+ register("bottom", Bottom)
28
+ end
29
+ end