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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # Which damage a damage trigger counts: `combat: true` only combat damage, `noncombat: true` only
7
+ # noncombat damage (CR 120.2a-b).
8
+ module DamageKind
9
+ def setup_kind(params, errors, path)
10
+ @combat = params.fetch("combat", false)
11
+ @noncombat = params.fetch("noncombat", false)
12
+ %w[combat noncombat].each do |key|
13
+ errors << "#{path}.#{key}: must be true or false" unless [true, false].include?(params.fetch(key, false))
14
+ end
15
+ errors << "#{path}: combat and noncombat can't both be true" if @combat == true && @noncombat == true
16
+ end
17
+
18
+ def kind_ok?(event) = !(@combat && !event.combat) && !(@noncombat && event.combat)
19
+ def kind_words = ("combat " if @combat) || ("noncombat " if @noncombat)
20
+ end
21
+
22
+ # "Whenever ~ deals combat damage to a player" / "Whenever a source you control deals noncombat damage
23
+ # to an opponent".
24
+ class DealsDamage < Trigger
25
+ include DamageKind
26
+
27
+ RECIPIENTS = %w[player opponent creature any].freeze
28
+
29
+ primitive :trigger, :deals_damage,
30
+ description: "The subject deals damage. `combat: true` only counts combat damage, `noncombat: true` " \
31
+ "only noncombat damage; `to`: player | opponent (of the source's controller) | creature " \
32
+ "| any (default). Triggers once per damage event (each combat damage step is one, so " \
33
+ "double strike can trigger twice).",
34
+ rules: %w[510.2 603.2 603.2c 120.2a 120.2b 120.3],
35
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "combat" => "true | false",
36
+ "noncombat" => "true | false", "to" => RECIPIENTS.join(" | ") },
37
+ example: { "deals_damage" => { "combat" => true, "to" => "player" } }
38
+
39
+ def self.keys = %w[subject combat noncombat to]
40
+ def event_class = Events::DamageDealt
41
+
42
+ def setup(params, errors, path)
43
+ setup_kind(params, errors, path)
44
+ @to = params.fetch("to", "any")
45
+ errors << "#{path}.to: must be one of #{RECIPIENTS.join(", ")}" unless RECIPIENTS.include?(@to)
46
+ end
47
+
48
+ def match?(engine, event, source)
49
+ return false unless kind_ok?(event) && recipient_ok?(engine, event.target, source)
50
+
51
+ obj = engine.state.objects[event.source] or return false
52
+ @subject.match?(engine, event.source, obj.controller, obj.card, source, types: engine.chars(obj).types)
53
+ end
54
+
55
+ def recipient_ok?(engine, ref, source)
56
+ index = Filters.player_index(engine, ref)
57
+ case @to
58
+ when "player" then !index.nil?
59
+ when "opponent" then !index.nil? && index != source["controller"]
60
+ when "creature" then engine.state.objects.key?(ref) && engine.chars(ref).creature?
61
+ else true
62
+ end
63
+ end
64
+
65
+ def to_s = "Whenever #{@subject} deals #{kind_words}damage#{" to a#{"n" if @to == "opponent"} #{@to}" unless @to == "any"}"
66
+ end
67
+
68
+ # "Whenever this creature is dealt damage", "Whenever enchanted creature is dealt damage", "Whenever an
69
+ # opponent is dealt noncombat damage" (CR 120.3, 603.2). "That creature" is the one dealt damage and
70
+ # "that much" the damage dealt.
71
+ class DealtDamage < Trigger
72
+ include DamageKind
73
+ include Batched
74
+
75
+ primitive :trigger, :dealt_damage,
76
+ description: "Damage is dealt to the `subject` (a permanent: \"self\" by default, \"attached\" or " \
77
+ "{\"each\": filter, ...}) or, with `player` (you | opponent | any), to a player. " \
78
+ "`combat` / `noncombat`: only that damage. {\"triggering\": \"object\"} is the permanent " \
79
+ "dealt damage, {\"triggering\": \"player\"} the player, {\"triggering\": \"amount\"} how " \
80
+ "much. `batched: true`: \"one or more ...\" (once per damage event, CR 603.2c).",
81
+ rules: %w[120.3 603.2 603.2c 120.2a 120.2b],
82
+ params: { "subject" => "\"self\" (default), \"attached\" or {\"each\": filter, ...}",
83
+ "player" => PLAYERS.join(" | "), "combat" => "true | false", "noncombat" => "true | false",
84
+ "batched" => "true | false" },
85
+ example: { "dealt_damage" => { "subject" => "self" } }
86
+
87
+ def self.keys = %w[subject player combat noncombat batched]
88
+ def event_class = Events::DamageDealt
89
+
90
+ def setup(params, errors, path)
91
+ setup_kind(params, errors, path)
92
+ setup_batched(params, errors, path)
93
+ @player = params["player"]
94
+ errors << "#{path}.player: must be one of #{PLAYERS.join(", ")}" unless @player.nil? || PLAYERS.include?(@player)
95
+ errors << "#{path}: use subject or player, not both" if @player && params.key?("subject")
96
+ end
97
+
98
+ def match?(engine, event, source)
99
+ return false unless kind_ok?(event)
100
+
101
+ index = Filters.player_index(engine, event.target)
102
+ return player_ok?(index, source) if index
103
+ return false if @player
104
+
105
+ obj = engine.state.objects[event.target] or return false
106
+ @subject.match?(engine, event.target, obj.controller, obj.card, source, types: engine.chars(obj).types)
107
+ end
108
+
109
+ def player_ok?(index, source)
110
+ return false unless @player
111
+
112
+ @player == "any" || (@player == "you") == (index == source["controller"])
113
+ end
114
+
115
+ def occurrences(engine, event, source)
116
+ return [] unless match?(engine, event, source)
117
+
118
+ facts = { "amount" => event.amount }
119
+ index = Filters.player_index(engine, event.target)
120
+ [index ? facts.merge("player" => index) : facts.merge("object" => event.target, "card" => engine.object(event.target).card)]
121
+ end
122
+
123
+ def to_s
124
+ who = @player ? { "you" => "you are", "opponent" => "an opponent is", "any" => "a player is" }[@player] : "#{@subject} is"
125
+ "Whenever #{who} dealt #{kind_words}damage"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever you face a dilemma" (CR 700.17): a player faces a dilemma whenever they
7
+ # choose one or more modes for a spell or ability. The engine records it when the spell or ability
8
+ # with chosen modes is cast, activated or put on the stack, so a cancelled cast never counts.
9
+ class FacesDilemma < Trigger
10
+ primitive :trigger, :faces_dilemma,
11
+ description: "A player faces a dilemma: they chose one or more modes for a spell or ability " \
12
+ "(cast, activated, or a triggered ability put on the stack). " \
13
+ "`controller`: you (default) | opponent | any. CR 700.17.",
14
+ rules: %w[700.17 700.2 601.2b 603.3c],
15
+ params: { "controller" => "you | opponent | any" },
16
+ example: { "faces_dilemma" => {} }
17
+
18
+ CONTROLLERS = %w[you opponent any].freeze
19
+
20
+ def self.keys = %w[controller]
21
+ def event_class = Events::DilemmaFaced
22
+
23
+ def setup(params, errors, path)
24
+ @controller = params.fetch("controller", "you")
25
+ errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}" unless CONTROLLERS.include?(@controller)
26
+ end
27
+
28
+ def match?(engine, event, source)
29
+ who = engine.names.index(event.player)
30
+ @controller == "any" || (@controller == "you") == (who == source["controller"])
31
+ end
32
+
33
+ def to_s = "Whenever #{{ "you" => "you face", "opponent" => "an opponent faces" }.fetch(@controller, "a player faces")} a dilemma"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever you draw a card" (CR 121.2): once for each card drawn.
7
+ class Draws < PlayerTrigger
8
+ primitive :trigger, :draws,
9
+ description: "A player draws a card; triggers once per card (draws happen one at a time). " \
10
+ "`controller`: you (default) | opponent | any.",
11
+ rules: %w[121.1 121.2],
12
+ params: { "controller" => PLAYERS.join(" | ") },
13
+ example: { "draws" => { "controller" => "you" } }
14
+
15
+ def event_class = Events::ZoneChanged
16
+
17
+ def match?(engine, event, source)
18
+ event.from == "library" && event.to == "hand" && event.reason == "draw" && player_ok?(engine, event.owner, source)
19
+ end
20
+
21
+ def to_s = "Whenever #{who} draw#{"s" unless @controller == "you"} a card"
22
+ end
23
+
24
+ # "Whenever you discard a card" / "one or more cards" (CR 701.9a). A cycled card is discarded too
25
+ # (CR 702.29d).
26
+ class Discards < PlayerTrigger
27
+ include Batched
28
+ include CardMatch
29
+
30
+ primitive :trigger, :discards,
31
+ description: "A player (`controller`: you (default) | opponent | any) discards a card (optional `card` " \
32
+ "filter). `batched: true`: \"one or more cards\", once per event batch; then \"that many\" " \
33
+ "is how many were discarded.",
34
+ rules: %w[701.9a 603.2c 702.29d],
35
+ params: { "controller" => PLAYERS.join(" | "), "card" => "card filter", "batched" => "true | false" },
36
+ example: { "discards" => { "controller" => "you", "batched" => true } }
37
+
38
+ def self.keys = %w[controller card batched]
39
+ def event_class = Events::ZoneChanged
40
+
41
+ def setup(params, errors, path)
42
+ super
43
+ setup_batched(params, errors, path)
44
+ setup_card(params, errors, path)
45
+ end
46
+
47
+ def match?(engine, event, source)
48
+ event.reason == "discard" && event.from == "hand" && player_ok?(engine, event.owner, source) && card_ok?(engine, event.card)
49
+ end
50
+
51
+ def to_s
52
+ "Whenever #{who} discard#{"s" unless @controller == "you"} #{@batched ? "one or more #{card_words}s" : "a #{card_words}"}"
53
+ end
54
+ end
55
+
56
+ # "Whenever you cycle a card" (CR 702.29a): a player activates a cycling (or typecycling) ability.
57
+ class Cycles < PlayerTrigger
58
+ include CardMatch
59
+
60
+ primitive :trigger, :cycles,
61
+ description: "A player (`controller`: you (default) | opponent | any) cycles a card (activates its " \
62
+ "cycling or typecycling ability; optional `card` filter).",
63
+ rules: %w[702.29a 702.29e],
64
+ params: { "controller" => PLAYERS.join(" | "), "card" => "card filter" },
65
+ example: { "cycles" => { "controller" => "you" } }
66
+
67
+ def self.keys = %w[controller card]
68
+ def event_class = Events::AbilityActivated
69
+
70
+ def setup(params, errors, path)
71
+ super
72
+ setup_card(params, errors, path)
73
+ end
74
+
75
+ def match?(engine, event, source)
76
+ ability = engine.catalog.fetch(event.card).ability(event.ability)
77
+ ability.respond_to?(:cycling?) && ability.cycling? && player_ok?(engine, event.player, source) &&
78
+ card_ok?(engine, event.card)
79
+ end
80
+
81
+ def to_s = "Whenever #{who} cycle#{"s" unless @controller == "you"} a #{card_words}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever you scry" / "Whenever you surveil" (CR 701.22, 701.25): once the whole action is done
7
+ # (CR 701.25d); scry 0 / surveil 0 isn't one (CR 701.22b, 701.25c). "Whenever you scry or surveil" is
8
+ # `any_of` both.
9
+ class KeywordActionTrigger < PlayerTrigger
10
+ def event_class = Events::KeywordAction
11
+ def match?(engine, event, source) = event.action == self.class.action && player_ok?(engine, event.player, source)
12
+ def to_s = "Whenever #{who} #{self.class.action}#{"s" unless @controller == "you"}"
13
+ end
14
+
15
+ # "Whenever you scry".
16
+ class Scries < KeywordActionTrigger
17
+ primitive :trigger, :scries,
18
+ description: "A player (`controller`: you (default) | opponent | any) scries (not scry 0). Triggers " \
19
+ "after the scry is complete.",
20
+ rules: %w[701.22a 701.22b],
21
+ params: { "controller" => PLAYERS.join(" | ") },
22
+ example: { "any_of" => [{ "scries" => {} }, { "surveils" => {} }] }
23
+
24
+ def self.action = "scry"
25
+ end
26
+
27
+ # "Whenever you surveil".
28
+ class Surveils < KeywordActionTrigger
29
+ primitive :trigger, :surveils,
30
+ description: "A player (`controller`: you (default) | opponent | any) surveils (not surveil 0). " \
31
+ "Triggers after the surveil is complete.",
32
+ rules: %w[701.25a 701.25c 701.25d],
33
+ params: { "controller" => PLAYERS.join(" | ") },
34
+ example: { "surveils" => { "controller" => "you" } }
35
+
36
+ def self.action = "surveil"
37
+ end
38
+
39
+ # "Whenever you earthbend / waterbend / airbend / firebend" (CR 701.66b, 701.67c, 701.65b, 702.189b): the
40
+ # bending happened (the earthbend delayed trigger was created, a waterbend cost was paid, objects were
41
+ # exiled by an airbend, a firebending ability resolved). "Whenever you waterbend, earthbend, firebend, or
42
+ # airbend" is `any_of` the four.
43
+ {
44
+ earthbends: ["earthbend", %w[701.66b]], waterbends: ["waterbend", %w[701.67c]],
45
+ airbends: ["airbend", %w[701.65b]], firebends: ["firebend", %w[702.189b]]
46
+ }.each do |name, (verb, rules)|
47
+ klass = Class.new(KeywordActionTrigger) { define_singleton_method(:action) { verb } }
48
+ Triggers.const_set(name.to_s.capitalize, klass)
49
+ klass.primitive :trigger, name,
50
+ description: "A player (`controller`: you (default) | opponent | any) #{verb}s.",
51
+ rules: rules, params: { "controller" => PLAYERS.join(" | ") }, example: { name.to_s => {} }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever you gain life" (CR 119.9): once per life gain event; gaining 0 life isn't one.
7
+ class GainsLife < PlayerTrigger
8
+ primitive :trigger, :gains_life,
9
+ description: "A player gains life (once per life gain event; lifelink combat damage is one event " \
10
+ "per source). `controller`: you (default) | opponent | any.",
11
+ rules: %w[119.9 120.3f],
12
+ params: { "controller" => PLAYERS.join(" | ") },
13
+ example: { "gains_life" => { "controller" => "you" } }
14
+
15
+ def event_class = Events::LifeChanged
16
+ def match?(engine, event, source) = event.gain.positive? && player_ok?(engine, event.player, source)
17
+ def to_s = "Whenever #{who} gain#{"s" unless @controller == "you"} life"
18
+ end
19
+
20
+ # "Whenever an opponent loses life".
21
+ class LosesLife < PlayerTrigger
22
+ primitive :trigger, :loses_life,
23
+ description: "A player loses life (damage to a player is life loss, CR 120.3a). " \
24
+ "`controller`: you (default) | opponent | any.",
25
+ rules: %w[119.3 120.3a],
26
+ params: { "controller" => PLAYERS.join(" | ") },
27
+ example: { "loses_life" => { "controller" => "opponent" } }
28
+
29
+ def event_class = Events::LifeChanged
30
+ def match?(engine, event, source) = event.loss.positive? && player_ok?(engine, event.player, source)
31
+ def to_s = "Whenever #{who} lose#{"s" unless @controller == "you"} life"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "At the beginning of your upkeep" / "... of each end step".
7
+ class StepBegins < Trigger
8
+ STEPS = %w[untap upkeep draw precombat_main beginning_of_combat declare_attackers declare_blockers
9
+ combat_damage end_of_combat postcombat_main end cleanup].freeze
10
+ WHOSE = %w[your each opponent].freeze
11
+
12
+ primitive :trigger, :step_begins,
13
+ description: "A step begins. `whose`: \"your\" (default), \"each\" or \"opponent\".",
14
+ rules: %w[603.6b 503.1a 513.1a],
15
+ params: { "step" => "upkeep | draw | precombat_main | end | ...", "whose" => WHOSE.join(" | ") },
16
+ example: { "step_begins" => { "step" => "upkeep", "whose" => "your" } }
17
+
18
+ def self.keys = %w[step whose]
19
+ def event_class = Events::StepBegan
20
+
21
+ def setup(params, errors, path)
22
+ @step = params["step"]
23
+ @whose = params.fetch("whose", "your")
24
+ errors << "#{path}.step: must be one of #{STEPS.join(", ")}" unless STEPS.include?(@step)
25
+ errors << "#{path}.whose: must be one of #{WHOSE.join(", ")}" unless WHOSE.include?(@whose)
26
+ end
27
+
28
+ def match?(engine, event, source)
29
+ return false unless event.step == @step
30
+
31
+ active = engine.state.active
32
+ @whose == "each" || (@whose == "your") == (active == source["controller"])
33
+ end
34
+
35
+ def to_s = "At the beginning of #{@whose == "each" ? "each" : @whose} #{@step.tr("_", " ")}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "Whenever ~ becomes untapped" (CR 603.2e): not a permanent that enters untapped.
7
+ class BecomesUntapped < Trigger
8
+ primitive :trigger, :becomes_untapped,
9
+ description: "The subject becomes untapped (in the untap step or by an effect). A permanent that " \
10
+ "enters untapped doesn't become untapped.",
11
+ rules: %w[603.2e 701.26b],
12
+ params: { "subject" => "\"self\" (default), \"attached\" or {\"each\": filter, ...}" },
13
+ example: { "becomes_untapped" => { "subject" => "attached" } }
14
+
15
+ def event_class = Events::Untapped
16
+
17
+ def match?(engine, event, source)
18
+ obj = engine.state.objects[event.object] or return false
19
+ @subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
20
+ end
21
+
22
+ def to_s = "Whenever #{@subject} becomes untapped"
23
+ end
24
+
25
+ # "Whenever ~ becomes tapped" (CR 603.2e): only a permanent already on the battlefield changing from
26
+ # untapped to tapped; entering tapped isn't becoming tapped. `for: "teamwork"`: "becomes tapped to pay a
27
+ # teamwork cost" (CR 702.194a), known once the spell is cast.
28
+ class BecomesTapped < Trigger
29
+ COSTS = %w[teamwork].freeze
30
+
31
+ primitive :trigger, :becomes_tapped,
32
+ description: "The subject becomes tapped (for any reason: a cost, attacking, an effect). A " \
33
+ "permanent that enters tapped doesn't become tapped. `for: \"teamwork\"`: only when " \
34
+ "it's tapped to pay a teamwork cost (it triggers once the spell is cast).",
35
+ rules: %w[603.2e 701.26a 702.194a],
36
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "for" => COSTS.join(" | ") },
37
+ example: { "becomes_tapped" => { "subject" => "self" } }
38
+
39
+ def self.keys = %w[subject for]
40
+ def event_class = @for ? Events::TappedForCost : Events::Tapped
41
+
42
+ def setup(params, errors, path)
43
+ @for = params["for"]
44
+ errors << "#{path}.for: must be one of #{COSTS.join(", ")}" unless @for.nil? || COSTS.include?(@for)
45
+ end
46
+
47
+ def match?(engine, event, source)
48
+ obj = engine.state.objects[event.object] or return false
49
+ @subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
50
+ end
51
+
52
+ def to_s = "Whenever #{@subject} becomes tapped#{" to pay a #{@for} cost" if @for}"
53
+ end
54
+
55
+ # "Whenever one or more [kind] counters are put on ~" (CR 122.6): counters put on a permanent, including
56
+ # the counters it enters with. Once per counter-placing event.
57
+ class CountersPlaced < Trigger
58
+ primitive :trigger, :counters_placed,
59
+ description: "One or more counters (only `counter`, if given) are put on the subject, including the " \
60
+ "counters it enters the battlefield with. Triggers once per event, however many counters.",
61
+ rules: %w[122.6 603.2 603.2c],
62
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "counter" => "counter name",
63
+ "first_time_this_turn" => "true: only the first time each turn such counters are put on " \
64
+ "that permanent (an earlier placement counts even if nothing " \
65
+ "could trigger then)" },
66
+ example: { "counters_placed" => { "counter" => "+1/+1" } }
67
+
68
+ def self.keys = %w[subject counter first_time_this_turn]
69
+ def watches?(event) = event.is_a?(Events::CountersChanged) || event.is_a?(Events::ZoneChanged)
70
+
71
+ def setup(params, errors, path)
72
+ @counter = params["counter"] && Counters.parse(params["counter"], errors, "#{path}.counter")
73
+ @first_time = params["first_time_this_turn"]
74
+ errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(@first_time)
75
+ end
76
+
77
+ def occurrences(engine, event, source)
78
+ id, amount = placed(engine, event)
79
+ return [] unless amount&.positive?
80
+ return [] if @first_time && engine.counter_placements_this_turn(id, @counter) != 1
81
+
82
+ obj = engine.object(id)
83
+ return [] unless @subject.match?(engine, id, obj.controller, obj.card, source, types: engine.chars(obj).types)
84
+
85
+ [{ "object" => id, "player" => obj.controller, "amount" => amount, "card" => obj.card }]
86
+ end
87
+
88
+ def match?(engine, event, source) = !occurrences(engine, event, source).empty?
89
+
90
+ def to_s
91
+ first = " for the first time each turn" if @first_time
92
+ "Whenever one or more #{"#{@counter} " if @counter}counters are put on #{@subject}#{first}"
93
+ end
94
+
95
+ private
96
+
97
+ # [object id, how many matching counters were put on it] for this event, or nil.
98
+ def placed(engine, event)
99
+ if event.is_a?(Events::CountersChanged)
100
+ return nil if @counter && event.counter != @counter
101
+
102
+ [event.object, event.to - event.from]
103
+ elsif event.to == "battlefield" && (obj = engine.state.objects[event.new_id])
104
+ counts = @counter ? [obj.counters.fetch(@counter, 0)] : obj.counters.values
105
+ [obj.id, counts.sum]
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end