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,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Combat (CR 506-511). state.attackers maps attacker id => {"target", "blocked"};
6
+ # state.blockers maps blocker id => attacker id.
7
+ module Combat
8
+ def defending_player = state.opponent(state.active)
9
+
10
+ # ---- declare attackers (CR 508) ------------------------------------------
11
+
12
+ def begin_declare_attackers!
13
+ return turn_based_actions_done! if eligible_attackers.empty?
14
+
15
+ ask!("kind" => "declare_attackers", "player" => state.active)
16
+ end
17
+
18
+ # CR 508.1a: untapped creatures you've controlled continuously since your turn began (or with haste).
19
+ # CR 508.1c: restrictions (defender, "can't attack", a player who can't attack) are obeyed.
20
+ def eligible_attackers
21
+ return [] if player_rule?(state.active, "cant_attack")
22
+
23
+ only = combat_attack_filter # "only land creatures can attack during that combat phase" (TurnStructure)
24
+ state.battlefield.select do |id|
25
+ obj = object(id)
26
+ current = chars(obj)
27
+ obj.controller == state.active && !obj.tapped && current.creature? &&
28
+ (!current.keyword?(:defender) || current.keyword?(:attacks_despite_defender)) &&
29
+ !current.keyword?(:cant_attack) && !attackers.key?(id) && !summoning_sick_for_tap?(obj) &&
30
+ (only.nil? || only.match?(self, id, state.active))
31
+ end
32
+ end
33
+
34
+ # CR 508.1d: creatures that attack each combat if able and could still be declared. The
35
+ # declaration may end only once every such requirement is obeyed (no attack has a cost yet).
36
+ def unmet_attack_requirements = attack_candidates.select { chars(it).keyword?(:attacks_each_combat) }
37
+
38
+ # CR 506.3, 508.1b: the defending player or a planeswalker they control.
39
+ def attack_targets
40
+ defender = defending_player
41
+ [Mechanics::Filters.player_ref(self, defender)] +
42
+ state.battlefield.select { |id| object(id).controller == defender && chars(id).planeswalker? }
43
+ end
44
+
45
+ # Creatures that may be declared next. CR 506.5, 508.1c: one that can't attack alone is offered
46
+ # only while another creature attacks or could still be declared.
47
+ def attack_candidates
48
+ eligible = eligible_attackers
49
+ eligible.select { |id| !attacks_alone_restricted?(id) || !attackers.empty? || eligible.size > 1 }
50
+ end
51
+
52
+ def attacks_alone_restricted?(id) = chars(id).keyword?(:cant_attack_alone)
53
+
54
+ # CR 508.1c: the declaration is illegal while a creature that can't attack alone attacks alone.
55
+ def attack_declaration_legal? = !(attackers.size == 1 && attacks_alone_restricted?(attackers.keys.first))
56
+
57
+ def declare_attacker!(id, target)
58
+ attackers[id] = { "target" => target, "blocked" => false }
59
+ state.attacks_declared += 1
60
+ emit Events::AttackerDeclared.new(object: id, target: target)
61
+ eligible_attackers.empty? && attack_declaration_legal? ? finish_attackers! : nil
62
+ end
63
+
64
+ # Undo one attack while the declaration isn't legal yet (CR 508.1c). The declaration it was part of
65
+ # never happened, so "whenever this attacks" abilities it triggered go too.
66
+ def withdraw_attacker!(id)
67
+ attackers.delete(id)
68
+ state.attacks_declared -= 1
69
+ attack_withdrawn!(id) # Engine::TurnHistory
70
+ state.pending_triggers.reject! { it["event"]["type"] == "attacker_declared" && it["event"]["object"] == id }
71
+ end
72
+
73
+ # CR 508.4: a creature put onto the battlefield attacking attacks the defending player. (Choosing one
74
+ # of their planeswalkers instead isn't offered yet.) It was never declared, so it never "attacked".
75
+ # Only called while its controller controls an attacking creature (see Abilities::EntersAttacking).
76
+ def enter_attacking!(obj, target = nil)
77
+ target = Mechanics::Filters.player_ref(self, state.opponent(obj.controller)) unless target.is_a?(String) # CR 506.3a
78
+ attackers[obj.id] = { "target" => target, "blocked" => false }
79
+ end
80
+
81
+ # CR 508.1f: attacking creatures become tapped. CR 508.3d: "whenever you attack" triggers
82
+ # once the declaration is complete, if one or more creatures attack.
83
+ def finish_attackers!
84
+ attackers.each_key { |id| tap!(object(id)) unless chars(id).keyword?(:vigilance) } # CR 702.20b
85
+ emit Events::AttacksDeclared.new(player: name(state.active), attackers: attackers.keys) unless attackers.empty?
86
+ turn_based_actions_done!
87
+ end
88
+
89
+ # ---- declare blockers (CR 509) -----------------------------------------------
90
+
91
+ def begin_declare_blockers!
92
+ return turn_based_actions_done! if block_options.empty? || attackers.empty?
93
+
94
+ ask!("kind" => "declare_blockers", "player" => defending_player)
95
+ end
96
+
97
+ # CR 509.1a; CR 509.1b: a creature that can't block is never offered.
98
+ def eligible_blockers = able_blockers.reject { state.blockers.key?(it) }
99
+
100
+ # The defending player's creatures that could block, whether they block already or not.
101
+ def able_blockers
102
+ state.battlefield.select do |id|
103
+ obj = object(id)
104
+ obj.controller == defending_player && !obj.tapped && chars(obj).creature? &&
105
+ !chars(obj).keyword?(:cant_block) && !chars(obj).keyword?(:decayed) # CR 702.147a
106
+ end
107
+ end
108
+
109
+ def declare_blocker!(id, attacker)
110
+ state.blockers[id] = attacker
111
+ attackers[attacker]["blocked"] = true # CR 509.1h: stays blocked even if the blocker leaves
112
+ emit Events::BlockerDeclared.new(object: id, attacker: attacker)
113
+ finish_blockers! if block_options.empty? && blocks_legal?
114
+ end
115
+
116
+ # The declaration is final: each blocked attacker "becomes blocked" once (CR 509.1h, 509.3c, 603.2e).
117
+ def finish_blockers!
118
+ attackers.each do |id, info|
119
+ emit Events::BecameBlocked.new(object: id, blockers: blockers_of(id)) if info["blocked"]
120
+ end
121
+ turn_based_actions_done!
122
+ end
123
+
124
+ # CR 509.1b restrictions: "can't be blocked", "can't be blocked by [quality] creatures", flying
125
+ # (702.9b) / reach (702.17b), protection from the blocker's quality (702.16f).
126
+ def can_block?(blocker, attacker)
127
+ return false if chars(attacker).keyword?(:cant_be_blocked)
128
+ return false if protected_from?(object(attacker), object(blocker))
129
+ return false if chars(attacker).abilities.any? { it.ability.kind == :cant_be_blocked_by && it.ability.forbids?(self, blocker) }
130
+ return false if chars(blocker).abilities.any? { it.ability.kind == :can_block_only && !it.ability.allows?(self, blocker, attacker) }
131
+ return true unless chars(attacker).keyword?(:flying)
132
+
133
+ chars(blocker).keyword?(:flying) || chars(blocker).keyword?(:reach)
134
+ end
135
+
136
+ def block_options
137
+ eligible_blockers.flat_map do |id|
138
+ attackers.keys.filter_map { |attacker| [id, attacker] if can_block?(id, attacker) }
139
+ end
140
+ end
141
+
142
+ # CR 702.111b: an attacker with menace can't be blocked by just one creature.
143
+ # CR 509.1c: an attacker that must be blocked if able is blocked whenever some creature could block it.
144
+ def blocks_legal? = menace_violations.empty? && unmet_block_requirements.empty?
145
+
146
+ # Attackers that must be blocked if able, unblocked while creatures could block them: blocking another
147
+ # attacker instead obeys fewer requirements (CR 509.1c). With menace it takes two (CR 509.1b).
148
+ def unmet_block_requirements
149
+ attackers.keys.select do |id|
150
+ next false unless chars(id).keyword?(:must_be_blocked) && blockers_of(id).empty?
151
+
152
+ able_blockers.count { could_obey?(it, id) } >= (chars(id).keyword?(:menace) ? 2 : 1)
153
+ end
154
+ end
155
+
156
+ # Whether `blocker` blocking `attacker` would obey more requirements: it can block it and blocks nothing,
157
+ # or blocks an attacker without such a requirement (moving it from one that must be blocked gains nothing).
158
+ def could_obey?(blocker, attacker)
159
+ return false unless can_block?(blocker, attacker)
160
+
161
+ blocking = state.blockers[blocker]
162
+ blocking.nil? || !chars(blocking).keyword?(:must_be_blocked)
163
+ end
164
+
165
+ # Blocks to undo so a creature can block an attacker that must be blocked.
166
+ def requirement_unblocks
167
+ unmet_block_requirements.flat_map { |id| state.blockers.keys.select { could_obey?(it, id) } }.uniq
168
+ end
169
+
170
+ def menace_violations
171
+ attackers.keys.select { |id| chars(id).keyword?(:menace) && blockers_of(id).size == 1 }
172
+ end
173
+
174
+ # Undo one block (offered while the declaration isn't legal yet).
175
+ def remove_blocker!(id)
176
+ attacker = state.blockers.delete(id)
177
+ attackers[attacker]["blocked"] = blockers_of(attacker).any? # the declaration isn't final yet
178
+ end
179
+
180
+ def blockers_of(attacker) = state.blockers.select { |_, a| a == attacker }.keys
181
+
182
+ # ---- combat damage (CR 510) -----------------------------------------------
183
+
184
+ # CR 510.1c: an attacker blocked by several creatures divides its damage among them
185
+ # as its controller chooses — asked one point at a time.
186
+ # CR 510.4: if any attacking or blocking creature has first or double strike, there are
187
+ # two combat damage steps: first strikers deal damage in the first, the rest in the second.
188
+ def begin_combat_damage!
189
+ state.combat_damage_pass ||= combatants.any? { first_striker?(it) } ? "first" : "regular"
190
+ queue = attackers.keys.select { |id| needs_assignment?(id) }
191
+ state.frames << { "type" => "combat_damage", "queue" => queue, "assigned" => {} }
192
+ end
193
+
194
+ def combatants = attackers.keys + state.blockers.keys
195
+
196
+ def first_striker?(id) = chars(id).keyword?(:first_strike) || chars(id).keyword?(:double_strike)
197
+
198
+ # Whether this creature deals combat damage in the current pass (CR 702.4b, 702.7b).
199
+ def deals_damage_now?(id)
200
+ case state.combat_damage_pass
201
+ when "first" then first_striker?(id)
202
+ when "regular_after_first" then !chars(id).keyword?(:first_strike) || chars(id).keyword?(:double_strike)
203
+ else true
204
+ end
205
+ end
206
+
207
+ # The second combat damage step follows a first-strike step (CR 510.4).
208
+ def second_combat_damage_step?
209
+ return false unless state.step == :combat_damage && state.combat_damage_pass == "first"
210
+
211
+ state.combat_damage_pass = "regular_after_first"
212
+ true
213
+ end
214
+
215
+ def trample?(id) = chars(id).keyword?(:trample)
216
+
217
+ # CR 510.1a: the combat damage a creature assigns: its power, or its toughness when an effect says so
218
+ # and its toughness is greater (assigns_damage_by_toughness).
219
+ def combat_damage_amount(id)
220
+ obj = object(id)
221
+ amount = power(obj)
222
+ chars(id).keyword?(:assigns_damage_by_toughness) ? [amount, toughness(obj)].max : amount
223
+ end
224
+
225
+ # CR 510.1c-d / 702.19b: several blockers, or trample, means the attacking player divides damage.
226
+ def needs_assignment?(id)
227
+ return false unless attackers[id]["blocked"] && deals_damage_now?(id) && combat_damage_amount(id).positive?
228
+
229
+ count = blockers_of(id).size
230
+ count > 1 || (count == 1 && trample?(id))
231
+ end
232
+
233
+ def combat_damage_frame = state.frames.last
234
+
235
+ # Steps the combat_damage frame: ask for the next point of damage to divide, or deal it all.
236
+ def step_combat_damage!(frame)
237
+ while (attacker = frame["queue"].first)
238
+ assigned = frame["assigned"][attacker] ||= {}
239
+ remaining = combat_damage_amount(attacker) - assigned.values.sum
240
+ if remaining.positive?
241
+ ask!("kind" => "assign_damage", "player" => state.active, "attacker" => attacker, "remaining" => remaining)
242
+ return :wait
243
+ end
244
+ frame["queue"].shift
245
+ end
246
+ deal_combat_damage!(frame["assigned"])
247
+ :done
248
+ end
249
+
250
+ # Where the next point of an attacker's damage may go: any blocker; with trample, also
251
+ # the player or planeswalker it attacks once every blocker has lethal damage assigned.
252
+ def assignment_targets(attacker)
253
+ blockers = blockers_of(attacker)
254
+ assigned = combat_damage_frame["assigned"].fetch(attacker, {})
255
+ return blockers unless trample?(attacker) && blockers.all? { lethal_assigned?(attacker, it, assigned) }
256
+
257
+ blockers + [attackers[attacker]["target"]]
258
+ end
259
+
260
+ # CR 702.19b: lethal damage counts damage already marked; with deathtouch, 1 is lethal (702.2c).
261
+ def lethal_assigned?(attacker, blocker, assigned)
262
+ assigned.fetch(blocker, 0) >= [lethal_damage(attacker, blocker), 1].max
263
+ end
264
+
265
+ def assign_damage!(attacker, target)
266
+ assigned = combat_damage_frame["assigned"][attacker]
267
+ assigned[target] = assigned.fetch(target, 0) + 1
268
+ emit Events::DamageAssigned.new(attacker: attacker, blocker: target)
269
+ ask!(nil)
270
+ end
271
+
272
+ # CR 510.2: all combat damage in a step is dealt simultaneously.
273
+ def deal_combat_damage!(assigned)
274
+ hits = []
275
+ attackers.each do |id, info|
276
+ next unless deals_damage_now?(id)
277
+
278
+ amount = combat_damage_amount(id)
279
+ blockers = blockers_of(id)
280
+ if !info["blocked"] then hits << [id, info["target"], amount]
281
+ elsif blockers.empty? then hits << [id, info["target"], amount] if trample?(id) # CR 702.19e
282
+ elsif assigned.key?(id) then assigned[id].each { |target, n| hits << [id, target, n] }
283
+ else hits << [id, blockers.first, amount]
284
+ end
285
+ end
286
+ state.blockers.each do |blocker, attacker|
287
+ hits << [blocker, attacker, combat_damage_amount(blocker)] if deals_damage_now?(blocker)
288
+ end
289
+ simultaneous_damage do # CR 510.2
290
+ dealt = Hash.new(0)
291
+ lifelinked = hits.map(&:first).uniq.select { damage_source_keyword?(it, :lifelink) }
292
+ hits.each { |source, target, amount| dealt[source] += deal_damage!(source, target, amount, combat: true, lifelink: false) }
293
+ lifelinked.each { lifelink!(it, dealt[it]) if dealt[it].positive? } # CR 120.3f, 119.9: one gain per source
294
+ end
295
+ turn_based_actions_done!
296
+ end
297
+
298
+ # CR 506.4: a permanent that leaves the battlefield is removed from combat.
299
+ def leave_combat!(id)
300
+ attackers.delete(id)
301
+ state.blockers.delete(id)
302
+ state.blockers.reject! { |_, attacker| attacker == id }
303
+ end
304
+
305
+ # CR 511.3: creatures stop being attacking/blocking at the end of combat.
306
+ def end_combat!
307
+ state.attacks_declared = 0
308
+ state.combat_damage_pass = nil
309
+ attackers.clear
310
+ state.blockers.clear
311
+ end
312
+ end
313
+ include Combat
314
+ end
315
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ class Engine
5
+ # Copying objects (CR 707).
6
+ #
7
+ # A copy takes the copiable values of the object it copies (CR 707.2): the values printed on its card,
8
+ # as modified by other copy effects and their exceptions (CR 707.9b). Every copiable value comes from a
9
+ # card definition plus a few exceptions, so a copy is kept as plain data on the GameObject:
10
+ #
11
+ # obj.card the card whose printed values it has: the copied card while a copy
12
+ # effect applies (so its abilities, triggers and stack objects all
13
+ # resolve against that card's data)
14
+ # memory["own_card"] the card the object really is, while a copy effect changed obj.card
15
+ # memory["copy_effects"] copy effects on a permanent, in timestamp order (CR 613.7): each
16
+ # {"card", "except", "duration" ("permanent" | "end_of_turn"), "timestamp"}
17
+ # memory["copy_except"] the exceptions of an object created as a copy (a token copy, CR 707.9)
18
+ # memory["token"] true for a token that is a copy of a card (CR 111.1, 608.3f)
19
+ # memory["copy"] "spell" for a copy of a spell (CR 707.10), "card" for a copy of a card
20
+ # (CR 707.12); they cease to exist outside the stack (and battlefield)
21
+ #
22
+ # Exceptions (CR 707.9a-b) are {"legendary": false, "keywords": [...], "types": [...], "subtypes": [...],
23
+ # "supertypes": [...], "name": ..., "power": n, "toughness": n, "colors": [...]}; they are part of the
24
+ # copy's copiable values, so copying a copy keeps them (CR 707.2).
25
+ module Copies
26
+ # ---- reading -------------------------------------------------------------------------------
27
+
28
+ def own_card(obj) = obj.memory["own_card"] || obj.card
29
+
30
+ # A token (CR 111.1): created as one, or a copy of a permanent spell that resolved (CR 608.3f).
31
+ def token?(obj) = obj.memory["token"] == true || catalog.fetch(own_card(obj)).token?
32
+
33
+ # The exceptions of the copy effect that applies (nil if none).
34
+ def copy_exceptions(obj)
35
+ effects = obj.memory["copy_effects"]
36
+ effects && !effects.empty? ? effects.last["except"] : obj.memory["copy_except"]
37
+ end
38
+
39
+ # What a copy of `obj` gets: [card id, exceptions] (CR 707.2, 707.3).
40
+ def copiable_values(obj) = [obj.card, copy_exceptions(obj) || {}]
41
+
42
+ # 707.9b: exceptions of a later copy effect apply on top of those it copied.
43
+ def self.merge_except(old, new)
44
+ old ||= {}
45
+ merged = old.merge(new.except("keywords", "types", "subtypes", "supertypes"))
46
+ %w[keywords types subtypes supertypes].each do |key|
47
+ list = Array(old[key]) | Array(new[key])
48
+ merged[key] = list unless list.empty?
49
+ end
50
+ merged
51
+ end
52
+
53
+ # The characteristics with a copy effect's exceptions applied (CR 707.9a-b).
54
+ def self.apply_exceptions(chars, except)
55
+ attrs = {}
56
+ attrs[:name] = except["name"] if except["name"]
57
+ attrs[:supertypes] = chars.supertypes | sym(except["supertypes"])
58
+ attrs[:supertypes] -= [:legendary] if except["legendary"] == false
59
+ attrs[:types] = chars.types | sym(except["types"])
60
+ attrs[:subtypes] = chars.subtypes | sym(except["subtypes"])
61
+ attrs[:keywords] = chars.keywords | sym(except["keywords"])
62
+ attrs[:colors] = sym(except["colors"]) if except["colors"]
63
+ attrs[:power] = except["power"] if except.key?("power")
64
+ attrs[:toughness] = except["toughness"] if except.key?("toughness")
65
+ chars.with(**attrs.transform_values { it.is_a?(Array) ? it.freeze : it })
66
+ end
67
+
68
+ def self.sym(list) = Array(list).map(&:to_sym)
69
+
70
+ # ---- making copies ---------------------------------------------------------------------------
71
+
72
+ # CR 707.8, 111.1: a token that's a copy of [card, except], created under `controller`'s control.
73
+ def create_token_copy!(values, controller, tapped: false)
74
+ card, except = values
75
+ memory = { "token" => true }
76
+ memory["copy_except"] = except unless except.empty?
77
+ create_token!(catalog.fetch(card), controller, tapped: tapped, memory: memory)
78
+ end
79
+
80
+ # CR 707.4, 613.2a: `obj` becomes a copy of [card, except]; no enter or leave triggers. A
81
+ # "permanent" copy effect lasts as long as the object stays on the battlefield.
82
+ def become_copy!(obj, values, duration: "permanent")
83
+ card, except = values
84
+ obj.memory["own_card"] ||= obj.card
85
+ (obj.memory["copy_effects"] ||= []) << { "card" => card, "except" => except, "duration" => duration,
86
+ "timestamp" => next_timestamp! }
87
+ obj.card = card
88
+ emit Events::EffectStarted.new(kind: "copy", affected: [obj.id], details: { "card" => card })
89
+ end
90
+
91
+ # CR 707.5: a permanent entering as a copy ({"card", "except"}) is one as it enters.
92
+ def enter_as_copy!(fresh, copy)
93
+ fresh.memory["own_card"] = fresh.card
94
+ fresh.memory["copy_effects"] = [copy.merge("duration" => "permanent", "timestamp" => next_timestamp!)]
95
+ fresh.card = copy["card"]
96
+ end
97
+
98
+ # CR 707.12: a copy of a card, created in the zone the card is in (owned by `controller`).
99
+ def create_card_copy!(values, controller, zone)
100
+ card, except = values
101
+ obj = create_object(card, controller, zone)
102
+ obj.memory["copy"] = "card"
103
+ obj.memory["copy_except"] = except unless except.empty?
104
+ state.zone_list(zone, controller).unshift(obj.id)
105
+ record_result!(obj.id)
106
+ emit Events::ZoneChanged.new(card: card, old_id: nil, new_id: obj.id, owner: name(controller),
107
+ from: "none", to: zone.to_s, reason: "copy")
108
+ obj
109
+ end
110
+
111
+ # CR 707.10: a copy of a spell or ability on the stack, with the same modes, X, kicker and other
112
+ # choices, put on top of the stack by `controller` (who owns and controls it). A spell copy isn't
113
+ # cast, an ability copy isn't activated; an ability copy has the same source (CR 707.10b) and keeps
114
+ # what the ability knew (its trigger event, the objects paid for its costs).
115
+ def copy_spell!(spell, controller, targets, except: {})
116
+ card, copied = spell.ability? ? [spell.card, {}] : copiable_values(spell)
117
+ except = except.empty? ? copied : Copies.merge_except(copied, except) # CR 707.9a
118
+ copy = create_object(card, controller, :stack)
119
+ if spell.ability?
120
+ copy.kind = "ability"
121
+ copy.source = spell.source
122
+ copy.ability = spell.ability
123
+ copy.memory = Snapshot.deep_copy(spell.memory.except("trigger_id")) # the copy wasn't triggered
124
+ else
125
+ copy.memory = Marshal.load(Marshal.dump(spell.memory.slice("kicked", "kicked_with", "gift", "division"))).merge("copy" => "spell")
126
+ end
127
+ copy.memory["copy_except"] = except unless except.empty?
128
+ copy.targets = targets
129
+ copy.modes = spell.modes&.dup
130
+ copy.x = spell.x
131
+ copy.mana_spent = nil # CR 707.10: mana isn't copied; no mana was spent to cast a copy
132
+ copy.paid = spell.paid&.map(&:dup)
133
+ state.stack.unshift(copy.id)
134
+ record_result!(copy.id)
135
+ emit Events::SpellCopied.new(player: name(controller), object: copy.id, card: card, original: spell.id,
136
+ targets: targets)
137
+ copy
138
+ end
139
+
140
+ # ---- zone changes and state-based actions ------------------------------------------------------
141
+
142
+ # CR 400.7: the new object is the card itself again; a token or copy stays one. A copy of a permanent
143
+ # spell becomes a token as it enters (CR 608.3f, 707.10f).
144
+ def carry_copy_status!(fresh, old, to)
145
+ fresh.memory["token"] = true if old.memory["token"]
146
+ fresh.memory["copy_except"] = old.memory["copy_except"] if old.memory["copy_except"]
147
+ kind = old.memory["copy"] or return
148
+ return fresh.memory["token"] = true if kind == "spell" && to == :battlefield
149
+
150
+ fresh.memory["copy"] = kind
151
+ end
152
+
153
+ # CR 704.5d, 111.7, 707.10a: tokens outside the battlefield, spell copies outside the stack and card
154
+ # copies outside the stack and battlefield cease to exist (state-based actions).
155
+ # Zones a token or card copy can be in (a phased-out permanent is still on the battlefield, CR 702.26d).
156
+ STAYING = %i[battlefield stack phased_out].freeze
157
+
158
+ def ceases_to_exist?(obj)
159
+ return false if obj.ability?
160
+
161
+ case obj.memory["copy"]
162
+ when "spell" then obj.zone != :stack
163
+ when "card" then !STAYING.include?(obj.zone) && !prepared_copy?(obj) # CR 723.3c (Engine::Faces)
164
+ else !STAYING.include?(obj.zone) && token?(obj)
165
+ end
166
+ end
167
+
168
+ # CR 514.2: "until end of turn" copy effects end; the permanent has the copiable values of the
169
+ # newest copy effect left, or its own.
170
+ def end_copy_effects!
171
+ state.battlefield.each do |id|
172
+ obj = object(id)
173
+ effects = obj.memory["copy_effects"] or next
174
+ next unless effects.any? { it["duration"] == "end_of_turn" }
175
+
176
+ effects.reject! { it["duration"] == "end_of_turn" }
177
+ obj.card = effects.last&.fetch("card") || obj.memory["own_card"]
178
+ obj.memory.delete("copy_effects") if effects.empty?
179
+ obj.memory.delete("own_card") if effects.empty?
180
+ emit Events::EffectsEnded.new(count: 1)
181
+ end
182
+ end
183
+
184
+ # ---- abilities granted to a card (CR 113.6b, 611.3) ----------------------------------------
185
+
186
+ # Abilities an object gained from a resolved effect (state.effects "card_ability", lasting while it
187
+ # stays in its zone or until end of turn), e.g. an exiled card: "granted:<granting card>:<ref>".
188
+ def card_granted_abilities(obj)
189
+ state.effects.filter_map do |effect|
190
+ next unless effect["kind"] == "card_ability" && effect["affected"].include?(obj.id)
191
+
192
+ ability = catalog.fetch(effect["card"]).ability(effect["ability"]) or next
193
+ AbilityInstance.new(ref: "granted:#{effect["card"]}:#{effect["ability"]}", ability: ability)
194
+ end
195
+ end
196
+
197
+ # Starts a "card_ability" effect; ones whose cards no longer exist are dropped.
198
+ def grant_card_ability!(ctx, affected, card, ref, duration)
199
+ state.effects.reject! { it["kind"] == "card_ability" && it["affected"].none? { state.objects.key?(it) } }
200
+ duration = "while_in_zone" if duration == "permanent" # a new object is a different one (CR 400.7)
201
+ state.effects << { "kind" => "card_ability", "timestamp" => next_timestamp!, "controller" => ctx.controller,
202
+ "affected" => affected, "duration" => duration, "card" => card, "ability" => ref }
203
+ emit Events::EffectStarted.new(kind: "card_ability", affected: affected, details: { "card" => card })
204
+ end
205
+ end
206
+ include Copies
207
+ end
208
+ end