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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Priority (CR 117): who may act, passing, and when the top of the stack resolves or the step ends.
6
+ module Priority
7
+ # CR 117.5: state-based actions are checked whenever a player would receive priority.
8
+ def give_priority!(index)
9
+ check_state_based_actions!
10
+ return if state.over?
11
+ return if legend_rule_first!(index)
12
+ return if put_triggers_first!(index)
13
+
14
+ state.priority = index
15
+ ask!("kind" => "priority", "player" => index)
16
+ end
17
+
18
+ # Any action other than passing resets the "pass in succession" count (CR 117.4).
19
+ def action_taken!(index)
20
+ state.passes = 0
21
+ give_priority!(index)
22
+ end
23
+
24
+ def pass_priority!(index)
25
+ emit Events::PriorityPassed.new(player: name(index))
26
+ state.passes += 1
27
+ if state.passes >= state.players.size && !state.stack.empty?
28
+ ask!(nil)
29
+ begin_resolution! # CR 117.4, 608
30
+ elsif state.passes >= state.players.size
31
+ # CR 117.4 / 500.2: all players passed with an empty stack -> the step ends.
32
+ ask!(nil)
33
+ state.step_state = "done"
34
+ else
35
+ give_priority!(state.opponent(index))
36
+ end
37
+ end
38
+
39
+ def sorcery_timing?(index)
40
+ state.active == index && Steps::MAIN_PHASE_STEPS.include?(state.step) && state.stack.empty? &&
41
+ state.pending&.fetch("kind") == "priority"
42
+ end
43
+ end
44
+
45
+ include Priority
46
+ end
47
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Resolving the top object of the stack (CR 608), as a resumable "resolve" frame:
6
+ # the frame remembers which effect is next ("pc") and each effect's scratch data
7
+ # ("locals"), so an effect may stop to ask a player and continue after the answer.
8
+ module Resolution
9
+ def begin_resolution!
10
+ state.frames << { "type" => "resolve", "object" => state.stack.first, "pc" => nil, "ctx" => nil,
11
+ "locals" => {} }
12
+ end
13
+
14
+ # Records a player's answer for the effect that is asking (see Effects::ChoiceEffect). An effect
15
+ # running a nested effect (a "may") keeps that effect's locals under "child": the innermost asks.
16
+ def effect_answer!(option)
17
+ (answering_locals["answers"] ||= []) << option.transform_keys(&:to_s)
18
+ ask!(nil)
19
+ end
20
+
21
+ # The locals of the effect that is asking: the innermost one of the resolving effect list.
22
+ def answering_locals
23
+ frame = state.frames.rfind { it["type"] == "resolve" }
24
+ locals = frame["locals"][frame["pc"].to_s]
25
+ locals = locals["child"] while locals["child"]
26
+ locals
27
+ end
28
+
29
+ def resolving_ability(obj)
30
+ obj.ability? ? stack_ability(obj) : definition(obj).spell_ability
31
+ end
32
+
33
+ def step_resolution!(frame)
34
+ obj = object(frame["object"])
35
+ ability = resolving_ability(obj)
36
+ start_resolution!(frame, obj, ability) if frame["pc"].nil?
37
+ return finish_fizzle!(frame, obj) if frame["fizzled"]
38
+
39
+ plan = ability ? ability.effect_plan(obj.modes || []) : []
40
+ while frame["pc"] < plan.size
41
+ effect, offset, count, index = plan[frame["pc"]]
42
+ ctx = effect_context(frame["ctx"], offset, count, index ? frame["pc"] - index : 0)
43
+ locals = (frame["locals"][frame["pc"].to_s] ||= {})
44
+ return :wait if recording_results(frame) { simultaneously { effect.run(self, ctx, locals) } } == :wait
45
+ return :done if frame["turn_ended"] # CR 725.1b: exiled mid-resolution (Engine::TurnStructure#end_the_turn!)
46
+
47
+ frame["pc"] += 1
48
+ end
49
+ if !obj.ability? && definition(obj).permanent? && entering_choice_pending?(obj.id, frame["enter"])
50
+ return ask_enter_choice!(obj.id, obj.controller) # CR 614.12: decided before it enters
51
+ end
52
+
53
+ emit Events::Resolved.new(object: obj.id, card: obj.card, kind: obj.kind)
54
+ finish_resolution!(obj, frame)
55
+ after_resolution!
56
+ end
57
+
58
+ # CR 608.2b: targets are checked once, as resolution begins.
59
+ def start_resolution!(frame, obj, ability)
60
+ specs = ability&.targets_for(obj.modes || [], kicked: obj.memory.key?("kicked_with")) || []
61
+ targets = legal_targets(obj, specs)
62
+ frame["pc"] = 0
63
+ # An "up to one" target left unchosen doesn't count: only chosen targets can all be illegal.
64
+ frame["fizzled"] = (!specs.empty? && obj.targets.to_a.flatten.any? && targets.flatten.none?) ||
65
+ !intervening_if_holds?(obj, ability)
66
+ frame["ctx"] = { "controller" => obj.controller, "source" => obj.ability? ? obj.source : obj.id,
67
+ "object" => obj.id, "targets" => targets, "results" => {} }
68
+ frame["ctx"]["linked"] = obj.memory["results"] if obj.memory.key?("results") # a delayed trigger's (CR 603.7c)
69
+ frame["ctx"]["linked_amounts"] = obj.memory["amounts"] if obj.memory.key?("amounts") # a reflexive trigger's
70
+ end
71
+
72
+ # CR 400.7j / 608.2c: objects an effect moves or creates, so later effects can refer to them
73
+ # ("the card exiled this way"). Kept per effect in the frame, so a waiting effect accumulates.
74
+ def recording_results(frame)
75
+ @recorded = []
76
+ @recorded_amount = 0
77
+ yield
78
+ ensure
79
+ (frame["ctx"]["results"] ||= {})[frame["pc"].to_s] = (frame["ctx"]["results"][frame["pc"].to_s] || []) + @recorded
80
+ record_amounts!(frame) if @recorded_amount.positive?
81
+ @recorded = @recorded_amount = nil
82
+ end
83
+
84
+ # How much an effect changed, e.g. life it made a player lose (CR 608.2c), per effect (see {"this_way": n}).
85
+ def record_amounts!(frame)
86
+ amounts = (frame["ctx"]["amounts"] ||= {})
87
+ amounts[frame["pc"].to_s] = amounts.fetch(frame["pc"].to_s, 0) + @recorded_amount
88
+ end
89
+
90
+ def record_amount!(amount) = @recorded_amount &&= @recorded_amount + amount
91
+
92
+ # Called by zone changes and token creation while an effect runs.
93
+ def record_result!(id) = @recorded&.push(id)
94
+
95
+ # The context for one effect; a modal ability's effects see only their mode's targets.
96
+ # CR 603.4: a triggered ability whose intervening "if" is false on resolution does nothing.
97
+ def intervening_if_holds?(obj, ability)
98
+ return true unless obj.ability? && ability.respond_to?(:condition_met?)
99
+
100
+ ability.condition_met?(self, obj.controller, obj.source, obj.id)
101
+ end
102
+
103
+ # `base` is the plan position of the first effect of this effect's list: result n is at base + n.
104
+ # A delayed trigger's effects see the results of the list that created it instead.
105
+ def effect_context(data, offset = 0, count = nil, base = 0)
106
+ targets = count ? data["targets"][offset, count] || [] : data["targets"]
107
+ own = data["results"] || {}
108
+ results = data["linked"] || Array.new([own.size - base, 0].max) { own[(base + it).to_s] || [] }
109
+ amounts = data["linked_amounts"] || Array.new(results.size) { data.fetch("amounts", {})[(base + it).to_s] || 0 }
110
+ Mechanics::EffectContext.new(data["controller"], data["source"], data["object"], targets, results, amounts)
111
+ end
112
+
113
+ def finish_fizzle!(_frame, obj)
114
+ emit Events::Fizzled.new(object: obj.id, card: obj.card)
115
+ obj.ability? ? remove_stack_object!(obj.id) : move!(obj.id, :graveyard, reason: "fizzled")
116
+ after_resolution!
117
+ end
118
+
119
+ def after_resolution!
120
+ state.passes = 0
121
+ give_priority!(state.active) # CR 117.3b
122
+ :done
123
+ end
124
+
125
+ # Targets that are still legal; illegal ones become nil and are ignored (CR 608.2b).
126
+ def legal_targets(obj, specs)
127
+ legal = lambda { |ref, spec|
128
+ spec&.match_for?(self, ref, obj.controller,
129
+ obj.source) && targetable?(ref, obj.controller, obj.source, targeter: obj.source || obj.id)
130
+ }
131
+ (obj.targets || []).each_with_index.map do |ref, i|
132
+ ref.is_a?(Array) ? ref.select { legal.call(it, specs[i]) } : (ref if legal.call(ref, specs[i])) # a counted slot's list
133
+ end
134
+ end
135
+
136
+ def finish_resolution!(obj, frame = {})
137
+ return remove_stack_object!(obj.id) if obj.ability?
138
+ return unless state.stack.include?(obj.id) # it may have left the stack during resolution
139
+
140
+ if definition(obj).permanent?
141
+ enters = with_sunburst(obj, enters_from_answer(obj.id, frame) || {}).merge("cast" => true)
142
+ enters = enters.merge("attached_to" => frame.dig("ctx", "targets", 0)) if aura?(obj) # CR 608.3c
143
+ pay_entering_costs!(obj.controller, frame)
144
+ move!(obj.id, :battlefield, controller: obj.controller, reason: "resolve", enters: enters) # CR 608.3
145
+ elsif !put_away_spell_face!(obj) && !Mechanics::Keywords::Paradigm.resolved!(self, obj) # Engine::Faces; CR 702.192a
146
+ move!(obj.id, :graveyard, reason: "resolve") # CR 608.2n
147
+ end
148
+ end
149
+
150
+ # CR 702.44a-b: a spell with sunburst enters with a counter for each color of mana spent on it.
151
+ def with_sunburst(obj, enters)
152
+ counters = Mechanics::Keywords::Sunburst.entering_counters(self, obj)
153
+ return enters if counters.empty?
154
+
155
+ enters ||= {}
156
+ enters.merge("counters" => enters.fetch("counters", {}).merge(counters) { |_, a, b| a + b })
157
+ end
158
+
159
+ # Abilities on the stack aren't cards; they simply cease to exist.
160
+ def remove_stack_object!(id)
161
+ state.stack.delete(id)
162
+ state.objects.delete(id)
163
+ end
164
+ end
165
+
166
+ include Resolution
167
+ end
168
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Player-level rules effects and the rules they change: "you have hexproof", "no maximum hand
6
+ # size", "can't attack", "prevent all damage that would be dealt to you", "you don't lose the
7
+ # game for having 0 or less life", "you can't sacrifice ..." (static abilities of permanents and
8
+ # resolved effects with a duration), winning and losing by an effect (CR 104.2b, 104.3e), extra
9
+ # turns (CR 500.7), control changes (CR 613.1b), assimilate (CR 701.72), "for as long as" effects
10
+ # (CR 611.2b) and targeting restrictions (CR 115.4).
11
+ module Rules
12
+ # ---- player rules -------------------------------------------------------------------
13
+
14
+ # Whether `rule` applies to the player: a `player_rule` effect naming them (state.effects) or a
15
+ # `player_rule` static ability of a permanent on the battlefield whose condition holds. `object`: the
16
+ # permanent or card a filtered rule ("can't sacrifice Treasure tokens", "creature spells can't be
17
+ # countered") is asked about.
18
+ def player_rule?(index, rule, object = nil)
19
+ ref = Mechanics::Filters.player_ref(self, index)
20
+ return true if state.effects.any? { it["kind"] == "player_rule" && it["rule"] == rule && it["affected"].include?(ref) }
21
+
22
+ player_rule_statics.any? do |source, ability|
23
+ ability.rule == rule && ability.applies_to?(index, source.controller) &&
24
+ (object.nil? || ability.matches?(self, object, source)) && ability.active?(self, source)
25
+ end
26
+ end
27
+
28
+ # A rule of the whole game ("Damage can't be prevented"), whichever player it was given to.
29
+ def game_rule?(rule) = state.players.each_index.any? { player_rule?(it, rule) }
30
+
31
+ # CR 101.2: "can't cast" beats any permission to cast. `obj`: the card (or stand-in) to be cast. The card's own
32
+ # "you can't cast this spell unless ..." too (CR 601.3).
33
+ def cast_forbidden?(index, obj)
34
+ player_rule?(index, "cant_cast", obj) || (obj.zone != :hand && player_rule?(index, "cant_cast_except_from_hand")) ||
35
+ chars(obj).abilities.any? { it.ability.kind == :cast_restriction && !it.ability.allows?(self, index, obj) }
36
+ end
37
+
38
+ # CR 602.5, 101.2: abilities of a permanent matching a `cant_activate` filter can't be activated, nor any of a
39
+ # permanent whose activated abilities can't be activated (#mana_abilities_forbidden?).
40
+ def activation_forbidden?(index, obj)
41
+ obj.zone == :battlefield && (mana_abilities_forbidden?(obj) || player_rule?(index, "cant_activate", obj))
42
+ end
43
+
44
+ # CR 602.5, 605.3a: "its activated abilities can't be activated" covers its mana abilities too.
45
+ def mana_abilities_forbidden?(obj) = chars(obj).keyword?(:abilities_cant_be_activated)
46
+
47
+ # CR 615.12: damage that can't be prevented.
48
+ def unpreventable?(combat) = game_rule?("damage_cant_be_prevented") || (combat && game_rule?("combat_damage_cant_be_prevented"))
49
+
50
+ def player_rule_statics = board_abilities(:player_rule)
51
+
52
+ # [[source, ability]] of abilities of `kind` on the battlefield and of emblems (CR 114.4), cached with the
53
+ # layer pass (they change only on the events that change characteristics).
54
+ def board_abilities(kind)
55
+ unless @board_abilities && @board_abilities_epoch == @epoch && @board_abilities_state.equal?(state)
56
+ @board_abilities_epoch = @epoch
57
+ @board_abilities_state = state
58
+ @board_abilities = {}
59
+ end
60
+ @board_abilities[kind] ||= (state.battlefield + (state.command || [])).flat_map do |id|
61
+ source = object(id)
62
+ chars(source).abilities.filter_map { [source, it.ability] if it.ability.kind == kind }
63
+ end
64
+ end
65
+
66
+ # CR 402.2 / 514.1: seven, or nil when the player has no maximum hand size.
67
+ def max_hand_size(index) = player_rule?(index, "no_max_hand_size") ? nil : HAND_SIZE
68
+
69
+ # CR 701.21a: only a permanent you control can be sacrificed, and "can't" effects forbid more
70
+ # (CR 614.17b: a cost that includes sacrificing it can't be paid).
71
+ def can_sacrifice?(obj, index)
72
+ obj.zone == :battlefield && obj.controller == index && !player_rule?(index, "cant_sacrifice", obj)
73
+ end
74
+
75
+ # CR 704.5a, unless an effect says the player doesn't lose for having 0 or less life.
76
+ def loses_for_life?(index) = player(index).life <= 0 && !player_rule?(index, "cant_lose_for_life")
77
+
78
+ # CR 615.1: a prevention effect protecting a player from all damage (Replacements#preventing!).
79
+ def damage_prevented?(ref)
80
+ index = Mechanics::Filters.player_index(self, ref)
81
+ !index.nil? && player_rule?(index, "prevent_all_damage")
82
+ end
83
+
84
+ # "Until your next turn" effects end as that player's next turn begins (CR 611.2a, 500.1).
85
+ def end_effects_until_turn_of!(index)
86
+ ending = state.effects.count { it["duration"] == "until_your_next_turn" && it["controller"] == index }
87
+ return if ending.zero?
88
+
89
+ state.effects.reject! { it["duration"] == "until_your_next_turn" && it["controller"] == index }
90
+ emit Events::EffectsEnded.new(count: ending)
91
+ end
92
+
93
+ # ---- targeting restrictions (CR 115.4, 702.11c) --------------------------------------
94
+
95
+ # Whether choosing `ref` as a target is forbidden for player `index`: a player with hexproof can't
96
+ # be targeted by opponents (CR 702.11c), and a permanent may say it can't be the target of
97
+ # abilities from some sources its controller's opponents control. `source`: the permanent whose
98
+ # ability is targeting (nil for a spell). `targeter`: the spell, or the ability's source, whose qualities
99
+ # protection and hexproof from a quality look at (CR 702.16b, 702.11d).
100
+ def targeting_forbidden?(ref, index, source = nil, targeter: source)
101
+ player = Mechanics::Filters.player_index(self, ref)
102
+ # CR 702.11c, 702.16k: a player with hexproof or protection from each of their opponents
103
+ return player != index && (player_rule?(player, "hexproof") || player_rule?(player, "protection_from_opponents")) if player
104
+
105
+ obj = state.objects[ref]
106
+ return false unless obj && obj.zone == :battlefield
107
+
108
+ quality = targeter && state.objects[targeter]
109
+ return true if protected_from?(obj, quality)
110
+ return false if obj.controller == index
111
+
112
+ src = source && state.objects[source]
113
+ protected_from?(obj, quality, :hexproof_from) ||
114
+ (!src.nil? && chars(obj).abilities.any? { it.ability.kind == :cant_be_targeted_by && it.ability.forbids?(self, src) })
115
+ end
116
+
117
+ # ---- protection and hexproof from a quality (CR 702.11d, 702.16) ----------------------
118
+
119
+ # Whether the permanent has protection (or, with `kind` :hexproof_from, hexproof) from `other`: a spell,
120
+ # the source of an ability or of damage, an Aura, an Equipment or a blocking creature.
121
+ def protected_from?(obj, other, kind = :protection_from)
122
+ return false if other.nil?
123
+
124
+ chars(obj).abilities.any? { it.ability.kind == kind && it.ability.from?(self, other, obj) }
125
+ end
126
+
127
+ # ---- winning and losing by an effect (CR 104.2b, 104.3e) ------------------------------
128
+
129
+ # CR 104.2b: in a two-player game, a player winning means every opponent loses. CR 104.3f: a
130
+ # player who would win and lose at once loses (handled: a loss already recorded stays).
131
+ def win_game!(index)
132
+ state.players.each_index { lose!(it, "opponent_won") unless it == index || player(it).lost }
133
+ end_game_if_decided!
134
+ end
135
+
136
+ def lose_game!(index)
137
+ lose!(index, "effect")
138
+ end_game_if_decided!
139
+ end
140
+
141
+ # ---- extra and skipped turns (CR 500.7, 614.10) ----------------------------------------
142
+
143
+ # CR 500.7: the turn is added directly after this one; the most recently created is taken first.
144
+ def add_extra_turn!(index)
145
+ state.extra_turns["queue"] << index
146
+ emit Events::ExtraTurnAdded.new(player: name(index))
147
+ end
148
+
149
+ # CR 614.10a: the player skips their next `count` turns (each skip waits for a turn that isn't skipped).
150
+ def skip_turns!(index, count)
151
+ return unless count.positive?
152
+
153
+ (state.extra_turns["skip"] ||= []).concat([index] * count)
154
+ end
155
+
156
+ # The player whose turn comes next: an extra turn if one was added, else the next in turn order. A
157
+ # skipped turn doesn't happen (CR 614.10): the turn after it comes next.
158
+ def take_next_turn!
159
+ last = state.active
160
+ loop do
161
+ extra = state.extra_turns["queue"].pop
162
+ index = extra || state.opponent(last)
163
+ skip = state.extra_turns.fetch("skip", []).index(index)
164
+ unless skip
165
+ state.extra_turns["begun"] |= [index] if extra
166
+ return index
167
+ end
168
+ state.extra_turns["skip"].delete_at(skip)
169
+ state.extra_turns.delete("skip") if state.extra_turns["skip"].empty?
170
+ emit Events::TurnSkipped.new(player: name(index))
171
+ last = index
172
+ end
173
+ end
174
+
175
+ def began_extra_turn?(index) = state.extra_turns["begun"].include?(index)
176
+
177
+ # ---- control changes (CR 613.1b, 108.4) -----------------------------------------------
178
+
179
+ # A control change with indefinite duration (CR 613.1b): it becomes a new controller's
180
+ # permanent, newly under their control (CR 302.6), and leaves combat (CR 506.4). The newest
181
+ # control change is remembered, so an older "until end of turn" one doesn't undo it as it ends.
182
+ def gain_control!(obj, controller, timestamp = next_timestamp!)
183
+ obj.memory["control_timestamp"] = timestamp
184
+ return if obj.controller == controller
185
+
186
+ leave_combat!(obj.id)
187
+ obj.controller = controller
188
+ obj.controlled_since = state.turn
189
+ emit Events::ControlChanged.new(object: obj.id, controller: name(controller))
190
+ end_while_source_effects!(obj.id, %w[while_you_control_source]) # CR 611.2b: "for as long as you control"
191
+ end
192
+
193
+ # Control of `obj` for a while: until end of turn, an effect that ends in the cleanup step (CR 514.2), handing
194
+ # it back unless a newer control change has happened since.
195
+ # `duration`: "end_of_turn", or "for as long as" its source remains tapped / on the battlefield (CR 611.2b,
196
+ # `source` set; Engine::Rules#end_while_source_effects! hands it back).
197
+ def gain_control_for!(obj, controller, ctx, duration: "end_of_turn", source: nil)
198
+ return if obj.controller == controller
199
+
200
+ timestamp = next_timestamp!
201
+ previous = obj.controller
202
+ gain_control!(obj, controller, timestamp)
203
+ state.effects << { "kind" => "control", "timestamp" => timestamp, "controller" => ctx.controller,
204
+ "affected" => [obj.id], "duration" => duration, "previous" => previous,
205
+ "source" => source }.compact
206
+ emit Events::EffectStarted.new(kind: "control", affected: [obj.id], details: { "controller" => controller })
207
+ end
208
+
209
+ # CR 514.2: as "until end of turn" control effects end, control returns to the previous controller.
210
+ def revert_temporary_control! = revert_control!(state.effects.select { it["duration"] == "end_of_turn" })
211
+
212
+ # As temporary control effects end, control returns to the previous controller. Several of them on one
213
+ # permanent all end together: it goes back to whoever had it before the first.
214
+ def revert_control!(ending)
215
+ ending.select { it["kind"] == "control" }.group_by { it["affected"].first }.each do |id, list|
216
+ obj = state.objects[id]
217
+ next unless obj && obj.zone == :battlefield && obj.memory["control_timestamp"] == list.last["timestamp"]
218
+
219
+ gain_control!(obj, list.first["previous"])
220
+ end
221
+ end
222
+
223
+ # ---- assimilate (CR 701.72) -------------------------------------------------------------
224
+
225
+ # CR 701.72a: a permanent: gain control of it, put a +1/+1 counter on it, it becomes a Borg artifact creature.
226
+ # CR 701.72b: a permanent card elsewhere: put it onto the battlefield under your control with a +1/+1
227
+ # counter; it's a Borg artifact creature. Returns the assimilated permanent's id (or nil).
228
+ # `enters`: what an "as this enters" choice adds for a card put onto the battlefield (CR 614.12a).
229
+ def assimilate!(ref, controller, ctx, enters: nil)
230
+ obj = state.objects[ref] or return nil
231
+ if obj.zone == :battlefield
232
+ gain_control!(obj, controller)
233
+ add_counters!(obj, "+1/+1", 1)
234
+ else
235
+ return nil unless obj.kind == "card" && catalog.fetch(obj.card).permanent? && %i[graveyard exile hand library].include?(obj.zone)
236
+
237
+ status = (enters || {}).merge("counters" => (enters || {}).fetch("counters", {}).merge("+1/+1" => 1) { |_, a, b| a + b })
238
+ obj = move!(ref, :battlefield, controller: controller, reason: "assimilate", enters: status) or return nil
239
+ end
240
+ # The type change lasts while it stays on the battlefield; ones about objects that left are dropped.
241
+ state.effects.reject! { it["kind"] == "assimilate" && !state.battlefield.include?(it["affected"].first) }
242
+ state.effects << { "kind" => "assimilate", "timestamp" => next_timestamp!, "controller" => ctx.controller,
243
+ "affected" => [obj.id], "duration" => "permanent" }
244
+ emit Events::EffectStarted.new(kind: "assimilate", affected: [obj.id], details: {})
245
+ obj.id
246
+ end
247
+
248
+ # ---- "for as long as [source] remains tapped" (CR 611.2b) ------------------------------
249
+
250
+ # Whether a `while_source_tapped` effect still applies: its source is on the battlefield and tapped.
251
+ def while_source_holds?(effect)
252
+ source = effect["source"]
253
+ state.battlefield.include?(source) && object(source).tapped
254
+ end
255
+
256
+ # "For as long as [source] remains tapped / on the battlefield", "for as long as you control [source]" (CR 611.2b).
257
+ WHILE_SOURCE = %w[while_source_tapped while_source_on_battlefield while_you_control_source].freeze
258
+
259
+ # Whether a "for as long as" duration about `source` holds now for an effect controlled by `controller`; one
260
+ # that has already ended never starts (CR 611.2b). Other durations always hold.
261
+ def source_duration_holds?(duration, source, controller)
262
+ return true unless WHILE_SOURCE.include?(duration)
263
+ return false unless state.battlefield.include?(source)
264
+
265
+ case duration
266
+ when "while_source_tapped" then object(source).tapped
267
+ when "while_you_control_source" then object(source).controller == controller
268
+ else true
269
+ end
270
+ end
271
+
272
+ # CR 611.2b: once the duration ends (the source untaps, leaves or phases out, CR 702.26f), the effect
273
+ # is over for good, even if the source becomes tapped again. Untapping ends only `while_source_tapped`.
274
+ def end_while_source_effects!(id, durations = WHILE_SOURCE)
275
+ ending = ->(effect) { durations.include?(effect["duration"]) && effect["source"] == id }
276
+ count = state.effects.count(&ending)
277
+ return if count.zero?
278
+
279
+ revert_control!(state.effects.select(&ending)) # "for as long as" control (CR 611.2b)
280
+ state.effects.reject!(&ending)
281
+ emit Events::EffectsEnded.new(count: count)
282
+ end
283
+ end
284
+
285
+ include Rules
286
+ end
287
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Sagas (CR 715, 704.5s): lore counters, chapter abilities and sacrificing a finished Saga.
6
+ module Sagas
7
+ # Chapter numbers of a permanent's chapter abilities (CR 715.2).
8
+ def chapter_numbers(id) = chars(id).abilities.filter_map { it.ability.chapter if it.ability.respond_to?(:chapter) }
9
+
10
+ # CR 715.3c / 505.4: as the precombat main phase begins, the active player puts a lore counter on
11
+ # each Saga they control with one or more chapter abilities (a turn-based action).
12
+ def add_lore_counters!(index)
13
+ sagas = state.battlefield.select do |id|
14
+ object(id).controller == index && chars(id).subtypes.include?(:saga) && !chapter_numbers(id).empty?
15
+ end
16
+ sagas.each { add_counters!(object(it), "lore", 1) }
17
+ end
18
+
19
+ # CR 715.4 / 704.5s: a Saga whose lore counters reach its final chapter number (CR 715.2d) is
20
+ # sacrificed, unless it's the source of a chapter ability that has triggered but not left the stack.
21
+ def saga_complete?(obj)
22
+ c = chars(obj)
23
+ return false unless c.subtypes.include?(:saga)
24
+
25
+ chapters = chapter_numbers(obj.id)
26
+ !chapters.empty? && obj.counters.fetch("lore", 0) >= chapters.max && !chapter_pending?(obj)
27
+ end
28
+
29
+ def chapter_pending?(obj)
30
+ chapter = ->(card, ref) { catalog.fetch(card).ability(ref).respond_to?(:chapter) }
31
+ state.pending_triggers.any? { it["source"] == obj.id && chapter.call(it["card"], it["ability"]) } ||
32
+ state.stack.any? do |id|
33
+ stacked = object(id)
34
+ stacked.ability? && stacked.source == obj.id && chapter.call(stacked.card, stacked.ability)
35
+ end
36
+ end
37
+ end
38
+
39
+ include Sagas
40
+ end
41
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Speed (CR 702.179) and max speed (CR 702.178). A player has no speed (PlayerState#speed nil) until
6
+ # start your engines! gives them 1 (CR 702.179a-b, a state-based action); it then rises to at most 4.
7
+ module Speed
8
+ MAX_SPEED = 4
9
+ SPEED_ABILITY = "keyword:start_your_engines"
10
+
11
+ # CR 702.179f: a player without speed has speed 0 for effects that refer to it.
12
+ def speed(index) = player(index).speed.to_i
13
+
14
+ # CR 702.179e: max speed is speed 4.
15
+ def max_speed?(index) = speed(index) >= MAX_SPEED
16
+
17
+ # CR 702.179c: a player without speed who is told to increase it gets that value. Never above 4.
18
+ def increase_speed!(index, amount)
19
+ set_speed!(index, [speed(index) + amount, MAX_SPEED].min)
20
+ end
21
+
22
+ def set_speed!(index, value)
23
+ pl = player(index)
24
+ from = pl.speed
25
+ return if from == value
26
+
27
+ pl.speed = value
28
+ emit Events::SpeedChanged.new(player: name(index), from: from, to: value)
29
+ end
30
+
31
+ # CR 704.5aa / 702.179a: a player who controls a permanent with start your engines! and has no speed gets
32
+ # speed 1. Returns true when a player's speed was set (state-based actions are checked again).
33
+ def start_engines!
34
+ return false unless speed_card
35
+
36
+ started = state.players.each_index.select do |i|
37
+ player(i).speed.nil? && state.battlefield.any? do |id|
38
+ obj = object(id)
39
+ obj.controller == i && chars(obj).keyword?(:start_your_engines)
40
+ end
41
+ end
42
+ started.each { set_speed!(it, 1) }
43
+ !started.empty?
44
+ end
45
+
46
+ # CR 702.179d: "Whenever one or more opponents lose life during your turn, if your speed is less than 4,
47
+ # your speed increases by 1. This ability triggers only once each turn." It has no source; it is put on the
48
+ # stack as the start your engines! keyword ability of a card in the game (its text, not its source).
49
+ def watch_speed!(event)
50
+ return unless event.is_a?(Events::LifeChanged) && event.loss.positive? && speed_card
51
+
52
+ index = state.active
53
+ return if index.nil? || names.index(event.player) == index
54
+ return unless (1...MAX_SPEED).cover?(speed(index)) # has speed (CR 702.179d); the intervening "if" (CR 603.4)
55
+
56
+ log = turn_log(index)
57
+ return if log["speed_triggered"]
58
+
59
+ log["speed_triggered"] = true
60
+ queue_pending_trigger!({ "id" => nil, "card" => speed_card_of(index), "controller" => index,
61
+ "ability" => SPEED_ABILITY }, event, {})
62
+ end
63
+
64
+ # The card whose start your engines! keyword ability stands for the speed trigger: one the player controls
65
+ # if any (for display only: the ability has no source), else any card in the game with the keyword.
66
+ def speed_card_of(index)
67
+ mine = state.battlefield.find do |id|
68
+ obj = object(id)
69
+ obj.controller == index && catalog.fetch(obj.card).keyword?(:start_your_engines)
70
+ end
71
+ mine ? object(mine).card : speed_card
72
+ end
73
+
74
+ # A card in this game with start your engines! (whose keyword ability holds the speed trigger), or nil.
75
+ def speed_card
76
+ @speed_card = catalog.find { it.keyword?(:start_your_engines) }&.id unless defined?(@speed_card)
77
+ @speed_card
78
+ end
79
+
80
+ # Max speed abilities that apply to `obj`: those of its card while `index` (its controller, or its owner
81
+ # off the battlefield, CR 702.178b) has max speed.
82
+ def active_max_speed(obj, index)
83
+ return EMPTY_ABILITIES unless max_speed?(index)
84
+
85
+ catalog.fetch(obj.card).ability_instances.select { it.ability.kind == :max_speed }
86
+ end
87
+ EMPTY_ABILITIES = [].freeze
88
+
89
+ # A card outside the battlefield has what its max speed abilities grant while its owner has max speed
90
+ # (CR 702.178b): e.g. "Max speed — {3}, Exile this card from your graveyard: Draw a card."
91
+ def with_max_speed(obj, base)
92
+ return base if obj.ability?
93
+
94
+ symbols = active_max_speed(obj, obj.owner)
95
+ return base if symbols.empty?
96
+
97
+ defn = catalog.fetch(obj.card)
98
+ base.with(abilities: (base.abilities + symbols.flat_map { defn.nested_instances(it.ref) }).freeze,
99
+ keywords: (base.keywords | symbols.flat_map { it.ability.keywords }).freeze)
100
+ end
101
+ end
102
+ include Speed
103
+ end
104
+ end