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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # Rules that apply to a player rather than to an object (see Engine::Rules#player_rule?):
6
+ # hexproof the player can't be the target of spells or abilities opponents control (702.11c)
7
+ # cant_attack the player can't declare attackers (508.1c)
8
+ # no_max_hand_size the player has no maximum hand size (402.2, 514.1)
9
+ # prevent_all_damage all damage that would be dealt to the player is prevented (615.1)
10
+ # cant_lose_for_life the player doesn't lose the game for having 0 or less life (704.5a, 104.3b)
11
+ # cant_sacrifice the player can't sacrifice permanents matching `filter` (701.21a, 614.17b)
12
+ # cant_cast the player can't cast spells (matching `spells`) (101.2, 601.3)
13
+ # cant_cast_except_from_hand the player can't cast spells from anywhere other than their hand (601.3)
14
+ # spells_cant_be_countered spells the player controls (matching `spells`) can't be countered (101.2, 701.6a)
15
+ # abilities_cant_be_countered activated and triggered abilities the player controls can't be countered (101.2)
16
+ # cant_gain_life the player can't gain life (119.7)
17
+ # cant_activate the player can't activate abilities of sources matching `filter`, except mana
18
+ # abilities (602.5)
19
+ # protection_from_opponents the player has protection from each of their opponents: opponents' spells
20
+ # and abilities can't target them and damage from opponents' sources is prevented
21
+ # (702.16b, 702.16e, 702.16k)
22
+ # damage_cant_be_prevented / combat_damage_cant_be_prevented game rules: while any player has one, (combat)
23
+ # damage can't be prevented (615.12)
24
+ module PlayerRules
25
+ RULES = {
26
+ "hexproof" => %w[702.11c 115.4], "cant_attack" => %w[508.1c], "no_max_hand_size" => %w[402.2 514.1],
27
+ "prevent_all_damage" => %w[615.1 615.1a], "cant_lose_for_life" => %w[704.5a 104.3b],
28
+ "cant_sacrifice" => %w[701.21a 614.17b], "cant_cast" => %w[101.2 601.3],
29
+ "cant_cast_except_from_hand" => %w[101.2 601.3], "spells_cant_be_countered" => %w[101.2 701.6a],
30
+ "abilities_cant_be_countered" => %w[101.2 701.6a],
31
+ "cant_gain_life" => %w[119.7], "cant_activate" => %w[101.2 602.5],
32
+ "damage_cant_be_prevented" => %w[615.12], "combat_damage_cant_be_prevented" => %w[615.12],
33
+ "protection_from_opponents" => %w[702.16b 702.16e 702.16k]
34
+ }.freeze
35
+ # Rules that take a `filter` of permanents (required for cant_sacrifice) or a card spec of `spells`.
36
+ FILTERED = %w[cant_sacrifice cant_activate].freeze
37
+ REQUIRED_FILTER = %w[cant_sacrifice].freeze
38
+ SPELLS = %w[cant_cast spells_cant_be_countered].freeze
39
+
40
+ def self.rule(params, errors, path)
41
+ rule = Mechanics.fetch_param(params, :rule)
42
+ errors << "#{path}.rule: must be one of #{RULES.keys.join(", ")}" unless RULES.key?(rule)
43
+ rule
44
+ end
45
+
46
+ # [filter, spells] of a rule's data, validated for the rule.
47
+ def self.qualifiers(params, rule, errors, path)
48
+ raw = Mechanics.fetch_param(params, :filter)
49
+ if raw.nil? && REQUIRED_FILTER.include?(rule)
50
+ errors << "#{path}.filter: required"
51
+ elsif raw && !FILTERED.include?(rule)
52
+ errors << "#{path}.filter: only for #{FILTERED.join(", ")}"
53
+ end
54
+ spells = Mechanics.fetch_param(params, :spells)
55
+ errors << "#{path}.spells: only for #{SPELLS.join(", ")}" if spells && !SPELLS.include?(rule)
56
+ [raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.filter"), spells && CardSpec.build(spells, errors, "#{path}.spells")]
57
+ end
58
+ end
59
+
60
+ module Abilities
61
+ # A static ability giving players a rule: "You don't lose the game for having 0 or less life",
62
+ # "You can't sacrifice Treasure tokens", "Your opponents can't attack", "Your opponents can't cast
63
+ # spells during your turn", "Creature spells you control can't be countered", "Players can't gain life".
64
+ class PlayerRule < Primitive
65
+ AFFECTS = %w[you opponents each].freeze
66
+
67
+ primitive :ability, :player_rule,
68
+ description: "While this permanent is on the battlefield (and its `if` condition holds), `rule` " \
69
+ "applies to the players in `affects` (you | opponents | each; default you). Rules: " \
70
+ "#{PlayerRules::RULES.keys.join(", ")}. `cant_sacrifice` takes a `filter` of the " \
71
+ "permanents that can't be sacrificed, `cant_activate` may take a `filter` of the " \
72
+ "sources whose abilities can't be activated (mana abilities still can), and " \
73
+ "`cant_cast` and `spells_cant_be_countered` may take a card spec of the `spells`. " \
74
+ "The damage rules apply to all damage whichever players they name.",
75
+ rules: PlayerRules::RULES.values.flatten.uniq + %w[604.1 604.2 611.3a],
76
+ params: { "rule" => PlayerRules::RULES.keys.join(" | "), "affects" => AFFECTS.join(" | "),
77
+ "filter" => "permanent filter (#{PlayerRules::FILTERED.join(", ")})",
78
+ "spells" => "card spec of the spells (#{PlayerRules::SPELLS.join(", ")})",
79
+ "if" => "condition (\"during your turn\": {\"your_turn\": true})" },
80
+ example: { "player_rule" => { "rule" => "cant_cast", "affects" => "opponents", "if" => { "your_turn" => true } } }
81
+
82
+ attr_reader :rule, :affects, :filter, :spells, :condition
83
+
84
+ def self.build(params, errors, path, **)
85
+ Mechanics.check_keys(params, %w[rule affects filter spells if], errors, path)
86
+ rule = PlayerRules.rule(params, errors, path)
87
+ affects = Mechanics.fetch_param(params, :affects) || "you"
88
+ errors << "#{path}.affects: must be one of #{AFFECTS.join(", ")}" unless AFFECTS.include?(affects)
89
+ filter, spells = PlayerRules.qualifiers(params, rule, errors, path)
90
+ raw_if = Mechanics.fetch_param(params, :if)
91
+ new(rule, affects, filter, spells: spells, condition: raw_if && Condition.build(raw_if, errors, "#{path}.if"))
92
+ end
93
+
94
+ def initialize(rule, affects, filter, spells: nil, condition: nil)
95
+ super()
96
+ @rule = rule
97
+ @affects = affects
98
+ @filter = filter
99
+ @spells = spells
100
+ @condition = condition
101
+ freeze
102
+ end
103
+
104
+ def kind = :player_rule
105
+ def mana_ability? = false
106
+ def allowed_types = nil
107
+ def try_effects = []
108
+
109
+ # Whether the rule applies to player `index` for a source controlled by `controller`.
110
+ def applies_to?(index, controller)
111
+ affects == "each" || (affects == "you") == (index == controller)
112
+ end
113
+
114
+ # Its "as long as" / "during your turn" condition (CR 604.2), from its source's point of view.
115
+ def active?(engine, source)
116
+ condition.nil? || condition.true?(engine, EffectContext.new(source.controller, source.id, source.id, [], []))
117
+ end
118
+
119
+ # Whether the object asked about (a permanent for `filter`, a card for `spells`) is one the rule is about.
120
+ def matches?(engine, object, source)
121
+ (filter.nil? || filter.match?(engine, object.id, source.controller)) && (spells.nil? || spells.match?(engine, object))
122
+ end
123
+
124
+ def to_s
125
+ who = { "you" => "you", "opponents" => "your opponents", "each" => "each player" }.fetch(affects)
126
+ about = filter || spells
127
+ "#{who}: #{rule.tr("_", " ")}#{" (#{about.is_a?(Class) ? about.primitive_name : about})" if about}" \
128
+ "#{" as long as #{condition}" if condition}"
129
+ end
130
+ end
131
+ end
132
+
133
+ module Effects
134
+ # "You gain hexproof until end of turn", "Prevent all damage that would be dealt to you this turn",
135
+ # "You have no maximum hand size until your next turn", "They can't attack this turn".
136
+ class PlayerRuleEffect < Effect
137
+ DURATIONS = %w[end_of_turn until_your_next_turn].freeze
138
+
139
+ primitive :effect, :player_rule,
140
+ description: "Each referenced player (default you) gets `rule` for the `duration`: end_of_turn " \
141
+ "(default) or until_your_next_turn (until the controller's next turn begins). Rules: " \
142
+ "#{(PlayerRules::RULES.keys - PlayerRules::REQUIRED_FILTER).join(", ")}.",
143
+ rules: PlayerRules::RULES.values.flatten.uniq + %w[611.2a 514.2],
144
+ params: { "rule" => (PlayerRules::RULES.keys - PlayerRules::REQUIRED_FILTER).join(" | "), "to" => "selector",
145
+ "duration" => DURATIONS.join(" | ") },
146
+ example: { "player_rule" => { "rule" => "no_max_hand_size", "duration" => "until_your_next_turn" } }
147
+
148
+ attr_reader :rule, :duration
149
+
150
+ def self.keys = %w[rule duration to if]
151
+ def self.default_to = "you"
152
+
153
+ def setup(params, errors, path)
154
+ @rule = PlayerRules.rule(params, errors, path)
155
+ errors << "#{path}.rule: #{@rule} needs a static ability (it takes a filter)" if PlayerRules::REQUIRED_FILTER.include?(@rule)
156
+ @duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
157
+ errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
158
+ end
159
+
160
+ def apply(engine, ctx)
161
+ affected = players(engine, ctx).map { Filters.player_ref(engine, it) }
162
+ engine.add_continuous_effect!("player_rule", ctx, affected, "rule" => rule, "duration" => duration)
163
+ end
164
+
165
+ def to_s = "#{rule.tr("_", " ")} (#{duration.tr("_", " ")}) → #{to}"
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ # Which permanents a static ability of a permanent applies to, relative to its controller:
6
+ # {"each": filter, "controller": "you" | "opponent" | "any", "other": true}
7
+ class Affects
8
+ CONTROLLERS = %w[you opponent any].freeze
9
+
10
+ attr_reader :filter, :controller, :other
11
+
12
+ def self.build(data, errors, path)
13
+ unless data.is_a?(Hash) && data["each"]
14
+ errors << "#{path}: must be {\"each\": filter, \"controller\": you|opponent|any, \"other\": bool}"
15
+ return new(nil, "any", false)
16
+ end
17
+ Mechanics.check_keys(data, %w[each controller other], errors, path)
18
+ controller = data.fetch("controller", "any")
19
+ errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}" unless CONTROLLERS.include?(controller)
20
+ new(Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each"), controller, data["other"] == true)
21
+ end
22
+
23
+ def initialize(filter, controller, other)
24
+ @filter = filter
25
+ @controller = controller
26
+ @other = other
27
+ freeze
28
+ end
29
+
30
+ def controller_ok?(object_controller, source)
31
+ controller == "any" || (controller == "you") == (object_controller == source.controller)
32
+ end
33
+
34
+ # A permanent on the battlefield, by its current characteristics and status.
35
+ def match?(engine, obj, source)
36
+ return false if other && obj.id == source.id
37
+ return false unless controller_ok?(obj.controller, source)
38
+
39
+ filter.match_for?(engine, obj.id, source.controller, source.id)
40
+ end
41
+
42
+ # A permanent about to enter under `entering_controller` (CR 614.12: as it would exist there).
43
+ def match_facts?(facts, entering_controller, source)
44
+ !filter.nil? && controller_ok?(entering_controller, source) && filter.matches_facts?(facts)
45
+ end
46
+
47
+ def to_s
48
+ name = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
49
+ "#{"other " if other}#{name}s#{{ "you" => " you control", "opponent" => " opponents control" }[controller]}"
50
+ end
51
+ end
52
+
53
+ module Abilities
54
+ # Base for replacement effects of a permanent's static abilities (CR 614.1, 614.12): no stack.
55
+ class StaticReplacement < Primitive
56
+ def mana_ability? = false
57
+ def allowed_types = nil
58
+ def try_effects = []
59
+ end
60
+
61
+ # "If you would draw a card, draw two cards instead." (CR 614.11)
62
+ class DrawReplacement < StaticReplacement
63
+ primitive :ability, :draw_replacement,
64
+ description: "While this permanent is on the battlefield, each time its controller would draw a " \
65
+ "card, they draw `amount` cards instead (even with an empty library). The draws it " \
66
+ "makes aren't replaced by it again, but another such effect applies to each of them.",
67
+ rules: %w[614.11 614.11a 614.5 121.2 616.1],
68
+ params: { "amount" => "integer >= 2" },
69
+ example: { "draw_replacement" => { "amount" => 2 } }
70
+
71
+ attr_reader :amount
72
+
73
+ def self.build(params, errors, path, **)
74
+ Mechanics.check_keys(params, %w[amount], errors, path)
75
+ new(Mechanics.integer_param(params, :amount, errors, path, min: 2))
76
+ end
77
+
78
+ def initialize(amount)
79
+ super()
80
+ @amount = amount
81
+ freeze
82
+ end
83
+
84
+ def kind = :draw_replacement
85
+ def replace_draw = true
86
+ def to_s = "if you would draw a card, draw #{amount} instead"
87
+ end
88
+
89
+ # "If another creature you control with a +1/+1 counter on it would be destroyed, instead remove all
90
+ # +1/+1 counters from it and remove all damage from it." (CR 614.1a, regeneration-like 614.8)
91
+ class DestroyReplacement < StaticReplacement
92
+ primitive :ability, :destroy_replacement,
93
+ description: "If a permanent matching `affects` would be destroyed (by an effect, lethal damage or " \
94
+ "deathtouch), instead: all its `remove_counters` counters are removed (if given) and, " \
95
+ "with `remove_damage: true`, all damage marked on it is removed. Sacrificing isn't " \
96
+ "destroying, and 0 toughness isn't either.",
97
+ rules: %w[614.1a 614.8 701.8a 701.21a 704.5f 704.5g 704.5h],
98
+ params: { "affects" => "{\"each\": filter, \"controller\": ..., \"other\": bool}",
99
+ "remove_counters" => "counter name", "remove_damage" => "boolean" },
100
+ example: { "destroy_replacement" => {
101
+ "affects" => { "each" => { "creature" => { "counters" => "+1/+1" } }, "controller" => "you", "other" => true },
102
+ "remove_counters" => "+1/+1", "remove_damage" => true
103
+ } }
104
+
105
+ attr_reader :affects, :counter, :damage
106
+
107
+ def self.build(params, errors, path, **)
108
+ Mechanics.check_keys(params, %w[affects remove_counters remove_damage], errors, path)
109
+ affects = Affects.build(Mechanics.fetch_param(params, :affects), errors, "#{path}.affects")
110
+ counter = Mechanics.fetch_param(params, :remove_counters)
111
+ counter &&= Counters.parse(counter, errors, "#{path}.remove_counters")
112
+ damage = Mechanics.fetch_param(params, :remove_damage) == true
113
+ errors << "#{path}: needs remove_counters and/or remove_damage" unless counter || damage
114
+ new(affects, counter, damage)
115
+ end
116
+
117
+ def initialize(affects, counter, damage)
118
+ super()
119
+ @affects = affects
120
+ @counter = counter
121
+ @damage = damage
122
+ freeze
123
+ end
124
+
125
+ def kind = :destroy_replacement
126
+ def replaces?(engine, obj, source) = affects.match?(engine, obj, source)
127
+
128
+ def replace_destroy(engine, obj)
129
+ engine.remove_all_counters!(obj, counter) if counter
130
+ engine.remove_damage!(obj) if damage
131
+ end
132
+
133
+ def to_s
134
+ instead = [counter && "remove all #{counter} counters", damage && "remove all damage"].compact.join(" and ")
135
+ "if #{affects} would be destroyed, instead #{instead}"
136
+ end
137
+ end
138
+
139
+ # "If one or more tokens would be created under your control, that many 3/3 Illusion tokens are
140
+ # created instead." (CR 614.1a, 111.1)
141
+ class TokenReplacement < StaticReplacement
142
+ primitive :ability, :token_replacement,
143
+ description: "If tokens would be created under this permanent's controller's control, that many " \
144
+ "`token` tokens are created instead (tapped or attacking if the originals would be). " \
145
+ "`token` is a predefined token name or inline card data.",
146
+ rules: %w[614.1a 614.16 111.1 616.1],
147
+ params: { "token" => "\"Treasure\" | \"Clue\" | \"Food\" | {card data}" },
148
+ example: { "token_replacement" => { "token" => { "name" => "Illusion", "types" => %w[artifact creature],
149
+ "subtypes" => ["illusion"], "power" => 3,
150
+ "toughness" => 3 } } }
151
+
152
+ attr_reader :token_definition
153
+
154
+ def self.build(params, errors, path, **)
155
+ Mechanics.check_keys(params, %w[token], errors, path)
156
+ raw = Mechanics.fetch_param(params, :token)
157
+ data = raw.is_a?(String) ? Enjine::Tokens.fetch(raw) : raw
158
+ return new(CardDefinition.token(data)) if data.is_a?(Hash)
159
+
160
+ errors << "#{path}.token: must be a predefined token (#{Enjine::Tokens::LIBRARY.keys.join(", ")}) or card data"
161
+ new(nil)
162
+ rescue InvalidCardDefinition => e
163
+ e.errors.each { errors << "#{path}.token.#{it}" }
164
+ new(nil)
165
+ end
166
+
167
+ def initialize(definition)
168
+ super()
169
+ @token_definition = definition
170
+ freeze
171
+ end
172
+
173
+ def kind = :token_replacement
174
+ def to_s = "if tokens would be created under your control, that many #{token_definition.name} tokens are created instead"
175
+ end
176
+
177
+ # "If one or more tokens would be created under your control, twice that many of those tokens are
178
+ # created instead." (CR 614.1a, 111.1)
179
+ class DoubleTokens < StaticReplacement
180
+ primitive :ability, :double_tokens,
181
+ description: "While this permanent is on the battlefield, if tokens would be created under its " \
182
+ "controller's control, twice that many of those tokens are created instead. Several " \
183
+ "apply one after another (two make four times as many).",
184
+ rules: %w[614.1a 614.5 111.1 616.1],
185
+ example: "double_tokens"
186
+
187
+ def self.build(params, errors, path, **)
188
+ Mechanics.check_keys(params, [], errors, path)
189
+ new
190
+ end
191
+
192
+ def initialize
193
+ super
194
+ freeze
195
+ end
196
+
197
+ def kind = :double_tokens
198
+ def doubles_tokens = true
199
+ def to_s = "if tokens would be created under your control, twice that many are created instead"
200
+ end
201
+
202
+ # "If a creature an opponent controls would die, exile it instead." (CR 614.1a, 700.4)
203
+ class ExileIfDiesStatic < StaticReplacement
204
+ primitive :ability, :exile_if_dies,
205
+ description: "While this permanent is on the battlefield, a permanent matching `affects` that would " \
206
+ "be put into a graveyard from the battlefield is exiled instead; the card is \"exiled " \
207
+ "with\" this permanent (exiled_with). `then`: effects right after (\"instead exile it " \
208
+ "and you gain 2 life\"; a reflexive_trigger for \"When you do, ...\"), reading " \
209
+ "{\"triggering\": \"object\"} (the exiled card) and \"player\" (who controlled it).",
210
+ rules: %w[614.1a 700.4 603.12 607.2a],
211
+ params: { "affects" => "{\"each\": filter, \"controller\": you|opponent|any, \"other\": bool}",
212
+ "then" => "list of effects" },
213
+ example: { "exile_if_dies" => { "affects" => { "each" => "creature", "controller" => "opponent" } } }
214
+
215
+ attr_reader :affects, :follow_up
216
+
217
+ def self.build(params, errors, path, **)
218
+ Mechanics.check_keys(params, %w[affects then], errors, path)
219
+ new(Affects.build(Mechanics.fetch_param(params, :affects), errors, "#{path}.affects"), FollowUp.build(params, errors, path))
220
+ end
221
+
222
+ def initialize(affects, follow_up = nil)
223
+ super()
224
+ @affects = affects
225
+ @follow_up = follow_up
226
+ freeze
227
+ end
228
+
229
+ def kind = :exile_if_dies
230
+ def try_effects = [follow_up].compact
231
+ def exiles_dying?(engine, obj, source) = affects.match?(engine, obj, source)
232
+
233
+ # What happens once the card is exiled: it is linked to `source` (CR 607.2a), then the follow-up.
234
+ def follow_up_entry(source, obj)
235
+ facts = { "player" => obj.controller }
236
+ (follow_up&.entry(source.card, source.controller, source.id, facts) || { "source" => source.id, "facts" => facts })
237
+ .merge("link" => true)
238
+ end
239
+
240
+ def to_s = "if #{affects} would die, exile it instead#{", then #{follow_up}" if follow_up}"
241
+ end
242
+
243
+ # "Lands you control enter untapped." (CR 614.1c, 614.12; applied after every "enters tapped", 616.1)
244
+ class EntersUntapped < StaticReplacement
245
+ primitive :ability, :enters_untapped,
246
+ description: "Permanents matching `affects` enter the battlefield untapped, even if another effect " \
247
+ "or their own ability would have them enter tapped.",
248
+ rules: %w[614.1c 614.12 616.1],
249
+ params: { "affects" => "{\"each\": filter, \"controller\": you|opponent|any}" },
250
+ example: { "enters_untapped" => { "affects" => { "each" => "land", "controller" => "you" } } }
251
+
252
+ attr_reader :affects
253
+
254
+ def self.build(params, errors, path, **)
255
+ Mechanics.check_keys(params, %w[affects], errors, path)
256
+ new(Affects.build(Mechanics.fetch_param(params, :affects), errors, "#{path}.affects"))
257
+ end
258
+
259
+ def initialize(affects)
260
+ super()
261
+ @affects = affects
262
+ freeze
263
+ end
264
+
265
+ def kind = :enters_untapped
266
+ def enters_untapped?(source, controller, facts) = affects.match_facts?(facts, controller, source)
267
+ def to_s = "#{affects} enter untapped"
268
+ end
269
+
270
+ # "If a creature you control would enter while you control an attacking creature, instead it enters
271
+ # tapped and attacking." (CR 614.1c, 508.4)
272
+ class EntersAttacking < StaticReplacement
273
+ primitive :ability, :enters_attacking,
274
+ description: "While this permanent's controller controls an attacking creature, permanents matching " \
275
+ "`affects` enter the battlefield tapped and attacking (the defending player). They " \
276
+ "never \"attacked\", so attack triggers don't see them.",
277
+ rules: %w[614.1c 508.4 506.4],
278
+ params: { "affects" => "{\"each\": filter, \"controller\": you|opponent|any}" },
279
+ example: { "enters_attacking" => { "affects" => { "each" => "creature", "controller" => "you" } } }
280
+
281
+ attr_reader :affects
282
+
283
+ def self.build(params, errors, path, **)
284
+ Mechanics.check_keys(params, %w[affects], errors, path)
285
+ new(Affects.build(Mechanics.fetch_param(params, :affects), errors, "#{path}.affects"))
286
+ end
287
+
288
+ def initialize(affects)
289
+ super()
290
+ @affects = affects
291
+ freeze
292
+ end
293
+
294
+ def kind = :enters_attacking
295
+
296
+ def enters_attacking?(engine, source, controller, facts)
297
+ engine.attackers.keys.any? { engine.object(it).controller == source.controller } &&
298
+ affects.match_facts?(facts, controller, source)
299
+ end
300
+
301
+ def to_s = "#{affects} enter tapped and attacking while you control an attacking creature"
302
+ end
303
+ end
304
+
305
+ module Effects
306
+ # "Until end of turn, if one or more counters would be put on a permanent you control, that many plus
307
+ # one of each of those kinds are put on it instead." (CR 614.1a, 611.2a)
308
+ class CounterBonus < Effect
309
+ primitive :effect, :counter_bonus,
310
+ description: "Until end of turn, whenever one or more counters would be put on a permanent the " \
311
+ "controller controls (including counters it enters with), that many plus `amount` " \
312
+ "of each of those kinds are put on it instead.",
313
+ rules: %w[614.1a 614.16 611.2a 122.6 616.1],
314
+ params: { "amount" => "integer (default 1)" },
315
+ example: { "counter_bonus" => { "amount" => 1 } }
316
+
317
+ def self.keys = %w[amount if]
318
+ def self.default_to = "you"
319
+
320
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
321
+ params = params.transform_keys(&:to_s)
322
+ params["amount"] = 1 unless params.key?("amount")
323
+ super
324
+ end
325
+
326
+ def apply(engine, ctx)
327
+ engine.add_continuous_effect!("counter_bonus", ctx, [], "amount" => amount_for(engine, ctx))
328
+ end
329
+
330
+ def to_s = "until end of turn, counters put on your permanents: that many plus #{amount}"
331
+ end
332
+
333
+ # "If that creature would die this turn, exile it instead." (CR 614.1a, 700.4)
334
+ class ExileIfDies < Effect
335
+ primitive :effect, :exile_if_dies,
336
+ description: "Until end of turn, if a referenced permanent would be put into a graveyard from the " \
337
+ "battlefield, it is exiled instead.",
338
+ rules: %w[614.1a 700.4 611.2a 611.2c],
339
+ params: { "to" => "selector" },
340
+ example: { "exile_if_dies" => { "to" => { "target" => 0 } } }
341
+
342
+ def self.keys = %w[to if]
343
+
344
+ def apply(engine, ctx) = engine.add_continuous_effect!("exile_if_dies", ctx, objects(engine, ctx), {})
345
+ def to_s = "if it would die this turn, exile it instead → #{to}"
346
+ end
347
+
348
+ # "Exile [this spell]" as part of its own resolution, so it doesn't go to the graveyard (CR 608.2n).
349
+ class ExileThisSpell < Effect
350
+ primitive :effect, :exile_this_spell,
351
+ description: "The resolving spell is exiled instead of being put into its owner's graveyard as it " \
352
+ "finishes resolving.",
353
+ rules: %w[608.2n 406.1],
354
+ example: "exile_this_spell"
355
+
356
+ def self.keys = %w[if]
357
+ def self.default_to = "you"
358
+
359
+ # The resolving object is on the stack; an ability (not a spell) has no card to exile.
360
+ def apply(engine, ctx)
361
+ engine.move!(ctx.object, :exile, reason: "exile") if engine.state.objects[ctx.object]&.kind == "card"
362
+ end
363
+
364
+ def to_s = "exile this spell"
365
+ end
366
+ end
367
+ end
368
+ end