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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # One-shot and continuous effects (CR 609-611). Each effect is built from data
7
+ # and applied at resolution with an EffectContext.
8
+ # Base effect: parses the common "to" selector, an "amount" (fixed or computed, see
9
+ # Mechanics::Amount) and an optional "if" condition checked on resolution (CR 608.2c).
10
+ class Effect < Primitive
11
+ attr_reader :to, :condition
12
+
13
+ def self.keys = %w[amount to if]
14
+ def self.default_to = nil
15
+ # Whether `amount` may be left out ("discard your hand").
16
+ def self.amount_optional?(_params) = false
17
+
18
+ # `results_count`: how many earlier effects of the enclosing list a {"result": n} selector may name.
19
+ def self.build(params, errors, path, targets_count: 0, results_count: 0, **)
20
+ Mechanics.check_keys(params, keys, errors, path)
21
+ raw_to = Mechanics.fetch_param(params, :to) || default_to
22
+ to = if raw_to.nil?
23
+ errors << "#{path}.to: required"
24
+ nil
25
+ else
26
+ Selector.build(raw_to, errors, "#{path}.to", targets_count: targets_count, results_count: results_count)
27
+ end
28
+ new(to: to, params: params, errors: errors, path: path, targets_count: targets_count, results_count: results_count)
29
+ end
30
+
31
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
32
+ super()
33
+ @to = to
34
+ @targets_count = targets_count
35
+ @results_count = results_count
36
+ if self.class.keys.include?("amount")
37
+ raw = Mechanics.fetch_param(params, :amount)
38
+ @amount = if raw.nil?
39
+ errors << "#{path}.amount: required" unless self.class.amount_optional?(params)
40
+ Amount::Fixed.new(0)
41
+ else
42
+ Amount.build(raw, errors, "#{path}.amount", targets_count: targets_count,
43
+ results_count: results_count)
44
+ end
45
+ end
46
+ raw_if = Mechanics.fetch_param(params, :if)
47
+ @condition = raw_if && Condition.build(raw_if, errors, "#{path}.if", targets_count: targets_count,
48
+ results_count: results_count)
49
+ setup(params, errors, path)
50
+ freeze
51
+ end
52
+
53
+ # The fixed amount (for display), or the amount computed for this resolution.
54
+ def amount = @amount.is_a?(Amount::Fixed) ? @amount.n : @amount
55
+ def amount_for(engine, ctx) = @amount.value(engine, ctx)
56
+
57
+ def setup(_params, _errors, _path) = nil
58
+
59
+ # Effects nested inside this one (a "may" or delayed trigger's list); see CardDefinition#all_effects.
60
+ def nested_effects = []
61
+
62
+ # CR 118.3: whether the effect can be done fully now (an optional effect offered as a cost).
63
+ def possible?(_engine, _ctx) = true
64
+
65
+ # Runs the effect during resolution. Effects that need a player's choice override this,
66
+ # keep their progress in `locals` (plain JSON data) and return :wait after asking.
67
+ def run(engine, ctx, _locals)
68
+ apply(engine, ctx) if condition.nil? || condition.true?(engine, ctx)
69
+ :done
70
+ end
71
+
72
+ def refs(engine, ctx) = to ? to.resolve(engine, ctx) : []
73
+
74
+ def to_s
75
+ words = self.class.primitive_name.tr("_", " ")
76
+ words += " #{amount}" if amount
77
+ "#{words} → #{to}"
78
+ end
79
+
80
+ def objects(engine, ctx)
81
+ refs(engine, ctx).select { engine.state.objects.key?(it) && engine.state.battlefield.include?(it) }
82
+ end
83
+
84
+ def players(engine, ctx) = refs(engine, ctx).filter_map { Filters.player_index(engine, it) }
85
+ end
86
+
87
+ # Moves each referenced permanent, or card in one of the class's other zones (a target card in a
88
+ # graveyard, a card chosen from a hand), to a zone. A card that has left the zone it was in
89
+ # (CR 400.7, 608.2b) isn't found any more, so it isn't moved.
90
+ class ZoneEffect < Effect
91
+ def self.keys = %w[to if]
92
+ def self.zone = raise(NotImplementedError)
93
+ def self.reason = name.split("::").last.downcase
94
+ def self.from_zones = %i[battlefield graveyard]
95
+
96
+ def apply(engine, ctx)
97
+ movable(engine, ctx).each { move(engine, ctx, it) }
98
+ end
99
+
100
+ # The referenced objects that are permanents or cards in the zones this effect moves cards from.
101
+ def movable(engine, ctx)
102
+ refs(engine, ctx).select do |id|
103
+ obj = engine.state.objects[id]
104
+ obj && !obj.ability? && self.class.from_zones.include?(obj.zone)
105
+ end
106
+ end
107
+
108
+ def move(engine, ctx, id)
109
+ Linked.remember!(engine, ctx, id)
110
+ engine.move!(id, self.class.zone, reason: self.class.reason)
111
+ end
112
+ end
113
+
114
+ # Base for effects that ask the controller (or a referenced player) questions while resolving.
115
+ # Progress lives in `locals` (plain data in the resolve frame), so they survive export/import.
116
+ class ChoiceEffect < Effect
117
+ def ask(engine, locals, player, prompt, choices)
118
+ locals["asked"] = true
119
+ engine.ask!("kind" => "effect_choice", "player" => player, "prompt" => prompt, "choices" => choices)
120
+ :wait
121
+ end
122
+
123
+ def answers(locals) = locals["answers"] ||= []
124
+ end
125
+
126
+ # Runs a nested effect list one effect at a time (`may`, `pay`, `choose_cards`); a waiting effect keeps its locals
127
+ # under "child", where Engine#effect_answer! delivers answers.
128
+ module NestedList
129
+ def run_list(engine, ctx, locals, list)
130
+ while locals["next"] < list.size
131
+ return :wait if list[locals["next"]].run(engine, ctx, locals["child"] ||= {}) == :wait
132
+
133
+ locals.delete("child")
134
+ locals["next"] += 1
135
+ end
136
+ :done
137
+ end
138
+
139
+ def parse_lists(params, errors, path, keys)
140
+ keys.to_h do |key|
141
+ [key, Abilities::StackAbility.parse_effects(params, errors, path, @targets_count, key: key.to_sym,
142
+ results_count: @results_count)]
143
+ end.freeze
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "[It] becomes a Ferengi Scout with base power and toughness 3/4 and gains '...'" (CR 611.2a, 613):
7
+ # one resolved effect that, for its duration, adds card types, supertypes and subtypes "in addition to
8
+ # its other types" and sets creature types (layer 4, CR 205.1a-b), sets colors (layer 5), grants keywords
9
+ # and abilities (layer 6) and sets base power and toughness (layer 7b). "This land becomes a 3/3 red
10
+ # Elemental creature with haste until end of turn. It's still a land." is one `become`. The affected set
11
+ # is locked in as it resolves (CR 611.2c). `duration: "indefinite"` lasts as long as the permanent stays
12
+ # on the battlefield. Granted abilities are part of this card's data, referenced "<this effect's ref>:<j>"
13
+ # (CardDefinition#effect_granted); on the permanent they are "granted:<card>:<ref>:<j>".
14
+ class Become < Effect
15
+ DURATIONS = %w[end_of_turn indefinite while_source_on_battlefield while_you_control_source].freeze
16
+ GRANTABLE = %i[triggered mana activated play_from additional_land_plays hexproof_from protection_from].freeze
17
+ # Card types an effect can add (CR 205.1b); instants, sorceries and planeswalkers aren't permanents it makes.
18
+ ADDABLE_TYPES = %w[artifact creature enchantment land].freeze
19
+ CHANGES = %w[types supertypes subtypes creature_types colors loses_abilities keywords base_power_toughness
20
+ abilities].freeze
21
+
22
+ primitive :effect, :become,
23
+ description: "Each referenced permanent becomes what the parameters say, for `duration` " \
24
+ "(end_of_turn, default; indefinite: as long as it stays on the battlefield; " \
25
+ "while_source_on_battlefield: for as long as this effect's source remains on the " \
26
+ "battlefield; while_you_control_source: for as long as you control it): it has " \
27
+ "the card `types`, `supertypes` and `subtypes` in addition to its other types (layer 4, " \
28
+ "\"becomes an artifact creature\"), its creature types are exactly `creature_types` " \
29
+ "(layer 4), its colors are `colors` (layer 5), with `loses_abilities: true` it loses all " \
30
+ "the abilities it had (layer 6; abilities granted later, keyword counters put on it " \
31
+ "later and this effect's own `keywords` and `abilities` stay, CR 613.7), it has the " \
32
+ "`keywords` and `abilities` " \
33
+ "(layer 6: triggered, activated, mana, play_from or additional_land_plays abilities) and " \
34
+ "its base " \
35
+ "power and toughness are `base_power_toughness` (layer 7b). A permanent that becomes a " \
36
+ "creature has its printed power and toughness (a Vehicle's), or 0/0.",
37
+ rules: %w[611.2a 611.2b 611.2c 613.1d 613.1e 613.1f 613.4b 613.7b 205.1a 205.1b 301.7b 514.2],
38
+ params: { "types" => "card types it also has: #{ADDABLE_TYPES.join(", ")}",
39
+ "supertypes" => "supertypes it also has (\"legendary\")",
40
+ "subtypes" => "subtypes it also has (\"in addition to its other types\")",
41
+ "creature_types" => "list of creature types (replaces the others)",
42
+ "colors" => "list of W, U, B, R, G (replaces its colors)",
43
+ "loses_abilities" => "true: it loses all abilities (\"loses all abilities\")",
44
+ "keywords" => "list of keywords it gains",
45
+ "base_power_toughness" => "{\"power\": n, \"toughness\": n}",
46
+ "abilities" => "list of abilities it gains", "duration" => DURATIONS.join(" | "),
47
+ "to" => "selector" },
48
+ example: { "become" => { "types" => ["creature"], "creature_types" => ["elemental"], "colors" => ["R"],
49
+ "base_power_toughness" => { "power" => 3, "toughness" => 3 },
50
+ "keywords" => ["haste"], "to" => "self" } }
51
+
52
+ attr_reader :ref, :creature_types, :base_power_toughness, :granted_abilities, :duration, :card_types,
53
+ :supertypes, :subtypes, :colors, :keywords
54
+
55
+ def self.keys = CHANGES + %w[duration to if]
56
+
57
+ # A list of registered names (card types, supertypes, keywords) as symbols; [] when absent.
58
+ def self.names_param(params, key, category, errors, path, allowed: nil)
59
+ raw = Mechanics.fetch_param(params, key)
60
+ return [].freeze if raw.nil?
61
+
62
+ names = raw.is_a?(Array) ? raw.map { it.to_s.downcase.tr(" -", "__") } : []
63
+ ok = !names.empty? && names.all? { Enjine.registry.fetch(category, it) && (allowed.nil? || allowed.include?(it)) }
64
+ unless ok
65
+ choices = allowed || Enjine.registry.names(category)
66
+ errors << "#{path}.#{key}: must be a non-empty list of #{category}s (#{choices.join(", ")})"
67
+ return [].freeze
68
+ end
69
+ names.uniq.map(&:to_sym).freeze
70
+ end
71
+
72
+ def setup(params, errors, path)
73
+ @ref = "become:#{path}"
74
+ @card_types = Become.names_param(params, :types, :type, errors, path, allowed: ADDABLE_TYPES)
75
+ @supertypes = Become.names_param(params, :supertypes, :supertype, errors, path)
76
+ @subtypes = parse_types(Mechanics.fetch_param(params, :subtypes), errors, "#{path}.subtypes", "subtypes") || []
77
+ @creature_types = parse_types(Mechanics.fetch_param(params, :creature_types), errors, "#{path}.creature_types",
78
+ "creature types")
79
+ @colors = Become.parse_colors(Mechanics.fetch_param(params, :colors), errors, path)
80
+ @loses_abilities = Mechanics.fetch_param(params, :loses_abilities)
81
+ errors << "#{path}.loses_abilities: must be true (or absent)" unless [nil, true].include?(@loses_abilities)
82
+ @keywords = Become.names_param(params, :keywords, :keyword, errors, path)
83
+ @base_power_toughness = Become.parse_power_toughness(Mechanics.fetch_param(params, :base_power_toughness), errors, path)
84
+ @granted_abilities = parse_abilities(params, errors, path)
85
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
86
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
87
+ return unless CHANGES.all? { Mechanics.fetch_param(params, it).nil? }
88
+
89
+ errors << "#{path}: needs at least one of #{CHANGES.join(", ")}"
90
+ end
91
+
92
+ def loses_abilities? = @loses_abilities == true
93
+
94
+ # The state.effects duration and the details it needs. "For as long as [source] remains on the
95
+ # battlefield" does nothing if the source has already left as this resolves (CR 611.2b).
96
+ def duration_details(engine, ctx)
97
+ case @duration
98
+ when "indefinite" then { "duration" => "permanent" }
99
+ when "end_of_turn" then { "duration" => "end_of_turn" }
100
+ else
101
+ holds = engine.source_duration_holds?(@duration, ctx.source, ctx.controller)
102
+ holds ? { "duration" => @duration, "source" => ctx.source } : nil
103
+ end
104
+ end
105
+
106
+ def apply(engine, ctx)
107
+ affected = objects(engine, ctx)
108
+ lasting = duration_details(engine, ctx) or return
109
+ add = ->(kind, details) { engine.add_continuous_effect!(kind, ctx, affected, details.merge(lasting)) }
110
+ # Removal first: this effect's own keywords and abilities come after it (CR 613.7b).
111
+ add.call("lose_abilities", {}) if loses_abilities?
112
+ unless card_types.empty? && supertypes.empty? && subtypes.empty?
113
+ add.call("add_types", "types" => card_types.map(&:to_s), "supertypes" => supertypes.map(&:to_s), "subtypes" => subtypes)
114
+ end
115
+ creatures = affected.select { engine.chars(it).creature? } # after the types above are added
116
+ engine.add_continuous_effect!("creature_types", ctx, creatures, { "types" => creature_types }.merge(lasting)) if creature_types
117
+ add.call("colors", "colors" => colors) if colors
118
+ add.call("grant", "keywords" => keywords.map(&:to_s)) unless keywords.empty?
119
+ engine.add_continuous_effect!("base_power_toughness", ctx, creatures, base_power_toughness.merge(lasting)) if base_power_toughness
120
+ return if granted_abilities.empty?
121
+
122
+ add.call("abilities", "card" => engine.object(ctx.object).card, "ref" => ref)
123
+ end
124
+
125
+ def nested_effects = granted_abilities.flat_map(&:try_effects)
126
+
127
+ def to_s
128
+ bits = []
129
+ bits << "loses all abilities" if loses_abilities?
130
+ bits << [*supertypes, *colors, *subtypes, *card_types].join(" ") unless (card_types + supertypes + subtypes).empty?
131
+ bits << creature_types.join(" ") if creature_types
132
+ bits << "base #{base_power_toughness.values.join("/")}" if base_power_toughness
133
+ bits << keywords.join(", ") unless keywords.empty?
134
+ bits << "\"#{granted_abilities.join("; ")}\"" unless granted_abilities.empty?
135
+ "becomes #{bits.join(", ")}#{" until end of turn" if duration == "end_of_turn"} → #{to}"
136
+ end
137
+
138
+ def self.parse_colors(raw, errors, path)
139
+ return nil if raw.nil?
140
+
141
+ ok = raw.is_a?(Array) && raw.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
142
+ errors << "#{path}.colors: must be a list of W, U, B, R, G ([] for colorless)" unless ok
143
+ ok ? raw.map { it.to_s.upcase }.uniq.freeze : nil
144
+ end
145
+
146
+ def self.parse_power_toughness(raw, errors, path)
147
+ return nil if raw.nil?
148
+
149
+ unless raw.is_a?(Hash)
150
+ errors << "#{path}.base_power_toughness: must be {\"power\": n, \"toughness\": n}"
151
+ return nil
152
+ end
153
+ sub = "#{path}.base_power_toughness"
154
+ Mechanics.check_keys(raw, %w[power toughness], errors, sub)
155
+ { "power" => Mechanics.integer_param(raw, :power, errors, sub, min: 0),
156
+ "toughness" => Mechanics.integer_param(raw, :toughness, errors, sub, min: 0) }.freeze
157
+ end
158
+
159
+ private
160
+
161
+ def parse_types(raw, errors, path, noun)
162
+ return nil if raw.nil?
163
+
164
+ ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
165
+ errors << "#{path}: must be a non-empty list of lowercase #{noun}" unless ok
166
+ ok ? raw.map(&:to_s).freeze : nil
167
+ end
168
+
169
+ def parse_abilities(params, errors, path)
170
+ Mechanics.list_param(params, :abilities, errors, path).each_with_index.filter_map do |entry, j|
171
+ ability = Mechanics.build_entry(:ability, entry, errors, "#{path}.abilities[#{j}]")
172
+ next ability if ability.nil? || (GRANTABLE.include?(ability.kind) && !(ability.kind == :play_from && ability.self?))
173
+
174
+ errors << "#{path}.abilities[#{j}]: only triggered, activated, mana, play_from (for other cards), " \
175
+ "additional_land_plays, hexproof_from and protection_from abilities can be gained"
176
+ nil
177
+ end.freeze
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "Behold a Dragon" as a resolving instruction (CR 701.4a), usually optional: "you may behold a Dragon.
7
+ # If you do, create a Treasure token" is {"may": {"effects": [{"behold": ...}], "then": [...]}}. You
8
+ # reveal a matching card from your hand or choose a matching permanent you control.
9
+ class Behold < ChoiceEffect
10
+ primitive :effect, :behold,
11
+ description: "You behold an object matching the card spec `card`: reveal such a card from your " \
12
+ "hand or choose such a permanent you control. Under `may`, \"yes\" is offered only " \
13
+ "when there is one.",
14
+ rules: %w[701.4a 701.4b 118.3],
15
+ params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"dragon\"]}" },
16
+ example: { "may" => { "effects" => [{ "behold" => { "card" => { "subtypes" => ["dragon"] } } }],
17
+ "then" => [{ "create_token" => { "token" => "Treasure" } }] } }
18
+
19
+ def self.keys = %w[card if]
20
+ def self.default_to = "you"
21
+
22
+ def setup(params, errors, path)
23
+ raw = Mechanics.fetch_param(params, :card)
24
+ errors << "#{path}.card: required" if raw.nil?
25
+ @card = CardSpec.build(raw, errors, "#{path}.card")
26
+ end
27
+
28
+ def possible?(engine, ctx) = !Beholding.candidates(engine, ctx.controller, @card).empty?
29
+
30
+ def run(engine, ctx, locals)
31
+ return :done unless condition.nil? || condition.true?(engine, ctx)
32
+
33
+ options = Beholding.candidates(engine, ctx.controller, @card)
34
+ chosen = answers(locals).first
35
+ return :done if options.empty? && chosen.nil?
36
+
37
+ choices = options.map { { "type" => "behold", "object" => it } }
38
+ return ask(engine, locals, ctx.controller, "Behold a #{@card}", choices) unless chosen
39
+
40
+ Beholding.behold!(engine, ctx.controller, chosen["object"]) if engine.state.objects.key?(chosen["object"])
41
+ :done
42
+ end
43
+
44
+ def to_s = "behold a #{@card.to_s.delete_suffix(" card")}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Blight N" as a cost (CR 701.68a, 601.2h, 602.2b): put N -1/-1 counters on a creature you control,
7
+ # chosen while casting or activating; the counters are put on it as the spell or ability becomes cast.
8
+ # Without a creature it can't be paid (CR 701.68b, 118.3). The paid entry names the blighted creature
9
+ # (CR 701.68c).
10
+ class Blight < Choosing
11
+ primitive :cost, :blight,
12
+ description: "Blight `amount`: put that many -1/-1 counters on a creature you control, chosen " \
13
+ "while casting or activating and put on it as it becomes cast.",
14
+ rules: %w[701.68a 701.68b 601.2h 602.2b 118.3],
15
+ params: { "amount" => "integer >= 1" },
16
+ example: { "blight" => { "amount" => 1 } }
17
+
18
+ attr_reader :amount
19
+
20
+ def self.keys = %w[amount]
21
+ def self.default_filter = "creature"
22
+
23
+ def initialize(filter, count, other, params, errors, path)
24
+ @amount = Mechanics.integer_param(params, :amount, errors, path, min: 1)
25
+ super
26
+ end
27
+
28
+ def pay_chosen!(engine, object, entries)
29
+ entries.each do |entry|
30
+ perm = engine.state.objects[entry["object"]]
31
+ engine.add_counters!(perm, "-1/-1", amount, by_player: object.controller) if perm&.zone == :battlefield
32
+ end
33
+ end
34
+
35
+ def to_s = "Blight #{amount}"
36
+ end
37
+ end
38
+
39
+ module Effects
40
+ # Blight N (CR 701.68a): each referenced player (default: you) puts N -1/-1 counters on a creature they
41
+ # control, of their choice. A player without creatures can't (CR 701.68b), so in a `may` yes is offered
42
+ # only when everyone can. The blighted creatures are the effect's results ("the blighted creature",
43
+ # CR 701.68c).
44
+ class Blight < ChoiceEffect
45
+ primitive :effect, :blight,
46
+ description: "Each referenced player (default: you) blights `amount`: puts that many -1/-1 " \
47
+ "counters on a creature they control, chosen as it resolves (one `effect_choice` " \
48
+ "each, in turn order). The blighted creatures are its results.",
49
+ rules: %w[701.68a 701.68b 701.68c],
50
+ params: { "amount" => "amount", "to" => "player selector (default you)" },
51
+ example: { "blight" => { "amount" => 2 } }
52
+
53
+ def self.default_to = "you"
54
+
55
+ def possible?(engine, ctx) = players(engine, ctx).all? { !creatures(engine, it).empty? }
56
+
57
+ def run(engine, ctx, locals)
58
+ return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
59
+
60
+ queue = (locals["queue"] ||= players(engine, ctx))
61
+ locals["n"] ||= amount_for(engine, ctx)
62
+ chosen = answers(locals)
63
+ while chosen.size < queue.size
64
+ index = queue[chosen.size]
65
+ options = creatures(engine, index).map { { "type" => "blight", "object" => it } }
66
+ return ask(engine, locals, index, "Blight #{locals["n"]}: choose a creature you control", options) unless options.empty?
67
+
68
+ chosen << { "type" => "none" } # CR 701.68b: nothing to blight
69
+ end
70
+ apply_all(engine, locals, queue, chosen)
71
+ end
72
+
73
+ def to_s = "blight #{amount} → #{to}"
74
+
75
+ private
76
+
77
+ def creatures(engine, index)
78
+ engine.state.battlefield.select { engine.object(it).controller == index && engine.chars(it).creature? }
79
+ end
80
+
81
+ # The counters are put on once every player has chosen.
82
+ def apply_all(engine, locals, queue, chosen)
83
+ chosen.each_with_index do |answer, i|
84
+ next unless answer["type"] == "blight" && locals["n"].positive?
85
+
86
+ engine.add_counters!(engine.object(answer["object"]), "-1/-1", locals["n"], by_player: queue[i])
87
+ engine.record_result!(answer["object"])
88
+ end
89
+ :done
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # Choose cards from among a set (CR 700.2-style choices made on resolution): from what an earlier
7
+ # effect moved ({"result": n}), from a player's hand or graveyard, or from permanents. The chosen
8
+ # cards go to `put`; `then` follows if a card was chosen ("if you do"), `otherwise` if none was
9
+ # ("if you can't" / "if you don't").
10
+ class ChooseCards < ChoiceEffect
11
+ include NestedList
12
+
13
+ PUT = %w[hand graveyard exile battlefield library_top library_bottom].freeze
14
+ ZONES = %w[hand graveyard].freeze
15
+ REVEAL = %w[chosen all].freeze
16
+ LISTS = %w[then otherwise].freeze
17
+
18
+ primitive :effect, :choose_cards,
19
+ description: "You choose `count` (default 1; \"any\": any number) cards matching `for` from `from`: a " \
20
+ "selector (e.g. {\"result\": 0}, the cards an earlier effect milled) or a player's zone, " \
21
+ "{\"hand\": selector} / {\"graveyard\": selector}. `optional`: you may choose fewer. Then " \
22
+ "they are put into `put` (`tapped` onto the battlefield, under your control; exiled cards " \
23
+ "`link`ed to the source). `look`: you look at the whole zone first; `reveal`: \"chosen\" " \
24
+ "reveals what you chose, \"all\" the whole set. `then` runs if a card was chosen, " \
25
+ "`otherwise` if none was (\"if you can't\"). `random: true`: the cards are picked at random " \
26
+ "(the game's random number generator) instead of chosen.",
27
+ rules: %w[701.20a 701.20e 400.7j 608.2c 607.2a 118.12 701.9b],
28
+ params: { "from" => "selector or {\"hand\"|\"graveyard\": player selector}", "for" => "card spec",
29
+ "count" => "integer or \"any\"", "optional" => "boolean", "put" => PUT.join(" | "),
30
+ "tapped" => "boolean", "link" => "boolean", "look" => "boolean", "random" => "boolean",
31
+ "reveal" => REVEAL.join(" | "), "then" => "list of effects", "otherwise" => "list of effects" },
32
+ example: { "choose_cards" => { "from" => { "result" => 0 }, "for" => { "permanent" => true },
33
+ "optional" => true, "put" => "hand" } }
34
+
35
+ attr_reader :spec, :count, :put
36
+
37
+ def self.keys = %w[from for count optional put tapped link look random reveal then otherwise if]
38
+ def self.default_to = "you"
39
+
40
+ def setup(params, errors, path)
41
+ @from_zone, @from = parse_from(Mechanics.fetch_param(params, :from), errors, path)
42
+ @spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
43
+ @count = Mechanics.fetch_param(params, :count) || 1
44
+ unless @count == "any" || (@count.is_a?(Integer) && @count.positive?)
45
+ errors << "#{path}.count: must be a positive integer or \"any\""
46
+ end
47
+ @optional = Mechanics.flag_param(params, "optional", errors, path)
48
+ @put = Mechanics.fetch_param(params, :put).to_s
49
+ errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
50
+ @flags = %w[tapped link look random].to_h { [it, Mechanics.flag_param(params, it, errors, path)] }
51
+ errors << "#{path}.count: must be a number to pick at random" if @flags["random"] && @count == "any"
52
+ @reveal = Mechanics.fetch_param(params, :reveal)
53
+ errors << "#{path}.reveal: must be one of #{REVEAL.join(", ")}" unless @reveal.nil? || REVEAL.include?(@reveal)
54
+ @lists = parse_lists(params, errors, path, LISTS)
55
+ end
56
+
57
+ def parse_from(raw, errors, path)
58
+ zone = raw.is_a?(Hash) && raw.size == 1 && ZONES.include?(raw.keys.first.to_s) ? raw.keys.first.to_s : nil
59
+ inner = zone ? raw.values.first : raw
60
+ return [zone, nil] if inner.nil? && (errors << "#{path}.from: required")
61
+
62
+ [zone, Selector.build(inner, errors, "#{path}.from#{".#{zone}" if zone}", targets_count: @targets_count,
63
+ results_count: @results_count)]
64
+ end
65
+
66
+ def nested_effects = @lists.values.flatten
67
+
68
+ def to_s
69
+ many = count == "any" ? "any number of" : "#{"up to " if @optional}#{count}"
70
+ "choose #{many} #{spec}#{" at random" if @flags["random"]} from #{"the #{@from_zone} of " if @from_zone}#{@from} " \
71
+ "→ #{put.tr("_", " ")}"
72
+ end
73
+
74
+ # CR 118.3: as the optional part of a "may", the full number of cards must be available.
75
+ def possible?(engine, ctx) = pool(engine, ctx).size >= (count == "any" ? 1 : count)
76
+
77
+ def run(engine, ctx, locals)
78
+ return :done unless locals["asked"] || locals.key?("chose") || condition.nil? || condition.true?(engine, ctx)
79
+
80
+ unless locals.key?("chose")
81
+ show!(engine, ctx, locals)
82
+ chosen = picked(engine, ctx, locals)
83
+ choices = @flags["random"] || stop?(locals, chosen) ? [] : choices(engine, ctx, chosen)
84
+ return ask(engine, locals, ctx.controller, "Choose #{spec}", choices) unless choices.empty?
85
+
86
+ return :wait if put == "battlefield" && engine.ask_entering_choices!(locals, chosen.map { [it, ctx.controller] }) == :wait
87
+
88
+ locals["chose"] = !perform!(engine, ctx, chosen, locals).empty?
89
+ locals["next"] = 0
90
+ end
91
+ run_list(engine, ctx, locals, @lists[locals["chose"] ? "then" : "otherwise"])
92
+ end
93
+
94
+ private
95
+
96
+ # The cards chosen so far, or picked at random once (as in CR 701.9b, through the game's RNG).
97
+ def picked(engine, ctx, locals)
98
+ return answers(locals).select { it["type"] == "choose" }.map { it["object"] } unless @flags["random"]
99
+
100
+ locals["picked"] ||= engine.state.rng.shuffle!(pool(engine, ctx)).first(count)
101
+ end
102
+
103
+ # The cards to choose from, in a stable order.
104
+ def pool(engine, ctx)
105
+ refs = set(engine, ctx)
106
+ refs.select { (obj = engine.state.objects[it]) && spec.match?(engine, obj, ctx) }
107
+ end
108
+
109
+ def set(engine, ctx)
110
+ refs = @from.resolve(engine, ctx)
111
+ return refs.select { engine.state.objects.key?(it) } unless @from_zone
112
+
113
+ refs.filter_map { Filters.player_index(engine, it) }.flat_map { engine.player(it).public_send(@from_zone) }
114
+ end
115
+
116
+ # Looking at or revealing the whole set happens once, before the choice (CR 701.20a, 701.20e).
117
+ def show!(engine, ctx, locals)
118
+ return if locals["shown"]
119
+
120
+ locals["shown"] = true
121
+ ids = set(engine, ctx)
122
+ if @reveal == "all"
123
+ ids.group_by { engine.object(it).owner }.each_value { engine.reveal!(engine.object(it.first).owner, it) }
124
+ elsif @flags["look"]
125
+ ids.group_by { engine.object(it).owner }.each { |owner, list| engine.look_at!(ctx.controller, owner, list) }
126
+ end
127
+ end
128
+
129
+ def stop?(locals, chosen)
130
+ answers(locals).any? { it["type"] == "done" } || (count != "any" && chosen.size >= count)
131
+ end
132
+
133
+ def choices(engine, ctx, chosen)
134
+ ids = pool(engine, ctx) - chosen
135
+ options = ids.map { { "type" => "choose", "object" => it } }
136
+ return options if options.empty?
137
+
138
+ options << { "type" => "done" } if @optional || count == "any"
139
+ options
140
+ end
141
+
142
+ def perform!(engine, ctx, chosen, locals)
143
+ engine.reveal!(ctx.controller, chosen) if @reveal == "chosen"
144
+ return enter!(engine, ctx, chosen, locals) if put == "battlefield"
145
+
146
+ chosen.filter_map { move(engine, ctx, it) }
147
+ end
148
+
149
+ # CR 614.12a: their "as this enters" choices were made first; they enter at the same time.
150
+ def enter!(engine, ctx, chosen, locals)
151
+ enters = @flags["tapped"] ? { "tapped" => true } : nil
152
+ engine.enter_battlefield!(locals, chosen.map { [it, ctx.controller, enters] }, reason: "put")
153
+ end
154
+
155
+ def move(engine, ctx, id)
156
+ case put
157
+ when "library_top", "library_bottom"
158
+ engine.move!(id, :library, reason: "put", position: put == "library_top" ? :top : :bottom)
159
+ else
160
+ fresh = engine.move!(id, put.to_sym, reason: put == "graveyard" ? "put" : put)
161
+ engine.link_exiled!(ctx.source, fresh.id) if fresh && @flags["link"]
162
+ fresh
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end