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,675 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # A number an effect uses, computed when the effect resolves (CR 608.2h):
6
+ # 3 a fixed number
7
+ # "x" the X announced for this spell or ability (CR 107.3)
8
+ # {"count": selector} how many objects/players match ({"count": {"result": 0}}:
9
+ # how many the first effect moved or created, e.g. cards drawn)
10
+ # {"power": selector} / {"toughness": ...} total power / toughness ("Picard's power": {"power": "self"})
11
+ # {"greatest_power" | "greatest_toughness": selector} the greatest power / toughness among them (0 for none)
12
+ # {"hand_size" | "life" | "graveyard_size": player selector}
13
+ # {"distinct_names": selector} how many different names they have (strange new worlds)
14
+ # {"mana_value": selector} their total mana value (a spell's X counts on the stack; a spell
15
+ # countered by the resolving one: as it last existed, CR 608.2h)
16
+ # {"distinct_types": selector} how many different creature types they have (CR 205.3m)
17
+ # {"distinct_colors": selector} how many colors among them (CR 105.2, 202.2)
18
+ # {"distinct_land_types": selector} how many basic land types among them (domain, CR 305.6)
19
+ # {"distinct_planeswalker_types": selector} how many planeswalker types among them (CR 205.3j)
20
+ # {"counters": {"of": selector, "kind": name}} counters on them, objects or players (any kind without "kind", CR 122)
21
+ # {"life_gained_this_turn" | "life_lost_this_turn" | "dilemmas_this_turn" | "returned_this_turn"
22
+ # | "cards_drawn_this_turn" | "cards_discarded_this_turn" | "noncombat_damage_this_turn"
23
+ # | "noncombat_damage_last_turn"
24
+ # | "spells_cast_this_game" | "turns_taken": player selector} this turn's (or game's) history, see
25
+ # Engine::TurnHistory ("turns_taken": turns begun, this one included)
26
+ # {"spells_cast_this_turn" | "lands_played_this_turn": {"by": player selector, "matching": filter,
27
+ # "from" | "not_from": zone}} spells cast / lands played this turn
28
+ # {"died_this_turn" | "entered_this_turn" | "left_this_turn" | "sacrificed_this_turn" | "attackers_this_turn"
29
+ # | "exiled_this_turn": {"each": filter, "controller": you|opponent|any}} objects in this turn's history
30
+ # {"this_way": n} how much effect n of this list changed (life it took)
31
+ # {"mana_spent": {"of": ..., "color" | "colors": ...}} mana spent to cast a spell (CR 601.2h), see mana_spent.rb
32
+ # {"times": [a, b]} / {"plus": [a, b]} / {"divide": [a, b]} arithmetic on amounts; division rounds down
33
+ # (CR 107.1a). "-X" is {"times": ["x", -1]}.
34
+ # A source that has left the battlefield is used as it last existed (CR 608.2h).
35
+ class Amount
36
+ SELECTOR_KINDS = %w[count power toughness greatest_power hand_size life graveyard_size distinct_names distinct_types
37
+ distinct_colors distinct_land_types distinct_planeswalker_types
38
+ life_gained_this_turn life_lost_this_turn dilemmas_this_turn returned_this_turn
39
+ cards_drawn_this_turn cards_discarded_this_turn spells_cast_this_game speed
40
+ noncombat_damage_this_turn noncombat_damage_last_turn turns_taken greatest_toughness
41
+ mana_value].freeze
42
+ ARITHMETIC = %w[times plus divide].freeze
43
+ BASIC_LAND_TYPES = %i[plains island swamp mountain forest].freeze # CR 305.6
44
+ # Object history lists: kind -> turn_log key (Engine::TurnHistory).
45
+ DEPARTED = { "died_this_turn" => "died", "entered_this_turn" => "entered", "left_this_turn" => "left",
46
+ "sacrificed_this_turn" => "sacrificed", "attackers_this_turn" => "attackers",
47
+ "exiled_this_turn" => "exiled" }.freeze
48
+ KINDS = (SELECTOR_KINDS + ARITHMETIC + DEPARTED.keys + %w[counters spells_cast_this_turn lands_played_this_turn this_way
49
+ mana_spent]).freeze
50
+ # Player history counters: kind -> turn_log/game_log key.
51
+ HISTORY = { "life_gained_this_turn" => "life_gained", "life_lost_this_turn" => "life_lost",
52
+ "dilemmas_this_turn" => "dilemmas", "returned_this_turn" => "returned",
53
+ "cards_drawn_this_turn" => "drawn", "cards_discarded_this_turn" => "discarded",
54
+ "noncombat_damage_this_turn" => "noncombat_damage" }.freeze
55
+ # The same, read from the previous turn's log ("was dealt noncombat damage last turn").
56
+ LAST_TURN = { "noncombat_damage_last_turn" => "noncombat_damage" }.freeze
57
+
58
+ # `min`: the smallest fixed number allowed (computed amounts may be anything their parts give).
59
+ def self.build(data, errors, path, targets_count:, min: 0, results_count: 0)
60
+ return Fixed.new(data) if data.is_a?(Integer) && data >= min
61
+ return X::INSTANCE if data == "x"
62
+ return Triggering::INSTANCE if data == { "triggering" => "amount" } # "that many" (CR 603.2)
63
+
64
+ unless data.is_a?(Hash) && data.size == 1 && KINDS.include?(data.keys.first.to_s)
65
+ errors << "#{path}: must be an integer >= #{min}, \"x\" or one of {#{KINDS.join("|")}: ...}"
66
+ return Fixed.new(0)
67
+ end
68
+ kind, ref = data.first
69
+ kind = kind.to_s
70
+ context = { targets_count: targets_count, results_count: results_count }
71
+ return Arithmetic.build(kind, ref, errors, "#{path}.#{kind}", **context) if ARITHMETIC.include?(kind)
72
+ return Counters.build(ref, errors, "#{path}.#{kind}", **context) if kind == "counters"
73
+ return SpellsCast.build(ref, errors, "#{path}.#{kind}", kind: kind, **context) if SpellsCast::LOGS.key?(kind)
74
+ return Departed.build(kind, ref, errors, "#{path}.#{kind}") if DEPARTED.key?(kind)
75
+ return ThisWay.build(ref, errors, "#{path}.#{kind}", results_count) if kind == "this_way"
76
+ return ManaSpent.build(ref, errors, "#{path}.#{kind}", **context) if kind == "mana_spent" # mana_spent.rb
77
+
78
+ new(kind, Selector.build(ref, errors, "#{path}.#{kind}", **context))
79
+ end
80
+
81
+ # Whether the amount can be computed during the layer pass (CR 613): it may count objects by
82
+ # their types, but must not read power or toughness (that would depend on the pass's result).
83
+ def self.layer_safe?(amount) = !amount.respond_to?(:reads_power?) || !amount.reads_power?
84
+
85
+ def initialize(kind, selector)
86
+ @kind = kind
87
+ @selector = selector
88
+ freeze
89
+ end
90
+
91
+ def reads_power? = %w[power toughness greatest_power greatest_toughness].include?(@kind)
92
+
93
+ def value(engine, ctx)
94
+ refs = @selector.resolve(engine, ctx) # built without errors: a selector
95
+ case @kind
96
+ when "count" then refs.size
97
+ when "power", "toughness" then refs.empty? ? Amount.last_known(engine, ctx, @selector, @kind) : stats(engine, refs).sum
98
+ when "greatest_power", "greatest_toughness" then stats(engine, refs).max.to_i
99
+ when "mana_value" then refs.sum do
100
+ Amount.mana_value(engine, engine.state.objects[it]) || engine.resolving_last_known(it)&.[]("mana_value").to_i
101
+ end
102
+ when "distinct_names" then live(engine, refs).map { engine.chars(it).name }.uniq.size
103
+ when "distinct_types" then live(engine, refs).flat_map { Amount.creature_types(engine.chars(it)) }.uniq.size
104
+ when "distinct_colors" then live(engine, refs).flat_map { engine.chars(it).colors }.uniq.size # CR 105.2
105
+ when "distinct_land_types" then (live(engine, refs).flat_map { engine.chars(it).subtypes } & BASIC_LAND_TYPES).size
106
+ when "distinct_planeswalker_types" # CR 205.3j: a planeswalker's subtypes are planeswalker types
107
+ live(engine, refs).flat_map { (c = engine.chars(it)).type?(:planeswalker) ? c.subtypes : [] }.uniq.size
108
+ else players(engine, refs).sum { player_value(engine, it) }
109
+ end
110
+ end
111
+
112
+ def to_s = "the #{@kind.tr("_", " ")} of #{@selector}"
113
+
114
+ # CR 202.3: the mana value of an object (0 without a mana cost); a spell on the stack counts its X (CR 202.3e).
115
+ # Nil for an object that's gone.
116
+ def self.mana_value(engine, obj)
117
+ return nil if obj.nil?
118
+
119
+ cost = engine.chars(obj).mana_cost or return 0
120
+ (obj.zone == :stack ? cost.with_x(obj.x.to_i) : cost).mana_value
121
+ end
122
+
123
+ # CR 205.3m: a creature's or kindred's subtypes are creature types, except those of its other card types.
124
+ def self.creature_types(chars)
125
+ chars.creature? || chars.type?(:kindred) ? chars.subtypes - Engine::Characteristics::NON_CREATURE_SUBTYPES : []
126
+ end
127
+
128
+ # CR 608.2h: "its power" of a source that has left the battlefield is its last known value.
129
+ def self.last_known(engine, ctx, selector, key)
130
+ return 0 unless selector.kind == :self
131
+
132
+ lki = source_lki(engine, ctx)
133
+ lki ? lki[key].to_i : 0
134
+ end
135
+
136
+ # How a source that has left the battlefield last existed: remembered by the resolving ability, or,
137
+ # while its leaves-the-battlefield trigger is being checked (CR 603.4, 603.10a), the engine's.
138
+ def self.source_lki(engine, ctx)
139
+ engine.state.objects[ctx.object]&.memory&.[]("source_lki") || engine.last_known(ctx.source)
140
+ end
141
+
142
+ private
143
+
144
+ def live(engine, refs) = refs.select { engine.state.objects.key?(it) }
145
+
146
+ def stats(engine, refs)
147
+ live(engine, refs).map { engine.public_send(@kind.end_with?("toughness") ? :toughness : :power, engine.object(it)) }
148
+ end
149
+
150
+ def players(engine, refs) = refs.filter_map { Filters.player_index(engine, it) }
151
+
152
+ def player_value(engine, index)
153
+ case @kind
154
+ when "hand_size" then engine.player(index).hand.size
155
+ when "life" then engine.player(index).life
156
+ when "speed" then engine.speed(index) # CR 702.179f: 0 without speed
157
+ when "graveyard_size" then engine.player(index).graveyard.size
158
+ when "spells_cast_this_game" then engine.game_log(index).fetch("spells", 0)
159
+ when "turns_taken" then engine.game_log(index).fetch("turns", 0) # this turn included, if it's theirs
160
+ when *LAST_TURN.keys then engine.last_turn_log(index).fetch(LAST_TURN.fetch(@kind), 0)
161
+ else engine.turn_log(index).fetch(HISTORY.fetch(@kind), 0)
162
+ end
163
+ end
164
+
165
+ # X (CR 107.3a): the value announced for the resolving spell or ability. An enters-the-battlefield
166
+ # trigger carries the X its permanent was cast with (CR 107.3m). 0 when none was announced.
167
+ class X
168
+ INSTANCE = new.freeze
169
+
170
+ def value(engine, ctx) = engine.state.objects[ctx.object]&.x.to_i
171
+ def to_s = "X"
172
+ end
173
+
174
+ # "That many": the number in the event that triggered the resolving ability (damage dealt, life
175
+ # gained, counters put), see Triggers.event_facts. 0 outside a triggered ability.
176
+ class Triggering
177
+ INSTANCE = new.freeze
178
+
179
+ def value(engine, ctx) = Selector.memory(engine, ctx).fetch("triggering", {})["amount"].to_i
180
+ def to_s = "that many"
181
+ end
182
+
183
+ # A fixed number.
184
+ class Fixed
185
+ attr_reader :n
186
+
187
+ def initialize(number)
188
+ @n = number
189
+ freeze
190
+ end
191
+
192
+ def value(_engine, _ctx) = @n
193
+ def to_s = @n.to_s
194
+ end
195
+
196
+ # {"times" | "plus" | "divide": [a, b]}: a × b, a + b, or a ÷ b rounded down (CR 107.1a).
197
+ class Arithmetic
198
+ def self.build(kind, parts, errors, path, **context)
199
+ unless parts.is_a?(Array) && parts.size == 2
200
+ errors << "#{path}: must be a list of two amounts"
201
+ return Fixed.new(0)
202
+ end
203
+ left, right = parts.each_with_index.map { |p, i| Amount.build(p, errors, "#{path}[#{i}]", min: -1000, **context) }
204
+ errors << "#{path}[1]: must be a positive integer" if kind == "divide" && !(parts[1].is_a?(Integer) && parts[1].positive?)
205
+ new(kind, left, right)
206
+ end
207
+
208
+ def initialize(kind, left, right)
209
+ @kind = kind
210
+ @left = left
211
+ @right = right
212
+ freeze
213
+ end
214
+
215
+ def reads_power? = [@left, @right].any? { !Amount.layer_safe?(it) }
216
+
217
+ def value(engine, ctx)
218
+ a = @left.value(engine, ctx)
219
+ b = @right.value(engine, ctx)
220
+ case @kind
221
+ when "times" then a * b
222
+ when "plus" then a + b
223
+ else a.div(b) # b was checked positive
224
+ end
225
+ end
226
+
227
+ def to_s = "#{@left} #{{ "times" => "×", "plus" => "+", "divide" => "÷" }[@kind]} #{@right}"
228
+ end
229
+
230
+ # {"this_way": n}: how much effect n of the same list changed, e.g. the life a lose_life effect took
231
+ # (CR 608.2c).
232
+ class ThisWay
233
+ def self.build(index, errors, path, results_count)
234
+ unless index.is_a?(Integer) && index >= 0 && index < results_count
235
+ errors << "#{path}: must refer to one of the #{results_count} earlier effects"
236
+ end
237
+ new(index.to_i)
238
+ end
239
+
240
+ def initialize(index)
241
+ @index = index
242
+ freeze
243
+ end
244
+
245
+ def value(_engine, ctx) = ctx.amounts&.[](@index).to_i
246
+ def to_s = "the amount from effect ##{@index + 1}"
247
+ end
248
+
249
+ # {"counters": {"of": selector, "kind": name}}: counters of one kind (or of every kind) on the
250
+ # objects or players (CR 122). A source that has left the battlefield counts as it last existed (CR 608.2h).
251
+ class Counters
252
+ def self.build(data, errors, path, **context)
253
+ data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
254
+ Mechanics.check_keys(data, %w[of kind], errors, path)
255
+ errors << "#{path}.of: required" unless data.key?("of")
256
+ kind = data["kind"] && Mechanics::Counters.parse(data["kind"], errors, "#{path}.kind")
257
+ new(Selector.build(data.fetch("of", "self"), errors, "#{path}.of", **context), kind)
258
+ end
259
+
260
+ def initialize(selector, kind)
261
+ @selector = selector
262
+ @kind = kind
263
+ freeze
264
+ end
265
+
266
+ def value(engine, ctx)
267
+ all = @selector.resolve(engine, ctx)
268
+ refs = all.select { engine.state.objects.key?(it) }
269
+ counters = refs.map { engine.object(it).counters } +
270
+ all.filter_map { Filters.player_index(engine, it) }.map { engine.player_counters(it) } # CR 122.1
271
+ counters = [Amount.source_lki(engine, ctx)&.[]("counters") || {}] if refs.empty? && @selector.kind == :self
272
+ counters.sum { @kind ? it.fetch(@kind, 0) : it.values.sum }
273
+ end
274
+
275
+ def to_s = "the number of #{"#{@kind} " if @kind}counters on #{@selector}"
276
+ end
277
+
278
+ # {"spells_cast_this_turn": {"by": "you", "matching": {"spell": {"not": ["creature"]}}}}: spells the
279
+ # players cast this turn whose card types match (CR 601.2i; a cancelled cast never counts).
280
+ # {"lands_played_this_turn": {"by": "you"}}: lands they played this turn (CR 305.1). `from` / `not_from`:
281
+ # only those cast or played from (not from) that zone (CR 601.2a).
282
+ class SpellsCast
283
+ LOGS = { "spells_cast_this_turn" => "spells", "lands_played_this_turn" => "lands_played" }.freeze
284
+
285
+ def self.build(data, errors, path, kind: "spells_cast_this_turn", **context)
286
+ data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
287
+ Mechanics.check_keys(data, %w[by matching from not_from], errors, path)
288
+ casters = Selector.build(data.fetch("by", "you"), errors, "#{path}.by", **context)
289
+ filter = data["matching"] && Mechanics.build_entry(:filter, data["matching"], errors, "#{path}.matching")
290
+ new(casters, filter, CastFrom.build(data, errors, path), LOGS.fetch(kind))
291
+ end
292
+
293
+ def initialize(casters, filter, zones = CastFrom.new(nil, nil), log = "spells")
294
+ @by = casters
295
+ @filter = filter
296
+ @zones = zones
297
+ @log = log
298
+ freeze
299
+ end
300
+
301
+ def value(engine, ctx)
302
+ players = @by.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }
303
+ players.sum do |index|
304
+ entries(engine.turn_log(index)).count do |card, zone|
305
+ @zones.match?(zone) && (@filter.nil? || Departed.facts_match?(engine, @filter, card, nil))
306
+ end
307
+ end
308
+ end
309
+
310
+ def to_s
311
+ what = @log == "spells" ? "#{@filter || "spell"}s cast" : "lands played"
312
+ "the number of #{what}#{@zones} this turn by #{@by}"
313
+ end
314
+
315
+ private
316
+
317
+ # [card, zone] per spell or land; a spell cast before zones were recorded counts as cast from the hand.
318
+ def entries(log)
319
+ return Array(log["lands_played"]) unless @log == "spells"
320
+
321
+ zones = Array(log["spells_from"])
322
+ Array(log["spells"]).each_with_index.map { |card, i| [card, zones[i] || "hand"] }
323
+ end
324
+ end
325
+
326
+ # {"died_this_turn" | "entered_this_turn" | ...: {"each": filter, "controller": ...}}: permanents that
327
+ # died (CR 700.4), entered, left the battlefield (CR 400.7) or were sacrificed (CR 701.21a) this turn,
328
+ # creatures that attacked (CR 508.1) and cards put into exile, matched by their card and the card types
329
+ # (and subtypes, except for died and exiled ones) they had then. `controller`: whose they were (the
330
+ # attacking player; for a card exiled from another zone, its owner). Without `each`, every one counts.
331
+ class Departed
332
+ def self.build(kind, data, errors, path)
333
+ errors << "#{path}: must be an object {\"each\": filter, \"controller\": ...}" unless data.is_a?(Hash)
334
+ data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
335
+ Mechanics.check_keys(data, %w[each controller], errors, path)
336
+ filter = data.key?("each") ? Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each") : nil
337
+ controller = data.fetch("controller", "any")
338
+ errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
339
+ new(DEPARTED.fetch(kind), filter, controller)
340
+ end
341
+
342
+ # Whether a card (with the card types it had, when given) matches a filter's characteristics.
343
+ def self.facts_match?(engine, filter, card, types, subtypes = nil)
344
+ defn = engine.catalog.fetch(card)
345
+ chars = defn.printed_characteristics
346
+ chars = chars.with(types: types.map(&:to_sym)) if types
347
+ chars = chars.with(subtypes: subtypes.map(&:to_sym)) if subtypes
348
+ filter.matches_facts?(Filters::Facts.new(chars: chars, counters: {}, token: defn.token?))
349
+ end
350
+
351
+ def initialize(key, filter, controller)
352
+ @key = key
353
+ @filter = filter
354
+ @controller = controller
355
+ freeze
356
+ end
357
+
358
+ def value(engine, ctx)
359
+ engine.state.players.each_index.sum do |index|
360
+ next 0 unless @controller == "any" || (@controller == "you") == (index == ctx.controller)
361
+
362
+ Array(engine.turn_log(index)[@key]).count do |card, types, subtypes|
363
+ @filter.nil? || Departed.facts_match?(engine, @filter, card, types, subtypes)
364
+ end
365
+ end
366
+ end
367
+
368
+ WORDS = { "died" => "that died", "entered" => "that entered", "left" => "that left the battlefield",
369
+ "sacrificed" => "sacrificed", "attackers" => "that attacked", "exiled" => "put into exile" }.freeze
370
+
371
+ def to_s = "the number of #{Triggers.filter_words(@filter) || "object"}s #{WORDS.fetch(@key)} this turn"
372
+ end
373
+ end
374
+
375
+ # A yes/no test about the game, checked when an effect resolves (CR 608.2c) or, as an
376
+ # intervening "if" (CR 603.4), both when a trigger condition is met and on resolution:
377
+ # {"amount": amount, "at_least": n, "at_most": n} any Amount compared with bounds (each a number or an amount),
378
+ # e.g. total power
379
+ # of creatures you control N or more: {"amount": {"power": {"each":
380
+ # "creature", "controller": "you"}}, "at_least": 8}; "if you do" after a
381
+ # mandatory effect: {"amount": {"count": {"result": 0}}, "at_least": 1}
382
+ # {"controls": selector, "at_least": 3} / {"life": "you", ...} / {"hand_size": "you", ...} (short forms)
383
+ # {"your_turn": true}
384
+ # {"max_speed": true} you have max speed: your speed is 4 (CR 702.179e)
385
+ # {"was_cast": true} "if you cast it": the source permanent was cast (CR 400.7d)
386
+ # {"cast_with": "warp"} / {"cast_from": "graveyard"} how this spell (or the spell this permanent was) was
387
+ # cast; {"cast_with_this_turn": "warp"}: a spell was cast with it this turn
388
+ # {"targets": filter} one of the chosen targets matches (e.g. "if it targets a tapped creature")
389
+ # {"attacked": true} a creature is attacking you (CR 508.1b)
390
+ # {"source": filter} the source is on the battlefield and matches ("if this isn't a creature":
391
+ # {"not": {"source": "creature"}})
392
+ # {"attacked_this_turn": true} the source attacked this turn
393
+ # {"you_attacked_this_turn": true} you attacked with one or more creatures this turn (raid, CR 508.1)
394
+ # {"nth_resolution": n} this is the nth time this turn this ability resolves (CR 608.2)
395
+ # {"share_card_type": selector | [selectors]} the referenced objects share a card type ("exiled_with": linked cards)
396
+ # {"chosen": word} that word was chosen for this object (choose_option, CR 607.2f)
397
+ # {"meld_with": name} you both own and control the source and a creature named so (CR 713.1a)
398
+ # {"not": condition}, {"all": [conditions]}, {"any": [conditions]}
399
+ class Condition
400
+ FLAGS = %w[your_turn was_cast attacked attacked_this_turn max_speed
401
+ you_attacked_this_turn].freeze
402
+ # Kinds named by one key and built by their class (ChosenIs: abilities/chosen_values.rb; Designation:
403
+ # keywords/storied.rb).
404
+ KEYED = { "targets" => :Targets, "triggering" => :TriggeringIs, "source" => :Source,
405
+ "nth_resolution" => :NthResolution, "chosen" => :ChosenIs, "designation" => :Designation,
406
+ "first_combat_phase" => :FirstCombatPhase, "meld_with" => :MeldWith }.freeze # MeldWith: effects/meld.rb
407
+
408
+ def self.build(data, errors, path, targets_count: 0, results_count: 0)
409
+ unless data.is_a?(Hash)
410
+ errors << "#{path}: must be a condition object"
411
+ return nil
412
+ end
413
+ data = data.transform_keys(&:to_s)
414
+ context = { targets_count: targets_count, results_count: results_count }
415
+ if data.key?("not") then Not.new(build(data["not"], errors, "#{path}.not", **context))
416
+ elsif data.key?("all") || data.key?("any")
417
+ key = data.key?("all") ? "all" : "any"
418
+ parts = Array(data[key]).each_with_index.map { |c, i| build(c, errors, "#{path}.#{key}[#{i}]", **context) }
419
+ Many.new(key, parts.compact)
420
+ elsif data.key?("kicked") then WasKicked.new(data["kicked"] != false)
421
+ elsif data.key?("sacrificed") then Sacrificed.build(data, errors, path)
422
+ elsif (flag = FLAGS.find { data.key?(it) }) then Flag.new(flag, data[flag] != false)
423
+ elsif data.key?("began_extra_turn") then BeganExtraTurn.new(data["began_extra_turn"] != false) # CR 500.7
424
+ elsif data.key?("share_card_type") then ShareCardType.build(data, errors, path, targets_count) # linked.rb
425
+ elsif (key = KEYED.keys.find { data.key?(it) }) then const_get(KEYED[key]).build(data, errors, path)
426
+ else build_more(data, errors, path, context)
427
+ end
428
+ end
429
+
430
+ # How a spell was cast (CastHow, keywords/alternative_costs.rb), or a comparison.
431
+ def self.build_more(data, errors, path, context)
432
+ key = CastHow::KEYS.find { data.key?(it) }
433
+ key ? CastHow.build(key, data, errors, path) : Compare.build(data, errors, path, **context)
434
+ end
435
+
436
+ # An Amount compared with bounds: {"amount": amount, "at_least": n, "at_most": n}, or a short form
437
+ # {"controls"|"life"|"hand_size": selector, ...}.
438
+ class Compare
439
+ SUBJECTS = { "controls" => "count", "life" => "life", "hand_size" => "hand_size" }.freeze
440
+
441
+ def self.build(data, errors, path, targets_count:, results_count: 0)
442
+ subject = (data.keys & (SUBJECTS.keys + ["amount"])).first
443
+ unless subject
444
+ errors << "#{path}: unknown condition (use amount, controls, life, hand_size, your_turn, was_cast, targets, " \
445
+ "attacked, source, attacked_this_turn, you_attacked_this_turn, nth_resolution, kicked, " \
446
+ "sacrificed, cast_with, cast_from, cast_with_this_turn, chosen, max_speed, designation, not, all, any)"
447
+ return nil
448
+ end
449
+ Mechanics.check_keys(data, [subject, "at_least", "at_most"], errors, path)
450
+ raw = subject == "amount" ? data[subject] : { SUBJECTS[subject] => data[subject] }
451
+ amount = Amount.build(raw, errors, "#{path}.#{subject}", targets_count: targets_count, results_count: results_count)
452
+ bounds = %w[at_least at_most].to_h { [it, data[it]] }.compact
453
+ errors << "#{path}: needs at_least and/or at_most" if bounds.empty?
454
+ bounds = bounds.to_h do |k, v| # a bound may itself be an amount ("less than its mana value")
455
+ [k, Amount.build(v, errors, "#{path}.#{k}", targets_count: targets_count, results_count: results_count, min: -1000)]
456
+ end
457
+ new(amount, bounds["at_least"], bounds["at_most"])
458
+ end
459
+
460
+ attr_reader :amount
461
+
462
+ def initialize(amount, at_least, at_most)
463
+ @amount = amount
464
+ @at_least = at_least
465
+ @at_most = at_most
466
+ freeze
467
+ end
468
+
469
+ def true?(engine, ctx)
470
+ n = @amount.value(engine, ctx)
471
+ (@at_least.nil? || n >= @at_least.value(engine, ctx)) && (@at_most.nil? || n <= @at_most.value(engine, ctx))
472
+ end
473
+
474
+ def to_s = "#{@amount} is #{[@at_least && "at least #{@at_least}", @at_most && "at most #{@at_most}"].compact.join(" and ")}"
475
+ end
476
+
477
+ # A yes/no fact about the turn or the source.
478
+ class Flag
479
+ def initialize(flag, wanted)
480
+ @flag = flag
481
+ @wanted = wanted
482
+ freeze
483
+ end
484
+
485
+ def true?(engine, ctx) = holds?(engine, ctx) == @wanted
486
+
487
+ WORDS = { "your_turn" => ["it's your turn", "it's not your turn"], "was_cast" => ["it was cast", "it wasn't cast"],
488
+ "attacked" => ["a creature is attacking you", "no creature is attacking you"],
489
+ "attacked_this_turn" => ["it attacked this turn", "it didn't attack this turn"],
490
+ "max_speed" => ["you have max speed", "you don't have max speed"],
491
+ "you_attacked_this_turn" => ["you attacked this turn", "you didn't attack this turn"] }.freeze
492
+
493
+ def to_s = WORDS.fetch(@flag)[@wanted ? 0 : 1]
494
+
495
+ private
496
+
497
+ def holds?(engine, ctx)
498
+ case @flag
499
+ when "your_turn" then engine.state.active == ctx.controller
500
+ when "attacked" # a creature is attacking the controller, not a planeswalker they control (CR 508.1b)
501
+ you = Filters.player_ref(engine, ctx.controller)
502
+ engine.attackers.values.any? { it["target"] == you }
503
+ when "attacked_this_turn" then engine.attacked_this_turn?(ctx.source)
504
+ when "max_speed" then engine.max_speed?(ctx.controller) # CR 702.179e
505
+ when "you_attacked_this_turn" then !engine.turn_log(ctx.controller).fetch("attackers", []).empty?
506
+ else was_cast?(engine, ctx)
507
+ end
508
+ end
509
+
510
+ # "If you cast it": the source permanent entered as a resolving spell (CR 400.7d). Once the
511
+ # source has left, its triggered ability remembers it (last known information, CR 603.4 / 608.2h).
512
+ def was_cast?(engine, ctx)
513
+ source = engine.state.objects[ctx.source]
514
+ cast = source ? source.memory["cast"] : engine.state.objects[ctx.object]&.memory&.[]("source_cast")
515
+ cast == true
516
+ end
517
+ end
518
+
519
+ # One of the chosen targets matches a filter. Before targets are chosen (`ctx.targets` nil, as when
520
+ # checking whether a cost reduction could apply) it counts as met.
521
+ class Targets
522
+ def self.build(data, errors, path)
523
+ Mechanics.check_keys(data, %w[targets], errors, path)
524
+ new(Mechanics.build_entry(:filter, data["targets"], errors, "#{path}.targets"))
525
+ end
526
+
527
+ def initialize(filter)
528
+ @filter = filter
529
+ freeze
530
+ end
531
+
532
+ def true?(engine, ctx)
533
+ return true if ctx.targets.nil?
534
+
535
+ ctx.targets.flatten.compact.any? { @filter&.match?(engine, it, ctx.controller) }
536
+ end
537
+
538
+ def to_s = "it targets a #{@filter.respond_to?(:primitive_name) ? @filter.primitive_name : @filter}"
539
+ end
540
+
541
+ # The source is on the battlefield and matches a filter, by its current characteristics.
542
+ class Source
543
+ def self.build(data, errors, path)
544
+ Mechanics.check_keys(data, %w[source], errors, path)
545
+ new(Mechanics.build_entry(:filter, data["source"], errors, "#{path}.source"))
546
+ end
547
+
548
+ def initialize(filter)
549
+ @filter = filter
550
+ freeze
551
+ end
552
+
553
+ def true?(engine, ctx)
554
+ !@filter.nil? && engine.state.battlefield.include?(ctx.source) && @filter.match?(engine, ctx.source, ctx.controller)
555
+ end
556
+
557
+ def to_s = "it's a #{@filter}"
558
+ end
559
+
560
+ # The nth resolution of an ability this turn: the resolving ability (ctx.object)
561
+ # has finished resolving n - 1 times this turn (Engine::TurnHistory counts completed resolutions).
562
+ class NthResolution
563
+ def self.build(data, errors, path)
564
+ Mechanics.check_keys(data, %w[nth_resolution], errors, path)
565
+ n = data["nth_resolution"]
566
+ errors << "#{path}.nth_resolution: must be a positive integer" unless n.is_a?(Integer) && n.positive?
567
+ new(n)
568
+ end
569
+
570
+ def initialize(nth)
571
+ @nth = nth
572
+ freeze
573
+ end
574
+
575
+ def true?(engine, ctx)
576
+ obj = engine.state.objects[ctx.object]
577
+ !obj.nil? && obj.ability? && engine.times_resolved(obj.source, obj.ability) + 1 == @nth
578
+ end
579
+
580
+ def to_s = "this is resolution number #{@nth} of this ability this turn"
581
+ end
582
+
583
+ # "If this spell was kicked" (CR 702.33d): the resolving spell (or the spell a permanent was) paid
584
+ # its kicker cost. Recorded on the stack object as it became cast, and carried to the permanent it
585
+ # becomes (CR 400.7d) and to that permanent's triggered abilities. While a trigger is being checked
586
+ # (no ability object yet, CR 603.4) the source permanent answers.
587
+ class WasKicked
588
+ def initialize(wanted)
589
+ @wanted = wanted
590
+ freeze
591
+ end
592
+
593
+ def true?(engine, ctx)
594
+ holder = engine.state.objects[ctx.object || ctx.source]
595
+ (holder&.memory&.[]("kicked") == true) == @wanted
596
+ end
597
+
598
+ def to_s = @wanted ? "it was kicked" : "it wasn't kicked"
599
+ end
600
+
601
+ # "If you sacrificed a Klingon this way" (CR 608.2h): a permanent sacrificed to pay the resolving
602
+ # spell's or ability's cost had these subtypes / card types as it last existed.
603
+ # {"sacrificed": {"subtypes": ["klingon"]}} {"sacrificed": {"types": ["artifact"]}}
604
+ class Sacrificed
605
+ def self.build(data, errors, path)
606
+ Mechanics.check_keys(data, %w[sacrificed], errors, path)
607
+ spec = data["sacrificed"]
608
+ ok = spec.is_a?(Hash) && !spec.empty? && (spec.keys - %w[types subtypes]).empty? &&
609
+ spec.values.all? { it.is_a?(Array) && !it.empty? && it.all?(String) }
610
+ errors << "#{path}.sacrificed: must be {\"types\": [...]} and/or {\"subtypes\": [...]}" unless ok
611
+ new(ok ? spec : {})
612
+ end
613
+
614
+ def initialize(spec)
615
+ @spec = spec
616
+ freeze
617
+ end
618
+
619
+ def true?(engine, ctx)
620
+ paid = engine.state.objects[ctx.object]&.paid || []
621
+ paid.any? { |entry| entry["subtypes"] && @spec.all? { |key, wanted| entry[key].intersect?(wanted) } }
622
+ end
623
+
624
+ def to_s = "a sacrificed permanent was #{@spec.values.flatten.join(" or ")}"
625
+ end
626
+
627
+ # {"triggering": filter}: "if it's a Saga spell" — the card the trigger event was about matches a
628
+ # filter on its card's characteristics (types, subtypes, ...), wherever it is now.
629
+ class TriggeringIs
630
+ def self.build(data, errors, path)
631
+ Mechanics.check_keys(data, %w[triggering], errors, path)
632
+ new(Mechanics.build_entry(:filter, data["triggering"], errors, "#{path}.triggering"))
633
+ end
634
+
635
+ def initialize(filter)
636
+ @filter = filter
637
+ freeze
638
+ end
639
+
640
+ def true?(engine, ctx)
641
+ card = Selector.memory(engine, ctx).fetch("triggering", {})["card"]
642
+ return false unless card && @filter
643
+
644
+ defn = engine.catalog.fetch(card)
645
+ @filter.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
646
+ end
647
+
648
+ def to_s = "it's a #{@filter.respond_to?(:primitive_name) ? @filter.primitive_name.tr("_", " ") : @filter}"
649
+ end
650
+
651
+ # Negation.
652
+ class Not
653
+ def initialize(inner)
654
+ @inner = inner
655
+ freeze
656
+ end
657
+
658
+ def true?(engine, ctx) = !@inner&.true?(engine, ctx)
659
+ def to_s = "not (#{@inner})"
660
+ end
661
+
662
+ # all / any.
663
+ class Many
664
+ def initialize(mode, parts)
665
+ @mode = mode
666
+ @parts = parts
667
+ freeze
668
+ end
669
+
670
+ def true?(engine, ctx) = @mode == "all" ? @parts.all? { it.true?(engine, ctx) } : @parts.any? { it.true?(engine, ctx) }
671
+ def to_s = @parts.join(@mode == "all" ? " and " : " or ")
672
+ end
673
+ end
674
+ end
675
+ end