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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "You may [effects]. If you do, [then]. If you don't, [otherwise]." (CR 118.12): the controller
7
+ # answers yes or no while it resolves (an effect_choice). Yes is offered only when every effect
8
+ # in `effects` can be done fully, as for any cost (CR 118.3). `then` follows a yes and
9
+ # `otherwise` a no, whatever the effects actually did.
10
+ class May < ChoiceEffect
11
+ include NestedList
12
+
13
+ LISTS = %w[effects then otherwise].freeze
14
+
15
+ primitive :effect, :may,
16
+ description: "An optional effect. The controller chooses yes or no as it resolves; yes is offered only " \
17
+ "if every effect in `effects` can be done fully. On yes, `effects` then `then` (\"if you " \
18
+ "do\") happen; on no, `otherwise` (\"if you don't\"). Nested effects may name the results " \
19
+ "of earlier effects of the enclosing list.",
20
+ rules: %w[118.12 118.3 608.2d 608.2e],
21
+ params: { "effects" => "list of effects (the optional part)", "then" => "list of effects",
22
+ "otherwise" => "list of effects",
23
+ "chooser" => "who decides (default: the controller): a selector naming a player, or a " \
24
+ "permanent whose controller decides (e.g. {\"target\": 0}), or " \
25
+ "\"active_player\"" },
26
+ example: { "may" => { "effects" => [{ "discard" => { "amount" => 1 } }],
27
+ "then" => [{ "draw" => { "amount" => 1 } }] } }
28
+
29
+ def self.keys = LISTS + %w[if chooser]
30
+ def self.default_to = "you"
31
+
32
+ def setup(params, errors, path)
33
+ @lists = parse_lists(params, errors, path, LISTS)
34
+ errors << "#{path}.effects: needs at least one effect" if @lists["effects"].empty?
35
+ raw = Mechanics.fetch_param(params, :chooser)
36
+ @chooser = if raw.nil? || raw == "active_player" then raw
37
+ else Selector.build(raw, errors, "#{path}.chooser", targets_count: @targets_count, results_count: @results_count)
38
+ end
39
+ end
40
+
41
+ def effects = @lists["effects"]
42
+ def nested_effects = @lists.values.flatten
43
+
44
+ # CR 608.2e: an effect may have a player other than its controller make a choice. The chooser is
45
+ # the named player, or the controller of the named permanent; nobody legal: the controller.
46
+ def chooser_index(engine, ctx)
47
+ return ctx.controller if @chooser.nil?
48
+ return engine.state.active if @chooser == "active_player"
49
+
50
+ ref = @chooser.resolve(engine, ctx).first
51
+ Filters.player_index(engine, ref) || engine.state.objects[ref]&.controller || ctx.controller
52
+ end
53
+
54
+ def to_s
55
+ text = "#{@chooser ? "#{@chooser} may have" : "you may"}: #{effects.join("; ")}"
56
+ text += " — if you do: #{@lists["then"].join("; ")}" unless @lists["then"].empty?
57
+ text += " — if you don't: #{@lists["otherwise"].join("; ")}" unless @lists["otherwise"].empty?
58
+ text
59
+ end
60
+
61
+ def run(engine, ctx, locals)
62
+ unless locals["chose"]
63
+ return :done unless condition.nil? || condition.true?(engine, ctx)
64
+
65
+ answer = answers(locals).first or return ask_yes_no(engine, ctx, locals)
66
+ locals["chose"] = answer["type"]
67
+ locals["next"] = 0
68
+ end
69
+ run_list(engine, ctx, locals, locals["chose"] == "yes" ? effects + @lists["then"] : @lists["otherwise"])
70
+ end
71
+
72
+ private
73
+
74
+ def ask_yes_no(engine, ctx, locals)
75
+ choices = [{ "type" => "no" }]
76
+ choices.unshift({ "type" => "yes" }) if effects.all? { it.possible?(engine, ctx) }
77
+ ask(engine, locals, chooser_index(engine, ctx), "You may: #{effects.join("; ")}", choices)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "Exile them, then meld them into [back face]" (CR 701.42a, 713.1a): the source, and a creature named
7
+ # `with` that its controller both owns and controls, are exiled; if the two are a meld pair (CR 701.42b)
8
+ # they come back as one permanent with the combined back face up (Engine::Faces#meld!), else they stay
9
+ # exiled (CR 701.42c). The melded permanent is its result.
10
+ class Meld < Effect
11
+ primitive :effect, :meld,
12
+ description: "Exile this permanent and a creature named `with` that you both own and control, then " \
13
+ "meld them: a meld pair (cards with layout \"meld\" sharing a back face) enters the " \
14
+ "battlefield as one permanent with that back face up. When it leaves the battlefield, " \
15
+ "both cards go to the new zone.",
16
+ rules: %w[701.42a 701.42b 701.42c 713.1a 713.6 713.8],
17
+ params: { "with" => "the name of the other card of the pair" },
18
+ example: { "meld" => { "with" => "Fang, Fearless l'Cie" } }
19
+
20
+ def self.keys = %w[with if]
21
+ def self.default_to = "self"
22
+
23
+ def setup(params, errors, path)
24
+ @with = Mechanics.fetch_param(params, :with)
25
+ errors << "#{path}.with: must be a card name" unless @with.is_a?(String) && !@with.empty?
26
+ end
27
+
28
+ def apply(engine, ctx)
29
+ pair = [Condition::MeldWith.own(engine, ctx.controller, ctx.source),
30
+ Condition::MeldWith.partner(engine, ctx.controller, @with)]
31
+ exiled = pair.compact.filter_map { engine.move!(it, :exile, reason: "meld") }
32
+ engine.meld!(*exiled, ctx.controller) if exiled.size == 2
33
+ end
34
+
35
+ def to_s = "exile this and #{@with}, then meld them"
36
+ end
37
+ end
38
+
39
+ class Condition
40
+ # "If you both own and control [this] and a creature named [name]" (the meld cards' condition, CR 713.1a):
41
+ # `{"meld_with": name}`.
42
+ class MeldWith
43
+ def self.build(data, errors, path)
44
+ Mechanics.check_keys(data, %w[meld_with], errors, path)
45
+ name = data["meld_with"]
46
+ errors << "#{path}.meld_with: must be a card name" unless name.is_a?(String) && !name.empty?
47
+ new(name)
48
+ end
49
+
50
+ # The source, if it's a permanent `index` owns and controls.
51
+ def self.own(engine, index, id)
52
+ obj = engine.state.objects[id]
53
+ id if obj&.zone == :battlefield && obj.owner == index && obj.controller == index
54
+ end
55
+
56
+ # A creature named `name` that `index` owns and controls.
57
+ def self.partner(engine, index, name)
58
+ engine.state.battlefield.find do |id|
59
+ obj = engine.object(id)
60
+ obj.owner == index && obj.controller == index && (c = engine.chars(obj)).creature? && c.name == name
61
+ end
62
+ end
63
+
64
+ def initialize(name)
65
+ @name = name
66
+ freeze
67
+ end
68
+
69
+ def true?(engine, ctx)
70
+ !MeldWith.own(engine, ctx.controller, ctx.source).nil? && !MeldWith.partner(engine, ctx.controller, @name).nil?
71
+ end
72
+
73
+ def to_s = "you both own and control this and a creature named #{@name}"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Mill N cards (CR 701.17).
7
+ class Mill < Effect
8
+ primitive :effect, :mill, description: "Each referenced player puts the top `amount` cards of their library " \
9
+ "into their graveyard (default: you).",
10
+ rules: %w[701.17a], params: { "amount" => "integer", "to" => "selector" },
11
+ example: { "mill" => { "amount" => 2, "to" => "each_opponent" } }
12
+ def self.default_to = "you"
13
+
14
+ def apply(engine, ctx)
15
+ players(engine, ctx).each do |index|
16
+ engine.player(index).library.first(amount_for(engine, ctx)).each { engine.move!(it, :graveyard, reason: "mill") }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Paying a cost while a spell or ability resolves (CR 118.12): any costs (mana, life, discarding,
7
+ # sacrificing, poison counters, a choice between costs). The payer is asked whether to pay — one choice per
8
+ # way the costs could be paid now (CR 118.3), so a `one_of` is chosen with it — then makes the cost's choices
9
+ # (which card to discard) one `effect_choice` at a time, then pays the mana in a "pay" frame (mana abilities
10
+ # allowed, CR 605.3a); declining or cancelling that payment counts as not paying, and nothing is paid (a card
11
+ # revealed to behold stays revealed, a permanent tapped for a cost stays tapped). The rest is paid once the
12
+ # mana is.
13
+ module PaysCosts
14
+ # The costs given as `cost` (a list), or as the shorthands `mana`, `life` and `waterbend`.
15
+ def parse_payment(params, errors, path)
16
+ keys = %w[mana life waterbend cost] & self.class.keys
17
+ given = keys.reject { Mechanics.fetch_param(params, it).nil? }
18
+ errors << "#{path}: needs exactly one of #{keys.join(", ")}" unless given.size == 1
19
+ raw = given.first && Mechanics.fetch_param(params, given.first)
20
+ case given.first
21
+ when "cost"
22
+ Abilities::StackAbility.parse_costs(params, errors, path).tap do
23
+ errors << "#{path}.cost: must be a non-empty list" if it.empty?
24
+ end
25
+ when "life"
26
+ return [Costs::PayLife.new(raw)].freeze if raw.is_a?(Integer) && raw >= 1
27
+
28
+ (errors << "#{path}.life: must be an integer >= 1") && [].freeze
29
+ when nil then [].freeze
30
+ else [Mechanics.build_entry(:cost, { given.first => raw }, errors, path)].compact.freeze
31
+ end
32
+ end
33
+
34
+ # Each way of paying `costs` open to the payer now: its other parts payable for `payer_obj` (an object
35
+ # the payer controls, CR 118.3) and its mana affordable.
36
+ def open_variants(engine, payer_obj, costs)
37
+ engine.cost_variants(costs).each_index.select do |i|
38
+ variant = engine.cost_variants(costs)[i]
39
+ variant.reject(&:mana?).all? { it.payable?(engine, payer_obj) } &&
40
+ engine.affordable?(payer_obj.controller, engine.costs_mana(variant), nil, engine.waterbend_purpose(variant))
41
+ end
42
+ end
43
+
44
+ # `{type: "pay"}` (with `option` and `cost` when the costs can be paid more than one way) for each open way.
45
+ def pay_choices(engine, payer_obj, costs)
46
+ ways = engine.cost_variants(costs)
47
+ open_variants(engine, payer_obj, costs).map do |i|
48
+ ways.size == 1 ? { "type" => "pay" } : { "type" => "pay", "option" => i, "cost" => ways[i].join(", ") }
49
+ end
50
+ end
51
+
52
+ # "{2}", "3 life", "discard a card or {2}".
53
+ def costs_text(costs) = costs.map { it.to_s.gsub(/(\A| or )[Pp]ay /, "\\1").sub(/\A\w/, &:downcase) }.join(", ")
54
+
55
+ # After a "pay" answer: makes the choices, pays the mana, then the rest. :wait while asking or paying
56
+ # mana; otherwise "paid" or "unpaid".
57
+ def pay_costs(engine, ctx, locals, payer_obj, costs)
58
+ paying = locals["paying"] ||= { "variant" => answers(locals).last["option"].to_i, "entries" => {},
59
+ "seen" => answers(locals).size }
60
+ variant = engine.cost_variants(costs)[paying["variant"]]
61
+ if answers(locals).size > paying["seen"]
62
+ paying["seen"] = answers(locals).size
63
+ answer = answers(locals).last
64
+ return "unpaid" if paying.delete("mana") && answer["type"] != "paid"
65
+
66
+ take_choice(engine, paying, variant, answer) if paying["cost"]
67
+ end
68
+ choose_next(engine, locals, paying, variant, payer_obj) || pay_variant(engine, ctx, paying, variant, payer_obj)
69
+ end
70
+
71
+ private
72
+
73
+ def take_choice(engine, paying, variant, answer)
74
+ i = paying.delete("cost")
75
+ (paying["entries"][i.to_s] ||= []) << variant[i].choose!(engine, { object: answer["object"] })
76
+ end
77
+
78
+ # Asks for the next choice a cost needs (CR 601.2h, as for casting); nil when none is left.
79
+ def choose_next(engine, locals, paying, variant, payer_obj)
80
+ variant.each_with_index do |cost, i|
81
+ next unless cost.respond_to?(:choices_needed)
82
+
83
+ chosen = paying["entries"].fetch(i.to_s, [])
84
+ options = cost.choice_options(engine, payer_obj, chosen)
85
+ next if chosen.size >= cost.choices_needed || options.empty?
86
+
87
+ paying["cost"] = i
88
+ return ask(engine, locals, payer_obj.controller, cost.to_s, options.map { { "type" => "cost", "object" => it[:object] } })
89
+ end
90
+ nil
91
+ end
92
+
93
+ def pay_variant(engine, ctx, paying, variant, payer_obj)
94
+ mana = engine.costs_mana(variant)
95
+ if mana.mana_value.positive? && !paying["mana_paid"]
96
+ paying["mana"] = paying["mana_paid"] = true
97
+ bend = engine.waterbend_generic(variant)
98
+ engine.begin_payment!(payer_obj.controller, mana, ctx.object, waterbend: bend.positive? ? bend : nil)
99
+ return :wait
100
+ end
101
+ variant.each_with_index do |cost, i|
102
+ next if cost.mana?
103
+
104
+ if cost.respond_to?(:choices_needed)
105
+ cost.pay_chosen!(engine, payer_obj,
106
+ paying["entries"].fetch(i.to_s, []))
107
+ else
108
+ cost.pay!(engine, payer_obj)
109
+ end
110
+ end
111
+ "paid"
112
+ end
113
+ end
114
+
115
+ # "[Player] may pay {1}. If they do, [then]. If they don't, [otherwise]." (CR 118.12): the payer answers
116
+ # pay or decline (pay only when the cost can be paid, CR 118.3), then pays (PaysCosts).
117
+ class Pay < ChoiceEffect
118
+ include NestedList
119
+ include PaysCosts
120
+
121
+ LISTS = %w[then otherwise].freeze
122
+
123
+ primitive :effect, :pay,
124
+ description: "You may pay `cost` (a list of costs: mana, life, discarding, sacrificing, a `one_of` " \
125
+ "choice, ...) as it resolves; `mana`, `life` and `waterbend` are shorthands for one cost. " \
126
+ "If you do, `then` happens; if you don't, `otherwise`. Paying mana works like paying a " \
127
+ "spell's cost (mana abilities may be activated; a `waterbend` cost may also be paid by " \
128
+ "tapping artifacts and creatures, CR 701.67a); a cost that can't be paid can't be " \
129
+ "chosen (CR 118.3). For another player paying, see `counter_unless_pays`.",
130
+ rules: %w[118.12 118.12a 118.3 119.4 605.3a 701.67a 701.67c],
131
+ params: { "cost" => "list of costs, e.g. [{\"one_of\": [{\"sacrifice_permanent\": {\"filter\": " \
132
+ "\"artifact\"}}, {\"discard\": {}}]}]",
133
+ "mana" => "mana cost string, e.g. \"{1}\"", "life" => "integer",
134
+ "waterbend" => "mana cost string: \"unless you waterbend {2}\"",
135
+ "then" => "list of effects",
136
+ "otherwise" => "list of effects" },
137
+ example: { "pay" => { "mana" => "{1}", "then" => [{ "draw" => { "amount" => 1 } }] } }
138
+
139
+ attr_reader :costs
140
+
141
+ def self.keys = %w[cost mana life waterbend then otherwise if]
142
+ def self.default_to = "you"
143
+
144
+ def setup(params, errors, path)
145
+ @costs = parse_payment(params, errors, path)
146
+ @lists = parse_lists(params, errors, path, LISTS)
147
+ end
148
+
149
+ def nested_effects = @lists.values.flatten
150
+ def what = costs_text(costs)
151
+
152
+ def to_s
153
+ text = "you may pay #{what}"
154
+ text += " — if paid: #{@lists["then"].join("; ")}" unless @lists["then"].empty?
155
+ text += " — if not: #{@lists["otherwise"].join("; ")}" unless @lists["otherwise"].empty?
156
+ text
157
+ end
158
+
159
+ def run(engine, ctx, locals)
160
+ unless locals["outcome"]
161
+ return :done unless condition.nil? || condition.true?(engine, ctx)
162
+
163
+ status = decide(engine, ctx, locals)
164
+ return status if status == :wait
165
+
166
+ locals["next"] = 0
167
+ end
168
+ run_list(engine, ctx, locals, @lists[locals["outcome"] == "paid" ? "then" : "otherwise"])
169
+ end
170
+
171
+ private
172
+
173
+ # Asks pay or decline, then pays; returns :wait while a decision or the payment is pending.
174
+ def decide(engine, ctx, locals)
175
+ payer = engine.object(ctx.object)
176
+ unless locals["paying"] || answers(locals).first
177
+ choices = pay_choices(engine, payer, costs) + [{ "type" => "decline" }]
178
+ return ask(engine, locals, payer.controller, "Pay #{what}?", choices)
179
+ end
180
+ return locals["outcome"] = "unpaid" if answers(locals).first["type"] == "decline"
181
+
182
+ result = pay_costs(engine, ctx, locals, payer, costs)
183
+ result == :wait ? :wait : (locals["outcome"] = result)
184
+ end
185
+ end
186
+
187
+ # "Counter target spell unless its controller pays {3}." (CR 701.6a, 118.12a): the spell's (or
188
+ # ability's) controller may pay (PaysCosts); if they don't, it's countered.
189
+ class CounterUnlessPays < ChoiceEffect
190
+ include PaysCosts
191
+
192
+ primitive :effect, :counter_unless_pays,
193
+ description: "Counter each referenced spell or ability unless its controller pays `mana` (or " \
194
+ "`waterbend`s that cost, CR 701.67a, or pays the list of costs `cost`) as this " \
195
+ "resolves (they choose, and pay like any cost; mana abilities allowed).",
196
+ rules: %w[701.6a 118.12a 605.3a 701.67a],
197
+ params: { "mana" => "mana cost string", "waterbend" => "mana cost string, instead of mana",
198
+ "cost" => "list of costs, instead of mana", "to" => "selector (usually {\"target\": 0})" },
199
+ example: { "counter_unless_pays" => { "mana" => "{3}", "to" => { "target" => 0 } } }
200
+
201
+ def self.keys = %w[mana waterbend cost to if]
202
+
203
+ attr_reader :costs
204
+
205
+ def setup(params, errors, path) = @costs = parse_payment(params, errors, path)
206
+
207
+ def what = costs_text(costs)
208
+ def to_s = "counter #{to} unless its controller pays #{what}"
209
+
210
+ def run(engine, ctx, locals)
211
+ return :done unless locals["target"] || condition.nil? || condition.true?(engine, ctx)
212
+
213
+ target = (locals["target"] ||= on_stack(engine, ctx).first) or return :done
214
+
215
+ payer = engine.object(target)
216
+ unless locals["paying"] || answers(locals).first
217
+ choices = pay_choices(engine, payer, costs) + [{ "type" => "decline" }]
218
+ return ask(engine, locals, payer.controller, "Pay #{what} or #{payer.card} is countered?", choices)
219
+ end
220
+ paid = answers(locals).first["type"] == "pay" && pay_costs(engine, ctx, locals, payer, costs)
221
+ return :wait if paid == :wait
222
+
223
+ engine.counter!(target) unless paid == "paid"
224
+ :done
225
+ end
226
+
227
+ def on_stack(engine, ctx) = refs(engine, ctx).select { engine.state.stack.include?(it) }
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../abilities/turn_rules"
4
+ require_relative "../keywords/evergreen"
5
+
6
+ module Enjine
7
+ module Mechanics
8
+ module Effects
9
+ # "All artifacts and creatures phase out" (CR 702.26b): the permanents
10
+ # are treated as though they don't exist until they phase in during their controller's next untap step
11
+ # (CR 502.1). Auras and Equipment attached to them phase out with them (CR 702.26g). See
12
+ # Engine::TurnStructure.
13
+ class PhaseOut < Effect
14
+ primitive :effect, :phase_out,
15
+ description: "Each referenced permanent phases out, with the Auras and Equipment attached to it " \
16
+ "(indirectly). It phases in during the untap step of the player who controlled it as " \
17
+ "it phased out. Phasing isn't a zone change: no enters or leaves triggers.",
18
+ rules: %w[702.26a 702.26b 702.26d 702.26g 702.26h 502.1 506.4],
19
+ params: { "to" => "selector" },
20
+ example: { "phase_out" => { "to" => { "each" => { "permanent" => { "any_of" => %w[artifact creature] } } } } }
21
+
22
+ def self.keys = %w[to if]
23
+
24
+ def apply(engine, ctx) = engine.phase_out!(objects(engine, ctx))
25
+
26
+ def to_s = "phase out → #{to}"
27
+ end
28
+ end
29
+
30
+ module Keywords
31
+ Evergreen.define(:phasing, "During each of its controller's untap steps, before they untap, it phases out if " \
32
+ "it's phased in (and phases in if it phased out under their control).",
33
+ %w[702.26a 702.26p 502.1])
34
+ end
35
+
36
+ module Abilities
37
+ # "Permanents can't phase in" (CR 702.26a, 502.1): while this permanent is on the battlefield, nothing
38
+ # phases in during untap steps.
39
+ class CantPhaseIn < Abilities::TurnRule
40
+ primitive :ability, :cant_phase_in,
41
+ description: "While this permanent is on the battlefield, permanents can't phase in.",
42
+ rules: %w[702.26a 502.1 604.1], example: "cant_phase_in"
43
+
44
+ INSTANCE = new.freeze
45
+
46
+ def kind = :cant_phase_in
47
+ def to_s = "Permanents can't phase in"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Proliferate (CR 701.34a): choose any number of permanents and/or players that have a counter, then
7
+ # give each one another counter of each kind it already has. "Proliferate X times" repeats it.
8
+ class Proliferate < ChoiceEffect
9
+ primitive :effect, :proliferate,
10
+ description: "You choose any number of permanents and/or players that have counters (one at a time, " \
11
+ "then done); each gets one more counter of each kind already on it. `amount` (default " \
12
+ "1): how many times to proliferate.",
13
+ rules: %w[701.34a 122.1 122.6],
14
+ params: { "amount" => "amount (default 1)" },
15
+ example: { "proliferate" => {} }
16
+
17
+ def self.keys = %w[amount if]
18
+ def self.default_to = "you"
19
+
20
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
21
+ params = params.transform_keys(&:to_s)
22
+ params["amount"] = 1 unless params.key?("amount")
23
+ super
24
+ end
25
+
26
+ def to_s = "proliferate#{" #{amount} times" if amount != 1}"
27
+
28
+ # Each round asks until "done" (or nothing is left to choose), then gives the counters.
29
+ def run(engine, ctx, locals)
30
+ unless locals["times"]
31
+ return :done unless condition.nil? || condition.true?(engine, ctx)
32
+
33
+ locals["times"] = amount_for(engine, ctx)
34
+ end
35
+ while locals["times"].positive?
36
+ chosen = answers(locals).reject { it["type"] == "done" }.map { it["object"] }
37
+ options = answers(locals).any? { it["type"] == "done" } ? [] : candidates(engine, chosen)
38
+ return ask(engine, locals, ctx.controller, "Proliferate: choose a permanent or player", options + DONE) unless options.empty?
39
+
40
+ chosen.each { give!(engine, ctx, it) }
41
+ locals["times"] -= 1
42
+ locals.delete("answers")
43
+ end
44
+ :done
45
+ end
46
+
47
+ DONE = [{ "type" => "done" }].freeze
48
+
49
+ private
50
+
51
+ # Permanents with a counter (battlefield order), then players with one (seat order).
52
+ def candidates(engine, chosen)
53
+ permanents = engine.state.battlefield.select { engine.object(it).counters.any? { |_, n| n.positive? } }
54
+ players = engine.state.players.each_index.select { engine.player_counters(it).any? { |_, n| n.positive? } }
55
+ .map { Filters.player_ref(engine, it) }
56
+ (permanents + players - chosen).map { { "type" => "choose", "object" => it } }
57
+ end
58
+
59
+ def give!(engine, ctx, ref)
60
+ if (index = Filters.player_index(engine, ref))
61
+ return engine.player_counters(index).each { |kind, n| engine.add_player_counters!(index, kind, 1) if n.positive? }
62
+ end
63
+
64
+ obj = engine.state.objects[ref]
65
+ return unless obj&.zone == :battlefield
66
+
67
+ obj.counters.select { |_, n| n.positive? }.each_key { engine.add_counters!(obj, it, 1, by_player: ctx.controller) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # +N/+N until end of turn (CR 611.2, 613.4c). Affected set is locked in on resolution (611.2c).
7
+ # "Until your next turn" lasts until the controller's next turn begins (CR 611.2a, 500.1; see
8
+ # Engine::Rules#end_effects_until_turn_of!).
9
+ class Pump < Effect
10
+ DURATIONS = %w[end_of_turn until_your_next_turn].freeze
11
+
12
+ primitive :effect, :pump,
13
+ description: "Each referenced creature gets +power/+toughness until end of turn (`duration: " \
14
+ "\"until_your_next_turn\"`: until its controller's next turn begins). The set of " \
15
+ "affected creatures is locked in when the effect starts.",
16
+ rules: %w[611.2a 611.2c 613.4c 514.2 500.1],
17
+ params: { "power" => "amount (may be negative, e.g. {\"times\": [\"x\", -1]} for -X)",
18
+ "toughness" => "amount (may be negative)", "to" => "selector",
19
+ "duration" => DURATIONS.join(" | ") },
20
+ example: { "pump" => { "power" => 3, "toughness" => 3, "to" => { "target" => 0 } } }
21
+
22
+ attr_reader :duration
23
+
24
+ def self.keys = %w[power toughness to duration if]
25
+
26
+ # A signed amount: an integer, or a computed Amount ("+X/+X, where X is ...", CR 608.2h).
27
+ def self.signed(params, key, errors, path, **context)
28
+ raw = Mechanics.fetch_param(params, key)
29
+ raw.nil? ? Amount::Fixed.new(0) : Amount.build(raw, errors, "#{path}.#{key}", min: -1_000_000, **context)
30
+ end
31
+
32
+ def self.signed_text(amount) = amount.is_a?(Amount::Fixed) ? format("%+d", amount.n) : "+(#{amount})"
33
+
34
+ def setup(params, errors, path)
35
+ context = { targets_count: @targets_count, results_count: @results_count }
36
+ @power = Pump.signed(params, :power, errors, path, **context)
37
+ @toughness = Pump.signed(params, :toughness, errors, path, **context)
38
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
39
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
40
+ end
41
+
42
+ # The fixed bonus (for display); computed bonuses are Amounts.
43
+ def power = @power.is_a?(Amount::Fixed) ? @power.n : @power
44
+ def toughness = @toughness.is_a?(Amount::Fixed) ? @toughness.n : @toughness
45
+
46
+ def to_s
47
+ "#{Pump.signed_text(@power)}/#{Pump.signed_text(@toughness)} until #{duration.delete_prefix("until_").tr("_", " ")} → #{to}"
48
+ end
49
+
50
+ # The amounts are computed once, as the effect starts (CR 608.2h, 611.2c). A noncreature it affects (an
51
+ # uncrewed Vehicle) gets the bonus once it's a creature this turn (CR 611.2c, 208.3).
52
+ def apply(engine, ctx)
53
+ engine.add_continuous_effect!("pump", ctx, objects(engine, ctx), "power" => @power.value(engine, ctx),
54
+ "toughness" => @toughness.value(engine, ctx),
55
+ "duration" => duration)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end