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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Increment (CR 702.191a): "Whenever you cast a spell, if this permanent is a creature and the amount of
7
+ # mana spent to cast that spell is greater than this creature's power or this creature's toughness, put a
8
+ # +1/+1 counter on this creature." Each instance triggers separately (CR 702.191b).
9
+ class Increment < Primitive
10
+ primitive :keyword, :increment,
11
+ description: "Whenever you cast a spell, if this is a creature and the mana spent to cast that " \
12
+ "spell is greater than its power or its toughness, put a +1/+1 counter on it.",
13
+ rules: %w[702.191a 702.191b 603.4], example: { "keywords" => ["increment"] }
14
+
15
+ # "greater than this creature's [stat]": mana spent - stat >= 1.
16
+ def self.exceeds(stat)
17
+ { "amount" => { "plus" => [{ "mana_spent" => { "of" => { "triggering" => "object" } } },
18
+ { "times" => [{ stat => "self" }, -1] }] }, "at_least" => 1 }
19
+ end
20
+
21
+ ABILITY = { "triggered" => {
22
+ "when" => { "cast" => { "controller" => "you" } },
23
+ "if" => { "all" => [{ "source" => "creature" }, { "any" => [exceeds("power"), exceeds("toughness")] }] },
24
+ "effects" => [{ "add_counters" => { "counter" => "+1/+1", "amount" => 1, "to" => "self" } }]
25
+ } }.freeze
26
+
27
+ def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "increment")].freeze
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Job select (CR 702.182a): "When this Equipment enters, create a 1/1 colorless Hero creature token,
7
+ # then attach this Equipment to it."
8
+ class JobSelect < Primitive
9
+ primitive :keyword, :job_select,
10
+ description: "When this Equipment enters, create a 1/1 colorless Hero creature token, then " \
11
+ "attach this Equipment to it.",
12
+ rules: %w[702.182a], example: { "keywords" => ["job_select"] }
13
+
14
+ HERO = { "name" => "Hero", "types" => ["creature"], "subtypes" => ["hero"], "colors" => [], "power" => 1,
15
+ "toughness" => 1 }.freeze
16
+ ABILITY = { "triggered" => { "when" => { "enters" => {} },
17
+ "effects" => [{ "create_token" => { "token" => HERO } },
18
+ { "attach" => { "to" => { "result" => 0 } } }] } }.freeze
19
+
20
+ def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "job_select")].freeze
21
+
22
+ # CR 702.182a: only an Equipment can be attached this way.
23
+ def self.validate(card, errors)
24
+ errors << "keywords: job_select is only for Equipment" unless Array(card["subtypes"]).include?("equipment")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # Additional costs of a spell (CR 601.2b, 601.2f-h, 118.8): paid as the spell is cast, on top of its
7
+ # mana cost. Mana parts add to the total cost; other parts (sacrifice a creature) are chosen while
8
+ # casting and paid as it becomes cast.
9
+ class SpellCost < Primitive
10
+ attr_reader :costs
11
+
12
+ def self.build(params, errors, path, **)
13
+ Mechanics.check_keys(params, %w[cost], errors, path)
14
+ costs = StackAbility.parse_costs(params, errors, path)
15
+ errors << "#{path}.cost: must be a non-empty list" if costs.empty? && errors.none? { it.start_with?("#{path}.cost") }
16
+ costs.each_with_index do |cost, i|
17
+ next if spell_cost?(cost)
18
+
19
+ errors << "#{path}.cost[#{i}]: a spell's additional cost must be mana, life or a chosen cost (sacrifice, tap, ...)"
20
+ end
21
+ new(costs)
22
+ end
23
+
24
+ def self.spell_cost?(cost)
25
+ return cost.options.all? { spell_cost?(it) } if cost.is_a?(Costs::OneOf)
26
+
27
+ cost.mana? || cost.respond_to?(:choices_needed) || cost.respond_to?(:spell_cost?)
28
+ end
29
+
30
+ def initialize(costs, optional: false)
31
+ super()
32
+ @costs = costs
33
+ @optional = optional
34
+ freeze
35
+ end
36
+
37
+ def mana_ability? = false
38
+ def allowed_types = nil
39
+ def try_effects = []
40
+ def mana_cost = Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
41
+ def choosing_costs = costs.reject(&:mana?)
42
+ # Recorded as the spell's "kicked_with" when this optional cost is paid.
43
+ def keyword = self.class.primitive_name.to_s
44
+ # Whether paying it makes the spell kicked (CR 702.33d).
45
+ def kicks? = false
46
+
47
+ # Whether the non-mana parts can be paid by the spell's controller (CR 118.3).
48
+ def payable?(engine, spell) = choosing_costs.all? { it.payable?(engine, spell) }
49
+ end
50
+
51
+ # Kicker (CR 702.33a): "You may pay an additional [cost] as you cast this spell." The choice is
52
+ # announced while casting (CR 601.2b); a kicked spell remembers it (condition {"kicked": true},
53
+ # CR 702.33d) and may have other targets (`kicked_targets` of its spell ability, CR 702.33g).
54
+ class Kicker < SpellCost
55
+ primitive :ability, :kicker,
56
+ description: "Kicker: as you cast this spell you may also pay `cost` (mana and/or chosen costs " \
57
+ "such as `sacrifice_permanent`). The spell is then kicked: `{\"kicked\": true}` " \
58
+ "conditions hold and its `kicked_targets` are used.",
59
+ rules: %w[702.33a 702.33d 702.33g 601.2b 601.2f 601.2h],
60
+ params: { "cost" => "list of costs, e.g. [{\"mana\": \"{1}{U}\"}]" },
61
+ example: { "kicker" => { "cost" => [{ "mana" => "{1}{U}" }] } }
62
+
63
+ def kind = :kicker
64
+ def kicks? = true
65
+ def to_s = "Kicker #{costs.join(", ")}"
66
+ end
67
+
68
+ # An additional cost: "As an additional cost to cast this spell, sacrifice a creature." (CR 601.2b,
69
+ # 601.2f-h, 118.8). An optional one ("you may behold a Dragon", CR 118.8b) is announced like kicker (the
70
+ # `choose_kicker` decision) without making the spell kicked: {"cast_with": "additional_cost"} holds if it
71
+ # was paid.
72
+ class AdditionalCost < SpellCost
73
+ primitive :ability, :additional_cost,
74
+ description: "As an additional cost to cast this spell, pay `cost` too (mana and/or chosen costs " \
75
+ "such as `sacrifice_permanent`). The spell can't be cast without it. `optional: true`: " \
76
+ "you may pay it (announced with the `choose_kicker` decision); the condition " \
77
+ "`{\"cast_with\": \"additional_cost\"}` then holds (the spell isn't kicked).",
78
+ rules: %w[601.2b 601.2f 601.2h 118.8 118.8b],
79
+ params: { "cost" => "list of costs", "optional" => "true: you may pay it" },
80
+ example: { "additional_cost" => { "cost" => [{ "sacrifice_permanent" => { "filter" => "creature" } }] } }
81
+
82
+ def self.build(params, errors, path, **)
83
+ optional = Mechanics.flag_param(params, :optional, errors, path)
84
+ new(super(params.except("optional", :optional), errors, path).costs, optional: optional)
85
+ end
86
+
87
+ def kind = @optional ? :kicker : :additional_cost
88
+ def to_s = "As an additional cost to cast this spell#{", you may" if @optional}: #{costs.join(", ")}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # A keyword ability with a number that stands for a triggered ability written in the card DSL
7
+ # ("Mobilize 2", "Firebending X"). Its `amount` is any amount; it's written into the generated
8
+ # effects, which validate it.
9
+ class NumberedTrigger < TriggeredAbility
10
+ def self.build(params, errors, path, **)
11
+ Mechanics.check_keys(params, %w[amount], errors, path)
12
+ amount = Mechanics.fetch_param(params, :amount)
13
+ errors << "#{path}.amount: required" if amount.nil?
14
+ data = expansion(amount || 0)
15
+ trigger = Mechanics.build_entry(:trigger, data["when"], errors, "#{path}.when")
16
+ body = parse_body(data.except("when"), errors, path)
17
+ new(**body, effects: (body[:effects] + extra_effects).freeze, trigger: trigger, amount: amount)
18
+ end
19
+
20
+ # Effects the keyword adds after its printed ones (none by default).
21
+ def self.extra_effects = []
22
+
23
+ def initialize(amount: nil, **)
24
+ @amount = amount
25
+ super(**)
26
+ end
27
+
28
+ def to_s = "#{self.class.primitive_name.to_s.tr("_", " ").capitalize} #{@amount.is_a?(Integer) ? @amount : "X"}"
29
+ end
30
+
31
+ # Mobilize N (CR 702.181a): "Whenever this creature attacks, create N 1/1 red Warrior creature tokens.
32
+ # Those tokens enter tapped and attacking. Sacrifice them at the beginning of the next end step."
33
+ class Mobilize < NumberedTrigger
34
+ primitive :ability, :mobilize,
35
+ description: "Mobilize `amount`: whenever this creature attacks, create that many 1/1 red Warrior " \
36
+ "creature tokens tapped and attacking; sacrifice them at the beginning of the next end step.",
37
+ rules: %w[702.181a 508.4],
38
+ params: { "amount" => "amount" },
39
+ example: { "mobilize" => { "amount" => 2 } }
40
+
41
+ WARRIOR = { "name" => "Warrior", "types" => ["creature"], "subtypes" => ["warrior"], "colors" => ["R"], "power" => 1,
42
+ "toughness" => 1 }.freeze
43
+
44
+ def self.expansion(amount)
45
+ { "when" => { "attacks" => {} },
46
+ "effects" => [{ "create_token" => { "token" => WARRIOR, "amount" => amount, "tapped" => true, "attacking" => true } },
47
+ { "delayed_trigger" => { "when" => { "step_begins" => { "step" => "end", "whose" => "each" } },
48
+ "effects" => [{ "sacrifice" => { "to" => { "result" => 0 } } }] } }] }
49
+ end
50
+ end
51
+
52
+ # Firebending N (CR 702.189a): "Whenever this creature attacks, add N {R}. Until end of combat, you
53
+ # don't lose this mana as steps and phases end."
54
+ class Firebending < NumberedTrigger
55
+ primitive :ability, :firebending,
56
+ description: "Firebending `amount`: whenever this creature attacks, add that much {R}; you don't " \
57
+ "lose this mana as steps end until end of combat.",
58
+ rules: %w[702.189a 500.5],
59
+ params: { "amount" => "amount" },
60
+ example: { "firebending" => { "amount" => 2 } }
61
+
62
+ def self.expansion(amount)
63
+ { "when" => { "attacks" => {} },
64
+ "effects" => [{ "add_mana" => { "produce" => "{R}", "amount" => amount, "until" => "end_of_combat" } }] }
65
+ end
66
+
67
+ # CR 702.189b: the ability resolving is firebending ("whenever you firebend").
68
+ def self.extra_effects = @extra_effects ||= [Effects::Firebent.new(to: nil, params: {}, errors: [], path: "firebending")].freeze
69
+ end
70
+ end
71
+
72
+ module Effects
73
+ # The last step of a firebending ability: it has resolved, so its controller firebended (CR 702.189b).
74
+ class Firebent < Effect
75
+ def self.keys = []
76
+ def to_s = "(you firebend)"
77
+
78
+ def apply(engine, ctx)
79
+ engine.emit Events::KeywordAction.new(player: engine.name(ctx.controller), action: "firebend", amount: nil)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../effects/delayed_triggers"
4
+ require_relative "../abilities/play_permissions"
5
+
6
+ module Enjine
7
+ module Mechanics
8
+ module Keywords
9
+ # Paradigm (CR 702.192a): two spell abilities that follow the spell's own effects. "If this is the first
10
+ # time a spell you control with this spell's name has resolved this game, at the beginning of each of
11
+ # your precombat main phases for the rest of the game, create a copy of this object in exile. You may
12
+ # cast the copy without paying its mana cost" and "Exile this spell." The engine applies them as the
13
+ # spell finishes resolving (Engine::Resolution#finish_resolution!); the delayed triggered ability is
14
+ # "delayed:keyword:paradigm" of the spell's card and lasts for the rest of the game.
15
+ class Paradigm < Primitive
16
+ primitive :keyword, :paradigm,
17
+ description: "As this instant or sorcery finishes resolving: if it's the first time a spell you " \
18
+ "control with its name has resolved this game, at the beginning of each of your " \
19
+ "precombat main phases for the rest of the game you create a copy of the card in exile " \
20
+ "and may cast it without paying its mana cost. Then the spell is exiled.",
21
+ rules: %w[702.192a 707.12 603.7 608.2g], example: { "keywords" => ["paradigm"] }
22
+
23
+ REF = "delayed:keyword:paradigm"
24
+
25
+ # Carries the delayed triggered ability, so the card's data names it (CardDefinition.keyword_delayed).
26
+ class Rule < Primitive
27
+ def kind = :paradigm
28
+ def mana_ability? = false
29
+ def allowed_types = %i[instant sorcery]
30
+ def try_effects = [Paradigm.delayed]
31
+ def to_s = "Paradigm"
32
+ end
33
+
34
+ def self.intrinsic_abilities = @intrinsic_abilities ||= [Rule.new.freeze].freeze
35
+ def self.delayed = @delayed ||= Effects::ParadigmDelayed.build({}, [], "keyword:paradigm")
36
+
37
+ def self.validate(card, errors)
38
+ types = Array(card["types"]).map(&:to_s)
39
+ errors << "keywords: paradigm is only for instants and sorceries" unless types.intersect?(%w[instant sorcery])
40
+ end
41
+
42
+ # As the spell finishes resolving: the delayed trigger the first time a spell with its name resolved
43
+ # for its controller (remembered in their game log), then it's exiled instead of going to the
44
+ # graveyard. False if the spell doesn't have paradigm.
45
+ def self.resolved!(engine, spell)
46
+ chars = engine.chars(spell)
47
+ return false unless chars.keyword?(:paradigm)
48
+
49
+ names = engine.game_log(spell.controller)["paradigm"] ||= []
50
+ unless names.include?(chars.name)
51
+ names << chars.name
52
+ engine.create_delayed_trigger!("controller" => spell.controller, "source" => spell.id, "card" => spell.card,
53
+ "ability" => REF, "game" => true)
54
+ end
55
+ engine.move!(spell.id, :exile, reason: "paradigm")
56
+ true
57
+ end
58
+ end
59
+ end
60
+
61
+ module Effects
62
+ # Paradigm's delayed triggered ability (CR 702.192a): at the beginning of each of your precombat main
63
+ # phases, create a copy of the card in exile; you may cast it without paying its mana cost (a copy not
64
+ # cast ceases to exist, CR 707.12). Not a card-data primitive.
65
+ class ParadigmDelayed < DelayedTrigger
66
+ MAIN = { "step_begins" => { "step" => "precombat_main", "whose" => "your" } }.freeze
67
+
68
+ def self.keys = []
69
+
70
+ def setup(_params, errors, path)
71
+ @ref = "delayed:#{path}"
72
+ trigger = Mechanics.build_entry(:trigger, MAIN, errors, "#{path}.when")
73
+ cast = CastFree.build({ "to" => { "result" => 0 } }, errors, "#{path}.cast", results_count: 1)
74
+ @delayed_ability = Abilities::TriggeredAbility.new(targets: [].freeze, effects: [ParadigmCopy.new, cast].freeze,
75
+ trigger: trigger)
76
+ end
77
+
78
+ def to_s = "at the beginning of each of your precombat main phases, copy this card in exile; you may cast the copy free"
79
+ end
80
+
81
+ # Creates a copy of the paradigm card (the delayed trigger's card) in exile, owned by its controller.
82
+ class ParadigmCopy < Effect
83
+ def self.keys = []
84
+
85
+ def initialize = super(to: nil, params: {}, errors: [], path: "paradigm")
86
+ def apply(engine, ctx) = engine.create_card_copy!([engine.object(ctx.object).card, {}], ctx.controller, :exile)
87
+ def to_s = "create a copy of this card in exile"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Power-up (CR 702.193): "Power-up — [Cost]: [Effect]" is an activated ability written with
7
+ # `"power_up": true` (Abilities::ActivatedAbility). It can be activated only once, and if its permanent
8
+ # entered this turn its cost is reduced by the permanent's mana cost (CR 702.193a). Generic mana reduces
9
+ # generic mana; colored and colorless mana reduce mana of the same type, and any excess reduces generic
10
+ # mana (CR 702.193b, 118.7b-d). A hybrid symbol reduces by one of its halves (CR 118.7e): the half the cost
11
+ # needs (a matching colored symbol, then a hybrid symbol that half can pay), otherwise one generic mana.
12
+ module PowerUp
13
+ module_function
14
+
15
+ # The total cost of `ability` of `source` after the power-up reduction.
16
+ def reduced(engine, cost, ability, source)
17
+ return cost unless ability.respond_to?(:power_up?) && ability.power_up?
18
+
19
+ obj = engine.state.objects[source]
20
+ return cost unless obj && engine.entered_this_turn?(obj)
21
+
22
+ mana_cost = engine.chars(obj).mana_cost
23
+ mana_cost ? reduce(cost, mana_cost) : cost
24
+ end
25
+
26
+ # `cost` reduced by the mana cost `reduction` (CR 118.7).
27
+ def reduce(cost, reduction)
28
+ colored = cost.colored.dup
29
+ hybrid = cost.hybrid.dup
30
+ generic = cost.generic
31
+ halves = reduction.colored.flat_map { |type, n| Array.new(n) { [type] } } + reduction.hybrid
32
+ halves.each { generic -= 1 unless reduce_symbol!(colored, hybrid, it) }
33
+ cost.with(generic: [generic - reduction.generic, 0].max, colored: colored.reject { |_, n| n.zero? }.freeze,
34
+ hybrid: hybrid.freeze)
35
+ end
36
+
37
+ # Takes one mana of one of `types` off the colored part, else a hybrid symbol one of them pays.
38
+ def reduce_symbol!(colored, hybrid, types)
39
+ type = types.find { colored.fetch(it, 0).positive? }
40
+ return colored[type] -= 1 if type
41
+
42
+ index = hybrid.index { it.intersect?(types) }
43
+ index && hybrid.delete_at(index)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Abilities
6
+ # "Hexproof from [quality]" and "protection from [quality]" (CR 702.11d, 702.16): static abilities of a
7
+ # permanent whose quality is a card spec matched against the spell, or the source of the ability or
8
+ # damage, as it is now ({"colors": ["B"]} "from black", {"types": ["instant"]} "from instants",
9
+ # {"color_count": {"equal": 1}} "from monocolored"); "everything" is protection from everything (CR
10
+ # 702.16j). "From A and from B" is two abilities (CR 702.11f, 702.16g). Two qualities depend on what has
11
+ # the ability: "each_opponent" (protection from each of your opponents: from everything they control, or own
12
+ # and nobody controls, CR 702.16i, 702.16k) and "its_colors" ("hexproof from each of its colors": anything
13
+ # sharing a color with it, CR 702.11g, 702.16h). The engine asks for them by kind
14
+ # (Engine::Rules#protected_from?).
15
+ class QualityAbility < Primitive
16
+ HOLDER_QUALITIES = %w[each_opponent its_colors].freeze
17
+
18
+ attr_reader :quality
19
+
20
+ # Its value may be the string "everything", "each_opponent" or "its_colors".
21
+ def self.scalar_params? = true
22
+
23
+ def self.build(params, errors, path, **)
24
+ return new(CardSpec::ANY) if params == "everything"
25
+ return new(params.to_sym) if HOLDER_QUALITIES.include?(params)
26
+
27
+ if params.is_a?(String)
28
+ errors << "#{path}: must be a card spec, \"everything\", \"each_opponent\" or \"its_colors\""
29
+ return new(CardSpec::ANY)
30
+ end
31
+ new(CardSpec.build(params, errors, path))
32
+ end
33
+
34
+ def initialize(quality)
35
+ super()
36
+ @quality = quality
37
+ freeze
38
+ end
39
+
40
+ def mana_ability? = false
41
+ def allowed_types = nil
42
+ def try_effects = []
43
+ def everything? = quality.equal?(CardSpec::ANY)
44
+
45
+ # Whether `object` (a spell, or the source of an ability or of damage) has the quality, for `holder` (the
46
+ # permanent with the ability, or the index of the player who has it).
47
+ def from?(engine, object, holder = nil)
48
+ return false if object.nil?
49
+
50
+ case quality
51
+ when :each_opponent then QualityAbility.opponents?(object, holder)
52
+ when :its_colors then holder.is_a?(GameObject) && engine.chars(object).colors.intersect?(engine.chars(holder).colors)
53
+ else quality.match?(engine, object)
54
+ end
55
+ end
56
+
57
+ # CR 702.16k: the object's controller (or its owner, if nobody controls it) is an opponent of the holder's
58
+ # controller.
59
+ def self.opponents?(object, holder)
60
+ return false if holder.nil?
61
+
62
+ (object.controller || object.owner) != (holder.is_a?(Integer) ? holder : holder.controller)
63
+ end
64
+
65
+ def quality_text
66
+ case quality
67
+ when :each_opponent then "each of your opponents"
68
+ when :its_colors then "each of its colors"
69
+ else everything? ? "everything" : quality.to_s.delete_suffix(" card")
70
+ end
71
+ end
72
+ end
73
+
74
+ # Hexproof from [quality] (CR 702.11d): it can't be the target of [quality] spells its controller's
75
+ # opponents control, or of abilities they control from [quality] sources.
76
+ class HexproofFrom < QualityAbility
77
+ primitive :ability, :hexproof_from,
78
+ description: "Hexproof from the quality: this permanent can't be the target of spells with the " \
79
+ "quality, or abilities from sources with it, that its controller's opponents control. " \
80
+ "The quality is a card spec (`{\"colors\": [\"B\"]}`, `{\"types\": [\"instant\"]}`, " \
81
+ "`{\"color_count\": {\"equal\": 1}}` for monocolored), or \"its_colors\" (hexproof " \
82
+ "from each of its colors: whatever shares a color with it).",
83
+ rules: %w[702.11d 702.11f 702.11g 115.4],
84
+ params: { "(value)" => "card spec of the quality, or \"its_colors\"" },
85
+ example: { "hexproof_from" => { "colors" => ["B"] } }
86
+
87
+ def kind = :hexproof_from
88
+ def to_s = "Hexproof from #{quality_text}"
89
+ end
90
+
91
+ # Protection from [quality] (CR 702.16b-f, "DEBT"): it can't be targeted by spells with the quality or
92
+ # abilities from sources with it, enchanted or equipped by Auras and Equipment with it, or blocked by
93
+ # creatures with it, and damage sources with it would deal to it is prevented.
94
+ class ProtectionFrom < QualityAbility
95
+ primitive :ability, :protection_from,
96
+ description: "Protection from the quality (a card spec, \"everything\", \"each_opponent\": from " \
97
+ "each of your opponents, or \"its_colors\"): damage from sources " \
98
+ "with the quality is prevented, and this permanent can't be enchanted or equipped " \
99
+ "by, blocked by or targeted by anything with the quality (Auras and Equipment " \
100
+ "already attached fall off as a state-based action).",
101
+ rules: %w[702.16a 702.16b 702.16c 702.16d 702.16e 702.16f 702.16g 702.16h 702.16i 702.16j 702.16k
102
+ 704.5m 704.5n],
103
+ params: { "(value)" => "card spec of the quality, \"everything\", \"each_opponent\" or \"its_colors\"" },
104
+ example: { "protection_from" => { "colors" => ["W"] } }
105
+
106
+ def kind = :protection_from
107
+ def to_s = "Protection from #{quality_text}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Keywords
6
+ # Builds the ability a keyword stands for from its card-DSL form, so it behaves exactly like the same
7
+ # ability printed on a card. The data is fixed, so an error here is a bug.
8
+ def self.ability_from_data(data, keyword)
9
+ errors = []
10
+ ability = Mechanics.build_entry(:ability, data, errors, "keyword:#{keyword}")
11
+ raise ArgumentError, errors.join("; ") unless errors.empty?
12
+
13
+ ability
14
+ end
15
+
16
+ # Prowess (CR 702.108a): "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of
17
+ # turn." An effect that grants prowess grants this triggered ability too (layer 6, CR 613.1f).
18
+ class Prowess < Primitive
19
+ primitive :keyword, :prowess,
20
+ description: "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of turn.",
21
+ rules: %w[702.108a], example: { "keywords" => ["prowess"] }
22
+
23
+ ABILITY = { "triggered" => { "when" => { "cast" => { "controller" => "you", "spell" => "noncreature" } },
24
+ "effects" => [{ "pump" => { "power" => 1, "toughness" => 1, "to" => "self" } }] } }.freeze
25
+
26
+ def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "prowess")].freeze
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../abilities/chosen_values"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Abilities
8
+ # Riot's "as this enters" choice (CR 702.136a): "counter" (it enters with an additional +1/+1 counter) or
9
+ # "haste". The choice is remembered like choose_option's ({"chosen": "haste"}).
10
+ class RiotChoice < ChooseOption
11
+ INSTANCE = new(%w[counter haste].freeze)
12
+
13
+ def to_s = "as this enters, choose a +1/+1 counter or haste"
14
+ def enters_for(answer) = answer[:option] == "counter" ? super.merge("counters" => { "+1/+1" => 1 }) : super
15
+ end
16
+ end
17
+
18
+ module Keywords
19
+ # Riot (CR 702.136a): "You may have this permanent enter with an additional +1/+1 counter on it. If you
20
+ # don't, it gains haste." Only one "as this enters" choice of a permanent is supported.
21
+ class Riot < Primitive
22
+ primitive :keyword, :riot,
23
+ description: "As this permanent enters, its controller chooses an additional +1/+1 counter or haste " \
24
+ "(an enter_choice: `{type: \"option\", option: \"counter\" | \"haste\"}`).",
25
+ rules: %w[702.136a 614.12], example: { "keywords" => ["riot"] }
26
+
27
+ HASTE = { "static" => { "affects" => "self", "if" => { "chosen" => "haste" }, "keywords" => ["haste"] } }.freeze
28
+
29
+ def self.intrinsic_abilities
30
+ @intrinsic_abilities ||= [Abilities::RiotChoice::INSTANCE, Keywords.ability_from_data(HASTE, "riot")].freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end