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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Put permanents or cards on top or bottom of their owners' libraries (CR 401.4); with
7
+ # `position: "owner_choice"` each owner chooses top or bottom.
8
+ class PutInLibrary < ChoiceEffect
9
+ POSITIONS = %w[top bottom owner_choice].freeze
10
+
11
+ primitive :effect, :put_in_library,
12
+ description: "Put each referenced permanent, or card in a graveyard or exile, into its owner's " \
13
+ "library: on the `top` or `bottom`, or (`owner_choice`) where its owner chooses.",
14
+ rules: %w[401.4 400.7 608.2b],
15
+ params: { "to" => "selector", "position" => POSITIONS.join(" | ") },
16
+ example: { "put_in_library" => { "to" => { "target" => 0 }, "position" => "bottom" } }
17
+
18
+ FROM = %i[battlefield graveyard exile].freeze
19
+
20
+ attr_reader :position
21
+
22
+ def self.keys = %w[to position if]
23
+
24
+ def setup(params, errors, path)
25
+ @position = Mechanics.fetch_param(params, :position).to_s
26
+ errors << "#{path}.position: must be one of #{POSITIONS.join(", ")}" unless POSITIONS.include?(@position)
27
+ end
28
+
29
+ def to_s = "put into its owner's library (#{position.tr("_", " ")}) → #{to}"
30
+
31
+ def run(engine, ctx, locals)
32
+ return :done unless locals["asked"] || locals.key?("refs") || condition.nil? || condition.true?(engine, ctx)
33
+
34
+ refs = (locals["refs"] ||= movable(engine, ctx))
35
+ done = (locals["done"] ||= 0)
36
+ while (id = refs[done])
37
+ where = position
38
+ if where == "owner_choice"
39
+ answer = answers(locals)[done] or return ask_owner(engine, locals, id)
40
+ where = answer["type"]
41
+ end
42
+ Linked.remember!(engine, ctx, id)
43
+ engine.move!(id, :library, reason: "put", position: where.to_sym)
44
+ locals["done"] = done += 1
45
+ end
46
+ :done
47
+ end
48
+
49
+ private
50
+
51
+ def movable(engine, ctx)
52
+ refs(engine, ctx).select { FROM.include?(engine.state.objects[it]&.zone) }
53
+ end
54
+
55
+ def ask_owner(engine, locals, id)
56
+ obj = engine.object(id)
57
+ ask(engine, locals, obj.owner, "Put #{engine.chars(obj).name} on the top or the bottom of your library?",
58
+ [{ "type" => "top", "object" => id }, { "type" => "bottom", "object" => id }])
59
+ end
60
+ end
61
+
62
+ # Shuffling cards into their owners' libraries (CR 701.24c): each owner shuffles their library even
63
+ # if none of the cards is still where it was.
64
+ class ShuffleIntoLibrary < Effect
65
+ primitive :effect, :shuffle_into_library,
66
+ description: "Put each referenced card (in a graveyard or exile) or permanent into its owner's " \
67
+ "library; each of those owners shuffles their library.",
68
+ rules: %w[701.24a 701.24c],
69
+ params: { "to" => "selector" },
70
+ example: { "shuffle_into_library" => { "to" => { "target" => 0 } } }
71
+
72
+ def self.keys = %w[to if]
73
+
74
+ def apply(engine, ctx)
75
+ owners = []
76
+ refs(engine, ctx).each do |id|
77
+ obj = engine.state.objects[id]
78
+ next unless obj && PutInLibrary::FROM.include?(obj.zone) # abilities are on the stack, players aren't objects
79
+
80
+ owners << obj.owner
81
+ engine.move!(id, :library, reason: "shuffle")
82
+ end
83
+ owners.uniq.sort.each { engine.shuffle_library!(it) }
84
+ end
85
+ end
86
+
87
+ # Shuffle a library (CR 701.24a).
88
+ class ShuffleLibrary < Effect
89
+ primitive :effect, :shuffle_library, description: "Each referenced player shuffles their library (default: you).",
90
+ rules: %w[701.24a], params: { "to" => "selector" },
91
+ example: { "shuffle_library" => {} }
92
+ def self.keys = %w[to if]
93
+ def self.default_to = "you"
94
+ def apply(engine, ctx) = players(engine, ctx).each { engine.shuffle_library!(it) }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sacrifice"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Effects
8
+ # "Return a land you control to its owner's hand": like sacrifice_choice, each referenced player
9
+ # chooses permanents they control matching `filter` (a choice, not a target), in turn order starting
10
+ # with the active player (CR 101.4); then all of them are put into their owners' hands at once.
11
+ class ReturnChoice < SacrificeChoice
12
+ primitive :effect, :return_choice,
13
+ description: "Each referenced player (default: you) returns `count` (default 1; \"any\": any number) " \
14
+ "permanents they control matching `filter` to their owners' hands, chosen by them in " \
15
+ "turn order starting with the active player (not targeted); all move at once.",
16
+ rules: %w[101.4 400.7 608.2c],
17
+ params: { "filter" => "a filter, e.g. \"land\"", "count" => "integer or \"any\"",
18
+ "to" => "selector of players" },
19
+ example: { "return_choice" => { "filter" => "land" } }
20
+
21
+ def finish!(engine, id) = engine.move!(id, :hand, reason: "return")
22
+ def choosable?(_engine, obj, index) = obj.zone == :battlefield && obj.controller == index
23
+ def verb = "return"
24
+
25
+ def to_s = "#{to} returns #{count == "any" ? "any number of" : count} #{words} of their choice to its owner's hand"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Puts cards from exile or a graveyard onto the battlefield ("return that card to the
7
+ # battlefield under its owner's control"). Usually aimed at {"result": n}: a flicker is
8
+ # `exile` followed by `return_to_battlefield` of the exile's result.
9
+ class ReturnToBattlefield < Effect
10
+ UNDER = %w[owner you].freeze
11
+ ZONES = %i[exile graveyard].freeze
12
+
13
+ primitive :effect, :return_to_battlefield,
14
+ description: "Put each referenced card that is in exile or a graveyard onto the battlefield under its " \
15
+ "owner's control (`under: \"you\"`: under the controller's), tapped if `tapped: true`. " \
16
+ "A card that is no longer in the zone it was expected in, or isn't a permanent card, " \
17
+ "stays where it is. `transformed: true`: it enters with its back face up; a card that " \
18
+ "isn't double-faced, or whose back face isn't a permanent, stays where it is.",
19
+ rules: %w[400.7 400.4a 110.2 111.8 603.7c 614.1c 122.6 712.11a 712.11b],
20
+ params: { "to" => "selector (usually {\"result\": n})", "tapped" => "boolean", "transformed" => "boolean",
21
+ "under" => UNDER.join(" | "),
22
+ "counters" => "{counter name: n}: it enters with these counters (\"with a +1/+1 counter on it\")" },
23
+ example: { "return_to_battlefield" => { "to" => { "result" => 0 } } }
24
+
25
+ attr_reader :tapped, :under, :counters, :transformed
26
+
27
+ def self.keys = %w[to tapped under counters transformed if]
28
+
29
+ def setup(params, errors, path)
30
+ @tapped = Mechanics.fetch_param(params, :tapped) == true
31
+ @transformed = Mechanics.fetch_param(params, :transformed) == true
32
+ @under = Mechanics.fetch_param(params, :under) || "owner"
33
+ errors << "#{path}.under: must be one of #{UNDER.join(", ")}" unless UNDER.include?(@under)
34
+ @counters = parse_counters(Mechanics.fetch_param(params, :counters) || {}, errors, "#{path}.counters")
35
+ end
36
+
37
+ def parse_counters(raw, errors, path)
38
+ return (errors << "#{path}: must be an object of counter name => count") && {} unless raw.is_a?(Hash)
39
+
40
+ raw.to_h do |name, n|
41
+ kind = Counters.parse(name.to_s, errors, "#{path}.#{name}")
42
+ errors << "#{path}.#{name}: must be a positive integer" unless n.is_a?(Integer) && n.positive?
43
+ [kind, n]
44
+ end.freeze
45
+ end
46
+
47
+ def to_s
48
+ extra = counters.map { |kind, n| " with #{n} #{kind} counter(s)" }.join
49
+ status = "#{" tapped" if tapped}#{" transformed" if transformed}"
50
+ "return to the battlefield#{status}#{" under your control" if under == "you"}#{extra} → #{to}"
51
+ end
52
+
53
+ # CR 614.12a: each card's "as this enters" choice is made first (asked in turn), then they all
54
+ # enter at the same time. The cards are fixed as the effect starts.
55
+ def run(engine, ctx, locals)
56
+ return :done unless locals.key?("cards") || condition.nil? || condition.true?(engine, ctx)
57
+
58
+ entries = (locals["cards"] ||= cards(engine, ctx).map { [it, under == "you" ? ctx.controller : engine.object(it).owner] })
59
+ entries = entries.select { engine.state.objects.key?(it[0]) }
60
+ return :wait if engine.ask_entering_choices!(locals, entries) == :wait
61
+
62
+ enters = { "tapped" => tapped || nil, "counters" => counters.empty? ? nil : counters.dup,
63
+ "face" => transformed ? 1 : nil }.compact
64
+ engine.enter_battlefield!(locals, entries.map { [*it, enters.empty? ? nil : enters] }, reason: "return")
65
+ :done
66
+ end
67
+
68
+ # CR 400.4a: an instant or sorcery card can't enter the battlefield. CR 111.8: a token that
69
+ # has left the battlefield can't come back (it ceases to exist at the next SBA check).
70
+ def cards(engine, ctx)
71
+ refs(engine, ctx).select do |id|
72
+ obj = engine.state.objects[id]
73
+ obj && ZONES.include?(obj.zone) && !engine.token?(obj) &&
74
+ !(engine.chars(obj).types - %i[instant sorcery]).empty? && (!transformed || engine.can_enter_face?(obj, 1))
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Return to its owner's hand (CR 400.6).
7
+ class ReturnToHand < ZoneEffect
8
+ primitive :effect, :return_to_hand,
9
+ description: "Return each referenced permanent, spell (\"return target spell to its owner's hand\"), or " \
10
+ "card in a graveyard or exile (\"target creature " \
11
+ "card from your graveyard\"), to its owner's hand.",
12
+ rules: %w[400.6 404.1 608.2b], params: { "to" => "selector" },
13
+ example: { "return_to_hand" => { "to" => { "target" => 0 } } }
14
+ def self.zone = :hand
15
+ def self.reason = "bounce"
16
+ def self.from_zones = %i[battlefield graveyard exile stack]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Rolling dice (CR 706): you roll `amount` dice with `sides` sides each (CR 706.1a), each result from 1 to
7
+ # `sides` at random (the game's random number generator). The total of the results is the effect's amount
8
+ # for later effects ({"this_way": n}), so a results table is a list of effects with `if` comparing it
9
+ # (CR 706.3a).
10
+ class RollDice < Effect
11
+ primitive :effect, :roll_dice,
12
+ description: "You roll `amount` dice (default 1) with `sides` sides (default 6). The total of the " \
13
+ "results is this effect's amount: {\"this_way\": n} in later effects and conditions " \
14
+ "(a results table is effects with an `if` on it).",
15
+ rules: %w[706.1 706.1a 706.2 706.3a 608.2c],
16
+ params: { "sides" => "integer >= 2 (default 6)", "amount" => "amount (default 1)" },
17
+ example: { "roll_dice" => { "sides" => 20 } }
18
+
19
+ def self.keys = %w[sides amount if]
20
+ def self.default_to = "you"
21
+
22
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
23
+ params = params.transform_keys(&:to_s)
24
+ params["amount"] = 1 unless params.key?("amount")
25
+ super
26
+ end
27
+
28
+ def setup(params, errors, path)
29
+ @sides = Mechanics.fetch_param(params, :sides) || 6
30
+ errors << "#{path}.sides: must be an integer >= 2" unless @sides.is_a?(Integer) && @sides >= 2
31
+ end
32
+
33
+ def to_s = "roll #{amount == 1 ? "a" : amount} d#{@sides}"
34
+
35
+ def apply(engine, ctx)
36
+ amount_for(engine, ctx).times do
37
+ result = engine.state.rng.rand(@sides) + 1 # CR 706.2: the natural result
38
+ engine.emit Events::RolledDie.new(player: engine.name(ctx.controller), sides: @sides, result: result)
39
+ engine.record_amount!(result)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Sacrifice permanents (CR 701.21): "sacrifice it at the beginning of the next end step".
7
+ class Sacrifice < Effect
8
+ primitive :effect, :sacrifice,
9
+ description: "The controller sacrifices each referenced permanent they control (it goes to its " \
10
+ "owner's graveyard; it isn't destroyed, so indestructible doesn't stop it).",
11
+ rules: %w[701.21a],
12
+ params: { "to" => "selector" },
13
+ example: { "sacrifice" => { "to" => { "result" => 0 } } }
14
+
15
+ def self.keys = %w[to if]
16
+
17
+ def apply(engine, ctx) = mine(engine, ctx).each { engine.move!(it, :graveyard, reason: "sacrifice") }
18
+
19
+ # CR 701.21a: a player can't sacrifice a permanent they don't control.
20
+ def mine(engine, ctx) = objects(engine, ctx).select { engine.can_sacrifice?(engine.object(it), ctx.controller) }
21
+ def possible?(engine, ctx) = !mine(engine, ctx).empty?
22
+ end
23
+
24
+ # Edicts and sacrifices of the player's choice: each referenced player chooses a number of permanents
25
+ # they control matching `filter`, in APNAP order (CR 101.4); then all of them are sacrificed at the
26
+ # same time (CR 701.21a).
27
+ class SacrificeChoice < ChoiceEffect
28
+ primitive :effect, :sacrifice_choice,
29
+ description: "Each referenced player (default: you) sacrifices `count` (default 1; \"any\": any number) " \
30
+ "permanents they control matching `filter`, chosen by them in turn order starting with " \
31
+ "the active player; all are sacrificed at once. The `filter`'s `controller` and `other` " \
32
+ "restrictions are relative to the effect's controller and source.",
33
+ rules: %w[701.21a 101.4 118.3],
34
+ params: { "filter" => "a filter, e.g. {\"creature\": {\"token\": false}}", "count" => "integer or \"any\"",
35
+ "to" => "selector of players" },
36
+ example: { "sacrifice_choice" => { "filter" => "creature", "to" => "each_opponent" } }
37
+
38
+ attr_reader :filter, :count
39
+
40
+ def self.keys = %w[filter count to if]
41
+ def self.default_to = "you"
42
+
43
+ def setup(params, errors, path)
44
+ raw = Mechanics.fetch_param(params, :filter)
45
+ errors << "#{path}.filter: required" if raw.nil?
46
+ @filter = raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.filter")
47
+ @count = Mechanics.fetch_param(params, :count) || 1
48
+ return if @count == "any" || (@count.is_a?(Integer) && @count.positive?)
49
+
50
+ errors << "#{path}.count: must be a positive integer or \"any\""
51
+ end
52
+
53
+ def to_s = "#{to} sacrifices #{count == "any" ? "any number of" : count} #{words} of their choice"
54
+ def words = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
55
+
56
+ # CR 118.3: as the optional part of a "may", there must be enough permanents to sacrifice.
57
+ def possible?(engine, ctx)
58
+ players(engine, ctx).all? { candidates(engine, ctx, it, []).size >= (count == "any" ? 0 : count) }
59
+ end
60
+
61
+ def run(engine, ctx, locals)
62
+ return :done unless locals["asked"] || locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
63
+
64
+ queue = (locals["queue"] ||= apnap(engine, players(engine, ctx)))
65
+ picks = (locals["picks"] ||= [])
66
+ take_answers(locals, picks)
67
+ while (index = queue.first)
68
+ mine = picks.select { it[0] == index }.map { it[1] }
69
+ ask_for = remaining(engine, ctx, locals, index, mine)
70
+ return ask_for if ask_for == :wait
71
+
72
+ queue.shift
73
+ end
74
+ picks.each { finish!(engine, it[1]) }
75
+ :done
76
+ end
77
+
78
+ # Hooks for Effects::ReturnChoice: what happens to the chosen permanents, who may choose one.
79
+ def finish!(engine, id) = engine.move!(id, :graveyard, reason: "sacrifice")
80
+ def choosable?(engine, obj, index) = engine.can_sacrifice?(obj, index)
81
+ def verb = "sacrifice"
82
+
83
+ private
84
+
85
+ def apnap(engine, indexes) = engine.turn_order_from_active.select { indexes.include?(it) }
86
+
87
+ # Permanents `index` controls that match the filter, not yet chosen.
88
+ def candidates(engine, ctx, index, chosen)
89
+ filter.candidates_for(engine, ctx.controller, ctx.source)
90
+ .select { engine.state.objects.key?(it) && choosable?(engine, engine.object(it), index) } - chosen
91
+ end
92
+
93
+ # Asks `index` for their next permanent, or picks automatically when there is no real choice.
94
+ def remaining(engine, ctx, locals, index, mine)
95
+ left = candidates(engine, ctx, index, mine)
96
+ return :next if left.empty? || locals["stopped"] == index || (count != "any" && mine.size >= count)
97
+
98
+ if count != "any" && left.size <= count - mine.size
99
+ left.each { locals["picks"] << [index, it] }
100
+ return :next
101
+ end
102
+ choices = left.map { { "type" => verb, "object" => it } }
103
+ choices << { "type" => "done" } if count == "any"
104
+ locals["chooser"] = index
105
+ ask(engine, locals, index, "#{verb.capitalize} #{words}", choices)
106
+ end
107
+
108
+ def take_answers(locals, picks)
109
+ (locals["taken"] ||= 0).upto(answers(locals).size - 1) do |i|
110
+ answer = answers(locals)[i]
111
+ answer["type"] == "done" ? locals["stopped"] = locals["chooser"] : picks << [locals["chooser"], answer["object"]]
112
+ end
113
+ locals["taken"] = answers(locals).size
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Scry N / surveil N (CR 701.22, 701.25): look at the top N, put each on the top or away
7
+ # (bottom / graveyard), then order the ones kept on top.
8
+ class LookAtTop < ChoiceEffect
9
+ def self.default_to = "you"
10
+ def self.away = raise(NotImplementedError)
11
+
12
+ def run(engine, ctx, locals)
13
+ index = ctx.controller
14
+ locals["amount"] ||= amount_for(engine, ctx)
15
+ seen = (locals["seen"] ||= engine.player(index).library.first(locals["amount"]))
16
+ decided = answers(locals).reject { it["type"] == "order" }
17
+ if decided.size < seen.size
18
+ card = seen[decided.size]
19
+ return ask(engine, locals, index, "#{self.class.primitive_name}: keep on top or #{self.class.away}?",
20
+ [{ "type" => "top", "object" => card }, { "type" => self.class.away, "object" => card }])
21
+ end
22
+ tops = decided.select { it["type"] == "top" }.map { it["object"] }
23
+ order = answers(locals).select { it["type"] == "order" }.map { it["object"] }
24
+ if tops.size - order.size > 1
25
+ return ask(engine, locals, index, "Choose the next card to put on top (the first chosen ends up on top)",
26
+ (tops - order).map { { "type" => "order", "object" => it } })
27
+ end
28
+ place(engine, index, order + (tops - order), decided.reject { it["type"] == "top" }.map { it["object"] })
29
+ performed!(engine, index, locals["amount"])
30
+ :done
31
+ end
32
+
33
+ # CR 701.22b, 701.25c-d: scry or surveil N > 0 is an event once it's complete, even if the library
34
+ # had fewer cards; "whenever you scry" triggers on it.
35
+ def performed!(engine, index, amount)
36
+ return unless amount.positive?
37
+
38
+ engine.emit Events::KeywordAction.new(player: engine.name(index), action: self.class.primitive_name.to_s,
39
+ amount: amount)
40
+ end
41
+ end
42
+
43
+ # Scry N (CR 701.22).
44
+ class Scry < LookAtTop
45
+ primitive :effect, :scry, description: "Look at the top `amount` cards of your library; put any number on the " \
46
+ "bottom and the rest on top in any order.",
47
+ rules: %w[701.22a], params: { "amount" => "integer" }, example: { "scry" => { "amount" => 2 } }
48
+ def self.away = "bottom"
49
+
50
+ def place(engine, index, tops, bottoms)
51
+ library = engine.player(index).library
52
+ library.replace(tops + (library - tops - bottoms) + bottoms)
53
+ engine.emit Events::LibraryReordered.new(player: engine.name(index), top: tops.size, bottom: bottoms.size)
54
+ end
55
+ end
56
+
57
+ # Surveil N (CR 701.25).
58
+ class Surveil < LookAtTop
59
+ primitive :effect, :surveil, description: "Look at the top `amount` cards of your library; put any number into " \
60
+ "your graveyard and the rest on top in any order.",
61
+ rules: %w[701.25a], params: { "amount" => "integer" },
62
+ example: { "surveil" => { "amount" => 2 } }
63
+ def self.away = "graveyard"
64
+
65
+ def place(engine, index, tops, gone)
66
+ library = engine.player(index).library
67
+ library.replace(tops + (library - tops))
68
+ gone.each { engine.move!(it, :graveyard, reason: "surveil") }
69
+ engine.emit Events::LibraryReordered.new(player: engine.name(index), top: tops.size, bottom: 0)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "library"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Effects
8
+ # Search your library (CR 701.23), optionally reveal what was found (CR 701.20a), put it
9
+ # into your hand or onto the battlefield, then shuffle (CR 701.24).
10
+ #
11
+ # The searcher picks one card at a time. Copies of the same card in a library are
12
+ # interchangeable, so each distinct card is offered once. Only the searcher sees the
13
+ # choices; other players see the reveal (when the effect reveals), the zone change
14
+ # as their zone rules allow, and the shuffle.
15
+ class SearchLibrary < ChoiceEffect
16
+ include EntersBattlefield # effects/library.rb
17
+
18
+ primitive :effect, :search_library,
19
+ description: "Search your library for up to `count` cards matching `for`, then put them into your " \
20
+ "hand (or onto the battlefield, tapped if `tapped`, with `counters`) and shuffle. With a " \
21
+ "stated quality you may find fewer (or none); a search for just \"a card\" must find one " \
22
+ "if it can. `reveal` shows the found cards to every player. `searcher` names another " \
23
+ "player who searches their own library (e.g. {\"controller_of\": {\"target\": 0}}); " \
24
+ "`may: true` lets the searcher decline (then nothing is shuffled). `different_names: true`: " \
25
+ "the cards found must all have different names (\"basic land cards with different names\").",
26
+ rules: %w[701.23a 701.23b 701.23d 701.23e 701.20a 701.24a 701.24b 118.12 110.2a],
27
+ params: { "for" => "card spec: \"card\" (default), {\"types\"|\"subtypes\"|\"supertypes\": [...], " \
28
+ "\"name\": \"...\"} or {\"any_of\": [spec, ...]}",
29
+ "count" => "integer (default 1), \"any\" (any number), \"x\" or an amount (\"up to that many\")",
30
+ "put" => "\"hand\" (default) or \"battlefield\"",
31
+ "tapped" => "true: the cards enter tapped (with put: battlefield)",
32
+ "counters" => "{counter name: n}: the cards enter with these counters (with put: battlefield)",
33
+ "reveal" => "true: reveal the found cards",
34
+ "searcher" => "selector of the searching player (default \"you\")",
35
+ "may" => "true: the searcher may choose not to search",
36
+ "different_names" => "true: each card found has a different name" },
37
+ example: { "search_library" => { "for" => { "supertypes" => ["basic"], "types" => ["land"] },
38
+ "reveal" => true } }
39
+
40
+ ZONES = %w[hand battlefield].freeze
41
+
42
+ attr_reader :spec, :count, :put, :tapped, :reveal
43
+
44
+ def self.keys = %w[for count put tapped counters reveal searcher may different_names if]
45
+ def self.default_to = "you"
46
+
47
+ def setup(params, errors, path)
48
+ @spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
49
+ @count = parse_count(Mechanics.fetch_param(params, :count) || 1, errors, path)
50
+ @put = (Mechanics.fetch_param(params, :put) || "hand").to_s
51
+ errors << "#{path}.put: must be one of #{ZONES.join(", ")}" unless ZONES.include?(@put)
52
+ @tapped = Mechanics.flag_param(params, :tapped, errors, path)
53
+ @reveal = Mechanics.flag_param(params, :reveal, errors, path)
54
+ @may = Mechanics.flag_param(params, :may, errors, path)
55
+ @different_names = Mechanics.flag_param(params, :different_names, errors, path)
56
+ errors << "#{path}.tapped: only cards put onto the battlefield can enter tapped" if @tapped && @put != "battlefield"
57
+ @counters = parse_counters(Mechanics.fetch_param(params, :counters), errors, path)
58
+ raw = Mechanics.fetch_param(params, :searcher) || "you"
59
+ @searcher = Selector.build(raw, errors, "#{path}.searcher", targets_count: @targets_count, results_count: @results_count)
60
+ end
61
+
62
+ def parse_count(raw, errors, path)
63
+ return raw if raw == "any" || (raw.is_a?(Integer) && raw.positive?)
64
+ return Linked.amount(raw, errors, "#{path}.count", @targets_count, @results_count) if raw.is_a?(Hash) || raw == "x"
65
+
66
+ errors << "#{path}.count: must be a positive integer, \"any\" or an amount"
67
+ 1
68
+ end
69
+
70
+ def parse_counters(raw, errors, path)
71
+ return {} if raw.nil?
72
+
73
+ unless raw.is_a?(Hash) && raw.all? { |name, n| Counters.valid?(Counters.normalize(name.to_s)) && n.is_a?(Integer) && n.positive? }
74
+ errors << "#{path}.counters: must be {counter name: positive integer}"
75
+ return {}
76
+ end
77
+ errors << "#{path}.counters: only cards put onto the battlefield enter with counters" if @put != "battlefield"
78
+ raw.to_h { |name, n| [Counters.normalize(name.to_s), n] }
79
+ end
80
+
81
+ def to_s
82
+ many = if count == "any" then "any number of"
83
+ elsif count.is_a?(Integer) then "up to #{count}"
84
+ else "up to #{count} (computed)"
85
+ end
86
+ "search your library for #{many} #{spec}#{", reveal" if reveal} → #{put}#{" tapped" if tapped}, shuffle"
87
+ end
88
+
89
+ def run(engine, ctx, locals)
90
+ return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
91
+
92
+ index = (locals["searcher"] ||= [searcher(engine, ctx)]).first
93
+ return :done if index.nil?
94
+
95
+ if @may && !locals["search"]
96
+ answer = answers(locals).first or return ask(engine, locals, index, "Search your library?",
97
+ [{ "type" => "search" }, { "type" => "decline" }])
98
+ return :done if answer["type"] == "decline"
99
+
100
+ locals["search"] = true
101
+ end
102
+ found = answers(locals).select { it["type"] == "find" }.map { it["object"] }
103
+ unless stop?(engine, ctx, locals, found)
104
+ choices = candidates(engine, index, found)
105
+ return ask(engine, locals, index, "Search your library (#{spec}): find a card", choices) unless choices.empty?
106
+ end
107
+ return :wait if entering_choices?(engine, index, locals, found)
108
+
109
+ finish!(engine, index, found, locals)
110
+ :done
111
+ end
112
+
113
+ private
114
+
115
+ def searcher(engine, ctx) = @searcher.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.first
116
+
117
+ def limit(engine, ctx, locals)
118
+ return count unless count.respond_to?(:value)
119
+
120
+ locals["limit"] ||= count.value(engine, ctx)
121
+ end
122
+
123
+ def stop?(engine, ctx, locals, found)
124
+ wanted = limit(engine, ctx, locals)
125
+ answers(locals).any? { it["type"] == "done" } || (wanted != "any" && found.size >= wanted)
126
+ end
127
+
128
+ # One option per distinct matching card (CR 701.23a); "done" stops looking unless the
129
+ # search is for just a quantity of cards (CR 701.23b, 701.23d).
130
+ def candidates(engine, index, found)
131
+ names = @different_names ? found.map { engine.chars(it).name } : []
132
+ ids = (engine.player(index).library - found).sort.select do
133
+ spec.match?(engine, engine.object(it)) && !names.include?(engine.chars(it).name)
134
+ end
135
+ choices = ids.uniq { engine.object(it).card }.map { { "type" => "find", "object" => it } }
136
+ return choices if choices.empty? || (spec.any? && count != "any")
137
+
138
+ choices << { "type" => "done" }
139
+ end
140
+
141
+ def finish!(engine, index, found, locals)
142
+ if reveal && put == "hand"
143
+ found.each do |id|
144
+ engine.emit Events::CardRevealed.new(player: engine.name(index), object: id, card: engine.object(id).card)
145
+ end
146
+ end
147
+ enters = {}
148
+ enters["tapped"] = true if tapped
149
+ enters["counters"] = @counters.dup unless @counters.empty?
150
+ put_cards!(engine, locals, found, index, reason: "search", enters: enters.empty? ? nil : enters)
151
+ engine.shuffle_library!(index) # CR 701.24b: the found cards have left; the rest is shuffled
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Tap permanents (CR 701.26).
7
+ class TapEffect < Effect
8
+ primitive :effect, :tap, description: "Tap each referenced permanent.", rules: %w[701.26a],
9
+ params: { "to" => "selector" }, example: { "tap" => { "to" => { "target" => 0 } } }
10
+ def self.keys = %w[to if]
11
+ def apply(engine, ctx) = objects(engine, ctx).each { engine.tap!(engine.object(it)) unless engine.object(it).tapped }
12
+ end
13
+
14
+ # Untap permanents (CR 701.26).
15
+ class UntapEffect < Effect
16
+ primitive :effect, :untap, description: "Untap each referenced permanent.", rules: %w[701.26b],
17
+ params: { "to" => "selector" }, example: { "untap" => { "to" => "self" } }
18
+ def self.keys = %w[to if]
19
+ def apply(engine, ctx) = objects(engine, ctx).each { engine.untap!(engine.object(it)) }
20
+ end
21
+ end
22
+ end
23
+ end