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,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Triggers
6
+ # "When ~ enters" / "Whenever a creature you control enters" (landfall: lands you control).
7
+ # `untapped: true` is "when this land enters untapped": the status it entered with (CR 614.12).
8
+ # `from`: only a permanent that entered from that zone ("enters from exile", CR 603.6a).
9
+ class Enters < Trigger
10
+ FROM = %w[hand graveyard exile library stack outside].freeze
11
+
12
+ primitive :trigger, :enters,
13
+ description: "The subject enters the battlefield. `untapped: true`: only if it entered untapped. " \
14
+ "`from`: only if it entered from that zone (\"enters from exile\").",
15
+ rules: %w[603.6a 603.2 614.12],
16
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, \"controller\": you|opponent|any, \"other\": true}",
17
+ "untapped" => "true | false", "from" => FROM.join(" | ") },
18
+ example: { "enters" => { "subject" => { "each" => "land", "controller" => "you" } } }
19
+
20
+ def self.keys = %w[subject untapped from]
21
+ def event_class = Events::ZoneChanged
22
+
23
+ def setup(params, errors, path)
24
+ @untapped = params.fetch("untapped", false)
25
+ errors << "#{path}.untapped: must be true or false" unless [true, false].include?(@untapped)
26
+ @from = params["from"]
27
+ errors << "#{path}.from: must be one of #{FROM.join(", ")}" unless @from.nil? || FROM.include?(@from)
28
+ end
29
+
30
+ def match?(engine, event, source)
31
+ return false unless event.to == "battlefield" && (@from.nil? || event.from == @from)
32
+
33
+ obj = engine.state.objects[event.new_id] or return false
34
+ return false if @untapped && obj.tapped
35
+
36
+ @subject.match?(engine, event.new_id, obj.controller, event.card, source, types: engine.chars(obj).types)
37
+ end
38
+
39
+ def to_s = "When #{@subject} enters#{" untapped" if @untapped}#{" from #{@from}" if @from}"
40
+ end
41
+
42
+ # "Whenever you play a land" (CR 305.1): a player plays a land, from their hand or, when an effect allows it,
43
+ # from another zone (`from` / `not_from`: "from exile", "from anywhere other than your hand"). Putting a land
44
+ # onto the battlefield with an effect isn't playing it.
45
+ class PlaysLand < PlayerTrigger
46
+ primitive :trigger, :plays_land,
47
+ description: "A player (`controller`: you (default) | opponent | any) plays a land. `from` / " \
48
+ "`not_from`: only a land played from (not from) that zone: hand | graveyard | exile | " \
49
+ "library | outside. Lands put onto the battlefield by effects aren't played.",
50
+ rules: %w[305.1 603.2 601.2a],
51
+ params: { "controller" => PLAYERS.join(" | "), "from" => "zone", "not_from" => "zone" },
52
+ example: { "plays_land" => { "not_from" => "hand" } }
53
+
54
+ def self.keys = %w[controller from not_from]
55
+ def event_class = Events::ZoneChanged
56
+
57
+ def setup(params, errors, path)
58
+ super
59
+ @zones = CastFrom.build(params, errors, path)
60
+ end
61
+
62
+ def match?(engine, event, source)
63
+ return false unless event.reason == "play_land" && event.to == "battlefield" && @zones.match?(event.from)
64
+
65
+ player = engine.state.objects[event.new_id]&.controller || engine.names.index(event.owner)
66
+ player_ok?(engine, engine.name(player), source)
67
+ end
68
+
69
+ def to_s = "Whenever #{who} play#{"s" unless @controller == "you"} a land#{@zones}"
70
+ end
71
+
72
+ # "When ~ dies" / "Whenever another creature you control dies".
73
+ class Dies < Trigger
74
+ primitive :trigger, :dies,
75
+ description: "The subject is put into a graveyard from the battlefield. Looks back in time (CR 603.10a).",
76
+ rules: %w[603.6c 603.10a 700.4],
77
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}" },
78
+ example: { "dies" => { "subject" => "self" } }
79
+
80
+ def event_class = Events::ZoneChanged
81
+ def leaves_battlefield? = true
82
+
83
+ def match?(engine, event, source)
84
+ return false unless event.from == "battlefield" && event.to == "graveyard"
85
+
86
+ lki = engine.last_known(event.old_id) or return false
87
+ @subject.match?(engine, event.old_id, lki["controller"], event.card, source, types: lki["types"])
88
+ end
89
+
90
+ def to_s = "When #{@subject} dies"
91
+ end
92
+
93
+ # "When ~ leaves the battlefield" / "Whenever another creature you control leaves the battlefield"
94
+ # (CR 603.6c): a permanent moves from the battlefield to any other zone (`to`: only that zone). It
95
+ # looks back in time (CR 603.10a): the permanent is matched as it last existed, and its own ability
96
+ # triggers although it has left.
97
+ class LeavesBattlefield < Trigger
98
+ ZONES = %w[graveyard exile hand library].freeze
99
+
100
+ primitive :trigger, :leaves_battlefield,
101
+ description: "The subject leaves the battlefield (for any zone, or only `to` that zone). Looks back " \
102
+ "in time: the permanent is matched as it last existed (types, controller, counters).",
103
+ rules: %w[603.6c 603.10a 400.7],
104
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, \"controller\": ..., \"other\": true}",
105
+ "to" => ZONES.join(" | ") },
106
+ example: { "leaves_battlefield" => { "subject" => { "each" => "creature", "controller" => "you",
107
+ "other" => true } } }
108
+
109
+ def self.keys = %w[subject to]
110
+ def event_class = Events::ZoneChanged
111
+ def leaves_battlefield? = true
112
+
113
+ def setup(params, errors, path)
114
+ @to = params["to"]
115
+ errors << "#{path}.to: must be one of #{ZONES.join(", ")}" unless @to.nil? || ZONES.include?(@to)
116
+ end
117
+
118
+ def match?(engine, event, source)
119
+ return false unless event.from == "battlefield" && (@to.nil? || event.to == @to)
120
+
121
+ lki = engine.last_known(event.old_id) or return false
122
+ @subject.match?(engine, event.old_id, lki["controller"], event.card, source, types: lki["types"])
123
+ end
124
+
125
+ def to_s
126
+ return "When #{@subject} leaves the battlefield" unless @to
127
+
128
+ "When #{@subject} is put into #{"a " unless @to == "exile"}#{@to} from the battlefield"
129
+ end
130
+ end
131
+
132
+ # "Whenever you sacrifice another permanent" / "a Food" (CR 701.21a): only its controller can sacrifice
133
+ # a permanent, so the subject's `controller` is who sacrificed it. Looks back in time (CR 603.10a).
134
+ class Sacrifices < Trigger
135
+ include Batched
136
+
137
+ primitive :trigger, :sacrifices,
138
+ description: "The subject is sacrificed (`controller` of the subject: who sacrificed it). Looks back " \
139
+ "in time, like leaves_battlefield. `batched: true`: \"one or more\", once per event batch.",
140
+ rules: %w[701.21a 603.10a 603.2c],
141
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, \"controller\": you, \"other\": true}",
142
+ "batched" => "true | false" },
143
+ example: { "sacrifices" => { "subject" => { "each" => "artifact", "controller" => "you" } } }
144
+
145
+ def self.keys = %w[subject batched]
146
+ def event_class = Events::ZoneChanged
147
+ def leaves_battlefield? = true
148
+ def setup(params, errors, path) = setup_batched(params, errors, path)
149
+
150
+ def match?(engine, event, source)
151
+ return false unless event.from == "battlefield" && event.reason == "sacrifice"
152
+
153
+ lki = engine.last_known(event.old_id) or return false
154
+ @subject.match?(engine, event.old_id, lki["controller"], event.card, source, types: lki["types"])
155
+ end
156
+
157
+ def to_s = "Whenever #{@subject} is sacrificed"
158
+ end
159
+
160
+ # "Whenever one or more creature cards are put into your graveyard from anywhere" / "cards are put into
161
+ # exile from graveyards and/or the battlefield". `controller`: whose graveyard or exile, i.e. the card's
162
+ # owner. CR 603.6c: a "from anywhere" trigger is never a leaves-the-battlefield ability.
163
+ class PutInto < PlayerTrigger
164
+ include Batched
165
+ include CardMatch
166
+
167
+ ZONES = %w[graveyard exile].freeze
168
+ FROM = %w[battlefield graveyard hand library stack exile].freeze
169
+
170
+ primitive :trigger, :put_into,
171
+ description: "A card is put into `zone` (graveyard | exile) from anywhere, or only `from` the listed " \
172
+ "zones. `controller`: the card's owner (you (default) | opponent | any); optional `card` " \
173
+ "filter on the card. `batched: true`: \"one or more cards\", once per event batch. With " \
174
+ "\"battlefield\" among `from` it looks back in time like leaves_battlefield.",
175
+ rules: %w[603.6c 603.2c 400.3],
176
+ params: { "zone" => ZONES.join(" | "), "from" => "list of #{FROM.join(", ")} (default: anywhere)",
177
+ "controller" => PLAYERS.join(" | "), "card" => "card filter", "batched" => "true | false" },
178
+ example: { "put_into" => { "zone" => "graveyard", "card" => "creature", "batched" => true } }
179
+
180
+ def self.keys = %w[zone from controller card batched]
181
+ def event_class = Events::ZoneChanged
182
+ # CR 603.6c: "put into a graveyard from the battlefield" is a leaves-the-battlefield ability; "from anywhere"
183
+ # never is.
184
+ def leaves_battlefield? = @from&.include?("battlefield") || false
185
+
186
+ def setup(params, errors, path)
187
+ super
188
+ setup_batched(params, errors, path)
189
+ setup_card(params, errors, path)
190
+ @zone = params["zone"]
191
+ errors << "#{path}.zone: must be one of #{ZONES.join(", ")}" unless ZONES.include?(@zone)
192
+ @from = params["from"]
193
+ return if @from.nil? || (@from.is_a?(Array) && !@from.empty? && (@from - FROM).empty?)
194
+
195
+ errors << "#{path}.from: must be a list of #{FROM.join(", ")}"
196
+ end
197
+
198
+ def match?(engine, event, source)
199
+ event.to == @zone && (@from.nil? || @from.include?(event.from)) && player_ok?(engine, event.owner, source) &&
200
+ card_ok?(engine, event.card)
201
+ end
202
+
203
+ def to_s
204
+ into = @zone == "exile" ? "exile" : "#{{ "you" => "your", "opponent" => "an opponent's" }.fetch(@controller, "a")} graveyard"
205
+ from = @from ? " from #{@from.join(" or ")}" : " from anywhere"
206
+ "Whenever #{@batched ? "one or more #{card_words}s are" : "a #{card_words} is"} put into #{into}#{from}"
207
+ end
208
+ end
209
+
210
+ # "Whenever a card leaves your graveyard" / "whenever one or more cards leave your graveyard" (`batched`).
211
+ # Looks back in time (CR 603.10a): the card is matched as it was in the graveyard.
212
+ class LeavesGraveyard < PlayerTrigger
213
+ primitive :trigger, :leaves_graveyard,
214
+ description: "A card leaves a graveyard (whose: `controller` you (default) | opponent | any). Optional " \
215
+ "`card` filter on its card types. `batched: true` is \"one or more cards leave\": once per " \
216
+ "event that moves cards (one effect, cost or action), however many cards.",
217
+ rules: %w[603.10a 603.2c 404.1],
218
+ params: { "controller" => PLAYERS.join(" | "), "card" => "card type filter, e.g. \"creature\"",
219
+ "batched" => "true | false" },
220
+ example: { "leaves_graveyard" => { "controller" => "you", "batched" => true } }
221
+
222
+ def self.keys = %w[controller card batched]
223
+ def event_class = Events::ZoneChanged
224
+ def batched? = @batched
225
+
226
+ def setup(params, errors, path)
227
+ super
228
+ @batched = params.fetch("batched", false)
229
+ errors << "#{path}.batched: must be true or false" unless [true, false].include?(@batched)
230
+ @card = params["card"] && Mechanics.build_entry(:filter, params["card"], errors, "#{path}.card")
231
+ end
232
+
233
+ def match?(engine, event, source)
234
+ return false unless event.from == "graveyard" && player_ok?(engine, event.owner, source)
235
+ return true unless @card
236
+
237
+ defn = engine.catalog.fetch(event.card)
238
+ @card.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
239
+ end
240
+
241
+ def to_s
242
+ what = @card ? Triggers.filter_words(@card) : "card"
243
+ whose = { "you" => "your", "opponent" => "an opponent's" }.fetch(@controller, "a")
244
+ "Whenever #{@batched ? "one or more #{what}s leave" : "a #{what} leaves"} #{whose} graveyard"
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Types
6
+ # Kindred (CR 308). A kindred card always has another card type, and is cast and resolves by that
7
+ # type's rules (CR 308.1): a "Kindred Instant" is an instant. Its subtypes are creature types
8
+ # (CR 308.2, 205.3m), so "Goblin spells" and "the number of creature types" see them.
9
+ class Kindred < Primitive
10
+ primitive :type, :kindred,
11
+ description: "A card type that goes with another one (\"Kindred Sorcery — Goblin\"): the card is " \
12
+ "cast and resolves as its other type, and its subtypes are creature types.",
13
+ rules: %w[308.1 308.2 205.3m],
14
+ example: { "name" => "Example Goblin Gambit", "mana_cost" => "{R}", "types" => %w[kindred sorcery],
15
+ "subtypes" => ["goblin"],
16
+ "abilities" => [{ "spell" => { "targets" => ["any_target"],
17
+ "effects" => [{ "deal_damage" => { "amount" => 1,
18
+ "to" => { "target" => 0 } } }] } }] }
19
+
20
+ def self.permanent? = false
21
+ def self.instant_speed? = false
22
+
23
+ # CR 308.1: each kindred card has another card type.
24
+ def self.validate(card, errors)
25
+ errors << "types: a kindred card needs another card type (CR 308.1)" if (Array(card["types"]).map(&:to_s) - ["kindred"]).empty?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Types
6
+ # Land (CR 305). Played as a special action, never cast; one per turn.
7
+ class Land < Primitive
8
+ primitive :type, :land,
9
+ description: "A permanent played with the special action \"play a land\" (one per turn, " \
10
+ "main phase, empty stack). It has no mana cost.",
11
+ rules: %w[305.1 305.2 305.3 202.1a],
12
+ example: { "name" => "Forest", "types" => ["land"], "supertypes" => ["basic"],
13
+ "subtypes" => ["forest"] }
14
+
15
+ def self.permanent? = true
16
+ def self.instant_speed? = false
17
+
18
+ def self.validate(card, errors)
19
+ errors << "mana_cost: lands have no mana cost (CR 202.1a)" if card.key?("mana_cost")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Types
6
+ # Shared rules of permanent card types that are cast (artifact, creature,
7
+ # enchantment, planeswalker): sorcery timing unless something grants flash (CR 307.1 analogues).
8
+ class PermanentType < Primitive
9
+ def self.permanent? = true
10
+ def self.instant_speed? = false
11
+ def self.validate(_card, _errors) = nil
12
+ end
13
+
14
+ # Creature (CR 302). Has power and toughness; can attack and block; summoning sickness (302.6).
15
+ class Creature < PermanentType
16
+ primitive :type, :creature,
17
+ description: "A permanent with power and toughness that can attack and block. Cast at sorcery " \
18
+ "speed. It can't attack or use {T} abilities unless controlled continuously since " \
19
+ "its controller's most recent turn began (summoning sickness).",
20
+ rules: %w[302.1 302.4 302.6 307.1],
21
+ example: { "name" => "Grizzly Bears", "mana_cost" => "{1}{G}", "types" => ["creature"],
22
+ "subtypes" => ["bear"], "power" => 2, "toughness" => 2 }
23
+
24
+ def self.validate(card, errors)
25
+ %w[power toughness].each { |k| errors << "#{k}: creatures need an integer #{k} (CR 302.4)" unless card.key?(k) }
26
+ end
27
+ end
28
+
29
+ # Artifact (CR 301).
30
+ class Artifact < PermanentType
31
+ primitive :type, :artifact, description: "A permanent cast at sorcery speed.", rules: %w[301.1],
32
+ example: { "name" => "Example Relic", "mana_cost" => "{2}", "types" => ["artifact"] }
33
+ end
34
+
35
+ # Enchantment (CR 303).
36
+ class Enchantment < PermanentType
37
+ primitive :type, :enchantment, description: "A permanent cast at sorcery speed.", rules: %w[303.1],
38
+ example: { "name" => "Example Aura", "mana_cost" => "{1}{W}",
39
+ "types" => ["enchantment"] }
40
+ end
41
+
42
+ # Planeswalker (CR 306). Enters with loyalty counters; one loyalty ability per turn.
43
+ class Planeswalker < PermanentType
44
+ primitive :type, :planeswalker,
45
+ description: "A permanent that enters with loyalty counters equal to its printed loyalty. " \
46
+ "Its loyalty abilities can be activated at sorcery speed, one per turn. Damage dealt " \
47
+ "to it removes loyalty counters; at 0 loyalty it is put into its owner's graveyard.",
48
+ rules: %w[306.5b 306.8 120.3c 606.3 704.5i],
49
+ example: { "name" => "Vessa, Rally Captain", "mana_cost" => "{2}{W}{W}", "types" => ["planeswalker"],
50
+ "loyalty" => 3,
51
+ "abilities" => [{ "loyalty" => { "cost" => 1, "effects" => [
52
+ { "pump" => { "power" => 1, "toughness" => 1,
53
+ "to" => { "each" => "creature", "controller" => "you" } } }
54
+ ] } }] }
55
+
56
+ def self.validate(card, errors)
57
+ errors << "loyalty: planeswalkers need an integer loyalty (CR 306.5b)" unless card["loyalty"].is_a?(Integer)
58
+ end
59
+
60
+ # CR 306.5b: a planeswalker enters with loyalty counters equal to its printed loyalty.
61
+ def self.entering(definition, enters)
62
+ enters["counters"]["loyalty"] = definition.loyalty if definition.loyalty
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Types
6
+ # Instants and sorceries: never permanents; they go to the graveyard on resolution (CR 608.2n).
7
+ class SpellType < Primitive
8
+ def self.permanent? = false
9
+ def self.validate(_card, _errors) = nil
10
+ end
11
+
12
+ # Instant (CR 304). Cast any time you have priority.
13
+ class Instant < SpellType
14
+ primitive :type, :instant,
15
+ description: "A spell cast any time its controller has priority. Its spell ability resolves, " \
16
+ "then it goes to the graveyard.",
17
+ rules: %w[304.1 304.2 608.2n],
18
+ example: { "name" => "Lightning Bolt", "mana_cost" => "{R}", "types" => ["instant"],
19
+ "abilities" => [{ "spell" => { "targets" => ["any_target"],
20
+ "effects" => [{ "deal_damage" => { "amount" => 3,
21
+ "to" => { "target" => 0 } } }] } }] }
22
+
23
+ def self.instant_speed? = true
24
+ end
25
+
26
+ # Sorcery (CR 307). Main phase, empty stack, own turn.
27
+ class Sorcery < SpellType
28
+ primitive :type, :sorcery,
29
+ description: "A spell cast only in its controller's main phase while the stack is empty.",
30
+ rules: %w[307.1 307.2 608.2n],
31
+ example: { "name" => "Divination", "mana_cost" => "{2}{U}", "types" => ["sorcery"],
32
+ "abilities" => [{ "spell" => { "effects" => [{ "draw" => { "amount" => 2 } }] } }] }
33
+
34
+ def self.instant_speed? = false
35
+ end
36
+ end
37
+ end
38
+ end