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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../abilities/play_permissions"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Keywords
8
+ # Suspend (CR 702.62a-b): a card that has suspend and is exiled with time counters is "suspended". At the
9
+ # beginning of its owner's upkeep they remove a time counter from it; when the last is removed, if it's
10
+ # exiled, they may play it without paying its mana cost, and a creature spell cast this way has haste
11
+ # while they control it. Both triggered abilities are the engine's (#watch!), put on the stack as this
12
+ # keyword's ability "keyword:suspend", so they work for a card that gained suspend ("exile it with four
13
+ # time counters; if it doesn't have suspend, it gains suspend", Effects::Suspend) as for a printed one.
14
+ class Suspend < Primitive
15
+ primitive :keyword, :suspend,
16
+ description: "This card has suspend: while it's exiled with time counters, its owner removes one at " \
17
+ "the beginning of each of their upkeeps; when the last is removed they may play it without " \
18
+ "paying its mana cost (a creature spell gains haste). \"Suspend N—cost\" is the ability " \
19
+ "`suspend`.",
20
+ rules: %w[702.62a 702.62b], example: { "keywords" => ["suspend"] }
21
+
22
+ REF = "keyword:suspend"
23
+
24
+ # Never triggers by itself (zone :rules): the engine queues it (#watch!).
25
+ class Rules
26
+ def watches?(_event) = false
27
+ def leaves_battlefield? = false
28
+ def to_s = "Suspend"
29
+ end
30
+
31
+ def self.intrinsic_abilities
32
+ @intrinsic_abilities ||= [
33
+ Abilities::TriggeredAbility.new(trigger: Rules.new.freeze, targets: [].freeze, zone: :rules,
34
+ effects: [Effects::SuspendStep.new].freeze)
35
+ ].freeze
36
+ end
37
+
38
+ # The card has suspend: printed, from "Suspend N—cost", or gained in exile (memory "suspend").
39
+ def self.has?(engine, obj)
40
+ return true if obj.memory["suspend"]
41
+
42
+ chars = engine.chars(obj)
43
+ chars.keyword?(:suspend) || chars.abilities.any? { it.ability.is_a?(Abilities::Suspend) }
44
+ end
45
+
46
+ # CR 702.62b.
47
+ def self.suspended?(engine, obj) = obj.zone == :exile && obj.counters["time"].to_i.positive? && has?(engine, obj)
48
+
49
+ # Queues the upkeep trigger of each suspended card of the active player, and the "last counter removed"
50
+ # trigger of an exiled card with suspend.
51
+ def self.watch!(engine, event)
52
+ case event
53
+ when Events::StepBegan
54
+ return unless event.step == "upkeep"
55
+
56
+ engine.state.exile.each do |id|
57
+ obj = engine.object(id)
58
+ queue!(engine, obj, event, "upkeep") if obj.owner == engine.state.active && suspended?(engine, obj)
59
+ end
60
+ when Events::CountersChanged
61
+ return unless event.counter == "time" && event.to.zero? && event.from.positive?
62
+
63
+ obj = engine.state.objects[event.object]
64
+ queue!(engine, obj, event, "last") if obj&.zone == :exile && has?(engine, obj)
65
+ end
66
+ end
67
+
68
+ # CR 108.4a: the ability of a card in exile is controlled by its owner.
69
+ def self.queue!(engine, obj, event, step)
70
+ engine.queue_pending_trigger!({ "id" => obj.id, "card" => obj.card, "controller" => obj.owner, "ability" => REF },
71
+ event, { "suspend" => step })
72
+ end
73
+ end
74
+ end
75
+
76
+ module Abilities
77
+ # "Suspend N—[cost]" (CR 702.62a, 116.2f): if you could begin to cast this card from your hand, you may pay
78
+ # `cost` and exile it with N time counters (a special action, `{type: "special_action"}`). The card has
79
+ # suspend (Keywords::Suspend).
80
+ class Suspend < Primitive
81
+ primitive :ability, :suspend,
82
+ description: "Suspend `amount`—`cost`: whenever you could begin to cast this card from your hand, you " \
83
+ "may pay `cost` and exile it with `amount` time counters (special action " \
84
+ "`{type: \"special_action\"}`); then it works as the keyword `suspend`.",
85
+ rules: %w[702.62a 702.62c 116.2f],
86
+ params: { "amount" => "integer >= 1 (time counters)", "cost" => "mana cost string" },
87
+ example: { "suspend" => { "amount" => 3, "cost" => "{2}{W}" } }
88
+
89
+ attr_reader :amount, :special_cost
90
+
91
+ def self.build(params, errors, path, **)
92
+ Mechanics.check_keys(params, %w[amount cost], errors, path)
93
+ amount = Mechanics.integer_param(params, :amount, errors, path, min: 1)
94
+ cost = Costs::ManaPayment.build(Mechanics.fetch_param(params, :cost).to_s, errors, "#{path}.cost").mana_cost
95
+ new(amount, cost)
96
+ end
97
+
98
+ def initialize(amount, cost)
99
+ super()
100
+ @amount = amount
101
+ @special_cost = cost
102
+ freeze
103
+ end
104
+
105
+ def kind = :suspend
106
+ def mana_ability? = false
107
+ def allowed_types = nil
108
+ def try_effects = []
109
+ def to_s = "Suspend #{amount}—#{special_cost}"
110
+
111
+ # CR 702.62a, 702.62c: when you could begin to cast it from your hand (timing; nothing forbids it).
112
+ def special_allowed?(engine, obj, index)
113
+ chars = engine.chars(obj)
114
+ obj.zone == :hand && (chars.type?(:instant) || chars.keyword?(:flash) || engine.sorcery_timing?(index)) &&
115
+ !engine.cast_forbidden?(index, obj)
116
+ end
117
+
118
+ def special!(engine, obj, _index) = Effects::Suspend.exile!(engine, obj.id, amount)
119
+ end
120
+ end
121
+
122
+ module Effects
123
+ # "Exile [it] with N time counters on it. If it doesn't have suspend, it gains suspend." (CR 702.62a-b)
124
+ class Suspend < Effect
125
+ primitive :effect, :suspend,
126
+ description: "Exile each referenced card or spell with `amount` time counters on it; if it doesn't " \
127
+ "have suspend, it gains suspend (keyword `suspend`) while it stays exiled.",
128
+ rules: %w[702.62a 702.62b 406.1],
129
+ params: { "amount" => "amount (time counters)", "to" => "selector (a spell, a card or a permanent)" },
130
+ example: { "suspend" => { "to" => { "triggering" => "object" }, "amount" => 4 } }
131
+
132
+ def self.keys = %w[amount to if]
133
+
134
+ def self.exile!(engine, id, amount)
135
+ fresh = engine.move!(id, :exile, reason: "suspend")
136
+ return unless fresh&.zone == :exile
137
+
138
+ fresh.memory["suspend"] = true
139
+ engine.add_counters!(fresh, "time", amount, by_player: fresh.owner)
140
+ end
141
+
142
+ def apply(engine, ctx)
143
+ n = amount_for(engine, ctx)
144
+ refs(engine, ctx).each do |id|
145
+ obj = engine.state.objects[id]
146
+ Suspend.exile!(engine, id, n) if obj && !obj.ability? && obj.zone != :exile
147
+ end
148
+ end
149
+
150
+ def to_s = "exile with #{amount} time counters (suspend) → #{to}"
151
+ end
152
+
153
+ # The resolving suspend trigger (Keywords::Suspend): "upkeep" removes a time counter if the card is still
154
+ # suspended (CR 603.4); "last" lets its owner play it free if it's still exiled. A creature spell cast this
155
+ # way has haste while they control it (memory "haste_while_controlled_by").
156
+ class SuspendStep < CastFree
157
+ def self.keys = []
158
+
159
+ def initialize
160
+ errors = []
161
+ super(to: Selector.build("self", errors, "keyword:suspend", targets_count: 0), params: { "play" => true },
162
+ errors: errors, path: "keyword:suspend")
163
+ end
164
+
165
+ def run(engine, ctx, locals)
166
+ card = engine.state.objects[ctx.source]
167
+ return :done unless card&.zone == :exile || locals["asked"] || locals["casting"]
168
+
169
+ if Selector.memory(engine, ctx)["suspend"] == "upkeep"
170
+ engine.remove_counters!(card, "time", 1) if Keywords::Suspend.suspended?(engine, card)
171
+ return :done
172
+ end
173
+ super
174
+ end
175
+
176
+ def to_s = "remove a time counter; when the last is removed, you may play it without paying its mana cost"
177
+
178
+ private
179
+
180
+ def settle_cast!(engine, locals)
181
+ id = locals["casting"]
182
+ super
183
+ return if id.nil? || engine.state.objects.key?(id) || engine.state.stack.empty?
184
+
185
+ spell = engine.object(engine.state.stack.first)
186
+ spell.memory["haste_while_controlled_by"] = spell.controller if engine.chars(spell).creature?
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "crew"
4
+ require_relative "kicker"
5
+
6
+ module Enjine
7
+ module Mechanics
8
+ module Costs
9
+ # The cost of teamwork N (CR 702.194a): "tap any number of creatures you control with total power N or
10
+ # more", chosen like crew's (one `choose_cost` decision per creature, `done` once the total is enough).
11
+ class Teamwork < Crew
12
+ def to_s = "Tap any number of untapped creatures you control with total power #{power} or greater"
13
+
14
+ # "Whenever this creature becomes tapped to pay a teamwork cost" (trigger becomes_tapped `for`).
15
+ def pay_chosen!(engine, object, entries)
16
+ entries.each { engine.emit Events::TappedForCost.new(object: it["object"], cost: "teamwork", spell: object.id) }
17
+ end
18
+ end
19
+ end
20
+
21
+ module Abilities
22
+ # Teamwork N (CR 702.194a): "As an additional cost to cast this spell, you may tap any number of creatures
23
+ # you control with total power N or more." It is announced like kicker (the `choose_kicker` decision) and
24
+ # paid like additional costs (CR 601.2b, 601.2f-h). "If this spell was cast using teamwork" is the condition
25
+ # `{"cast_with": "teamwork"}` (CR 702.194b); targets only for teamwork are `kicked_targets` (CR 702.194c).
26
+ class Teamwork < Kicker
27
+ primitive :ability, :teamwork,
28
+ description: "Teamwork N: as you cast this spell you may also tap any number of untapped creatures " \
29
+ "you control with total power N or more (the `choose_kicker` decision, then one " \
30
+ "`choose_cost` per creature). `{\"cast_with\": \"teamwork\"}` holds if you did; " \
31
+ "`kicked_targets` are its targets then.",
32
+ rules: %w[702.194a 702.194b 702.194c 601.2b 601.2f 601.2h],
33
+ params: { "power" => "integer >= 1 (the N of teamwork N)" },
34
+ example: { "teamwork" => { "power" => 4 } }
35
+
36
+ def self.build(params, errors, path, **)
37
+ Mechanics.check_keys(params, %w[power], errors, path)
38
+ new([Costs::Teamwork.new(Mechanics.integer_param(params, :power, errors, path, min: 1).to_i)].freeze)
39
+ end
40
+
41
+ def to_s = "Teamwork #{costs.first.power}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../effects/pay"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Abilities
8
+ # Ward (CR 702.21a): "Whenever this permanent becomes the target of a spell or ability an opponent
9
+ # controls, counter that spell or ability unless that player pays [cost]." A triggered ability: it
10
+ # triggers as the target is chosen (CR 601.2c), goes on the stack once the spell is cast, and on
11
+ # resolution the targeting spell's controller may pay. Engine#choose_target! tells the trigger which
12
+ # spell or ability targeted it (its memory "targeted_by").
13
+ class Ward < TriggeredAbility
14
+ primitive :ability, :ward,
15
+ description: "Ward: whenever this permanent becomes the target of a spell or ability an opponent " \
16
+ "controls, counter that spell or ability unless its controller pays `cost`: a mana cost, " \
17
+ "or a list of costs (\"Ward—Pay 2 life\", \"Ward—Discard a card or pay {2}\" with " \
18
+ "`one_of`). With `waterbend`, they waterbend that cost: \"Ward—Waterbend {4}\".",
19
+ rules: %w[702.21a 601.2c 603.2 701.6a 701.67a],
20
+ params: { "cost" => "mana cost string, e.g. \"{2}\", or a list of costs, e.g. [{\"pay_life\": 2}]",
21
+ "waterbend" => "mana cost string, instead of cost" },
22
+ example: { "ward" => { "cost" => "{2}" } }
23
+
24
+ # The trigger: this permanent becomes the target of an opponent's spell or ability.
25
+ class BecomesTarget
26
+ def event_class = Events::TargetChosen
27
+ def watches?(event) = event.is_a?(Events::TargetChosen)
28
+ def leaves_battlefield? = false
29
+ def to_s = "When this becomes the target of a spell or ability an opponent controls"
30
+
31
+ def match?(engine, event, source)
32
+ return false unless event.target == source["id"]
33
+
34
+ engine.names.index(event.player) != source["controller"]
35
+ end
36
+ end
37
+
38
+ # Counters the spell or ability that targeted the permanent unless its controller pays.
39
+ class Counter < Effects::CounterUnlessPays
40
+ def refs(engine, ctx) = [engine.object(ctx.object).memory["targeted_by"]]
41
+ def to_s = "counter that spell or ability unless its controller pays #{what}"
42
+ end
43
+
44
+ TRIGGER = BecomesTarget.new.freeze
45
+
46
+ # `cost`: a mana cost string ("Ward {2}") or a list of costs ("Ward—Pay 2 life", CR 702.21a).
47
+ def self.build(params, errors, path, **)
48
+ Mechanics.check_keys(params, %w[cost waterbend], errors, path)
49
+ raw = Mechanics.fetch_param(params, :cost)
50
+ bend = Mechanics.fetch_param(params, :waterbend)
51
+ errors << "#{path}.cost: required" if raw.nil? && bend.nil?
52
+ errors << "#{path}: needs exactly one of cost, waterbend" if params.key?("cost") && params.key?("waterbend")
53
+ effect = if raw.is_a?(Array) then Counter.new(to: nil, params: { "cost" => raw }, errors: errors, path: path)
54
+ elsif bend then Counter.new(to: nil, params: { "waterbend" => bend }, errors: errors, path: path)
55
+ else Counter.new(to: nil, params: { "mana" => raw || "{0}" }, errors: errors, path: "#{path}.cost")
56
+ end
57
+ new(targets: [].freeze, effects: [effect].freeze, trigger: TRIGGER)
58
+ end
59
+
60
+ def to_s
61
+ costs = effects.first.costs
62
+ costs.size == 1 && costs.first.instance_of?(Costs::ManaPayment) ? "Ward #{costs.first}" : "Ward—#{costs.join(", ")}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # Linked abilities (CR 607.2a, 406.6): cards an ability of a source exiled are kept in the source's
6
+ # memory, so another ability of the same object can name "the cards exiled with it". A source that
7
+ # changes zones is a new object (CR 400.7) with no memory, so the link ends with it. Also the last
8
+ # known controller of objects a resolving effect moved (CR 608.2h), for {"controller_of": selector}.
9
+ module Linked
10
+ EXILED = "exiled_with"
11
+ UNTIL_LEAVES = "until_leaves"
12
+ LKI = "lki"
13
+
14
+ # Cards still in exile that an ability of `source` exiled (CR 607.2a). Once the source has left the
15
+ # battlefield, its triggered ability remembers them ("when this leaves, return the exiled card").
16
+ def self.exiled_with(engine, source, ctx = nil)
17
+ obj = engine.state.objects[source]
18
+ ids = obj ? obj.memory[EXILED] : ctx && engine.state.objects[ctx.object]&.memory&.dig("source_lki", EXILED)
19
+ Array(ids).select { engine.state.exile.include?(it) }
20
+ end
21
+
22
+ # Called by a resolving effect before it moves an object: who controlled it (CR 608.2h), kept on the
23
+ # resolving spell or ability. Only a permanent has a controller other than its owner.
24
+ def self.remember!(engine, ctx, id)
25
+ obj = engine.object(id)
26
+ (engine.object(ctx.object).memory[LKI] ||= {})[id] = obj.controller if obj.zone == :battlefield
27
+ end
28
+
29
+ # An amount whose {"count": selector} may name earlier effects' results ("up to that many").
30
+ def self.amount(raw, errors, path, targets_count, results_count)
31
+ return Amount.build(raw, errors, path, targets_count: targets_count) unless raw.is_a?(Hash) && raw.keys.map(&:to_s) == ["count"]
32
+
33
+ Amount.new("count", Selector.build(raw.values.first, errors, "#{path}.count", targets_count: targets_count,
34
+ results_count: results_count))
35
+ end
36
+ end
37
+
38
+ class Condition
39
+ # Whether the referenced objects (e.g. the cards exiled with a source, CR 607.2a) have at least one
40
+ # card type in common. No objects share nothing.
41
+ class ShareCardType
42
+ def self.build(data, errors, path, targets_count)
43
+ Mechanics.check_keys(data, %w[share_card_type], errors, path)
44
+ raw = data["share_card_type"]
45
+ list = raw.is_a?(Array) ? raw : [raw]
46
+ new(list.each_with_index.map do |sel, i|
47
+ Selector.build(sel, errors, "#{path}.share_card_type#{"[#{i}]" if raw.is_a?(Array)}", targets_count: targets_count)
48
+ end)
49
+ end
50
+
51
+ def initialize(selectors)
52
+ @selectors = selectors
53
+ freeze
54
+ end
55
+
56
+ # A list of selectors is "they share a card type": each must still name an object (CR 701.12a).
57
+ def true?(engine, ctx)
58
+ groups = @selectors.map { |sel| sel&.resolve(engine, ctx).to_a.filter_map { engine.state.objects[it] } }
59
+ objects = groups.flatten
60
+ groups.none?(&:empty?) && !objects.map { engine.chars(it).types }.reduce(:&).empty?
61
+ end
62
+
63
+ def to_s = "#{@selectors.join(" and ")} share a card type"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ class Amount
6
+ # {"mana_spent": {"of": ..., "color": "W" | "colors": true}}: the mana spent to cast a spell (CR 601.2h),
7
+ # recorded by type as it was paid (an "any type" payment counts as the mana's real type, CR 609.4b):
8
+ # no option the total amount of mana spent ("X is the amount of mana spent to cast it")
9
+ # "color": "W" how much of one type was spent ("if {W}{W} was spent": at_least 2; "C" for colorless)
10
+ # "colors": true how many colors were spent (converge, CR 207.2c; colorless mana is no color, CR 105.1)
11
+ # "from": spec how much came from sources matching a card spec ("if mana from a Treasure was spent":
12
+ # {"subtypes": ["treasure"]}), as they were when they made it (Engine::ManaSources)
13
+ # `of` is whose: "self" (default) is this spell, or the permanent this card became as a resolving spell
14
+ # (CR 400.7d; its triggered abilities remember it once it has left); {"triggering": "object"} is the
15
+ # spell a "whenever you cast" trigger is about, as it was cast; {"target": n} is a target spell. Nothing
16
+ # was spent on an object that wasn't cast, and a copy of a spell had no mana spent on it (CR 707.10).
17
+ class ManaSpent
18
+ OF = "\"self\", {\"triggering\": \"object\"} or {\"target\": n}"
19
+
20
+ def self.build(data, errors, path, targets_count:, results_count: 0)
21
+ errors << "#{path}: must be an object" unless data.is_a?(Hash)
22
+ data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
23
+ Mechanics.check_keys(data, %w[of color colors from], errors, path)
24
+ of = Selector.build(data.fetch("of", "self"), errors, "#{path}.of", targets_count: targets_count,
25
+ results_count: results_count)
26
+ errors << "#{path}.of: must be #{OF}" if of && !(%i[self target].include?(of.kind) || of.value == "object")
27
+ color = data["color"]
28
+ errors << "#{path}.color: must be one of #{Mana::TYPES.join(", ")}" unless color.nil? || Mana::TYPES.map(&:to_s).include?(color)
29
+ errors << "#{path}: color and colors can't be used together" if color && data["colors"]
30
+ errors << "#{path}.colors: must be true" unless [nil, true].include?(data["colors"])
31
+ errors << "#{path}: from can't be used with color or colors" if data.key?("from") && (color || data["colors"])
32
+ from = data.key?("from") ? CardSpec.build(data["from"], errors, "#{path}.from") : nil
33
+ new(of, color, data["colors"] == true, from)
34
+ end
35
+
36
+ def initialize(whose, color, colors, from = nil)
37
+ @of = whose
38
+ @color = color
39
+ @colors = colors
40
+ @from = from
41
+ freeze
42
+ end
43
+
44
+ def value(engine, ctx)
45
+ return from_count(engine, ctx) if @from
46
+
47
+ spent = ManaSpent.spent(engine, ctx, @of) || []
48
+ return (spent.uniq & Keywords::Sunburst::COLORS).size if @colors
49
+ return spent.count(@color) if @color
50
+
51
+ spent.size
52
+ end
53
+
54
+ def to_s
55
+ what = if @colors then "the number of colors of mana"
56
+ elsif @color then "the amount of {#{@color}}"
57
+ elsif @from then "the amount of mana from a #{@from.to_s.delete_suffix(" card")} source"
58
+ else "the amount of mana"
59
+ end
60
+ "#{what} spent to cast #{@of&.kind == :self ? "it" : @of}"
61
+ end
62
+
63
+ # How much of the mana spent came from sources matching `from` (their characteristics then, CR 106.6).
64
+ def from_count(engine, ctx)
65
+ sources = ManaSpent.spent(engine, ctx, @of, key: "mana_from") || []
66
+ sources.count { @from.chars_match?(engine, Engine::ManaSources.source_chars(it)) }
67
+ end
68
+
69
+ # The mana types spent on the object `whose` (the `of` selector) names, or nil when none was recorded.
70
+ # `key` "mana_from": instead, the remembered sources of the mana that came from artifacts.
71
+ def self.spent(engine, ctx, whose, key: "mana_spent")
72
+ case whose&.kind
73
+ when :target # a spell the resolving one countered: as it last existed (Engine::TurnHistory#remember_spell!)
74
+ ref = ctx.targets&.[](whose.value)
75
+ obj = engine.state.objects[ref]
76
+ obj ? recorded(obj, key) : engine.resolving_last_known(ref)&.[](key)
77
+ when :triggering then Selector.memory(engine, ctx).dig("triggering", key)
78
+ else own(engine, ctx, key)
79
+ end
80
+ end
81
+
82
+ # What `obj` recorded as it was cast: its mana spent (an attribute) or the mana's sources (memory).
83
+ def self.recorded(obj, key) = key == "mana_spent" ? obj.mana_spent : obj.memory[key]
84
+
85
+ # "This spell" while it resolves (or as it enters), else the source permanent, else what the resolving
86
+ # triggered ability remembers of a source that has left (CR 603.4, 608.2h).
87
+ def self.own(engine, ctx, key = "mana_spent")
88
+ holder = engine.state.objects[ctx.object]
89
+ return recorded(holder, key) if holder && !holder.ability?
90
+
91
+ source = engine.state.objects[ctx.source]
92
+ return recorded(source, key) if source && !source.ability? && %i[battlefield stack].include?(source.zone)
93
+
94
+ holder&.memory&.[]("source_#{key}")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end