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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever a creature you control attacking causes a triggered ability of that creature to trigger" (CR 603.2):
7
+ # another ability triggering is the event. `cause: "attacks"`: the other ability triggered on its own source
8
+ # attacking (CR 508.1). "Copy that ability" is copy_spell of {"triggering": "ability"}: the triggered ability
9
+ # once it's on the stack (CR 707.10); the controller orders the two abilities (CR 603.3b).
10
+ class AbilityTriggers < Trigger
11
+ CAUSES = %w[attacks].freeze
12
+
13
+ primitive :trigger, :ability_triggers,
14
+ description: "A triggered ability of the subject triggers. `cause: \"attacks\"` (required): its trigger " \
15
+ "event was the subject attacking. {\"triggering\": \"ability\"} is that triggered ability " \
16
+ "while it's on the stack.",
17
+ rules: %w[603.2 603.3b 508.1 707.10],
18
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, \"controller\": ..., \"other\": true}",
19
+ "cause" => CAUSES.join(" | ") },
20
+ example: { "ability_triggers" => { "subject" => { "each" => "creature", "controller" => "you" },
21
+ "cause" => "attacks" } }
22
+
23
+ def self.keys = %w[subject cause]
24
+ def event_class = Events::AbilityTriggered
25
+
26
+ def setup(params, errors, path)
27
+ @cause = params["cause"]
28
+ errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless CAUSES.include?(@cause)
29
+ end
30
+
31
+ def match?(engine, event, source)
32
+ pending = engine.state.pending_triggers.find { it["id"] == event.trigger } or return false
33
+ cause = pending["event"] || {}
34
+ return false unless cause["type"] == "attacker_declared" && cause["object"] == event.source # CR 508.1
35
+
36
+ obj = engine.state.objects[event.source] or return false
37
+ @subject.match?(engine, event.source, obj.controller, obj.card, source, types: engine.chars(obj).types)
38
+ end
39
+
40
+ def occurrences(engine, event, source)
41
+ return [] unless match?(engine, event, source)
42
+
43
+ [{ "object" => event.source, "player" => engine.object(event.source).controller, "card" => event.card,
44
+ "ability" => event.trigger }]
45
+ end
46
+
47
+ def to_s = "Whenever #{@subject} attacking causes a triggered ability of it to trigger"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever ~ enters or attacks": one ability with several trigger events. It triggers once
7
+ # per event that matches any of them (CR 603.2c).
8
+ class AnyOf < Trigger
9
+ primitive :trigger, :any_of,
10
+ description: "Triggers when any of the listed trigger conditions is met.",
11
+ rules: %w[603.2 603.2c],
12
+ params: { "(list)" => "two or more trigger conditions" },
13
+ example: { "any_of" => [{ "enters" => {} }, { "attacks" => {} }] }
14
+
15
+ attr_reader :parts
16
+
17
+ def self.scalar_params? = true
18
+
19
+ def self.build(params, errors, path, **)
20
+ unless params.is_a?(Array) && params.size >= 2
21
+ errors << "#{path}: must be a list of at least two trigger conditions"
22
+ params = []
23
+ end
24
+ parts = params.each_with_index.filter_map { |entry, i| Mechanics.build_entry(:trigger, entry, errors, "#{path}[#{i}]") }
25
+ new(parts)
26
+ end
27
+
28
+ def initialize(parts) # rubocop:disable Lint/MissingSuper -- no subject or params of its own
29
+ @parts = parts.freeze
30
+ freeze
31
+ end
32
+
33
+ def watches?(event) = parts.any? { it.watches?(event) }
34
+ def leaves_battlefield? = parts.any?(&:leaves_battlefield?)
35
+ def match?(engine, event, source) = parts.any? { it.watches?(event) && it.match?(engine, event, source) }
36
+
37
+ def to_s
38
+ parts.map(&:to_s).each_with_index.map { |text, i| i.zero? ? text : text.sub(/\A(When|Whenever) /, "") }.join(" or ")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # Trigger conditions for triggered abilities (CR 603). A trigger watches one kind of
7
+ # event and decides whether it matches for a given source permanent:
8
+ # match?(engine, event, source) -> true/false
9
+ # `source` is a Hash with "id", "card", "controller" (for a permanent that just left
10
+ # the battlefield it describes the object as it last existed, CR 603.10a).
11
+ # What an event tells a triggered ability about itself (CR 603.2): the object and player it is
12
+ # about and a number ("that many"). Effects name them with {"triggering": "object" | "player"}
13
+ # and the amount {"triggering": "amount"}; "card" is the object's card, for conditions.
14
+ def self.event_facts(engine, event)
15
+ facts = {}
16
+ case event
17
+ when Events::ZoneChanged then facts.merge!("object" => event.new_id, "player" => engine.names.index(event.owner))
18
+ when Events::SpellCast, Events::DilemmaFaced, Events::Tapped, Events::AttackerDeclared, Events::BecameBlocked,
19
+ Events::BlockerDeclared
20
+ facts["object"] = event.object
21
+ when Events::AbilityActivated then facts.merge!("object" => event.source, "ability" => event.object)
22
+ when Events::TriggerPutOnStack then facts["ability"] = event.object
23
+ when Events::DamageDealt
24
+ facts.merge!("object" => event.source, "amount" => event.amount, "player" => Filters.player_index(engine, event.target))
25
+ when Events::LifeChanged then facts["amount"] = [event.gain, event.loss].max
26
+ when Events::CountersChanged then facts.merge!("object" => event.object, "amount" => event.to - event.from)
27
+ when Events::StepBegan then facts["player"] = engine.state.active # "that player": whose step it is
28
+ end
29
+ cast_facts(engine, event, facts) if event.is_a?(Events::SpellCast)
30
+ facts["player"] ||= engine.names.index(event.player) if event.respond_to?(:player)
31
+ facts["card"] = event.respond_to?(:card) ? event.card : engine.state.objects[facts["object"]]&.card
32
+ facts.compact
33
+ end
34
+
35
+ # CR 601.2h: the mana spent to cast "that spell" and the sources of what artifacts made, as it was cast;
36
+ # storm's "that many" (CR 702.40a).
37
+ def self.cast_facts(engine, event, facts)
38
+ spell = engine.state.objects[event.object]
39
+ facts["mana_spent"] = spell&.mana_spent&.dup
40
+ facts["mana_from"] = Snapshot.deep_copy(spell&.memory&.[]("mana_from"))
41
+ facts["amount"] = Triggers.spells_before(engine)
42
+ end
43
+
44
+ def self.filter_words(filter)
45
+ return nil if filter.nil?
46
+
47
+ filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
48
+ end
49
+
50
+ # One entry (the event facts) per occurrence of the trigger event in `event` (CR 603.2c). Works for
51
+ # any trigger condition, including ones that only answer match? (a Saga's chapter trigger).
52
+ def self.occurrences(trigger, engine, event, source)
53
+ return trigger.occurrences(engine, event, source) if trigger.respond_to?(:occurrences)
54
+
55
+ trigger.match?(engine, event, source) ? [event_facts(engine, event)] : []
56
+ end
57
+
58
+ # Who a trigger is about: "self" (default), "attached" (the creature the source equips or
59
+ # enchants, CR 301.5f / 303.4m), "watched" (the objects a delayed triggered ability was created
60
+ # to watch, CR 603.7c: "when that creature dies this turn") or {"each": filter, "controller": ...,
61
+ # "other": true}.
62
+ class Subject
63
+ def self.build(data, errors, path)
64
+ data = "self" if data.nil?
65
+ return new(data.to_sym) if %w[self attached watched].include?(data)
66
+
67
+ unless data.is_a?(Hash) && data["each"]
68
+ errors << "#{path}: must be \"self\", \"attached\", \"watched\" or " \
69
+ "{\"each\": filter, \"controller\": you|opponent|any, \"other\": bool}"
70
+ return nil
71
+ end
72
+ Mechanics.check_keys(data, %w[each controller other], errors, path)
73
+ filter = Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each")
74
+ controller = data.fetch("controller", "any")
75
+ errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
76
+ new(:each, filter, controller, other: data["other"] == true)
77
+ end
78
+
79
+ def initialize(kind, filter = nil, controller = nil, other: false)
80
+ @kind = kind
81
+ @filter = filter
82
+ @controller = controller
83
+ @other = other
84
+ freeze
85
+ end
86
+
87
+ # Does the object `object_id` (controlled by `object_controller`, of card `card`) count?
88
+ # `types`: its card types at the time (current or last known, CR 603.10a); printed if omitted.
89
+ def match?(engine, object_id, object_controller, card, source, types: nil)
90
+ return object_id == source["id"] if @kind == :self
91
+ return object_id == engine.state.objects[source["id"]]&.attached_to if @kind == :attached
92
+ return Array(source["watched"]).include?(object_id) if @kind == :watched
93
+ return false if @other && object_id == source["id"]
94
+ return false unless controller_ok?(object_controller, source["controller"])
95
+
96
+ obj = engine.state.objects[object_id]
97
+ return @filter.match_for?(engine, object_id, source["controller"], source["id"]) if obj&.zone == :battlefield
98
+
99
+ # Left the battlefield: as it last existed (CR 603.10a), from its card, last known types and counters.
100
+ defn = engine.catalog.fetch(card)
101
+ chars = types ? defn.printed_characteristics.with(types: types) : defn.printed_characteristics
102
+ lki = engine.last_known(object_id) || {}
103
+ token = lki.fetch("token") { defn.token? } # a token copy of a card (CR 111.1)
104
+ @filter.matches_facts?(Filters::Facts.new(chars: chars, counters: lki.fetch("counters", nil) || {}, token: token,
105
+ chosen: engine.state.objects[source["id"]]&.memory))
106
+ end
107
+
108
+ def controller_ok?(object_controller, you)
109
+ @controller == "any" || (@controller == "you") == (object_controller == you)
110
+ end
111
+
112
+ def to_s
113
+ return "this" if @kind == :self
114
+ return "the attached creature" if @kind == :attached
115
+ return "that object" if @kind == :watched
116
+
117
+ "#{"another " if @other}#{@filter.primitive_name.tr("_",
118
+ " ")}#{{ "you" => " you control",
119
+ "opponent" => " an opponent controls" }[@controller]}"
120
+ end
121
+ end
122
+
123
+ # Base class for trigger conditions.
124
+ class Trigger < Primitive
125
+ def self.keys = %w[subject]
126
+
127
+ def self.build(params, errors, path, **)
128
+ Mechanics.check_keys(params, keys, errors, path)
129
+ new(params.transform_keys(&:to_s), errors, path)
130
+ end
131
+
132
+ def initialize(params, errors, path)
133
+ super()
134
+ @subject = Subject.build(params["subject"], errors, "#{path}.subject") if self.class.keys.include?("subject")
135
+ setup(params, errors, path)
136
+ freeze
137
+ end
138
+
139
+ def setup(_params, _errors, _path) = nil
140
+ def leaves_battlefield? = false
141
+
142
+ # Whether this trigger looks at `event` at all (one event class by default).
143
+ def watches?(event) = event.is_a?(event_class)
144
+
145
+ # "One or more ...": all occurrences in one event batch trigger it once (CR 603.2c).
146
+ def batched? = false
147
+
148
+ # "When you cast this spell": the trigger functions while its card is a spell on the stack.
149
+ def own_spell? = false
150
+
151
+ # The event facts of each occurrence this trigger matches (CR 603.2c); see Triggers.event_facts.
152
+ def occurrences(engine, event, source) = match?(engine, event, source) ? [Triggers.event_facts(engine, event)] : []
153
+ end
154
+
155
+ # Matching the source's "as this enters" choice (CR 614.12, 607.2): `"name": "chosen"` is "with the
156
+ # chosen name" (CR 201.4) and `"mana_value": "chosen"` is "with mana value equal to the chosen number".
157
+ module ChosenMatch
158
+ def setup_chosen(params, errors, path)
159
+ @chosen = %w[name mana_value].select { params.key?(it) }
160
+ @chosen.each { errors << "#{path}.#{it}: must be \"chosen\"" unless params[it] == "chosen" }
161
+ end
162
+
163
+ # `card`: the card the spell or source is; `x_value`: the X announced for a spell on the stack (CR 202.3e).
164
+ def chosen_ok?(engine, source, card, x_value = 0)
165
+ memory = engine.state.objects[source["id"]]&.memory || {}
166
+ defn = engine.catalog.fetch(card)
167
+ @chosen.all? do |key|
168
+ if key == "name" then !memory["chosen_name"].nil? && defn.name == memory["chosen_name"]
169
+ else
170
+ cost = defn.mana_cost
171
+ !memory["chosen_number"].nil? && (cost ? cost.mana_value + (cost.x_symbols * x_value.to_i) : 0) == memory["chosen_number"]
172
+ end
173
+ end
174
+ end
175
+
176
+ def chosen_words = @chosen.map { it == "name" ? " with the chosen name" : " with mana value equal to the chosen number" }.join
177
+ end
178
+
179
+ PLAYERS = %w[you opponent any].freeze
180
+
181
+ # Base for triggers about a player ("whenever you gain life", "whenever you attack"):
182
+ # `controller` is you (default) | opponent | any, relative to the source's controller.
183
+ class PlayerTrigger < Trigger
184
+ def self.keys = %w[controller]
185
+
186
+ def setup(params, errors, path)
187
+ @controller = params.fetch("controller", "you")
188
+ errors << "#{path}.controller: must be one of #{PLAYERS.join(", ")}" unless PLAYERS.include?(@controller)
189
+ end
190
+
191
+ def player_ok?(engine, player_name, source)
192
+ return true if @controller == "any"
193
+
194
+ (@controller == "you") == (engine.names.index(player_name) == source["controller"])
195
+ end
196
+
197
+ def who = { "you" => "you", "opponent" => "an opponent", "any" => "a player" }.fetch(@controller)
198
+ end
199
+
200
+ # A `batched` flag: "one or more ..." triggers once per event batch (CR 603.2c), and its "that many"
201
+ # ({"triggering": "amount"}) is how many occurrences the batch had (Engine::Triggering#queue_trigger).
202
+ module Batched
203
+ def setup_batched(params, errors, path)
204
+ @batched = params.fetch("batched", false)
205
+ errors << "#{path}.batched: must be true or false" unless [true, false].include?(@batched)
206
+ end
207
+
208
+ def batched? = @batched
209
+ end
210
+
211
+ # Matching a card (not a permanent) by its printed characteristics.
212
+ module CardMatch
213
+ def setup_card(params, errors, path)
214
+ @card = params["card"] && Mechanics.build_entry(:filter, params["card"], errors, "#{path}.card")
215
+ end
216
+
217
+ def card_ok?(engine, card)
218
+ return true unless @card
219
+
220
+ defn = engine.catalog.fetch(card)
221
+ @card.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
222
+ end
223
+
224
+ def card_words = @card ? Triggers.filter_words(@card) : "card"
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever you cast a spell" (optionally only matching spells); "when you cast this spell" (`self`).
7
+ # It triggers once the spell becomes cast (CR 601.2i), so a cast that is cancelled never triggers it.
8
+ class Cast < Trigger
9
+ include ChosenMatch
10
+
11
+ primitive :trigger, :cast,
12
+ description: "A player casts a spell. `controller`: you (default) | opponent | any; `spell`: creature | " \
13
+ "noncreature | instant_or_sorcery, or any spell filter, e.g. {\"spell\": {\"subtypes\": " \
14
+ "[\"saga\"]}} or {\"one_of\": [...]} for \"creature or Spacecraft spell\"; `name` / " \
15
+ "`mana_value`: \"chosen\" (the source's as-it-entered choice); `self: true`: \"when you cast " \
16
+ "this spell\" (it functions on the stack). `targets`: only a spell that targets something " \
17
+ "matching this filter (\"self\": this permanent), e.g. {\"creature\": {\"controller\": " \
18
+ "\"you\"}}. `from` / `not_from`: only a spell cast from (not from) that zone: hand | " \
19
+ "graveyard | exile | library | outside. `single_target: true`: only a spell with exactly " \
20
+ "one target.",
21
+ rules: %w[603.2 601.2i 113.6 202.3e 201.4 607.2 115.1 601.2a 601.2c],
22
+ params: { "controller" => "you | opponent | any", "spell" => "creature | noncreature | instant_or_sorcery | filter",
23
+ "name" => "\"chosen\"", "mana_value" => "\"chosen\"", "self" => "true",
24
+ "targets" => "\"self\" or a target filter", "from" => "zone", "not_from" => "zone",
25
+ "single_target" => "true" },
26
+ example: { "cast" => { "controller" => "you", "spell" => "noncreature" } }
27
+
28
+ SPELLS = %w[creature noncreature instant_or_sorcery].freeze
29
+
30
+ def self.keys = %w[controller spell name mana_value self targets from not_from single_target]
31
+ def event_class = Events::SpellCast
32
+ def own_spell? = @self
33
+
34
+ def setup(params, errors, path)
35
+ @controller = params.fetch("controller", "you")
36
+ @self = params["self"] == true
37
+ raw = params["spell"]
38
+ errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(@controller)
39
+ errors << "#{path}.self: must be true" unless params["self"].nil? || @self
40
+ if raw.is_a?(String) && SPELLS.include?(raw) then @spell = raw
41
+ elsif !raw.nil? && !raw.is_a?(String) then @filter = Mechanics.build_entry(:filter, raw, errors, "#{path}.spell")
42
+ elsif raw then errors << "#{path}.spell: must be one of #{SPELLS.join(", ")} or a spell filter"
43
+ end
44
+ setup_chosen(params, errors, path)
45
+ setup_targets(params, errors, path)
46
+ @single = params["single_target"] == true
47
+ errors << "#{path}.single_target: must be true" unless params["single_target"].nil? || @single
48
+ @zones = CastFrom.build(params, errors, path)
49
+ end
50
+
51
+ def match?(engine, event, source)
52
+ return event.object == source["id"] if @self
53
+
54
+ caster = engine.names.index(event.player)
55
+ return false unless @controller == "any" || (@controller == "you") == (caster == source["controller"])
56
+ return false unless spell_ok?(engine, event, caster, source) && targets_ok?(engine, event, source)
57
+ return false unless @zones.match?((spell = engine.state.objects[event.object]) && engine.cast_origin(spell))
58
+
59
+ chosen_ok?(engine, source, event.card, engine.state.objects[event.object]&.x)
60
+ end
61
+
62
+ # "Those creatures" ({"triggering": "targets"}): the spell's targets that matched `targets` (all of them
63
+ # without it), as they were chosen (CR 601.2c).
64
+ def occurrences(engine, event, source)
65
+ return [] unless match?(engine, event, source)
66
+
67
+ [Triggers.event_facts(engine, event).merge("targets" => matched_targets(engine, event, source))]
68
+ end
69
+
70
+ # "A spell that targets this creature / a creature you control" (CR 115.1, 601.2c): one of the
71
+ # targets chosen as it was cast matches, relative to the trigger's source.
72
+ def setup_targets(params, errors, path)
73
+ raw = params["targets"]
74
+ @targets = raw == "self" ? :self : raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.targets")
75
+ end
76
+
77
+ def targets_ok?(engine, event, source)
78
+ return false if @single && Array(event.targets).flatten.compact.uniq.size != 1 # "with a single target"
79
+
80
+ !@targets || matched_targets(engine, event, source).any?
81
+ end
82
+
83
+ def matched_targets(engine, event, source)
84
+ Array(event.targets).flatten.compact.uniq.select do |ref|
85
+ next true unless @targets
86
+
87
+ @targets == :self ? ref == source["id"] : @targets.match_for?(engine, ref, source["controller"], source["id"])
88
+ end
89
+ end
90
+
91
+ def spell_ok?(engine, event, caster, source)
92
+ return @filter.match_for?(engine, event.object, caster, source["id"]) if @filter # "of the chosen color"
93
+
94
+ types = engine.catalog.fetch(event.card).types
95
+ case @spell
96
+ when "creature" then types.include?(:creature)
97
+ when "noncreature" then !types.include?(:creature)
98
+ when "instant_or_sorcery" then types.intersect?(%i[instant sorcery])
99
+ else true
100
+ end
101
+ end
102
+
103
+ def to_s
104
+ return "When you cast this spell" if @self
105
+
106
+ kind = Triggers.filter_words(@filter) || @spell || "spell"
107
+ "Whenever #{{ "you" => "you cast", "opponent" => "an opponent casts" }.fetch(@controller, "a player casts")} a #{kind}" \
108
+ "#{@zones}#{target_words}#{chosen_words}"
109
+ end
110
+
111
+ def target_words
112
+ return "" unless @targets || @single
113
+
114
+ return " with a single target" if @single && !@targets
115
+
116
+ " that targets #{@targets == :self ? "this" : Triggers.filter_words(@targets)}"
117
+ end
118
+ end
119
+
120
+ # "Whenever a creature you control becomes the target of a spell or ability an opponent controls"
121
+ # (CR 115.1, 603.2): checked once the spell is cast, the ability activated or the triggered ability
122
+ # put on the stack with its targets, so a cancelled cast never triggers it. Each matching object
123
+ # that became a target is one occurrence.
124
+ class BecomesTarget < Trigger
125
+ BY = %w[opponent you any].freeze
126
+ EVENTS = %i[SpellCast AbilityActivated TriggerPutOnStack].freeze
127
+
128
+ primitive :trigger, :becomes_target,
129
+ description: "The subject becomes the target of a spell or ability (controlled by `by`: opponent | you | " \
130
+ "any (default), relative to the source's controller). One trigger per matching object " \
131
+ "targeted.",
132
+ rules: %w[115.1 601.2c 603.2 603.2c],
133
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "by" => BY.join(" | ") },
134
+ example: { "becomes_target" => { "subject" => { "each" => "creature", "controller" => "you" },
135
+ "by" => "opponent" } }
136
+
137
+ def self.keys = %w[subject by]
138
+ def watches?(event) = EVENTS.any? { event.is_a?(Events.const_get(it)) }
139
+
140
+ def setup(params, errors, path)
141
+ @by = params.fetch("by", "any")
142
+ errors << "#{path}.by: must be one of #{BY.join(", ")}" unless BY.include?(@by)
143
+ end
144
+
145
+ def occurrences(engine, event, source)
146
+ player = engine.names.index(event.player)
147
+ return [] unless @by == "any" || (@by == "you") == (player == source["controller"])
148
+
149
+ Array(event.targets).uniq.filter_map do |ref|
150
+ obj = engine.state.objects[ref]
151
+ next unless obj && obj.zone == :battlefield
152
+ next unless @subject.match?(engine, ref, obj.controller, obj.card, source, types: engine.chars(obj).types)
153
+
154
+ { "object" => ref, "player" => player, "card" => obj.card }
155
+ end
156
+ end
157
+
158
+ def match?(engine, event, source) = !occurrences(engine, event, source).empty?
159
+
160
+ def to_s
161
+ "Whenever #{@subject} becomes the target of a spell or ability#{{ "you" => " you control",
162
+ "opponent" => " an opponent controls" }[@by]}"
163
+ end
164
+ end
165
+
166
+ # "Whenever a player activates an ability of a source with ..." (CR 602.2, 605.1): non-mana activated
167
+ # and loyalty abilities only (mana abilities aren't activated through the stack and aren't watched).
168
+ class ActivatesAbility < PlayerTrigger
169
+ include ChosenMatch
170
+
171
+ primitive :trigger, :activates_ability,
172
+ description: "A player (`controller`: you (default) | opponent | any) activates an ability that isn't a " \
173
+ "mana ability. `source`: optional filter on the source's card; `name: \"chosen\"`: only a " \
174
+ "source with the name chosen as this permanent entered. `exhaust: true`: only an exhaust " \
175
+ "ability (\"whenever you activate an exhaust ability\", CR 702.177). `boast: true`: only a " \
176
+ "boast ability (\"whenever a creature you control boasts\", CR 702.142b).",
177
+ rules: %w[602.2 605.1a 603.2 201.4 702.177a 702.142b],
178
+ params: { "controller" => PLAYERS.join(" | "), "source" => "card filter", "name" => "\"chosen\"",
179
+ "exhaust" => "true: only exhaust abilities", "boast" => "true: only boast abilities" },
180
+ example: { "activates_ability" => { "controller" => "opponent" } }
181
+
182
+ def self.keys = %w[controller source name exhaust boast]
183
+ KINDS = { "exhaust" => :exhaust?, "boast" => :boast? }.freeze
184
+ def event_class = Events::AbilityActivated
185
+
186
+ def setup(params, errors, path)
187
+ super
188
+ @source = params["source"] && Mechanics.build_entry(:filter, params["source"], errors, "#{path}.source")
189
+ setup_chosen(params, errors, path)
190
+ @kinds = KINDS.select { |key, _| params[key] == true }.values
191
+ KINDS.each_key { errors << "#{path}.#{it}: must be true or false" unless [nil, true, false].include?(params[it]) }
192
+ end
193
+
194
+ def match?(engine, event, source)
195
+ return false unless player_ok?(engine, event.player, source) && chosen_ok?(engine, source, event.card)
196
+ return false unless @kinds.all? { kind?(engine, event, it) }
197
+ return true unless @source
198
+
199
+ defn = engine.catalog.fetch(event.card)
200
+ @source.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
201
+ end
202
+
203
+ def to_s
204
+ what = "#{@kinds.map { "#{it.to_s.delete("?")} " }.join}ability"
205
+ "Whenever #{who} activate#{"s" unless @controller == "you"} #{what.start_with?("b") ? "a" : "an"} #{what}#{chosen_words}"
206
+ end
207
+
208
+ private
209
+
210
+ # The activated ability on the stack is an exhaust ability (CR 702.177a) / a boast ability (CR 702.142b).
211
+ def kind?(engine, event, flag)
212
+ obj = engine.state.objects[event.object]
213
+ ability = obj && engine.stack_ability(obj)
214
+ ability.respond_to?(flag) && ability.public_send(flag)
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever ~ attacks" / "Whenever ~ attacks for the first time each turn".
7
+ class Attacks < Trigger
8
+ primitive :trigger, :attacks,
9
+ description: "The subject is declared as an attacker. `first_time_this_turn: true`: only the first " \
10
+ "time that creature attacks this turn (later combats of the turn don't trigger it).",
11
+ rules: %w[508.3a 603.2],
12
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "first_time_this_turn" => "boolean" },
13
+ example: { "attacks" => { "subject" => "self", "first_time_this_turn" => true } }
14
+
15
+ def self.keys = %w[subject first_time_this_turn]
16
+ def event_class = Events::AttackerDeclared
17
+
18
+ def setup(params, errors, path)
19
+ @first_time = params["first_time_this_turn"] == true
20
+ errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(params["first_time_this_turn"])
21
+ end
22
+
23
+ def match?(engine, event, source)
24
+ obj = engine.state.objects[event.object] or return false
25
+ return false if @first_time && engine.attacks_this_turn(event.object) != 1
26
+
27
+ @subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
28
+ end
29
+
30
+ def to_s = "Whenever #{@subject} attacks#{" for the first time each turn" if @first_time}"
31
+ end
32
+
33
+ # "Whenever you attack" (CR 508.3d): once per combat, if one or more creatures attack.
34
+ # "Whenever you attack with creatures with total power 12 or greater for the first time each turn".
35
+ class PlayerAttacks < PlayerTrigger
36
+ primitive :trigger, :player_attacks,
37
+ description: "A player declares one or more attackers (once per combat, not per creature). " \
38
+ "`controller`: you (default) | opponent | any. `attacking_power` ({\"at_least\": n}): " \
39
+ "only when the attacking creatures' total power is at least n as they're declared; " \
40
+ "with `first_time_this_turn: true`, only the first combat this turn in which it was.",
41
+ rules: %w[508.3d 508.1 603.2],
42
+ params: { "controller" => PLAYERS.join(" | "), "attacking_power" => "{\"at_least\": n}",
43
+ "first_time_this_turn" => "boolean" },
44
+ example: { "player_attacks" => { "controller" => "you", "attacking_power" => { "at_least" => 12 },
45
+ "first_time_this_turn" => true } }
46
+
47
+ def self.keys = %w[controller attacking_power first_time_this_turn]
48
+ def event_class = Events::AttacksDeclared
49
+
50
+ def setup(params, errors, path)
51
+ super
52
+ power = params["attacking_power"]
53
+ @at_least = power.is_a?(Hash) && power.keys == ["at_least"] && power["at_least"].is_a?(Integer) ? power["at_least"] : nil
54
+ errors << "#{path}.attacking_power: must be {\"at_least\": n}" if power && @at_least.nil?
55
+ @first_time = params["first_time_this_turn"] == true
56
+ errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(params["first_time_this_turn"])
57
+ end
58
+
59
+ # The attackers' total power was recorded as they were declared (Engine::TurnHistory, "attack_powers").
60
+ def match?(engine, event, source)
61
+ return false unless player_ok?(engine, event.player, source)
62
+
63
+ powers = engine.turn_log(engine.names.index(event.player)).fetch("attack_powers", [])
64
+ wanted = ->(power) { power >= (@at_least || 0) }
65
+ return false unless wanted.call(powers.last.to_i)
66
+
67
+ !@first_time || powers[0...-1].none?(&wanted)
68
+ end
69
+
70
+ def to_s
71
+ power = " with total power #{@at_least} or greater" if @at_least
72
+ "Whenever #{who} attack#{"s" unless @controller == "you"}#{power}#{" for the first time each turn" if @first_time}"
73
+ end
74
+ end
75
+
76
+ # "Whenever ~ blocks" / "Whenever this creature blocks a creature with flying" (CR 509.3a-b, 509.3f).
77
+ class Blocks < Trigger
78
+ primitive :trigger, :blocks,
79
+ description: "The subject is declared as a blocker. With `blocking`, only when the attacking creature " \
80
+ "it blocks matches that filter (\"blocks a creature with flying\"). {\"triggering\": " \
81
+ "\"object\"} is the blocking creature.",
82
+ rules: %w[509.3a 509.3b 509.3f 603.2],
83
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "blocking" => "filter" },
84
+ example: { "blocks" => { "blocking" => { "creature" => { "keywords" => ["flying"] } } } }
85
+
86
+ def self.keys = %w[subject blocking]
87
+ def event_class = Events::BlockerDeclared
88
+
89
+ def setup(params, errors, path)
90
+ @blocking = params["blocking"] && Mechanics.build_entry(:filter, params["blocking"], errors, "#{path}.blocking")
91
+ end
92
+
93
+ def match?(engine, event, source)
94
+ obj = engine.state.objects[event.object] or return false
95
+ return false if @blocking && !@blocking.match_for?(engine, event.attacker, source["controller"], source["id"])
96
+
97
+ @subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
98
+ end
99
+
100
+ def to_s = "Whenever #{@subject} blocks#{" a #{Triggers.filter_words(@blocking)}" if @blocking}"
101
+ end
102
+
103
+ # "Whenever ~ becomes blocked" (CR 509.3c): once per combat, however many creatures block it.
104
+ class BecomesBlocked < Trigger
105
+ primitive :trigger, :becomes_blocked,
106
+ description: "The subject becomes blocked: once per combat when blockers are declared, even if " \
107
+ "several creatures block it.",
108
+ rules: %w[509.3c 509.1h 603.2e],
109
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}" },
110
+ example: { "becomes_blocked" => { "subject" => "self" } }
111
+
112
+ def event_class = Events::BecameBlocked
113
+
114
+ def match?(engine, event, source)
115
+ obj = engine.state.objects[event.object] or return false
116
+ @subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
117
+ end
118
+
119
+ def to_s = "Whenever #{@subject} becomes blocked"
120
+ end
121
+ end
122
+ end
123
+ end