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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Enjine
6
+ module Mechanics
7
+ module Effects
8
+ # Create token permanents (CR 111.1, 701.7).
9
+ class CreateToken < Effect
10
+ primitive :effect, :create_token,
11
+ description: "Create `amount` tokens under the referenced player's control (default: you). `token` is " \
12
+ "a predefined token name (Treasure, Clue, Food) or an inline card definition. " \
13
+ "`tapped: true` creates them tapped; `attacking: true` attacking (only for the attacking " \
14
+ "player during combat, CR 506.3b).",
15
+ rules: %w[111.1 111.10 701.7a 704.5d 508.4 506.3b],
16
+ params: { "token" => "\"Treasure\" | \"Clue\" | \"Food\" | {card data}", "amount" => "integer (default 1)",
17
+ "tapped" => "boolean", "attacking" => "boolean", "to" => "selector (default \"you\")" },
18
+ example: { "create_token" => { "token" => { "name" => "Soldier", "types" => ["creature"],
19
+ "subtypes" => ["soldier"], "power" => 1, "toughness" => 1 } } }
20
+
21
+ attr_reader :definition, :tapped, :attacking
22
+
23
+ def self.keys = %w[token amount tapped attacking to if]
24
+ ATTACK_STEPS = %i[declare_attackers declare_blockers combat_damage end_of_combat].freeze
25
+ def self.default_to = "you"
26
+
27
+ def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
28
+ params = params.transform_keys(&:to_s)
29
+ params["amount"] = 1 unless params.key?("amount")
30
+ super
31
+ end
32
+
33
+ def setup(params, errors, path)
34
+ raw = Mechanics.fetch_param(params, :token)
35
+ data = raw.is_a?(String) ? Enjine::Tokens.fetch(raw) : raw
36
+ @tapped = Mechanics.fetch_param(params, :tapped) == true
37
+ @attacking = Mechanics.fetch_param(params, :attacking) == true
38
+ unless data.is_a?(Hash)
39
+ errors << "#{path}.token: must be a predefined token (#{Enjine::Tokens::LIBRARY.keys.join(", ")}) or card data"
40
+ return
41
+ end
42
+ @definition = CardDefinition.token(data)
43
+ rescue InvalidCardDefinition => e
44
+ e.errors.each { errors << "#{path}.token.#{it}" }
45
+ end
46
+
47
+ # CR 614.12a: an "as this enters" choice of each token is made (one token at a time) before any is
48
+ # created; the number and players are fixed as the effect starts.
49
+ def run(engine, ctx, locals)
50
+ return :done unless definition && (locals.key?("tokens") || condition.nil? || condition.true?(engine, ctx))
51
+
52
+ n = amount_for(engine, ctx) unless locals.key?("tokens")
53
+ entries = locals["tokens"] ||= players(engine, ctx).flat_map do |index|
54
+ Array.new(n) { [engine.token_key(definition, "#{index}.#{it}"), index] }
55
+ end
56
+ return :wait if engine.ask_entering_choices!(locals, entries) == :wait
57
+
58
+ entries.each do |key, index|
59
+ enters = engine.with_entering_answer(locals, key, index, nil)
60
+ enters = (enters || {}).merge("attacking" => true) if attacks?(engine, index)
61
+ engine.create_token!(definition, index, tapped: tapped, enters: enters)
62
+ end
63
+ :done
64
+ end
65
+
66
+ def to_s
67
+ "create #{amount} #{definition&.name} token#{"s" if amount != 1}#{" tapped" if tapped}" \
68
+ "#{" attacking" if attacking} → #{to}"
69
+ end
70
+
71
+ # CR 508.4, 506.3b: tokens enter attacking only under the attacking player's control, in combat.
72
+ def attacks?(engine, index) = attacking && engine.state.active == index && ATTACK_STEPS.include?(engine.state.step)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # "Any target": a creature, player or planeswalker (CR 115.4).
7
+ class AnyTarget < Filter
8
+ primitive :filter, :any_target, description: "A creature, player or planeswalker (battles: later).",
9
+ rules: %w[115.4], example: "any_target"
10
+ def self.players?(_engine, index, _controller) = !index.nil?
11
+ def self.match_object?(engine, object, controller) = CreatureOrPlaneswalker.match_object?(engine, object, controller)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # Target filters (CR 115) and "each" selectors. A filter lists candidate
7
+ # references in a deterministic order: players (seat order), then battlefield
8
+ # order, then stack order (top first). References are object ids or "player:<name>".
9
+ PLAYER_PREFIX = "player:"
10
+
11
+ def self.player_ref(engine, index) = "#{PLAYER_PREFIX}#{engine.name(index)}"
12
+
13
+ def self.player_index(engine, ref)
14
+ return nil unless ref.is_a?(String) && ref.start_with?(PLAYER_PREFIX)
15
+
16
+ engine.names.index(ref.delete_prefix(PLAYER_PREFIX))
17
+ end
18
+
19
+ # What is known about an object without computing its current characteristics: its printed
20
+ # (or last known) characteristics, its counters and whether it is a token. `chosen`: the memory of
21
+ # the object whose choice "the chosen color/type" refers to (CR 607.2d), when the filter needs one.
22
+ Facts = Data.define(:chars, :counters, :token, :chosen) do
23
+ def initialize(chars:, counters:, token:, chosen: nil) = super
24
+ end
25
+
26
+ # The choices "the chosen ..." can refer to (CR 607.2d): the resolving spell's or ability's own
27
+ # (an effect's "choose a creature type"), else its source's (an "as this enters" choice, CR 614.12).
28
+ def self.chosen_memory(engine, source, ctx)
29
+ own = engine.state.objects[source]&.memory || {}
30
+ resolving = ctx && ctx.object != source ? engine.state.objects[ctx.object]&.memory : nil
31
+ resolving ? own.merge(resolving.select { |k, _| k.start_with?("chosen_") }) : own
32
+ end
33
+
34
+ # Base filter: subclasses define players? and match_object?.
35
+ class Filter < Primitive
36
+ def self.players?(_engine, _index, _controller) = false
37
+ def self.match_object?(_engine, _object, _controller) = false
38
+ def self.zone = :battlefield
39
+
40
+ def self.candidates(engine, controller)
41
+ players = engine.state.players.each_index.select { players?(engine, it, controller) }
42
+ .map { Filters.player_ref(engine, it) }
43
+ objects = engine.state.public_send(zone).select { |id| match_object?(engine, engine.object(id), controller) }
44
+ players + objects
45
+ end
46
+
47
+ def self.match?(engine, ref, controller)
48
+ index = Filters.player_index(engine, ref)
49
+ return players?(engine, index, controller) if index
50
+ return false unless engine.state.objects.key?(ref)
51
+
52
+ obj = engine.object(ref)
53
+ obj.zone == zone && match_object?(engine, obj, controller)
54
+ end
55
+
56
+ # A bare filter is the class itself; parameters wrap it in a Restricted filter (see Restricted).
57
+ def self.build(params, errors, path, **)
58
+ Mechanics.check_keys(params, Restricted::KEYS, errors, path)
59
+ return self if params.empty?
60
+
61
+ Restricted.build(self, params.transform_keys(&:to_s), errors, path)
62
+ end
63
+
64
+ def self.candidates_for(engine, controller, _source, **) = candidates(engine, controller)
65
+
66
+ def self.match_for?(engine, ref, controller, _source, **) = match?(engine, ref, controller)
67
+
68
+ def self.type?(engine, object, type) = object.kind == "card" && engine.chars(object).type?(type)
69
+
70
+ # Whether an object with these card types matches.
71
+ def self.matches_types?(types) = respond_to?(:card_types) ? types.intersect?(card_types) : true
72
+
73
+ # For the layer pass, entering replacements and trigger subjects that left the battlefield:
74
+ # a match on characteristics and counters only (Facts), never on current power/toughness.
75
+ def self.matches_facts?(facts) = matches_types?(facts.chars.types)
76
+ def self.live_keys = []
77
+ def self.uses_chosen? = false
78
+ end
79
+
80
+ # A filter narrowed by restrictions (CR 115.1, 109.2), e.g.
81
+ # {"creature": {"controller": "you"}} {"creature": {"other": true}}
82
+ # {"permanent": {"not": ["land"]}} {"permanent": {"any_of": ["artifact", "creature"]}}
83
+ # {"creature": {"attacking": true}} {"creature": {"power": {"at_least": 4}}}
84
+ # {"permanent": {"mana_value": {"at_most": 3}}} {"creature": {"subtypes": ["human"]}}
85
+ # {"creature": {"supertypes": ["legendary"]}} {"creature": {"not_supertypes": ["legendary"]}}
86
+ # {"creature": {"token": false}} {"permanent": {"counters": true}} / {"counters": "+1/+1"}
87
+ # {"creature": {"modified": true}} {"spell": {"not": ["creature"]}}
88
+ # {"creature": {"attached_subtypes": ["communicator"]}} with a Communicator attached to it (CR 301.5, 303.4)
89
+ # {"creature": {"keywords": ["flying"]}} with flying (any listed); "not_keywords": without (CR 702.1)
90
+ # {"creature": {"subtypes": "chosen"}} of the chosen creature type; also "colors", "types", "not",
91
+ # "not_subtypes": "chosen" (CR 607.2d, see Filters.chosen_memory)
92
+ # {"creature": {"transformed": true}} back face up (CR 701.27h); {"prepared": true} (CR 723.3a)
93
+ # {"creature": {"saddled": true}} saddled this turn (CR 702.171b)
94
+ # {"creature": {"entered_this_turn": true}} entered the battlefield this turn
95
+ class Restricted
96
+ TYPES = %w[artifact creature enchantment land planeswalker instant sorcery].freeze
97
+ BOOLEANS = %w[other attacking blocking tapped token modified transformed prepared saddled entered_this_turn].freeze
98
+ RANGES = %w[power toughness mana_value].freeze
99
+ TYPE_LISTS = %w[not any_of types].freeze
100
+ NAME_LISTS = %w[subtypes not_subtypes supertypes not_supertypes attached_subtypes].freeze
101
+ KEYWORD_LISTS = %w[keywords not_keywords].freeze
102
+ KEYS = (%w[controller counters colors] + BOOLEANS + RANGES + TYPE_LISTS + NAME_LISTS + KEYWORD_LISTS).freeze
103
+ # Lists that may be "chosen": the choice remembered under that memory key (CR 607.2d).
104
+ CHOSEN = { "colors" => "chosen_color", "types" => "chosen_card_type", "not" => "chosen_card_type",
105
+ "subtypes" => "chosen_type", "not_subtypes" => "chosen_type" }.freeze
106
+ # Keys that need current characteristics or combat/tapped status: not usable in the layer pass.
107
+ LIVE = %w[power toughness attacking blocking tapped attached_subtypes transformed prepared saddled
108
+ entered_this_turn].freeze
109
+
110
+ attr_reader :base
111
+
112
+ def self.build(base, params, errors, path)
113
+ controller = params["controller"]
114
+ errors << "#{path}.controller: must be you or opponent" if controller && !%w[you opponent].include?(controller)
115
+ validate_lists(params, errors, path)
116
+ BOOLEANS.each do |key|
117
+ errors << "#{path}.#{key}: must be true or false" unless params[key].nil? || [true, false].include?(params[key])
118
+ end
119
+ ranges = build_ranges(params, errors, path)
120
+ counters = params["counters"]
121
+ unless counters.nil? || [true, false].include?(counters) || Counters.valid?(counters)
122
+ errors << "#{path}.counters: must be true, false or a counter name"
123
+ end
124
+ colors = params["colors"]
125
+ unless colors.nil? || colors == "chosen" ||
126
+ (colors.is_a?(Array) && !colors.empty? && colors.all? { Mana::COLORS.include?(it.to_s.to_sym) })
127
+ errors << "#{path}.colors: must be a non-empty list of W, U, B, R, G (any of them) or \"chosen\""
128
+ end
129
+ new(base, params, ranges)
130
+ end
131
+
132
+ def self.validate_lists(params, errors, path)
133
+ validate_keywords(params, errors, path)
134
+ TYPE_LISTS.each do |key|
135
+ list = params[key]
136
+ next if list.nil? || (list == "chosen" && CHOSEN.key?(key)) || (list.is_a?(Array) && (list - TYPES).empty?)
137
+
138
+ errors << "#{path}.#{key}: must be a list of card types (#{TYPES.join(", ")})"
139
+ end
140
+ NAME_LISTS.each do |key|
141
+ list = params[key]
142
+ next if list.nil? || (list == "chosen" && CHOSEN.key?(key))
143
+
144
+ supertypes = key.end_with?("supertypes")
145
+ ok = list.is_a?(Array) && !list.empty? &&
146
+ list.all? { supertypes ? Enjine.registry.fetch(:supertype, it) : CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
147
+ errors << "#{path}.#{key}: must be a non-empty list of #{supertypes ? "supertypes" : "lowercase subtypes"}" unless ok
148
+ end
149
+ end
150
+
151
+ # A keyword's registered name: "first strike" -> "first_strike".
152
+ def self.keyword(name) = name.to_s.downcase.tr(" -", "__")
153
+
154
+ # "with flying" / "without flying": keyword names as in a card's `keywords` (CR 702.1).
155
+ def self.validate_keywords(params, errors, path)
156
+ KEYWORD_LISTS.each do |key|
157
+ list = params[key]
158
+ next if list.nil?
159
+
160
+ ok = list.is_a?(Array) && !list.empty? && list.all? { Enjine.registry.fetch(:keyword, Restricted.keyword(it)) }
161
+ errors << "#{path}.#{key}: must be a non-empty list of keywords (e.g. [\"flying\"])" unless ok
162
+ end
163
+ end
164
+
165
+ BOUNDS = %w[at_least at_most equal].freeze
166
+
167
+ # {"at_least": n}, {"at_most": n} and/or {"equal": n}; each bound is an integer or a computed
168
+ # amount ("mana value equal to the number of charge counters on it", CR 202.3, 608.2h). Returns
169
+ # the bounds as Amounts.
170
+ def self.validate_range(range, errors, path)
171
+ unless range.is_a?(Hash) && !range.empty? && (range.keys - BOUNDS).empty?
172
+ errors << "#{path}: must be {\"at_least\": n}, {\"at_most\": n} and/or {\"equal\": n} (n: an amount)"
173
+ return {}
174
+ end
175
+ range.to_h { |key, n| [key, Amount.build(n, errors, "#{path}.#{key}", targets_count: 0, min: -1_000)] }
176
+ end
177
+
178
+ def self.build_ranges(params, errors, path)
179
+ RANGES.filter_map { |key| [key, validate_range(params[key], errors, "#{path}.#{key}")] if params.key?(key) }.to_h
180
+ end
181
+
182
+ def initialize(base, params, ranges = {})
183
+ @base = base
184
+ @controller = params["controller"]
185
+ @other = params["other"] == true
186
+ @params = params
187
+ @chosen = CHOSEN.select { |key, _| params[key] == "chosen" }
188
+ @lists = (TYPE_LISTS + NAME_LISTS).to_h { |key| [key, @chosen.key?(key) ? [] : Array(params[key]).map(&:to_sym)] }
189
+ KEYWORD_LISTS.each { |key| @lists[key] = Array(params[key]).map { Restricted.keyword(it).to_sym } }
190
+ @colors = params["colors"].is_a?(Array) ? params["colors"].map(&:to_sym) : nil
191
+ @ranges = ranges.freeze
192
+ @computed = ranges.values.any? { |range| range.values.any? { !it.is_a?(Amount::Fixed) } }
193
+ freeze
194
+ end
195
+
196
+ def primitive_name = base.primitive_name
197
+ def uses_chosen? = !@chosen.empty?
198
+
199
+ # A computed bound depends on the game, so (like power) it isn't usable in the layer pass.
200
+ def live_keys = LIVE.select { @params.key?(it) } + (@computed ? ["computed_range"] : [])
201
+
202
+ def candidates(engine, controller) = candidates_for(engine, controller, nil)
203
+
204
+ # `ctx`: the resolving spell's or ability's EffectContext, for computed range bounds (a source that
205
+ # left the battlefield counts as it last existed, CR 608.2h).
206
+ def candidates_for(engine, controller, source, ctx: nil)
207
+ bounds = resolved_ranges(engine, controller, source, ctx)
208
+ base.candidates(engine, controller).select { allowed?(engine, it, controller, source, bounds, ctx) }
209
+ end
210
+
211
+ def match?(engine, ref, controller) = match_for?(engine, ref, controller, nil)
212
+
213
+ def match_for?(engine, ref, controller, source, ctx: nil)
214
+ base.match?(engine, ref, controller) &&
215
+ allowed?(engine, ref, controller, source, resolved_ranges(engine, controller, source, ctx), ctx)
216
+ end
217
+
218
+ # Card types only.
219
+ def matches_types?(types)
220
+ base.matches_types?(types) && !types.intersect?(@lists["not"]) &&
221
+ (@lists["any_of"].empty? || types.intersect?(@lists["any_of"])) && (@lists["types"] - types).empty?
222
+ end
223
+
224
+ def matches_facts?(facts) = base.matches_facts?(facts) && characteristics_ok?(facts)
225
+
226
+ def to_s
227
+ words = []
228
+ words << "another" if @other
229
+ words << "attacking" if @params["attacking"]
230
+ words << (@params["token"] ? "token" : "nontoken") unless @params["token"].nil?
231
+ words.concat(status_words)
232
+ words.concat(@lists["supertypes"]).concat(@lists["not_supertypes"].map { "non#{it}" })
233
+ words << "non#{@lists["not"].join("/non")}" unless @lists["not"].empty?
234
+ words.concat(@lists["types"] + @lists["subtypes"]).concat(@lists["not_subtypes"].map { "non-#{it}" })
235
+ words.concat(chosen_words)
236
+ words << (@lists["any_of"].empty? ? base.primitive_name.tr("_", " ") : @lists["any_of"].join(" or "))
237
+ words << { "you" => "you control", "opponent" => "an opponent controls" }[@controller]
238
+ words.concat(@ranges.map { |key, range| "with #{key.tr("_", " ")} #{range_words(range)}" })
239
+ words << "with a #{@lists["attached_subtypes"].join(" or ")} attached" unless @lists["attached_subtypes"].empty?
240
+ (words + [counter_words] + keyword_words).compact.join(" ")
241
+ end
242
+
243
+ RANGE_WORDS = { "at_least" => "%s or greater", "at_most" => "%s or less", "equal" => "equal to %s" }.freeze
244
+ private_constant :RANGE_WORDS
245
+
246
+ private
247
+
248
+ # "modified"; "transformed" / "untransformed", "prepared" / "unprepared" (CR 701.27h, 723.3a).
249
+ def status_words
250
+ words = @params["modified"] ? ["modified"] : []
251
+ words += %w[transformed prepared saddled].reject { @params[it].nil? }.map { @params[it] ? it : "un#{it}" }
252
+ words + { true => ["that entered this turn"], false => ["that didn't enter this turn"] }.fetch(@params["entered_this_turn"], [])
253
+ end
254
+
255
+ def range_words(range) = range.map { |k, n| format(RANGE_WORDS.fetch(k), n) }.join(" and ")
256
+
257
+ def chosen_words = @chosen.keys.map { "#{"non-" if it.start_with?("not")}chosen-#{it.delete_prefix("not_")}" }
258
+
259
+ def keyword_words
260
+ [("with #{@lists["keywords"].join(" or ")}" unless @lists["keywords"].empty?),
261
+ ("without #{@lists["not_keywords"].join(" or ")}" unless @lists["not_keywords"].empty?)]
262
+ end
263
+
264
+ def counter_words
265
+ case @params["counters"]
266
+ when true then "with a counter on it"
267
+ when false then "with no counters on it"
268
+ when String then "with a #{@params["counters"]} counter on it"
269
+ end
270
+ end
271
+
272
+ # The range bounds as numbers, computed once per query. Without a resolving context (a target
273
+ # being chosen) the filter's source stands for "it".
274
+ def resolved_ranges(engine, controller, source, ctx)
275
+ return @ranges.transform_values { |range| range.transform_values(&:n) } unless @computed
276
+
277
+ ctx ||= EffectContext.new(controller, source, source, [], [])
278
+ @ranges.transform_values { |range| range.transform_values { it.value(engine, ctx) } }
279
+ end
280
+
281
+ def allowed?(engine, ref, controller, source, bounds, ctx)
282
+ return false if @other && ref == source
283
+
284
+ index = Filters.player_index(engine, ref)
285
+ owner = index || engine.state.objects[ref]&.controller
286
+ return false if @controller && (@controller == "you") != (owner == controller)
287
+ return true if index
288
+
289
+ object_allowed?(engine, ref, bounds, uses_chosen? ? Filters.chosen_memory(engine, source, ctx) : nil)
290
+ end
291
+
292
+ def object_allowed?(engine, ref, bounds, chosen)
293
+ obj = engine.object(ref)
294
+ current = engine.chars(ref)
295
+ facts = Facts.new(chars: current, counters: obj.counters, token: engine.token?(obj), chosen: chosen)
296
+ return false unless characteristics_ok?(facts) && status_ok?(engine, obj)
297
+ return false if @params["modified"] == true && !modified?(engine, obj)
298
+ return false unless attached_ok?(engine, obj)
299
+
300
+ bounds.all? { |key, range| in_range?(stat(engine, obj, current, key), range) }
301
+ end
302
+
303
+ # Types, subtypes, supertypes, keywords, the chosen values, token-ness, counters and printed mana
304
+ # value (CR 109.3, 122, 202.3).
305
+ def characteristics_ok?(facts)
306
+ c = facts.chars
307
+ return false unless chosen_ok?(c, facts.chosen)
308
+ return false unless matches_types?(c.types) && (@lists["subtypes"].empty? || c.subtypes.intersect?(@lists["subtypes"]))
309
+ return false if c.subtypes.intersect?(@lists["not_subtypes"]) || c.supertypes.intersect?(@lists["not_supertypes"])
310
+ return false unless (@lists["supertypes"] - c.supertypes).empty?
311
+ return false if @colors && !c.colors.intersect?(@colors) # CR 105.2
312
+ return false unless keywords_ok?(c.keywords)
313
+ return false if !@params["token"].nil? && facts.token != @params["token"]
314
+ return false if @params["modified"] == false && Counters.any?(facts.counters)
315
+
316
+ counters_ok?(facts.counters)
317
+ end
318
+
319
+ # Current keywords (CR 702.1): granted ones and keyword counters count.
320
+ def keywords_ok?(keywords)
321
+ (@lists["keywords"].empty? || keywords.intersect?(@lists["keywords"])) && !keywords.intersect?(@lists["not_keywords"])
322
+ end
323
+
324
+ # "Of the chosen color/type" (CR 607.2d): nothing matches while nothing was chosen, and "not of
325
+ # the chosen type" then excludes nothing.
326
+ def chosen_ok?(chars, memory)
327
+ @chosen.all? do |key, memory_key|
328
+ value = memory&.[](memory_key)
329
+ have = case key
330
+ when "colors" then chars.colors
331
+ when "types", "not" then chars.types
332
+ else chars.subtypes
333
+ end
334
+ hit = !value.nil? && have.include?(value.to_sym)
335
+ key.start_with?("not") ? !hit : hit
336
+ end
337
+ end
338
+
339
+ def counters_ok?(counters)
340
+ case (wanted = @params["counters"])
341
+ when nil then true
342
+ when true, false then Counters.any?(counters) == wanted
343
+ else counters.fetch(wanted, 0).positive?
344
+ end
345
+ end
346
+
347
+ def status_ok?(engine, obj)
348
+ return false if !@params["attacking"].nil? && engine.attackers.key?(obj.id) != @params["attacking"]
349
+ return false if !@params["blocking"].nil? && engine.state.blockers.key?(obj.id) != @params["blocking"]
350
+ return false if !@params["transformed"].nil? && engine.transformed?(obj) != @params["transformed"] # CR 701.27h
351
+ return false if !@params["prepared"].nil? && engine.prepared?(obj) != @params["prepared"] # CR 723.3a
352
+ return false if !@params["saddled"].nil? && engine.saddled?(obj) != @params["saddled"] # CR 702.171b
353
+ return false if !@params["entered_this_turn"].nil? && engine.entered_this_turn?(obj) != @params["entered_this_turn"]
354
+
355
+ @params["tapped"].nil? || obj.tapped == @params["tapped"]
356
+ end
357
+
358
+ # "With a [subtype] attached to it": an Aura, Equipment or other permanent attached to it has one.
359
+ def attached_ok?(engine, obj)
360
+ wanted = @lists["attached_subtypes"]
361
+ wanted.empty? || engine.attachments_of(obj.id).any? { engine.chars(it).subtypes.intersect?(wanted) }
362
+ end
363
+
364
+ # CR 700.9: a permanent is modified if it has counters, is equipped, or is enchanted by an Aura
365
+ # its controller controls (Engine#modifying_attachment?, CR 301.5, 303.4).
366
+ def modified?(engine, obj)
367
+ Counters.any?(obj.counters) || engine.modifying_attachment?(obj)
368
+ end
369
+
370
+ # Current power/toughness (CR 208.3) and mana value (CR 202.3; 0 without a mana cost, 202.3a).
371
+ def stat(engine, obj, current, key)
372
+ case key
373
+ when "power" then engine.power(obj)
374
+ when "toughness" then engine.toughness(obj)
375
+ else current.mana_cost&.mana_value.to_i
376
+ end
377
+ end
378
+
379
+ def in_range?(value, range)
380
+ (range["at_least"].nil? || value >= range["at_least"]) && (range["at_most"].nil? || value <= range["at_most"]) &&
381
+ (range["equal"].nil? || value == range["equal"])
382
+ end
383
+ end
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # A card in a graveyard (CR 404): "target creature card from a graveyard" is
7
+ # {"graveyard_card": {"any_of": ["creature"]}}; add "controller": "you" for "from your graveyard".
8
+ class GraveyardCard < Filter
9
+ primitive :filter, :graveyard_card,
10
+ description: "A card in a graveyard (any player's, in seat order; restrict with " \
11
+ "`controller` for its owner and `any_of`/`not` for its card types).",
12
+ rules: %w[404.1 115.1], example: { "graveyard_card" => { "any_of" => ["creature"] } }
13
+
14
+ def self.zone = :graveyard
15
+ def self.match_object?(_engine, object, _controller) = object.kind == "card"
16
+
17
+ def self.candidates(engine, _controller)
18
+ engine.state.players.flat_map(&:graveyard)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # Matches what any of its filters matches: "a land, or a creature with power 6 or more" is
7
+ # [{"land": {}}, {"creature": {"power": {"at_least": 6}}}] (a union of restricted filters, CR 115.1).
8
+ class OneOf < Filter
9
+ primitive :filter, :one_of,
10
+ description: "Anything at least one of the listed filters matches (each may have restrictions).",
11
+ rules: %w[115.1 109.2],
12
+ params: { "(list)" => "two or more filters" },
13
+ example: { "one_of" => [{ "permanent" => { "any_of" => %w[artifact enchantment] } },
14
+ { "creature" => { "power" => { "at_least" => 4 } } }] }
15
+
16
+ def self.scalar_params? = true
17
+
18
+ def self.build(params, errors, path, **)
19
+ unless params.is_a?(Array) && params.size >= 2
20
+ errors << "#{path}: must be a list of at least two filters"
21
+ return self
22
+ end
23
+ new(params.each_with_index.filter_map { |f, i| Mechanics.build_entry(:filter, f, errors, "#{path}[#{i}]") })
24
+ end
25
+
26
+ attr_reader :filters
27
+
28
+ def initialize(filters)
29
+ super()
30
+ @filters = filters.freeze
31
+ freeze
32
+ end
33
+
34
+ def primitive_name = "one_of"
35
+ def live_keys = filters.flat_map(&:live_keys).uniq
36
+ def uses_chosen? = filters.any?(&:uses_chosen?)
37
+ def to_s = filters.map { it.is_a?(Class) ? it.primitive_name.tr("_", " ") : it.to_s }.join(" or ")
38
+ def candidates(engine, controller) = candidates_for(engine, controller, nil)
39
+
40
+ def candidates_for(engine, controller, source, ctx: nil)
41
+ filters.flat_map { it.candidates_for(engine, controller, source, ctx: ctx) }.uniq
42
+ end
43
+
44
+ def match?(engine, ref, controller) = match_for?(engine, ref, controller, nil)
45
+
46
+ def match_for?(engine, ref, controller, source, ctx: nil)
47
+ filters.any? { it.match_for?(engine, ref, controller, source, ctx: ctx) }
48
+ end
49
+
50
+ def matches_types?(types) = filters.any? { it.matches_types?(types) }
51
+ def matches_facts?(facts) = filters.any? { it.matches_facts?(facts) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # A creature on the battlefield.
7
+ class Creature < Filter
8
+ primitive :filter, :creature, description: "A creature on the battlefield.", rules: %w[115.1], example: "creature"
9
+ def self.match_object?(engine, object, _controller) = type?(engine, object, :creature)
10
+ def self.card_types = [:creature]
11
+ end
12
+
13
+ # A planeswalker on the battlefield.
14
+ class Planeswalker < Filter
15
+ primitive :filter, :planeswalker, description: "A planeswalker on the battlefield.", rules: %w[115.1],
16
+ example: "planeswalker"
17
+ def self.match_object?(engine, object, _controller) = type?(engine, object, :planeswalker)
18
+ def self.card_types = [:planeswalker]
19
+ end
20
+
21
+ # A creature or planeswalker on the battlefield.
22
+ class CreatureOrPlaneswalker < Filter
23
+ primitive :filter, :creature_or_planeswalker, description: "A creature or planeswalker on the battlefield.",
24
+ rules: %w[115.1], example: "creature_or_planeswalker"
25
+ def self.match_object?(engine, object, _controller)
26
+ type?(engine, object, :creature) || type?(engine, object, :planeswalker)
27
+ end
28
+ end
29
+
30
+ # A land on the battlefield.
31
+ class Land < Filter
32
+ primitive :filter, :land, description: "A land on the battlefield.", rules: %w[115.1], example: "land"
33
+ def self.match_object?(engine, object, _controller) = type?(engine, object, :land)
34
+ def self.card_types = [:land]
35
+ end
36
+
37
+ # An artifact on the battlefield.
38
+ class Artifact < Filter
39
+ primitive :filter, :artifact, description: "An artifact on the battlefield.", rules: %w[115.1], example: "artifact"
40
+ def self.match_object?(engine, object, _controller) = type?(engine, object, :artifact)
41
+ def self.card_types = [:artifact]
42
+ end
43
+
44
+ # Any permanent.
45
+ class Permanent < Filter
46
+ primitive :filter, :permanent, description: "Any permanent on the battlefield.", rules: %w[110.1],
47
+ example: "permanent"
48
+ def self.match_object?(_engine, _object, _controller) = true
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enjine
4
+ module Mechanics
5
+ module Filters
6
+ # A player.
7
+ class Player < Filter
8
+ primitive :filter, :player, description: "A player.", rules: %w[115.1], example: "player"
9
+ def self.players?(_engine, index, _controller) = !index.nil?
10
+ end
11
+
12
+ # An opponent of the controller.
13
+ class Opponent < Filter
14
+ primitive :filter, :opponent, description: "An opponent of the ability's controller.", rules: %w[102.2],
15
+ example: "opponent"
16
+ def self.players?(_engine, index, controller) = !index.nil? && index != controller
17
+ end
18
+ end
19
+ end
20
+ end