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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Cards with more than one face (CR 712 double-faced, 716 adventurer, 721 omen, 723 preparation cards;
6
+ # see CardDefinition::Faces). The face that is up is the object's `card`: a face's id ("alfa/1") while
7
+ # that face is up, the card's own id while the front face is. So every characteristic, ability and
8
+ # copiable value (CR 707.8) comes from the face that's up with no special case.
9
+ #
10
+ # memory["transformed_at"] when the permanent last transformed (a timestamp), CR 701.27g
11
+ # memory["prepared"] the id of the prepare spell copy in exile while it's prepared (CR 723.3c)
12
+ # memory["prepared_for"] on that copy: the prepared permanent
13
+ # memory["melded"] on a melded permanent (its object is one card of the pair, back face up): the
14
+ # other card's id (CR 713.1a)
15
+ module Faces
16
+ ALTERNATIVE_SPELLS = %w[modal adventure omen].freeze
17
+
18
+ # ---- reading -----------------------------------------------------------------------------
19
+
20
+ # CR 701.27h: a double-faced permanent with its back face up.
21
+ def transformed?(obj)
22
+ defn = catalog.fetch(own_card(obj))
23
+ obj.zone == :battlefield && defn.double_faced? && defn.face_index.positive?
24
+ end
25
+
26
+ def prepared?(obj) = obj.zone == :battlefield && obj.memory.key?("prepared")
27
+
28
+ # The card an object is after moving to `to` (CR 400.7): its front face (CR 712.6a, 716.4, 721.4),
29
+ # except that a spell becoming a permanent keeps the face it had on the stack (CR 712.10) and a copy
30
+ # keeps the face it copies (CR 707.8, 716.3c, 723.3c).
31
+ def card_after_move(old, to)
32
+ own = own_card(old)
33
+ return own if old.memory["copy"] || (old.zone == :stack && to == :battlefield)
34
+
35
+ catalog.fetch(own).card.id
36
+ end
37
+
38
+ # ---- entering with a face up (CR 712.9, 712.11a-b) --------------------------------------------
39
+
40
+ # Whether the card can enter the battlefield with face `index` up: it's a double-faced card and that
41
+ # face is a permanent (CR 712.11a-b); otherwise it stays where it is.
42
+ def can_enter_face?(obj, index)
43
+ defn = catalog.fetch(own_card(obj)).card
44
+ faced = defn.double_faced? || (defn.layout == "meld" && obj.memory.key?("melded")) # CR 713.1b: only melded
45
+ faced && index < defn.faces.size && defn.face(index).permanent?
46
+ end
47
+
48
+ def enter_face!(fresh, index)
49
+ fresh.card = catalog.fetch(fresh.card).card.face(index).id if can_enter_face?(fresh, index)
50
+ end
51
+
52
+ # The definition of the face an entering object will have up (for its own entering replacements,
53
+ # CR 614.12), or nil when it enters as it is.
54
+ def entering_face(obj, enters)
55
+ index = enters && enters["face"]
56
+ index && can_enter_face?(obj, index) ? catalog.fetch(own_card(obj)).card.face(index) : nil
57
+ end
58
+
59
+ # ---- transforming (CR 701.27) ---------------------------------------------------------------
60
+
61
+ # Turns a double-faced permanent over (CR 701.27a). Nothing happens to a permanent that isn't a
62
+ # double-faced card or token (CR 701.27d; a copy of a double-faced card is, CR 707.8a) or whose other
63
+ # face is an instant or sorcery (CR 701.27e). It stays the same object (CR 701.27c, 712.15).
64
+ def transform!(obj)
65
+ own = catalog.fetch(own_card(obj))
66
+ return false unless obj.zone == :battlefield && own.double_faced?
67
+
68
+ into = own.other_face
69
+ return false if into.type?(:instant) || into.type?(:sorcery)
70
+
71
+ if obj.memory["own_card"] then obj.memory["own_card"] = into.id # still a copy of something else
72
+ else obj.card = into.id
73
+ end
74
+ obj.memory["transformed_at"] = next_timestamp!
75
+ emit Events::Transformed.new(object: obj.id, card: into.id)
76
+ true
77
+ end
78
+
79
+ # CR 701.27g: an activated or triggered ability of the permanent transforms it only if it hasn't
80
+ # transformed since the ability was put on the stack (the ability object's id is numbered then).
81
+ def transform_still_allowed?(obj, ctx)
82
+ ability = state.objects[ctx.object]
83
+ return true unless ability&.ability? && ability.source == obj.id
84
+
85
+ at = obj.memory["transformed_at"] or return true
86
+ at < ability.id.split("#").last.to_i
87
+ end
88
+
89
+ # ---- meld (CR 701.42, 713) -------------------------------------------------------------------
90
+
91
+ # CR 701.42b: two cards (not tokens or copies) of one meld pair: meld cards with the same back face name.
92
+ def meld_pair?(one, two)
93
+ a, b = [one, two].map { catalog.fetch(own_card(it)).card }
94
+ [one, two].none? { token?(it) || it.memory["copy"] } && a.id != b.id && [a, b].all? { it.layout == "meld" } &&
95
+ a.face(1).name == b.face(1).name
96
+ end
97
+
98
+ # CR 701.42a, 713.6: the exiled cards `first` and `second` of a meld pair enter the battlefield as one
99
+ # permanent with the back face up, under `controller`'s control; `first`'s object stands for both. Cards
100
+ # that can't be melded stay where they are (CR 701.42c).
101
+ def meld!(first, second, controller)
102
+ return unless [first, second].all? { it.zone == :exile } && meld_pair?(first, second)
103
+
104
+ state.exile.delete(second.id)
105
+ state.objects.delete(second.id)
106
+ move!(first.id, :battlefield, controller: controller, reason: "meld",
107
+ enters: { "face" => 1, "memory" => { "melded" => second.card } })
108
+ end
109
+
110
+ # CR 713.8, 713.8c: a melded permanent that leaves the battlefield puts its two cards into the new zone;
111
+ # the other card is a new object there too, and both are the result of what moved it.
112
+ def unmeld!(old, to, data)
113
+ other = create_object(old.memory["melded"], old.owner, to)
114
+ arrive!(other, old, data.merge("enters" => { "tapped" => false, "counters" => {} })) if to == :battlefield
115
+ state.zone_list(to, old.owner).unshift(other.id)
116
+ record_result!(other.id)
117
+ emit Events::ZoneChanged.new(card: other.card, old_id: nil, new_id: other.id, owner: name(old.owner),
118
+ from: "battlefield", to: to.to_s, reason: data["reason"])
119
+ end
120
+
121
+ # ---- casting another face (CR 712.8b, 716.3, 721.3) ----------------------------------------------
122
+
123
+ # The faces other than the front a card can be cast as: a modal card's nonland faces, an Adventure, an
124
+ # Omen. A preparation card's spell is cast only as a copy (CR 723.3).
125
+ def alternative_faces(obj)
126
+ defn = catalog.fetch(obj.card)
127
+ return [] if obj.memory["copy"] || defn.face_index.positive? || !ALTERNATIVE_SPELLS.include?(defn.layout)
128
+
129
+ (1...defn.faces.size).reject { defn.face(it).land? }
130
+ end
131
+
132
+ # Evaluates the block with face `index` up (CR 712.8c, 716.3a: only that face is evaluated). Nothing in
133
+ # the block changes state.
134
+ def with_face(obj, index)
135
+ card = obj.card
136
+ obj.card = catalog.fetch(obj.card).face(index).id
137
+ yield
138
+ ensure
139
+ obj.card = card
140
+ end
141
+
142
+ # Priority options for casting the cards in `index`'s hand as another face.
143
+ def face_cast_options(index, id)
144
+ obj = object(id)
145
+ alternative_faces(obj).filter_map do |face|
146
+ { type: "cast", object: id, face: face } if with_face(obj, face) { castable?(index, id) }
147
+ end
148
+ end
149
+
150
+ # CR 712.9: a modal double-faced card is played as a land with a land face up.
151
+ def face_land_options(id)
152
+ defn = catalog.fetch(object(id).card)
153
+ return [] unless defn.layout == "modal" && defn.face_index.zero?
154
+
155
+ (1...defn.faces.size).filter_map { { type: "play_land", object: id, face: it } if defn.face(it).land? }
156
+ end
157
+
158
+ # The spell goes on the stack with the chosen face up (CR 712.8b, 716.3b, 721.3b).
159
+ def turn_spell_face!(obj, face)
160
+ obj.card = catalog.fetch(obj.card).face(face).id if face
161
+ end
162
+
163
+ # CR 716.3d, 721.3d: a resolving Adventure is exiled (its controller may play the card while it stays
164
+ # exiled, not as an Adventure) and a resolving Omen is shuffled into its owner's library, instead of
165
+ # going to the graveyard. A copy isn't a card (it ceases to exist). True when handled.
166
+ def put_away_spell_face!(obj)
167
+ defn = catalog.fetch(obj.card)
168
+ return false if obj.memory["copy"] || defn.face_index.zero?
169
+
170
+ case defn.layout
171
+ when "adventure"
172
+ fresh = move!(obj.id, :exile, reason: "adventure")
173
+ grant_play_permission!(obj.controller, [fresh.id], duration: "while_exiled", play: "play") if fresh&.zone == :exile
174
+ when "omen"
175
+ move!(obj.id, :library, reason: "omen")
176
+ shuffle_library!(obj.owner)
177
+ else return false
178
+ end
179
+ true
180
+ end
181
+
182
+ # ---- prepared (CR 723.3) --------------------------------------------------------------------
183
+
184
+ # CR 723.3a, 723.3c: a permanent with a prepare spell (in its copiable values, CR 723.2b) becomes
185
+ # prepared: its controller creates a copy of the prepare spell in exile and may cast it while the
186
+ # permanent stays prepared. A permanent already prepared can't become prepared again.
187
+ def prepare!(obj)
188
+ defn = catalog.fetch(obj.card)
189
+ return false unless obj.zone == :battlefield && !obj.memory.key?("prepared") && defn.layout == "prepare"
190
+ return false if defn.face_index.positive?
191
+
192
+ spell = create_card_copy!([defn.face(1).id, {}], obj.controller, :exile)
193
+ spell.memory["prepared_for"] = obj.id
194
+ obj.memory["prepared"] = spell.id
195
+ grant_play_permission!(obj.controller, [spell.id], duration: "while_exiled", play: "cast")
196
+ emit Events::PreparedChanged.new(object: obj.id, prepared: true)
197
+ true
198
+ end
199
+
200
+ # CR 723.3b: it loses the designation; its copy in exile ceases to exist (Engine::Copies).
201
+ def unprepare!(obj)
202
+ return false unless obj.memory.delete("prepared")
203
+
204
+ emit Events::PreparedChanged.new(object: obj.id, prepared: false)
205
+ true
206
+ end
207
+
208
+ # CR 723.3c: the copy stays in exile while its permanent is on the battlefield and prepared with it.
209
+ def prepared_copy?(obj)
210
+ perm = obj.zone == :exile && state.objects[obj.memory["prepared_for"].to_s]
211
+ perm ? perm.zone == :battlefield && perm.memory["prepared"] == obj.id : false
212
+ end
213
+
214
+ # CR 723.3c: the permanent loses the designation as its copy becomes cast (`origin`: the copy's id).
215
+ def cast_prepared_copy!(origin)
216
+ perm = state.battlefield.map { object(it) }.find { it.memory["prepared"] == origin } or return
217
+ unprepare!(perm)
218
+ end
219
+ end
220
+ include Faces
221
+ end
222
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # The rest of a replacement or prevention effect, done right after the event it modified (CR 615.5,
6
+ # 614.1a): "... and put that many +1/+1 counters on him", "... and you gain 2 life", "When you do, ..."
7
+ # (Mechanics::FollowUp). A replacement adds an entry to the event's data["follow_ups"].
8
+ module FollowUps
9
+ # `facts` overrides the entries' facts (the card exiled instead, known once the move is done). An entry
10
+ # with "link" links that card to its source ("exiled with [it]", CR 607.2a).
11
+ def run_follow_ups!(data, facts = {})
12
+ Array(data.delete("follow_ups")).each do |entry|
13
+ seen = entry["facts"].merge(facts).compact
14
+ link_exiled!(entry["source"], seen["object"]) if entry["link"] && seen["object"]
15
+ next unless entry["ref"]
16
+
17
+ effects = catalog.fetch(entry["card"]).ability(entry["ref"]).effects
18
+ memory = { "triggering" => seen, "source_card" => entry["card"] }
19
+ ctx = Mechanics::EffectContext.new(entry["controller"], entry["source"], entry["source"], [], [], [], memory)
20
+ effects.each { it.run(self, ctx, {}) } # effects that need no choice (CR 615.5)
21
+ end
22
+ end
23
+ end
24
+
25
+ include FollowUps
26
+ end
27
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Mana the player could make (CR 106, 605): affordability of costs, mana abilities and their options,
6
+ # restricted mana (CR 106.6), mana costs of mana abilities paid from the pool (CR 605.3a), and adding
7
+ # mana to and emptying mana pools (CR 106.4).
8
+ module ManaSources
9
+ # Whether the cost could be paid with the mana pool plus untapped mana sources (CR 601.2g-h).
10
+ # Each colored symbol needs mana of that color, a hybrid symbol mana of either of its colors
11
+ # (CR 107.4e); generic is paid by anything left. `purpose` ({"spell" => id} or {"ability" => source})
12
+ # lets restricted mana count where it may be spent (CR 106.6).
13
+ def affordable?(index, cost, excluded_source, purpose = nil)
14
+ return true if cost.nil? || cost.mana_value.zero?
15
+
16
+ covers?(cost, mana_units(index, excluded_source, purpose))
17
+ end
18
+
19
+ # Whether `units` can pay `cost`, a monocolored hybrid {2/B} as {B} or as {2} (CR 107.4e, 601.2b).
20
+ def covers?(cost, units)
21
+ cost.equivalents.any? { units.size >= it.mana_value && match_colored(it.requirements, units) }
22
+ end
23
+
24
+ # Every unit of mana the player could make: pool mana (one fixed type each), restricted pool mana
25
+ # that may be spent on `purpose`, and one unit per untapped source, with the set of types it could
26
+ # produce (a Treasure: any color).
27
+ def mana_units(index, excluded_source, purpose = nil)
28
+ pool_units(index, player(index).mana_pool, purpose) + source_units(index, [excluded_source], purpose) +
29
+ convoke_units(index, purpose) + # CR 702.51a (Engine::AltCasting)
30
+ waterbend_units(index, purpose, [excluded_source]) # CR 701.67a (Engine::Waterbending)
31
+ end
32
+
33
+ def pool_units(index, pool, purpose, restricted = player(index).restricted_mana)
34
+ units = pool.flat_map { |type, n| Array.new([n, 0].max) { [type] } } +
35
+ restricted.select { spendable?(it, purpose) }.map { [it["mana"].to_sym] }
36
+ as_any_type(units, purpose)
37
+ end
38
+
39
+ # CR 609.4b: mana spent "as though it were mana of any type" can pay any symbol.
40
+ def as_any_type(units, purpose) = purpose&.[]("any_type") ? units.map { Mana::TYPES } : units
41
+
42
+ # Whether a restricted pool entry may be spent on `purpose` (CR 106.6).
43
+ def spendable?(entry, purpose)
44
+ entry["spend_only"].nil? || Mechanics::SpendOnly.from_data(entry["spend_only"]).allows?(self, purpose)
45
+ end
46
+
47
+ # One unit per untapped source not in `excluded`; a source that makes several mana ({C}{C})
48
+ # gives that many units. A source of restricted mana counts only where that mana may be spent; one
49
+ # whose ability costs mana isn't counted (it converts pool mana, CR 605.3a).
50
+ def source_units(index, excluded, purpose = nil)
51
+ by_source = Hash.new { |h, k| h[k] = [] }
52
+ all_mana_sources(index).each do |id, ref|
53
+ next if excluded.include?(id)
54
+
55
+ ability = chars(id).ability(ref)
56
+ next if ability.mana_cost || (ability.spend_only && !ability.spend_only.allows?(self, purpose))
57
+
58
+ by_source[id].concat(ability.outcomes(self, object(id)))
59
+ end
60
+ units = by_source.values.flat_map { |options| Array.new(options.map(&:size).max) { options.flatten.uniq } }
61
+ as_any_type(units, purpose)
62
+ end
63
+
64
+ # Bipartite matching (augmenting paths): can every colored or hybrid symbol (the list of types
65
+ # that can pay it) get its own unit?
66
+ def match_colored(symbols, units)
67
+ owner = Array.new(units.size)
68
+ symbols.each_index.all? { |s| augment(s, symbols, units, owner, Array.new(units.size, false)) }
69
+ end
70
+
71
+ def augment(symbol, symbols, units, owner, seen)
72
+ units.each_index.any? do |u|
73
+ next false if seen[u] || !units[u].intersect?(symbols[symbol])
74
+
75
+ seen[u] = true
76
+ next false unless owner[u].nil? || augment(owner[u], symbols, units, owner, seen)
77
+
78
+ owner[u] = symbol
79
+ true
80
+ end
81
+ end
82
+
83
+ # Every activatable mana ability [id, ability_index] the player controls. Memoized only
84
+ # while legal options are being computed (state can't change then); recomputed otherwise.
85
+ def all_mana_sources(index)
86
+ return scan_mana_sources(index) unless @options_memo
87
+
88
+ @options_memo[index] ||= scan_mana_sources(index)
89
+ end
90
+
91
+ def scan_mana_sources(index)
92
+ out = []
93
+ state.battlefield.each do |id|
94
+ obj = object(id)
95
+ next unless obj.controller == index && !mana_abilities_forbidden?(obj) # Engine::Rules
96
+
97
+ chars(obj).mana_abilities.each { |inst| out << [id, inst.ref] if inst.ability.activatable?(self, obj) }
98
+ end
99
+ out
100
+ end
101
+
102
+ # Activation options for mana abilities: one per source, or one per mana it could add when the
103
+ # mana is chosen as it's activated (`mana`: e.g. "{G}{G}", CR 605.3b).
104
+ def mana_activation_options(sources)
105
+ sources.flat_map do |id, ref|
106
+ ability = chars(id).ability(ref)
107
+ next [{ type: "activate_mana", object: id, ability: ref }] unless ability.choice?
108
+
109
+ ability.outcomes(self, object(id)).map { { type: "activate_mana", object: id, ability: ref, mana: Mana.symbols(it) } }
110
+ end
111
+ end
112
+
113
+ # CR 605.3: pay the ability's costs and add its mana immediately. Shared by priority and payment.
114
+ # `mana`: the mana chosen as it was activated (an option's "mana"). Restricted mana keeps its
115
+ # restriction in the pool (CR 106.6).
116
+ def activate_mana_ability!(index, id, ability_ref, mana = nil)
117
+ obj = object(id)
118
+ ability = chars(obj).ability(ability_ref) or raise ArgumentError, "#{id} has no ability #{ability_ref}"
119
+ chosen = mana && Mana.parse_produce(mana, [], "mana")
120
+ from = mana_provenance(id) # before a cost sacrifices it (a Treasure)
121
+ pay_from_pool!(index, ability.mana_cost, id) if ability.mana_cost
122
+ ability.costs.each { |cost| cost.pay!(self, obj) }
123
+ produced = chosen || ability.outcomes(self, obj).first # CR 605.3b: a fixed amount is counted as it resolves
124
+ add_mana!(index, produced, id, restriction: ability.spend_only&.data, from: from)
125
+ end
126
+
127
+ # Whether the mana in the pool (unrestricted) can pay `cost` (a mana ability's mana cost, CR 605.3a).
128
+ def pool_covers?(index, cost)
129
+ covers?(cost, player(index).mana_pool.flat_map { |type, n| Array.new(n) { [type] } })
130
+ end
131
+
132
+ # Pays a mana ability's mana cost from the pool: each colored or hybrid symbol with mana that keeps
133
+ # the rest payable, then generic mana with colorless first, then the most plentiful color.
134
+ def pay_from_pool!(index, cost, source)
135
+ pool = player(index).mana_pool
136
+ units = pool.flat_map { |type, n| Array.new(n) { type } }
137
+ reqs = cost.requirements
138
+ spent = reqs.each_with_index.map do |req, i|
139
+ type = req.find do |t|
140
+ at = units.index(t) or next false
141
+ match_colored(reqs.drop(i + 1), units.dup.tap { it.delete_at(at) }.map { [it] })
142
+ end
143
+ units.delete_at(units.index(type))
144
+ type
145
+ end
146
+ cost.generic.times do
147
+ type = units.include?(:C) ? :C : units.tally.max_by { |_, n| n }.first
148
+ spent << units.delete_at(units.index(type))
149
+ end
150
+ spent.each do |type|
151
+ pool[type] -= 1
152
+ emit Events::ManaPaid.new(player: name(index), mana: "{#{type}}", object: source)
153
+ end
154
+ trim_pool_sources!(player(index)) # mana spent on a mana ability: its source no longer matters
155
+ end
156
+
157
+ # ---- mana (CR 106) ----------------------------------------------------------
158
+
159
+ # CR 106.4: mana goes into the pool. Mana with a spending restriction (CR 106.6), or that isn't lost as
160
+ # steps end (`keep_until: "end_of_combat"`, CR 702.189a), is kept apart, one entry per mana. Mana made by
161
+ # an artifact remembers its source ("if mana from a Treasure was spent", `from`: #mana_provenance): an
162
+ # entry's "from", or for pool mana one PlayerState#pool_sources entry per mana.
163
+ def add_mana!(index, produce, source, restriction: nil, keep_until: nil, from: mana_provenance(source))
164
+ return if produce.empty?
165
+
166
+ pl = player(index)
167
+ if restriction || keep_until
168
+ entry = { "spend_only" => restriction && Snapshot.deep_copy(restriction), "until" => keep_until, "from" => from }.compact
169
+ produce.each { pl.restricted_mana << { "mana" => it.to_s }.merge(entry) }
170
+ else
171
+ produce.each { |type| pl.mana_pool[type] += 1 }
172
+ produce.each { (pl.pool_sources ||= []) << { "mana" => it.to_s, "from" => from } } if from
173
+ end
174
+ emit Events::ManaAdded.new(player: name(index), mana: Mana.symbols(produce), source: source)
175
+ end
176
+
177
+ # Pool mana of `type` that remembers no source (CR 106.6): the mana of that type beyond the remembered.
178
+ def unsourced_mana(player, type) = player.mana_pool[type.to_sym] - (player.pool_sources || []).count { it["mana"] == type.to_s }
179
+
180
+ # Keeps no more remembered sources of a type than there is mana of it in the pool.
181
+ def trim_pool_sources!(player)
182
+ return if player.pool_sources.nil? || player.pool_sources.empty?
183
+
184
+ kept = Hash.new(0)
185
+ player.pool_sources = player.pool_sources.select { (kept[it["mana"]] += 1) <= player.mana_pool[it["mana"].to_sym] }
186
+ end
187
+
188
+ # Where mana comes from, when a spell may ask ("mana from a Treasure", CR 106.6): an artifact source that
189
+ # isn't a land, as it is now (it may be sacrificed for the mana). Nil for other sources.
190
+ def mana_provenance(source)
191
+ obj = state.objects[source] or return nil
192
+ c = chars(obj)
193
+ return nil unless c.type?(:artifact) && !c.land?
194
+
195
+ { "name" => c.name, "types" => c.types.map(&:to_s), "subtypes" => c.subtypes.map(&:to_s),
196
+ "supertypes" => c.supertypes.map(&:to_s), "colors" => c.colors.map(&:to_s) }
197
+ end
198
+
199
+ # The characteristics a mana source had as it made the mana (#mana_provenance), to match a card spec.
200
+ def self.source_chars(from)
201
+ Enjine::Characteristics::FACE_DOWN.with(name: from["name"], types: from["types"].map(&:to_sym),
202
+ subtypes: from["subtypes"].map(&:to_sym), supertypes: from["supertypes"].map(&:to_sym),
203
+ colors: from["colors"].map(&:to_sym))
204
+ end
205
+
206
+ # Mana that stays as `step` ends: "until end of combat" mana through the combat steps (CR 702.189a).
207
+ def self.mana_kept?(entry, step) = entry["until"] == "end_of_combat" && COMBAT_STEPS.include?(step)
208
+ COMBAT_STEPS = %i[beginning_of_combat declare_attackers declare_blockers combat_damage].freeze
209
+
210
+ # Whether such mana may be in the pool during `step` (it empties as end of combat ends).
211
+ def self.mana_held?(entry, step) = mana_kept?(entry, step) || (entry["until"] == "end_of_combat" && step == :end_of_combat)
212
+
213
+ def empty_mana_pools!
214
+ state.players.each do |pl|
215
+ kept = pl.restricted_mana.select { ManaSources.mana_kept?(it, state.step) }
216
+ lost = pl.restricted_mana - kept
217
+ next if pl.mana_pool.values.all?(&:zero?) && lost.empty?
218
+
219
+ text = pl.mana_pool.flat_map { |type, n| ["{#{type}}"] * n }.join + lost.map { "{#{it["mana"]}}" }.join
220
+ pl.mana_pool.transform_values! { 0 }
221
+ pl.restricted_mana = kept
222
+ pl.pool_sources = []
223
+ emit Events::ManaEmptied.new(player: pl.name, mana: text)
224
+ end
225
+ end
226
+ end
227
+
228
+ include ManaSources
229
+ end
230
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Phasing (CR 702.26) isn't a zone change (702.26d): a phased-out permanent keeps its id, counters,
6
+ # tapped status, attachments and controller. It is moved from `state.battlefield` to the pseudo-zone
7
+ # `state.phased_out` (zone `:phased_out`), so every rule that looks at the battlefield treats it as
8
+ # though it doesn't exist (702.26b) without knowing about phasing. `GameObject#phased_out` records
9
+ # who controlled it as it phased out (for 502.1) and whether it phased out indirectly, attached to
10
+ # another permanent (702.26g).
11
+ module Phasing
12
+ # Phases out the permanents `ids` at the same time. Auras and Equipment attached to them phase out
13
+ # indirectly with them; one that would phase out both ways phases out indirectly (CR 702.26g-h).
14
+ def phase_out!(ids)
15
+ ids = ids.select { state.battlefield.include?(it) }
16
+ indirect = ids.flat_map { attached_tree(it) }
17
+ (ids - indirect).each do |id|
18
+ obj = object(id)
19
+ phase_out_one!(obj, false)
20
+ attached_tree(id).each { phase_out_one!(object(it), true) }
21
+ end
22
+ end
23
+
24
+ # Every permanent attached to `id`, and every permanent attached to those (CR 702.26g).
25
+ def attached_tree(id)
26
+ attachments_of(id).flat_map { [it, *attached_tree(it)] }
27
+ end
28
+
29
+ def phase_out_one!(obj, indirectly)
30
+ return unless state.battlefield.delete(obj.id)
31
+
32
+ obj.zone = :phased_out
33
+ obj.phased_out = { "by" => obj.controller, "indirectly" => indirectly }
34
+ state.phased_out << obj.id
35
+ leave_combat!(obj.id) # CR 506.4: removed from combat
36
+ end_while_source_effects!(obj.id) # CR 702.26f: "for as long as" effects can no longer see it
37
+ emit Events::PhasedOut.new(object: obj.id, indirectly: indirectly)
38
+ end
39
+
40
+ # CR 502.1, 702.26a: simultaneously, the phased-in permanents with phasing that `index` controls phase
41
+ # out, and the permanents that phased out directly under `index`'s control phase in, with whatever
42
+ # phased out indirectly attached to them (CR 702.26g). The set phasing in is fixed first, so nothing
43
+ # phases out and back in at once. Nothing phases in while an effect says permanents can't phase in.
44
+ def phasing_step!(index)
45
+ coming = phasing_in_prevented? ? [] : state.phased_out.select { phases_in_for?(object(it), index) }
46
+ phase_out!(state.battlefield.select { object(it).controller == index && chars(it).keyword?(:phasing) })
47
+ coming.each { phase_in!(object(it)) }
48
+ end
49
+
50
+ def phases_in_for?(obj, index) = obj.phased_out["by"] == index && !obj.phased_out["indirectly"]
51
+
52
+ def phasing_in_prevented?
53
+ state.battlefield.any? { |id| chars(id).abilities.any? { it.ability.kind == :cant_phase_in } }
54
+ end
55
+
56
+ # CR 702.26c, 702.26i: it phases in attached to what it was attached to if that is still on the
57
+ # battlefield (phased in or not), else unattached; state-based actions deal with the rest.
58
+ def phase_in!(obj)
59
+ state.phased_out.delete(obj.id)
60
+ indirectly = obj.phased_out["indirectly"]
61
+ obj.zone = :battlefield
62
+ obj.phased_out = nil
63
+ state.battlefield << obj.id
64
+ to = obj.attached_to
65
+ obj.attached_to = nil if !indirectly && to && !state.battlefield.include?(to) && !state.phased_out.include?(to)
66
+ emit Events::PhasedIn.new(object: obj.id)
67
+ state.phased_out.select { object(it).attached_to == obj.id && object(it).phased_out["indirectly"] }
68
+ .each { phase_in!(object(it)) }
69
+ end
70
+ end
71
+
72
+ include Phasing
73
+ end
74
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Starting the game (CR 103): choosing who plays first, mulligans (London mulligan,
6
+ # CR 103.5) and putting cards on the bottom.
7
+ module Pregame
8
+ def start!
9
+ state.decklists.each_with_index do |list, owner|
10
+ list.each { |card| state.players[owner].library << create_object(card, owner, :library).id }
11
+ end
12
+ emit Events::GameStarted.new(players: names, seed: state.seed)
13
+ winner = state.rng.rand(state.players.size)
14
+ emit Events::DieRolled.new(winner: name(winner))
15
+ ask!("kind" => "play_or_draw", "player" => winner)
16
+ end
17
+
18
+ # CR 103.1-103.4: starting player chosen, libraries shuffled, opening hands drawn.
19
+ def choose_starting_player!(chooser, play)
20
+ starting = play ? chooser : state.opponent(chooser)
21
+ state.starting_player = starting
22
+ emit Events::StartingPlayerChosen.new(chooser: name(chooser), choice: play ? "play" : "draw",
23
+ starting_player: name(starting))
24
+ turn_order.each do |i|
25
+ shuffle_library!(i)
26
+ HAND_SIZE.times { draw!(i) }
27
+ end
28
+ ask!("kind" => "mulligan", "player" => starting)
29
+ end
30
+
31
+ # Player indexes in turn order, starting with the starting player.
32
+ def turn_order
33
+ first = state.starting_player || 0
34
+ Array.new(state.players.size) { |i| (first + i) % state.players.size }
35
+ end
36
+
37
+ # CR 103.5 (London mulligan): players declare in turn order, then all mulligans happen at once.
38
+ def declare_mulligan!(index, choice)
39
+ player(index).mulligan_choice = choice
40
+ emit Events::MulliganDeclared.new(player: name(index), choice: choice)
41
+ undecided = turn_order.find { |i| !player(i).kept && player(i).mulligan_choice.nil? }
42
+ return ask!("kind" => "mulligan", "player" => undecided) if undecided
43
+
44
+ resolve_mulligan_round!
45
+ end
46
+
47
+ # CR 103.5: all declared mulligans happen at the same time. Taking a mulligan is:
48
+ # shuffle the hand away, draw a new hand, then put N cards (N = mulligans taken so
49
+ # far) on the bottom. Only then does the next round of declarations start.
50
+ def resolve_mulligan_round!
51
+ turn_order.each do |i|
52
+ pl = player(i)
53
+ if pl.mulligan_choice == "keep"
54
+ pl.kept = true
55
+ emit Events::HandKept.new(player: pl.name, mulligans: pl.mulligans)
56
+ elsif pl.mulligan_choice == "mulligan"
57
+ take_mulligan!(i)
58
+ end
59
+ pl.mulligan_choice = nil
60
+ end
61
+ next_bottom_or_declaration!
62
+ end
63
+
64
+ def take_mulligan!(index)
65
+ pl = player(index)
66
+ pl.mulligans += 1
67
+ pl.hand.dup.each { |id| move!(id, :library, reason: "mulligan") }
68
+ shuffle_library!(index)
69
+ HAND_SIZE.times { draw!(index) }
70
+ emit Events::MulliganTaken.new(player: pl.name, mulligans: pl.mulligans)
71
+ end
72
+
73
+ # Bottoms owed from this round's mulligans (in turn order), then the next round of
74
+ # declarations, then the first turn.
75
+ def next_bottom_or_declaration!
76
+ index = turn_order.find { |i| bottom_remaining(i).positive? }
77
+ return ask!("kind" => "bottom", "player" => index, "count" => bottom_remaining(index)) if index
78
+
79
+ next_player = turn_order.find { |i| !player(i).kept }
80
+ return ask!("kind" => "mulligan", "player" => next_player) if next_player
81
+
82
+ ask!(nil)
83
+ begin_turn!(state.starting_player)
84
+ advance!
85
+ end
86
+
87
+ def bottom_remaining(index)
88
+ pl = player(index)
89
+ [pl.hand.size - (HAND_SIZE - pl.mulligans), 0].max
90
+ end
91
+ end
92
+
93
+ include Pregame
94
+ end
95
+ end