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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Keywords a spell on the stack has besides its own (CR 113.6, 611.3a): granted by a resolved effect ("those
6
+ # spells gain wither", a `grant` effect on the spell) or by a static ability of a permanent ("Instant and
7
+ # sorcery spells you cast from your hand have cascade", ability `spell_keywords`). A keyword that stands for
8
+ # an ability brings that ability (CR 702.85a: a granted cascade triggers as the spell is cast).
9
+ module SpellGrants
10
+ def with_spell_grants(obj, base)
11
+ return base if obj.ability? || @granting&.include?(obj.id) # a static's filter reads the spell's own keywords
12
+
13
+ keywords = granting(obj.id) { granted_spell_keywords(obj) } - base.keywords
14
+ return base if keywords.empty?
15
+
16
+ base.with(keywords: (base.keywords + keywords).freeze,
17
+ abilities: (base.abilities + keywords.flat_map { CardDefinition.keyword_instances(it) }).freeze)
18
+ end
19
+
20
+ private
21
+
22
+ def granting(id)
23
+ (@granting ||= []) << id
24
+ yield
25
+ ensure
26
+ @granting.delete(id)
27
+ end
28
+
29
+ def granted_spell_keywords(obj)
30
+ from_effects = state.effects.select { it["kind"] == "grant" && it["affected"].include?(obj.id) }
31
+ .flat_map { it["keywords"].map(&:to_sym) }
32
+ from_statics = state.battlefield.flat_map do |id|
33
+ source = object(id)
34
+ next [] unless source.controller == obj.controller
35
+
36
+ chars(source).abilities.flat_map do |inst|
37
+ inst.ability.kind == :spell_keywords && inst.ability.applies_to?(self, obj, source) ? inst.ability.keywords : []
38
+ end
39
+ end
40
+ (from_effects + from_statics).uniq
41
+ end
42
+ end
43
+
44
+ include SpellGrants
45
+ end
46
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # State-based actions (CR 704) and the end of the game (CR 104).
6
+ module StateBasedActions
7
+ def check_state_based_actions!
8
+ loop do
9
+ check_player_state_based_actions!
10
+ break if state.over? || !check_permanent_state_based_actions!
11
+ end
12
+ end
13
+
14
+ def check_player_state_based_actions!
15
+ state.players.each_with_index do |pl, i|
16
+ next if pl.lost
17
+
18
+ reason = if loses_for_life?(i) then "life" # CR 704.5a (unless an effect says otherwise)
19
+ elsif pl.drew_from_empty then "empty_library" # CR 704.5b
20
+ elsif pl.poison >= 10 then "poison" # CR 704.5c
21
+ end
22
+ pl.drew_from_empty = false
23
+ lose!(i, reason) if reason
24
+ end
25
+ end_game_if_decided!
26
+ end
27
+
28
+ def concede!(index)
29
+ lose!(index, "concede") # CR 104.3a
30
+ end_game_if_decided!
31
+ end
32
+
33
+ def lose!(index, reason)
34
+ player(index).lost = reason
35
+ emit Events::PlayerLost.new(player: name(index), reason: reason)
36
+ end
37
+
38
+ # CR 104.2a: last player standing wins; CR 104.4a: all players losing at once is a draw.
39
+ def end_game_if_decided!
40
+ return if state.over?
41
+
42
+ losers = state.players.select(&:lost)
43
+ return if losers.empty?
44
+
45
+ survivors = state.players.reject(&:lost)
46
+ winner = survivors.size == 1 ? survivors.first.name : nil
47
+ reason = losers.map(&:lost).uniq.join(",")
48
+ state.result = { "winner" => winner, "reason" => reason }
49
+ ask!(nil)
50
+ state.frames.clear
51
+ emit Events::GameEnded.new(winner: winner, reason: reason)
52
+ end
53
+
54
+ # CR 704.5d, 704.5f, 704.5g, 704.5i, 704.5m, 704.5n, 704.5q. All applicable actions happen
55
+ # simultaneously (CR 704.3). Returns true when anything happened, so the check repeats.
56
+ def check_permanent_state_based_actions!
57
+ ceased = remove_tokens_outside_battlefield! # CR 704.5d
58
+ ceased = annihilate_counters! || ceased # CR 704.5q
59
+ ceased = unattach_illegal_attachments! || ceased # CR 704.5n, 704.5p
60
+ ceased = start_engines! || ceased # CR 704.5aa (Engine::Speed)
61
+ ceased = tell_enduring_stories! || ceased # CR 702.195a (Engine::Designations)
62
+ doomed = nil
63
+ state.battlefield.each do |id|
64
+ obj = object(id)
65
+ defn = chars(obj)
66
+ reason = if defn.creature? then creature_sba(obj)
67
+ elsif defn.planeswalker? && loyalty(obj) <= 0 then "sba_loyalty"
68
+ end
69
+ reason ||= "sba_aura" if illegal_aura?(obj) # CR 704.5m
70
+ reason ||= "sacrifice" if saga_complete?(obj) # CR 704.5s: its controller sacrifices it
71
+ (doomed ||= []) << [id, reason] if reason
72
+ end
73
+ return ceased unless doomed
74
+
75
+ simultaneously { doomed.each { |id, reason| move!(id, :graveyard, reason: reason) } } # CR 704.3, 603.10a
76
+ true
77
+ end
78
+
79
+ # CR 704.5q, 122.3: N +1/+1 and N -1/-1 counters are removed, N the smaller count.
80
+ def annihilate_counters!
81
+ hit = state.battlefield.filter_map do |id|
82
+ obj = object(id)
83
+ n = [obj.counters.fetch("+1/+1", 0), obj.counters.fetch("-1/-1", 0)].min
84
+ [obj, n] if n.positive?
85
+ end
86
+ hit.each do |obj, n|
87
+ remove_counters!(obj, "+1/+1", n)
88
+ remove_counters!(obj, "-1/-1", n)
89
+ end
90
+ !hit.empty?
91
+ end
92
+
93
+ # CR 704.5d / 111.7: a token in a zone other than the battlefield ceases to exist. Phased-out tokens are
94
+ # still on the battlefield (CR 702.26d), see Engine::Copies#ceases_to_exist?.
95
+ def remove_tokens_outside_battlefield!
96
+ gone = state.objects.values.select { ceases_to_exist?(it) } # and copies, CR 707.10a (Engine::Copies)
97
+ gone.each do |obj|
98
+ state.zone_list(obj.zone, obj.owner).delete(obj.id)
99
+ state.objects.delete(obj.id)
100
+ emit Events::TokenCeased.new(object: obj.id, card: obj.card, zone: obj.zone.to_s)
101
+ end
102
+ !gone.empty?
103
+ end
104
+
105
+ # CR 704.5j: the legend rule needs a choice, so it runs as a frame before priority.
106
+ def legend_rule_first!(index)
107
+ return false if state.frames.any? { it["type"] == "legend_rule" }
108
+
109
+ conflict = legend_conflicts.first or return false
110
+ state.frames << { "type" => "legend_rule", "then" => index, "player" => conflict[0], "name" => conflict[1] }
111
+ ask!(nil)
112
+ true
113
+ end
114
+
115
+ # [[controller, card name, [ids]]] for every legend-rule violation.
116
+ def legend_conflicts
117
+ groups = state.battlefield.group_by do |id|
118
+ c = chars(id)
119
+ c.legendary? ? [object(id).controller, c.name] : nil
120
+ end
121
+ groups.filter_map { |key, ids| [key[0], key[1], ids] if key && ids.size > 1 }
122
+ end
123
+
124
+ def legend_candidates(frame)
125
+ legend_conflicts.find { |player, name, _| player == frame["player"] && name == frame["name"] }&.last || []
126
+ end
127
+
128
+ def step_legend_rule!(frame)
129
+ candidates = legend_candidates(frame)
130
+ if candidates.size > 1 && !frame["keep"]
131
+ ask!("kind" => "legend_rule", "player" => frame["player"], "name" => frame["name"])
132
+ return :wait
133
+ end
134
+ (candidates - [frame["keep"]]).each { move!(it, :graveyard, reason: "sba_legend_rule") }
135
+ give_priority!(frame["then"]) # state-based actions are checked again (CR 704.3)
136
+ :done
137
+ end
138
+
139
+ def keep_legend!(id)
140
+ state.frames.rfind { it["type"] == "legend_rule" }["keep"] = id
141
+ ask!(nil)
142
+ end
143
+
144
+ def creature_sba(obj)
145
+ toughness = toughness(obj)
146
+ return "sba_toughness" if toughness <= 0 # CR 704.5f (indestructible doesn't help)
147
+ return nil if chars(obj).keyword?(:indestructible) # CR 702.12b
148
+
149
+ if obj.damage.positive? && obj.damage >= toughness then "sba_lethal_damage" # CR 704.5g
150
+ elsif obj.deathtouched then "sba_deathtouch" # CR 704.5h (wither damage is dealt but not marked, CR 702.80a)
151
+ end
152
+ end
153
+ end
154
+
155
+ include StateBasedActions
156
+ end
157
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Triggered abilities (CR 603): matching events, the pending queue, and putting
6
+ # triggers on the stack before a player receives priority (CR 117.5, 603.3).
7
+ module Triggering
8
+ # The object as it last existed on the battlefield, while its zone-change event is
9
+ # being matched (CR 603.10a). Nil otherwise.
10
+ # A permanent that left earlier in the same simultaneous zone change is remembered too (#simultaneously).
11
+ def last_known(id) = @last_known&.[](id) || @departed_together&.dig(id, "lki")
12
+
13
+ # What a permanent looked like as it last existed on the battlefield (CR 603.10a, 608.2h):
14
+ # controller and current characteristics, so "a creature you control dies" checks the
15
+ # types it had then, not its printed ones.
16
+ def last_known_info(obj)
17
+ current = chars(obj)
18
+ { "controller" => obj.controller, "card" => obj.card, "types" => current.types, "subtypes" => current.subtypes,
19
+ "power" => current.power, "toughness" => current.toughness, "counters" => obj.counters.dup,
20
+ "keywords" => current.keywords.map(&:to_s), # deathtouch, lifelink, wither (CR 702.2e, 702.15c, 702.80b)
21
+ "token" => token?(obj), "except" => copy_exceptions(obj), "abilities" => current.abilities }
22
+ .merge(obj.memory.slice(Mechanics::Linked::EXILED)) # the cards exiled with it (CR 607.2a)
23
+ end
24
+
25
+ def with_last_known(id, info)
26
+ (@last_known ||= {})[id] = info
27
+ yield
28
+ ensure
29
+ @last_known&.delete(id)
30
+ end
31
+
32
+ # The activated or triggered ability on the stack named by `ref`: its object id, or the id of the pending
33
+ # trigger it was put on the stack from ("copy that ability", CR 707.10). Nil once it has left the stack.
34
+ def stack_object_for(ref) = state.stack.find { it == ref || object(it).memory["trigger_id"] == ref }
35
+
36
+ # Called for every event: queue every triggered ability whose condition matches. Only an ability_triggers
37
+ # trigger watches another ability triggering (Events::AbilityTriggered).
38
+ def watch_triggers!(event)
39
+ return if state.step.nil? # no triggers during the pregame
40
+
41
+ record_counter_placement!(event)
42
+
43
+ trigger_sources.each do |id, instances|
44
+ obj = state.objects[id] or next
45
+ source = { "id" => id, "card" => obj.card, "controller" => obj.controller }
46
+ instances.each { |inst| queue_trigger(inst, event, source) }
47
+ end
48
+ return if event.is_a?(Events::AbilityTriggered) # only abilities on the battlefield watch it (Triggers::AbilityTriggers)
49
+
50
+ watch_leaving!(event)
51
+ watch_own_cast!(event) if event.is_a?(Events::SpellCast)
52
+ watch_graveyards!(event)
53
+ watch_delayed!(event) unless state.delayed_triggers.empty?
54
+ watch_speed!(event) # CR 702.179d (Engine::Speed)
55
+ Mechanics::Keywords::Suspend.watch!(self, event) # CR 702.62a: suspended cards in exile
56
+ end
57
+
58
+ # "For the first time each turn" (trigger counters_placed with first_time_this_turn): every placement of
59
+ # counters on a permanent is counted per kind in its memory, stamped with the turn, whether or not any
60
+ # ability triggers on it, so a later placement is never the first (CR 603.2, 122.6). Entering with
61
+ # counters is a placement (CR 122.6).
62
+ def record_counter_placement!(event)
63
+ kinds = case event
64
+ when Events::CountersChanged then event.to > event.from ? [event.counter] : []
65
+ when Events::ZoneChanged
66
+ obj = event.to == "battlefield" && state.objects[event.new_id]
67
+ obj ? obj.counters.select { |_, n| n.positive? }.keys : []
68
+ else []
69
+ end
70
+ return if kinds.empty?
71
+
72
+ id = event.is_a?(Events::CountersChanged) ? event.object : event.new_id
73
+ return unless state.objects.key?(id)
74
+
75
+ placed = object_memory!(id, "counters_placed")
76
+ (kinds + ["*"]).each { placed[it] = placed.fetch(it, 0) + 1 }
77
+ end
78
+
79
+ # How many times counters of `kind` (nil: any kind) were put on `id` this turn.
80
+ def counter_placements_this_turn(id, kind)
81
+ turned_memory(state.objects[id], "counters_placed")&.fetch(kind || "*", 0).to_i
82
+ end
83
+
84
+ # CR 603.7: creates a delayed triggered ability. `entry` names the card and the ability ref
85
+ # (see Effects::DelayedTrigger) plus what it remembers ("results", "watched" objects) and, for
86
+ # one with a duration ("this turn"), the turn it lasts for ("turn").
87
+ def create_delayed_trigger!(entry)
88
+ state.delayed_triggers << entry.merge("id" => "d#{next_timestamp!}")
89
+ end
90
+
91
+ # CR 603.7b: a delayed triggered ability triggers only once, the next time its event occurs, unless
92
+ # it has a stated duration ("whenever ... this turn"): then it triggers each time until the turn ends.
93
+ def watch_delayed!(event)
94
+ state.delayed_triggers.reject! { it["turn"] && it["turn"] != state.turn } # "this turn" has ended
95
+ fired = []
96
+ state.delayed_triggers.each do |entry|
97
+ trigger = catalog.fetch(entry["card"]).ability(entry["ability"]).trigger
98
+ next unless trigger.watches?(event)
99
+
100
+ found = Mechanics::Triggers.occurrences(trigger, self, event, delayed_source(entry))
101
+ found = found.first(1) unless repeats?(entry)
102
+ fired << entry unless found.empty?
103
+ found.each do
104
+ queue_pending_trigger!(delayed_source(entry).merge("ability" => entry["ability"]), event, delayed_memory(entry, it))
105
+ end
106
+ end
107
+ state.delayed_triggers -= fired.reject { repeats?(it) }
108
+ end
109
+
110
+ # "Whenever ... this turn" triggers each time; "the next ... this turn" ("once") only the first time; one
111
+ # "for the rest of the game" ("game", paradigm, CR 702.192a) each time, forever.
112
+ def repeats?(entry) = (entry["turn"] && !entry["once"]) || entry["game"] == true
113
+
114
+ def delayed_memory(entry, facts)
115
+ { "results" => entry["results"], "watched" => entry["watched"], "triggering" => facts }.compact
116
+ end
117
+
118
+ # CR 603.7d-e: its source and controller are those of the spell or ability that created it.
119
+ def delayed_source(entry)
120
+ { "id" => entry["source"], "card" => entry["card"], "controller" => entry["controller"], "watched" => entry["watched"] }
121
+ end
122
+
123
+ # Battlefield permanents with triggered abilities, cached with the layer pass
124
+ # (it changes only on the same events that change characteristics).
125
+ def trigger_sources
126
+ return @trigger_sources if @trigger_sources && @trigger_epoch == @epoch && @trigger_state.equal?(state)
127
+
128
+ @trigger_epoch = @epoch
129
+ @trigger_state = state
130
+ @trigger_sources = (state.battlefield + (state.command || [])).filter_map do |id| # emblems too (CR 114.4)
131
+ instances = chars(id).triggered_abilities.select { it.ability.zone == :battlefield }
132
+ [id, instances] unless instances.empty?
133
+ end
134
+ end
135
+
136
+ # "When ~ dies" / "leaves": the leaving object's own abilities, as it last existed. Permanents that left
137
+ # earlier in the same simultaneous event look back too (CR 603.10a: an artifact destroyed along with
138
+ # creatures sees them die), see #simultaneously.
139
+ def watch_leaving!(event)
140
+ return unless event.is_a?(Events::ZoneChanged) && event.from == "battlefield"
141
+
142
+ lki = last_known(event.old_id) or return
143
+ @departed_together&.each_value { queue_leaving_triggers(it, event) }
144
+ gone = { "id" => event.old_id, "card" => event.card, "controller" => lki["controller"], "lki" => lki }
145
+ queue_leaving_triggers(gone, event)
146
+ @departed_together[event.old_id] = gone if @departed_together
147
+ end
148
+
149
+ # The abilities it had as it last existed (CR 603.10a): not those it had lost, but those it was granted.
150
+ def queue_leaving_triggers(gone, event)
151
+ source = gone.slice("id", "card", "controller")
152
+ gone["lki"]["abilities"].each do |inst|
153
+ ability = inst.ability
154
+ queue_trigger(inst, event, source) if ability.kind == :triggered && ability.zone == :battlefield &&
155
+ ability.trigger.leaves_battlefield?
156
+ end
157
+ end
158
+
159
+ # Zone changes made inside the block happen at once (one effect, or state-based actions, CR 704.3):
160
+ # leaves-the-battlefield abilities of permanents that left look back in time at the others (CR 603.10a).
161
+ def simultaneously
162
+ outer = @departed_together
163
+ @departed_together ||= {}
164
+ yield
165
+ ensure
166
+ @departed_together = outer
167
+ end
168
+
169
+ # "When you cast this spell" (CR 601.2i): the spell's own ability, functioning on the stack.
170
+ def watch_own_cast!(event)
171
+ obj = state.objects[event.object] or return
172
+ source = { "id" => obj.id, "card" => obj.card, "controller" => obj.controller }
173
+ chars(obj).triggered_abilities.each do |inst|
174
+ queue_trigger(inst, event, source) if inst.ability.trigger.respond_to?(:own_spell?) && inst.ability.trigger.own_spell?
175
+ end
176
+ end
177
+
178
+ # CR 113.6k: triggered abilities that function from a graveyard, controlled by the card's owner.
179
+ def watch_graveyards!(event)
180
+ state.players.each_with_index do |pl, owner|
181
+ pl.graveyard.each do |id|
182
+ instances = graveyard_triggers(object(id).card)
183
+ next if instances.empty?
184
+
185
+ source = { "id" => id, "card" => object(id).card, "controller" => owner }
186
+ instances.each { queue_trigger(it, event, source) }
187
+ end
188
+ end
189
+ end
190
+
191
+ def graveyard_triggers(card)
192
+ (@graveyard_triggers ||= {})[card] ||= catalog.fetch(card).ability_instances.select do |inst|
193
+ inst.ability.kind == :triggered && inst.ability.zone == :graveyard
194
+ end
195
+ end
196
+
197
+ def queue_trigger(inst, event, source)
198
+ ability = inst.ability
199
+ trigger = ability.trigger
200
+ return unless trigger.watches?(event)
201
+
202
+ found = Mechanics::Triggers.occurrences(trigger, self, event, source)
203
+ return if found.empty?
204
+
205
+ # CR 603.4: an intervening "if" is checked for each trigger event, and may ask about it ("that spell")
206
+ found = found.select { ability.condition_met?(self, source["controller"], source["id"], facts: it) }
207
+ return if found.empty?
208
+ return if once_each_turn_used!(ability, inst.ref, source)
209
+
210
+ memory = trigger_memory(source)
211
+ batch = trigger.respond_to?(:batched?) && trigger.batched? ? event_batch : nil
212
+ times = 1 + additional_triggers(event, source)
213
+ card, ref, granter = AbilityLayers.granted_parts(inst.ref) # a granted ability lives in the granting card's data (CR 613.1f)
214
+ queued = source.merge("ability" => ref || inst.ref, "card" => card || source["card"])
215
+ memory = memory.merge("granter" => granter) if granter # CR 607.1a (Selector "granter")
216
+ found.first(limited?(ability) ? 1 : found.size).each do |facts|
217
+ next if batch && batched_pending!(source, queued["ability"], batch)
218
+
219
+ facts = facts.merge("amount" => 1) if batch # "that many": the occurrences so far (batched_pending!)
220
+ times.times { queue_pending_trigger!(queued, event, memory.merge("triggering" => facts), batch: batch) }
221
+ end
222
+ end
223
+
224
+ # A once-each-turn limit: remembered on the source object, so the same card
225
+ # as a new object (CR 400.7) may trigger again.
226
+ def limited?(ability) = ability.respond_to?(:limit) && !ability.limit.nil?
227
+
228
+ def once_each_turn_used!(ability, ref, source)
229
+ return false unless limited?(ability)
230
+
231
+ obj = state.objects[source["id"]] or return false
232
+ used = (obj.memory["triggered_turn"] ||= {})
233
+ return true if used[ref] == state.turn
234
+
235
+ used[ref] = state.turn
236
+ false
237
+ end
238
+
239
+ # CR 603.2d: static abilities such as "if you gaining life causes a triggered ability of a permanent
240
+ # you control to trigger, that ability triggers an additional time". Only abilities of permanents;
241
+ # never delayed or reflexive triggered abilities.
242
+ # Resolved effects do the same for the triggered abilities of the objects they affect
243
+ # (Effects::AdditionalTriggersEffect, "for as long as [its source] remains tapped", CR 611.2b).
244
+ def additional_triggers(event, source)
245
+ return 0 unless state.battlefield.include?(source["id"])
246
+
247
+ statics = state.battlefield.sum do |id|
248
+ obj = object(id)
249
+ chars(obj).abilities.count do |inst|
250
+ inst.ability.respond_to?(:additional_trigger?) && inst.ability.additional_trigger?(self, event, obj, source)
251
+ end
252
+ end
253
+ statics + state.effects.count do |effect|
254
+ effect["kind"] == "additional_triggers" && effect["affected"].include?(source["id"]) && while_source_holds?(effect)
255
+ end
256
+ end
257
+
258
+ # The event batch an event belongs to, for "one or more" triggers (CR 603.2c): everything one effect
259
+ # of a resolving spell or ability does, or one cast or activation (its costs). Outside those, each
260
+ # event is its own batch.
261
+ def event_batch
262
+ frame = state.frames.last or return "event:#{state.next_id}"
263
+ "#{frame["type"]}:#{frame["object"]}:#{frame["pc"]}"
264
+ end
265
+
266
+ # Whether a "one or more" trigger already waits for this batch. If so, this occurrence joins it: its
267
+ # "that many" counts the occurrences in the batch (CR 603.2c).
268
+ def batched_pending!(source, ref, batch)
269
+ waiting = state.pending_triggers.select { it["source"] == source["id"] && it["ability"] == ref && it["batch"] == batch }
270
+ waiting.each do |trigger|
271
+ facts = trigger["memory"]["triggering"]
272
+ facts["amount"] = facts.fetch("amount", 1) + 1
273
+ end
274
+ !waiting.empty?
275
+ end
276
+
277
+ # `memory` travels to the ability on the stack (its source's last known status, a delayed
278
+ # trigger's remembered objects, the event facts it may refer to). `event` is nil for a reflexive
279
+ # triggered ability (CR 603.12), which triggers on something done during a resolution.
280
+ def queue_pending_trigger!(source, event, memory, batch: nil)
281
+ state.pending_triggers << { "id" => "t#{next_timestamp!}", "controller" => source["controller"],
282
+ "source" => source["id"], "card" => source["card"], "ability" => source["ability"],
283
+ "event" => event.to_h, "memory" => memory, "batch" => batch }.compact
284
+ emit Events::AbilityTriggered.new(player: name(source["controller"]), source: source["id"],
285
+ card: source["card"], ability: source["ability"],
286
+ trigger: state.pending_triggers.last["id"])
287
+ end
288
+
289
+ # CR 117.5 / 603.3: before a player receives priority, pending triggers go on the stack.
290
+ # Returns true when that process was started (priority is given afterwards).
291
+ def put_triggers_first!(index)
292
+ return false if state.pending_triggers.empty?
293
+ return true if state.frames.any? { it["type"] == "put_triggers" }
294
+
295
+ state.frames << { "type" => "put_triggers", "then" => index, "next" => nil }
296
+ ask!(nil)
297
+ true
298
+ end
299
+
300
+ # Steps the put_triggers frame: APNAP order (CR 603.3b); a player with several
301
+ # triggers picks which goes on the stack next.
302
+ def step_put_triggers!(frame)
303
+ player_index = turn_order_from_active.find { |i| state.pending_triggers.any? { it["controller"] == i } }
304
+ unless player_index
305
+ give_priority!(frame["then"])
306
+ return :done
307
+ end
308
+
309
+ mine = pending_triggers_for(player_index)
310
+ chosen = mine.find { it["id"] == frame["next"] } || (mine.first if mine.size == 1)
311
+ unless chosen
312
+ ask!("kind" => "order_triggers", "player" => player_index, "count" => mine.size)
313
+ return :wait
314
+ end
315
+
316
+ frame["next"] = nil
317
+ place_trigger!(chosen)
318
+ :push
319
+ end
320
+
321
+ def turn_order_from_active
322
+ Array.new(state.players.size) { |i| (state.active + i) % state.players.size }
323
+ end
324
+
325
+ def pending_triggers_for(index) = state.pending_triggers.select { it["controller"] == index }
326
+
327
+ def choose_trigger!(id)
328
+ state.frames.rfind { it["type"] == "put_triggers" }["next"] = id
329
+ ask!(nil)
330
+ end
331
+
332
+ # CR 107.3m: an enters-the-battlefield trigger uses the X its permanent was cast with.
333
+ def entered_x(trigger)
334
+ event = trigger["event"]
335
+ return nil unless event["type"] == "zone_changed" && event["to"] == "battlefield" &&
336
+ event["new_id"] == trigger["source"]
337
+
338
+ state.objects[trigger["source"]]&.x
339
+ end
340
+
341
+ # Creates the triggered ability on the stack and starts choosing its targets.
342
+ # CR 603.3d: a trigger without legal targets is removed.
343
+ def place_trigger!(trigger)
344
+ state.pending_triggers.delete(trigger)
345
+ ability = catalog.fetch(trigger["card"]).ability(trigger["ability"])
346
+ unless ability_targetable?(ability, trigger["controller"], nil, trigger["source"], ref: trigger["ability"])
347
+ emit Events::TriggerRemoved.new(player: name(trigger["controller"]), source: trigger["source"],
348
+ card: trigger["card"], reason: "no legal targets")
349
+ return
350
+ end
351
+
352
+ obj = create_object(trigger["card"], trigger["controller"], :stack)
353
+ obj.kind = "ability"
354
+ obj.source = trigger["source"]
355
+ obj.ability = trigger["ability"]
356
+ obj.memory = Snapshot.deep_copy(trigger.fetch("memory", {})).merge("trigger_id" => trigger["id"])
357
+ obj.x = entered_x(trigger)
358
+ state.stack.unshift(obj.id)
359
+ start_casting!(trigger["controller"], obj.id, "trigger", source: trigger["source"], ability: trigger["ability"])
360
+ end
361
+ end
362
+ include Triggering
363
+ end
364
+ end