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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "crew"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Costs
8
+ # The cost of saddle N (CR 702.171a): "Tap any number of other untapped creatures you control with total
9
+ # power N or greater", chosen exactly like crew's. Each creature tapped for it saddles the permanent
10
+ # (CR 702.171c).
11
+ class Saddle < Crew
12
+ def choose!(engine, option) = super.merge("saddles" => true)
13
+ def action = :saddle
14
+
15
+ def pay_chosen!(engine, object, entries)
16
+ entries.each { engine.emit Events::SaddledMount.new(object: it["object"], mount: object.id) }
17
+ end
18
+ end
19
+ end
20
+
21
+ module Effects
22
+ # "[It] becomes saddled until end of turn" (CR 702.171a-b): the saddled designation, which stays until the
23
+ # turn ends or the permanent leaves the battlefield and isn't copiable (it lives on the object, stamped
24
+ # with the turn, Engine#saddled?). The effect of a saddle ability, and of cards that saddle a Mount
25
+ # without its saddle ability.
26
+ class BecomeSaddled < Effect
27
+ primitive :effect, :become_saddled,
28
+ description: "Each referenced permanent (default: this one) becomes saddled until end of turn " \
29
+ "(filters: `\"saddled\": true`).",
30
+ rules: %w[702.171a 702.171b],
31
+ params: { "to" => "selector of permanents (default self)" },
32
+ example: { "become_saddled" => { "to" => { "target" => 0 } } }
33
+
34
+ def self.keys = %w[to if]
35
+ def self.default_to = "self"
36
+ def to_s = "becomes saddled until end of turn → #{to}"
37
+
38
+ def apply(engine, ctx)
39
+ riders = (engine.object(ctx.object).paid || []).select { it["saddles"] }.map { it["object"] }
40
+ objects(engine, ctx).each do |id|
41
+ engine.saddle!(id)
42
+ engine.emit Events::BecameSaddled.new(object: id, riders: riders)
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ module Abilities
49
+ # Saddle N (CR 702.171a): an activated ability of a Mount, at sorcery speed. Written {"saddle": {"power": N}}.
50
+ class Saddle < ActivatedAbility
51
+ primitive :ability, :saddle,
52
+ description: "Saddle N: tap any number of other untapped creatures you control with total power N " \
53
+ "or more (chosen one at a time; `done` once the total is enough): this permanent " \
54
+ "becomes saddled until end of turn. Activate only as a sorcery.",
55
+ rules: %w[702.171a 702.171b 702.171c],
56
+ params: { "power" => "integer >= 1 (the N of saddle N)" },
57
+ example: { "saddle" => { "power" => 2 } }
58
+
59
+ def self.build(params, errors, path, **)
60
+ Mechanics.check_keys(params, %w[power], errors, path)
61
+ n = Mechanics.integer_param(params, :power, errors, path, min: 1)
62
+ new(sorcery: true, targets: [].freeze, costs: [Costs::Saddle.new(n)].freeze,
63
+ effects: [Effects::BecomeSaddled.new(to: Selector.new(:self), params: {}, errors: [], path: path)].freeze)
64
+ end
65
+
66
+ def power = costs.first.power
67
+ def to_s = "Saddle #{power}"
68
+ end
69
+ end
70
+
71
+ module Triggers
72
+ # "Whenever this creature becomes saddled" (CR 702.171b): a saddle ability (or another effect) saddled it.
73
+ class BecomesSaddled < BecomesCrewed
74
+ primitive :trigger, :becomes_saddled,
75
+ description: "The subject becomes saddled (its saddle ability resolved, or an effect saddled it).",
76
+ rules: %w[702.171b 603.2],
77
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}" },
78
+ example: { "becomes_saddled" => {} }
79
+
80
+ def event_class = Events::BecameSaddled
81
+
82
+ def occurrences(engine, event, source)
83
+ return [] unless match?(engine, event, source)
84
+
85
+ obj = engine.object(event.object)
86
+ [{ "object" => obj.id, "player" => obj.controller, "card" => obj.card, "amount" => event.riders.size }]
87
+ end
88
+
89
+ def to_s = "Whenever #{@subject} becomes saddled"
90
+ end
91
+
92
+ # "Whenever this creature saddles a Mount" (CR 702.171c): it was tapped to pay a saddle cost. The event's
93
+ # object for the effects ("that Mount") is the Mount.
94
+ class SaddlesMount < CrewsVehicle
95
+ primitive :trigger, :saddles_mount,
96
+ description: "The subject (a creature) saddles a Mount: it's tapped to pay that Mount's saddle " \
97
+ "cost. {\"triggering\": \"object\"} is the Mount.",
98
+ rules: %w[702.171c 603.2],
99
+ params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}" },
100
+ example: { "saddles_mount" => { "subject" => "self" } }
101
+
102
+ def event_class = Events::SaddledMount
103
+
104
+ def occurrences(engine, event, source)
105
+ return [] unless match?(engine, event, source)
106
+
107
+ mount = engine.state.objects[event.mount]
108
+ [{ "object" => event.mount, "player" => engine.object(event.object).controller, "card" => mount&.card }.compact]
109
+ end
110
+
111
+ def to_s = "Whenever #{@subject} saddles a Mount"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Effects
6
+ # "Your speed increases by 1" (CR 702.179c-d): a player without speed gets that much; never above 4.
7
+ class IncreaseSpeed < Effect
8
+ def self.keys = %w[to]
9
+ def apply(engine, ctx) = engine.increase_speed!(ctx.controller, 1)
10
+ def to_s = "your speed increases by 1"
11
+ end
12
+ end
13
+
14
+ module Keywords
15
+ # Start your engines! (CR 702.179a, 704.5aa): a player who controls a permanent with it and has no speed
16
+ # gets speed 1 (a state-based action). The inherent triggered ability of a player with speed (CR 702.179d)
17
+ # has no source; the engine (Engine::Speed) puts it on the stack as this keyword's ability
18
+ # "keyword:start_your_engines", which never triggers from the permanent itself (its zone is :rules).
19
+ class StartYourEngines < Primitive
20
+ primitive :keyword, :start_your_engines,
21
+ description: "If you control this permanent and have no speed, your speed becomes 1. Once you have " \
22
+ "speed, it increases by 1 (to at most 4) once on each of your turns when one or more " \
23
+ "opponents lose life. Pair with `max_speed` abilities and the `speed` amount.",
24
+ rules: %w[702.179a 702.179b 702.179c 702.179d 704.3],
25
+ example: { "keywords" => ["start_your_engines"] }
26
+
27
+ # "Whenever one or more opponents lose life during your turn" (matched by Engine::Speed#watch_speed!).
28
+ class OpponentsLoseLife
29
+ def watches?(_event) = false
30
+ def leaves_battlefield? = false
31
+ def to_s = "Whenever one or more opponents lose life during your turn"
32
+ end
33
+
34
+ def self.intrinsic_abilities
35
+ @intrinsic_abilities ||= [
36
+ Abilities::TriggeredAbility.new(
37
+ trigger: OpponentsLoseLife.new.freeze, targets: [].freeze, limit: "once_each_turn", zone: :rules,
38
+ condition: Condition.build({ "amount" => { "speed" => "you" }, "at_most" => 3 }, [], "speed"),
39
+ effects: [Effects::IncreaseSpeed.new(to: nil, params: {}, errors: [], path: "speed")].freeze
40
+ )
41
+ ].freeze
42
+ end
43
+ end
44
+ end
45
+
46
+ module Abilities
47
+ # Max speed (CR 702.178a): "As long as your speed is 4, this object has [ability]." Like a station symbol,
48
+ # its keywords and abilities are part of the object only while it applies; a card that isn't on the
49
+ # battlefield uses its owner's speed (CR 702.178b, 113.6c: e.g. an ability activated from the graveyard).
50
+ class MaxSpeed < Primitive
51
+ primitive :ability, :max_speed,
52
+ description: "While your speed is 4 (max speed), this object has `keywords` and `abilities` (any " \
53
+ "kind: activated, triggered, static, mana; an ability with a graveyard cost works from " \
54
+ "your graveyard).",
55
+ rules: %w[702.178a 702.178b 702.179e 113.6c],
56
+ params: { "keywords" => "list of keywords", "abilities" => "list of abilities" },
57
+ example: { "max_speed" => { "abilities" => [{ "activated" => { "cost" => ["tap"],
58
+ "effects" => [{ "draw" => { "amount" => 1 } }] } }] } }
59
+
60
+ attr_reader :keywords, :nested_abilities
61
+
62
+ def self.build(params, errors, path, **)
63
+ Mechanics.check_keys(params, %w[keywords abilities], errors, path)
64
+ keywords = StationSymbol.build_keywords(params, errors, path)
65
+ abilities = StationSymbol.build_abilities(params, errors, path)
66
+ abilities.each_with_index do |ability, i|
67
+ errors << "#{path}.abilities[#{i}]: max speed abilities can't be nested" if ability.kind == :max_speed
68
+ end
69
+ errors << "#{path}: needs keywords or abilities" if keywords.empty? && abilities.empty?
70
+ new(keywords, abilities)
71
+ end
72
+
73
+ def initialize(keywords, abilities)
74
+ super()
75
+ @keywords = keywords.freeze
76
+ @nested_abilities = abilities.freeze
77
+ freeze
78
+ end
79
+
80
+ def kind = :max_speed
81
+ def power = nil
82
+ def mana_ability? = false
83
+ def allowed_types = nil
84
+ def try_effects = []
85
+ def to_s = "Max speed — #{(keywords.map(&:to_s) + nested_abilities.map(&:to_s)).join("; ")}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Costs
6
+ # "Tap another untapped creature you control" as a cost (CR 702.184a, 602.2b). The player chooses
7
+ # the creature while activating (a `choose_cost` decision). Summoning sickness doesn't matter:
8
+ # CR 302.6 restricts only a creature's own {T} abilities.
9
+ class TapAnotherCreature < Primitive
10
+ primitive :cost, :tap_another_creature,
11
+ description: "Tap another untapped creature you control, chosen while activating. A creature " \
12
+ "that came under your control this turn can be tapped this way.",
13
+ rules: %w[702.184a 601.2h 602.2b 302.6],
14
+ example: "tap_another_creature"
15
+
16
+ INSTANCE = new.freeze
17
+
18
+ def self.build(params, errors, path, **)
19
+ Mechanics.check_keys(params, [], errors, path)
20
+ INSTANCE
21
+ end
22
+
23
+ def mana? = false
24
+ def payable?(engine, object) = !choice_candidates(engine, object).empty?
25
+ # Paid when chosen (Engine#choose_cost!).
26
+ def pay!(_engine, _object) = nil
27
+ def to_s = "Tap another untapped creature you control"
28
+
29
+ def choices_needed = 1
30
+ def choice_options(engine, object, _chosen) = choice_candidates(engine, object).map { { object: it } }
31
+
32
+ def choose!(engine, option)
33
+ creature = engine.object(option[:object])
34
+ engine.tap!(creature)
35
+ { "object" => creature.id, "power" => Abilities::TappingPower.of(engine, creature, :station), "tapped" => true }
36
+ end
37
+
38
+ def choice_candidates(engine, object)
39
+ engine.state.battlefield.select do |id|
40
+ other = engine.object(id)
41
+ id != object.id && other.controller == object.controller && !other.tapped && engine.chars(other).creature?
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ module Effects
48
+ # The effect of a station ability (CR 702.184a): put charge counters on its source equal to the
49
+ # power of the creature tapped to pay its cost. That's the creature's current power, or, if it has
50
+ # left the battlefield, its power when it was tapped; an ability may make it use its toughness (CR 702.184c,
51
+ # Abilities::TappingPower).
52
+ class StationCharge < Effect
53
+ def self.keys = %w[to]
54
+
55
+ def apply(engine, ctx)
56
+ paid = engine.object(ctx.object).paid&.first or return
57
+ creature = paid["object"]
58
+ on = engine.state.battlefield.include?(creature)
59
+ n = on ? Abilities::TappingPower.of(engine, engine.object(creature), :station) : paid["power"]
60
+ objects(engine, ctx).each { engine.add_counters!(engine.object(it), "charge", n) }
61
+ end
62
+
63
+ def to_s = "put charge counters equal to the tapped creature's power on this"
64
+ end
65
+ end
66
+
67
+ module Keywords
68
+ # Station (CR 702.184a): "Tap another untapped creature you control: Put a
69
+ # number of charge counters on this permanent equal to the tapped creature's power. Activate only as
70
+ # a sorcery." A station card has it at all times, whatever its charge counters (CR 722.4).
71
+ class Station < Primitive
72
+ primitive :keyword, :station,
73
+ description: "An activated ability: tap another untapped creature you control to put charge " \
74
+ "counters equal to its power on this permanent. Sorcery speed. Pair with " \
75
+ "`station_symbol` abilities for what the counters unlock.",
76
+ rules: %w[702.184a 722.4],
77
+ example: { "keywords" => ["station"] }
78
+
79
+ def self.intrinsic_abilities
80
+ @intrinsic_abilities ||= [
81
+ Abilities::ActivatedAbility.new(
82
+ sorcery: true, targets: [].freeze, costs: [Costs::TapAnotherCreature::INSTANCE].freeze,
83
+ effects: [Effects::StationCharge.new(to: Selector.new(:self), params: {}, errors: [], path: "station")].freeze
84
+ )
85
+ ].freeze
86
+ end
87
+ end
88
+ end
89
+
90
+ module Abilities
91
+ # A station symbol "{N+}" (CR 722.2): as long as this permanent has N or more charge counters, it
92
+ # has the listed keywords and abilities and, with a power/toughness box, is a creature with that base
93
+ # power and toughness in addition to its other types (CR 722.2a-b). A station card has no power or
94
+ # toughness off the battlefield (CR 722.2c), so P/T goes here, not on the card.
95
+ class StationSymbol < Primitive
96
+ primitive :ability, :station_symbol,
97
+ description: "While this permanent has at least `at` charge counters it has `keywords` and " \
98
+ "`abilities`; with `power`/`toughness` it is also a creature with that base power " \
99
+ "and toughness (layers 4 and 7b).",
100
+ rules: %w[722.2a 722.2b 722.2c 613.1d 613.4b],
101
+ params: { "at" => "integer >= 1 (the N of {N+})", "power" => "integer", "toughness" => "integer",
102
+ "keywords" => "list of keywords", "abilities" => "list of abilities" },
103
+ example: { "station_symbol" => { "at" => 6, "power" => 3, "toughness" => 4, "keywords" => ["flying"] } }
104
+
105
+ attr_reader :threshold, :power, :toughness, :keywords, :nested_abilities
106
+
107
+ def self.build(params, errors, path, **)
108
+ Mechanics.check_keys(params, %w[at power toughness keywords abilities], errors, path)
109
+ threshold = Mechanics.integer_param(params, :at, errors, path, min: 1)
110
+ stats = %w[power toughness].map { Mechanics.fetch_param(params, it) }
111
+ if stats.compact.size == 1 || !stats.compact.all?(Integer)
112
+ errors << "#{path}: power and toughness must both be integers, or both be left out"
113
+ stats = [nil, nil]
114
+ end
115
+ keywords = build_keywords(params, errors, path)
116
+ abilities = build_abilities(params, errors, path)
117
+ errors << "#{path}: needs power/toughness, keywords or abilities" if stats.compact.empty? && keywords.empty? && abilities.empty?
118
+ new(threshold, *stats, keywords, abilities)
119
+ end
120
+
121
+ def self.build_keywords(params, errors, path)
122
+ list = Mechanics.list_param(params, :keywords, errors, path).map { it.to_s.downcase.tr(" -", "__") }
123
+ list.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
124
+ list.map(&:to_sym)
125
+ end
126
+
127
+ def self.build_abilities(params, errors, path)
128
+ Mechanics.list_param(params, :abilities, errors, path).each_with_index.filter_map do |entry, i|
129
+ ability = Mechanics.build_entry(:ability, entry, errors, "#{path}.abilities[#{i}]")
130
+ if ability&.kind == :station_symbol
131
+ errors << "#{path}.abilities[#{i}]: station symbols can't be nested"
132
+ next
133
+ end
134
+ ability
135
+ end
136
+ end
137
+
138
+ def initialize(threshold, power, toughness, keywords, abilities)
139
+ super()
140
+ @threshold = threshold
141
+ @power = power
142
+ @toughness = toughness
143
+ @keywords = keywords.freeze
144
+ @nested_abilities = abilities.freeze
145
+ freeze
146
+ end
147
+
148
+ def kind = :station_symbol
149
+ def mana_ability? = false
150
+ def allowed_types = nil
151
+ def try_effects = []
152
+
153
+ def to_s
154
+ bits = []
155
+ bits << "#{power}/#{toughness} creature" if power
156
+ bits.concat(keywords.map(&:to_s))
157
+ bits.concat(nested_abilities.map(&:to_s))
158
+ "#{threshold}+ | #{bits.join("; ")}"
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Storied (CR 702.195a): "Any time you control three or more permanents that are artifacts, Sagas, and/or
7
+ # legendary and you don't have an enduring story, you have an enduring story for the rest of the game."
8
+ # The engine checks it (Engine::Designations#tell_enduring_stories!); cards read the designation with
9
+ # the condition `{"designation": "enduring_story"}`.
10
+ class Storied < Primitive
11
+ primitive :keyword, :storied,
12
+ description: "While you control this permanent, once you control three or more permanents that are " \
13
+ "artifacts, Sagas and/or legendary, you have an enduring story for the rest of the game " \
14
+ "(condition `{\"designation\": \"enduring_story\"}`).",
15
+ rules: %w[702.195a 702.195b 702.195c], example: { "keywords" => ["storied"] }
16
+ end
17
+ end
18
+
19
+ class Condition
20
+ # "If you have an enduring story" / "as long as you have an enduring story": the controller has a player
21
+ # designation (Engine::Designations; CR 702.195b).
22
+ class Designation
23
+ def self.build(data, errors, path)
24
+ Mechanics.check_keys(data, %w[designation], errors, path)
25
+ value = data["designation"]
26
+ known = Engine::Designations::NAMES
27
+ errors << "#{path}.designation: must be one of #{known.join(", ")}" unless known.include?(value)
28
+ new(value)
29
+ end
30
+
31
+ def initialize(designation)
32
+ @designation = designation
33
+ freeze
34
+ end
35
+
36
+ def true?(engine, ctx) = engine.designation?(ctx.controller, @designation)
37
+ def to_s = "you have #{@designation == "enduring_story" ? "an enduring story" : @designation.tr("_", " ")}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../abilities/play_permissions"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Triggers
8
+ # The number of spells cast this turn before the one whose cast is being looked at (its SpellCast is
9
+ # already in the turn's history, Engine::TurnHistory): a cast trigger's "that many" (storm, CR 702.40a).
10
+ def self.spells_before(engine)
11
+ engine.state.players.each_index.sum { engine.turn_log(it).fetch("spells", []).size } - 1
12
+ end
13
+ end
14
+
15
+ module Effects
16
+ # Cascade's effect (CR 702.85a): exile cards from the top of your library until you exile a nonland card
17
+ # whose mana value is less than this spell's; you may cast it without paying its mana cost; then all
18
+ # exiled cards not cast go on the bottom of your library in a random order. Discover's steps with
19
+ # another limit and miss.
20
+ class Cascade < Discover
21
+ def self.build_for_keyword = new(to: Selector.new(:you), params: { "amount" => 0 }, errors: [], path: "cascade")
22
+
23
+ def to_s = "cascade"
24
+
25
+ private
26
+
27
+ # Less than this spell's mana value (X included on the stack, CR 202.3e); if the spell has left the
28
+ # stack, its card's mana value.
29
+ def limit(engine, ctx)
30
+ spell = engine.state.objects[ctx.source]
31
+ cost = spell ? engine.chars(spell).mana_cost&.with_x(spell.x.to_i) : triggering_cost(engine, ctx)
32
+ cost ? cost.mana_value - 1 : -1
33
+ end
34
+
35
+ def triggering_cost(engine, ctx)
36
+ card = Selector.memory(engine, ctx).dig("triggering", "card")
37
+ card && engine.catalog.fetch(card).mana_cost
38
+ end
39
+
40
+ def miss_type = "decline"
41
+ def prompt = "Cascade: you may cast it without paying its mana cost"
42
+ def miss!(_engine, _hit) = nil
43
+ end
44
+ end
45
+
46
+ module Keywords
47
+ # Storm (CR 702.40a): "When you cast this spell, copy it for each other spell that was cast before it this
48
+ # turn. If the spell has any targets, you may choose new targets for any of the copies." Each instance
49
+ # triggers separately (CR 702.40b).
50
+ class Storm < Primitive
51
+ primitive :keyword, :storm,
52
+ description: "When you cast this spell, copy it for each other spell cast before it this turn; you " \
53
+ "may choose new targets for the copies.",
54
+ rules: %w[702.40a 702.40b 707.10], example: { "keywords" => ["storm"] }
55
+
56
+ ABILITY = { "triggered" => { "when" => { "cast" => { "self" => true } },
57
+ "effects" => [{ "copy_spell" => { "of" => "self", "amount" => { "triggering" => "amount" },
58
+ "new_targets" => true } }] } }.freeze
59
+
60
+ def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "storm")].freeze
61
+ end
62
+
63
+ # Cascade (CR 702.85a): "When you cast this spell, exile cards from the top of your library until you exile
64
+ # a nonland card whose mana value is less than this spell's mana value. You may cast that card without
65
+ # paying its mana cost if the resulting spell's mana value is less than this spell's mana value. Then put
66
+ # all cards exiled this way that weren't cast on the bottom of your library in a random order." Each
67
+ # instance triggers separately (CR 702.85c).
68
+ class Cascade < Primitive
69
+ primitive :keyword, :cascade,
70
+ description: "When you cast this spell, exile cards from the top of your library until you exile a " \
71
+ "nonland card with lesser mana value. You may cast it without paying its mana cost. " \
72
+ "Then the exiled cards not cast go on the bottom of your library in a random order.",
73
+ rules: %w[702.85a 702.85c 608.2g], example: { "keywords" => ["cascade"] }
74
+
75
+ def self.intrinsic_abilities
76
+ @intrinsic_abilities ||= begin
77
+ trigger = Keywords.ability_from_data({ "triggered" => { "when" => { "cast" => { "self" => true } },
78
+ "effects" => [] } }, "cascade").trigger
79
+ [Abilities::TriggeredAbility.new(trigger: trigger, targets: [].freeze,
80
+ effects: [Effects::Cascade.build_for_keyword].freeze)].freeze
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Sunburst (CR 702.44): enters with a counter for each color of mana spent to cast it.
7
+ class Sunburst < Primitive
8
+ primitive :keyword, :sunburst,
9
+ description: "As this permanent enters from the stack as a resolving spell, it gets a +1/+1 counter " \
10
+ "(a creature) or a charge counter (otherwise) for each color of mana spent to cast it.",
11
+ rules: %w[702.44a 702.44b], example: { "keywords" => ["sunburst"] }
12
+
13
+ COLORS = Mana::COLORS.map(&:to_s).freeze
14
+
15
+ # The counters a resolving spell enters with (CR 702.44a); type-changing effects are ignored,
16
+ # so the printed card types decide the kind. CR 702.44b: only colored mana counts.
17
+ def self.entering_counters(engine, spell)
18
+ printed = engine.chars(spell)
19
+ return {} unless printed.keyword?(:sunburst)
20
+
21
+ colors = (spell.mana_spent || []).uniq & COLORS
22
+ return {} if colors.empty?
23
+
24
+ { printed.creature? ? "+1/+1" : "charge" => colors.size }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end