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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/SUPPORTED.md +387 -0
- data/docs/ARCHITECTURE.md +141 -0
- data/docs/DESIGN.md +139 -0
- data/docs/MECHANICS_COVERAGE.md +670 -0
- data/docs/guides/adding-a-mechanic.md +60 -0
- data/docs/guides/api.md +614 -0
- data/docs/guides/card-data-format.md +1472 -0
- data/docs/guides/deck-builder.md +157 -0
- data/docs/plans/standard.md +371 -0
- data/docs/reference/abilities.md +1680 -0
- data/docs/reference/costs.md +339 -0
- data/docs/reference/counters.md +121 -0
- data/docs/reference/decisions.md +102 -0
- data/docs/reference/effects.md +2340 -0
- data/docs/reference/filters.md +172 -0
- data/docs/reference/keywords.md +647 -0
- data/docs/reference/subtypes.md +169 -0
- data/docs/reference/supertypes.md +31 -0
- data/docs/reference/triggers.md +797 -0
- data/docs/reference/types.md +216 -0
- data/exe/enjine +7 -0
- data/lib/enjine/actions/activate_mana_ability.rb +24 -0
- data/lib/enjine/actions/cast.rb +57 -0
- data/lib/enjine/actions/pass.rb +18 -0
- data/lib/enjine/actions/play_land.rb +52 -0
- data/lib/enjine/actions/special_action.rb +37 -0
- data/lib/enjine/actions.rb +20 -0
- data/lib/enjine/agents/busy.rb +27 -0
- data/lib/enjine/agents/random.rb +28 -0
- data/lib/enjine/card_definition.rb +433 -0
- data/lib/enjine/card_faces.rb +116 -0
- data/lib/enjine/catalog.rb +56 -0
- data/lib/enjine/characteristics.rb +39 -0
- data/lib/enjine/cli/text.rb +295 -0
- data/lib/enjine/cli.rb +312 -0
- data/lib/enjine/decisions/bottom.rb +29 -0
- data/lib/enjine/decisions/casting.rb +188 -0
- data/lib/enjine/decisions/combat.rb +75 -0
- data/lib/enjine/decisions/discard.rb +29 -0
- data/lib/enjine/decisions/effect_choice.rb +21 -0
- data/lib/enjine/decisions/enter_choice.rb +26 -0
- data/lib/enjine/decisions/legend_rule.rb +21 -0
- data/lib/enjine/decisions/mulligan.rb +26 -0
- data/lib/enjine/decisions/order_triggers.rb +21 -0
- data/lib/enjine/decisions/play_or_draw.rb +19 -0
- data/lib/enjine/decisions/priority.rb +22 -0
- data/lib/enjine/decisions/untap_choice.rb +35 -0
- data/lib/enjine/decisions.rb +22 -0
- data/lib/enjine/deck.rb +151 -0
- data/lib/enjine/deck_builder.rb +454 -0
- data/lib/enjine/docs.rb +90 -0
- data/lib/enjine/engine/ability_layers.rb +118 -0
- data/lib/enjine/engine/attachments.rb +86 -0
- data/lib/enjine/engine/casting/alt_casting.rb +254 -0
- data/lib/enjine/engine/casting/cast_choices.rb +292 -0
- data/lib/enjine/engine/casting/casting.rb +326 -0
- data/lib/enjine/engine/casting/cost_options.rb +99 -0
- data/lib/enjine/engine/casting/costs.rb +117 -0
- data/lib/enjine/engine/casting/payment.rb +353 -0
- data/lib/enjine/engine/casting/permissions.rb +412 -0
- data/lib/enjine/engine/casting/target_counts.rb +113 -0
- data/lib/enjine/engine/casting/waterbend.rb +90 -0
- data/lib/enjine/engine/characteristics.rb +379 -0
- data/lib/enjine/engine/class_levels.rb +25 -0
- data/lib/enjine/engine/combat.rb +315 -0
- data/lib/enjine/engine/copies.rb +208 -0
- data/lib/enjine/engine/damage.rb +196 -0
- data/lib/enjine/engine/designations.rb +46 -0
- data/lib/enjine/engine/emblems.rb +37 -0
- data/lib/enjine/engine/entering.rb +156 -0
- data/lib/enjine/engine/face_down.rb +30 -0
- data/lib/enjine/engine/faces.rb +222 -0
- data/lib/enjine/engine/follow_ups.rb +27 -0
- data/lib/enjine/engine/mana_sources.rb +230 -0
- data/lib/enjine/engine/phasing.rb +74 -0
- data/lib/enjine/engine/pregame.rb +95 -0
- data/lib/enjine/engine/priority.rb +47 -0
- data/lib/enjine/engine/resolution.rb +168 -0
- data/lib/enjine/engine/rules.rb +287 -0
- data/lib/enjine/engine/sagas.rb +41 -0
- data/lib/enjine/engine/speed.rb +104 -0
- data/lib/enjine/engine/spell_grants.rb +46 -0
- data/lib/enjine/engine/state_based_actions.rb +157 -0
- data/lib/enjine/engine/triggers.rb +364 -0
- data/lib/enjine/engine/turn_history.rb +240 -0
- data/lib/enjine/engine/turn_structure.rb +224 -0
- data/lib/enjine/engine/zones.rb +278 -0
- data/lib/enjine/engine.rb +55 -0
- data/lib/enjine/errors.rb +62 -0
- data/lib/enjine/events.rb +161 -0
- data/lib/enjine/frames.rb +84 -0
- data/lib/enjine/fuzz.rb +112 -0
- data/lib/enjine/game.rb +204 -0
- data/lib/enjine/history.rb +26 -0
- data/lib/enjine/mana.rb +142 -0
- data/lib/enjine/mechanics/abilities/additional_triggers.rb +86 -0
- data/lib/enjine/mechanics/abilities/chosen_values.rb +213 -0
- data/lib/enjine/mechanics/abilities/cost_changes.rb +154 -0
- data/lib/enjine/mechanics/abilities/damage_replacements.rb +569 -0
- data/lib/enjine/mechanics/abilities/double_counters.rb +71 -0
- data/lib/enjine/mechanics/abilities/entering_choices.rb +91 -0
- data/lib/enjine/mechanics/abilities/entering_copy.rb +69 -0
- data/lib/enjine/mechanics/abilities/enters_tapped.rb +159 -0
- data/lib/enjine/mechanics/abilities/enters_with_counters.rb +116 -0
- data/lib/enjine/mechanics/abilities/mana_ability.rb +209 -0
- data/lib/enjine/mechanics/abilities/play_permissions.rb +527 -0
- data/lib/enjine/mechanics/abilities/player_rules.rb +169 -0
- data/lib/enjine/mechanics/abilities/replacement_effects.rb +368 -0
- data/lib/enjine/mechanics/abilities/restrictions.rb +144 -0
- data/lib/enjine/mechanics/abilities/spell_keywords.rb +55 -0
- data/lib/enjine/mechanics/abilities/stack_abilities.rb +542 -0
- data/lib/enjine/mechanics/abilities/static.rb +365 -0
- data/lib/enjine/mechanics/abilities/tapping_power.rb +71 -0
- data/lib/enjine/mechanics/abilities/turn_rules.rb +49 -0
- data/lib/enjine/mechanics/card_spec.rb +177 -0
- data/lib/enjine/mechanics/copy_except.rb +73 -0
- data/lib/enjine/mechanics/costs/base.rb +115 -0
- data/lib/enjine/mechanics/costs/behold.rb +76 -0
- data/lib/enjine/mechanics/costs/discard.rb +65 -0
- data/lib/enjine/mechanics/costs/exert.rb +29 -0
- data/lib/enjine/mechanics/costs/exile_cards.rb +39 -0
- data/lib/enjine/mechanics/costs/from_graveyard.rb +45 -0
- data/lib/enjine/mechanics/costs/get_counters.rb +39 -0
- data/lib/enjine/mechanics/costs/mana.rb +37 -0
- data/lib/enjine/mechanics/costs/one_of.rb +49 -0
- data/lib/enjine/mechanics/costs/pay_life.rb +50 -0
- data/lib/enjine/mechanics/costs/remove_counters.rb +51 -0
- data/lib/enjine/mechanics/costs/sacrifice.rb +59 -0
- data/lib/enjine/mechanics/costs/tap.rb +56 -0
- data/lib/enjine/mechanics/costs/waterbend.rb +29 -0
- data/lib/enjine/mechanics/counters/kinds.rb +190 -0
- data/lib/enjine/mechanics/effects/add_subtypes.rb +30 -0
- data/lib/enjine/mechanics/effects/airbend.rb +41 -0
- data/lib/enjine/mechanics/effects/amass.rb +124 -0
- data/lib/enjine/mechanics/effects/assimilate.rb +72 -0
- data/lib/enjine/mechanics/effects/base.rb +148 -0
- data/lib/enjine/mechanics/effects/become.rb +182 -0
- data/lib/enjine/mechanics/effects/behold.rb +48 -0
- data/lib/enjine/mechanics/effects/blight.rb +94 -0
- data/lib/enjine/mechanics/effects/choose_cards.rb +168 -0
- data/lib/enjine/mechanics/effects/copy.rb +370 -0
- data/lib/enjine/mechanics/effects/counter_spell.rb +21 -0
- data/lib/enjine/mechanics/effects/counters.rb +161 -0
- data/lib/enjine/mechanics/effects/damage.rb +72 -0
- data/lib/enjine/mechanics/effects/delayed_triggers.rb +112 -0
- data/lib/enjine/mechanics/effects/destroy.rb +22 -0
- data/lib/enjine/mechanics/effects/discard.rb +86 -0
- data/lib/enjine/mechanics/effects/draw.rb +16 -0
- data/lib/enjine/mechanics/effects/earthbend.rb +70 -0
- data/lib/enjine/mechanics/effects/emblem.rb +52 -0
- data/lib/enjine/mechanics/effects/endure.rb +63 -0
- data/lib/enjine/mechanics/effects/exile.rb +49 -0
- data/lib/enjine/mechanics/effects/extra_turn.rb +122 -0
- data/lib/enjine/mechanics/effects/faces.rb +115 -0
- data/lib/enjine/mechanics/effects/fight.rb +76 -0
- data/lib/enjine/mechanics/effects/flip_coins.rb +97 -0
- data/lib/enjine/mechanics/effects/gain_control.rb +94 -0
- data/lib/enjine/mechanics/effects/game_end.rb +42 -0
- data/lib/enjine/mechanics/effects/library.rb +327 -0
- data/lib/enjine/mechanics/effects/life.rb +83 -0
- data/lib/enjine/mechanics/effects/look_at_hand.rb +17 -0
- data/lib/enjine/mechanics/effects/loot.rb +118 -0
- data/lib/enjine/mechanics/effects/mana.rb +96 -0
- data/lib/enjine/mechanics/effects/may.rb +82 -0
- data/lib/enjine/mechanics/effects/meld.rb +77 -0
- data/lib/enjine/mechanics/effects/mill.rb +22 -0
- data/lib/enjine/mechanics/effects/pay.rb +231 -0
- data/lib/enjine/mechanics/effects/phasing.rb +51 -0
- data/lib/enjine/mechanics/effects/proliferate.rb +72 -0
- data/lib/enjine/mechanics/effects/pump.rb +60 -0
- data/lib/enjine/mechanics/effects/put_in_library.rb +98 -0
- data/lib/enjine/mechanics/effects/return_choice.rb +29 -0
- data/lib/enjine/mechanics/effects/return_to_battlefield.rb +80 -0
- data/lib/enjine/mechanics/effects/return_to_hand.rb +20 -0
- data/lib/enjine/mechanics/effects/roll_dice.rb +45 -0
- data/lib/enjine/mechanics/effects/sacrifice.rb +118 -0
- data/lib/enjine/mechanics/effects/scry_and_surveil.rb +74 -0
- data/lib/enjine/mechanics/effects/search.rb +156 -0
- data/lib/enjine/mechanics/effects/tap_untap.rb +23 -0
- data/lib/enjine/mechanics/effects/tokens.rb +76 -0
- data/lib/enjine/mechanics/filters/any_target.rb +15 -0
- data/lib/enjine/mechanics/filters/base.rb +386 -0
- data/lib/enjine/mechanics/filters/graveyard.rb +23 -0
- data/lib/enjine/mechanics/filters/one_of.rb +55 -0
- data/lib/enjine/mechanics/filters/permanents.rb +52 -0
- data/lib/enjine/mechanics/filters/players.rb +20 -0
- data/lib/enjine/mechanics/filters/stack.rb +97 -0
- data/lib/enjine/mechanics/follow_up.rb +46 -0
- data/lib/enjine/mechanics/keywords/alternative_costs.rb +361 -0
- data/lib/enjine/mechanics/keywords/beam_me_up.rb +78 -0
- data/lib/enjine/mechanics/keywords/changeling.rb +58 -0
- data/lib/enjine/mechanics/keywords/convoke.rb +77 -0
- data/lib/enjine/mechanics/keywords/crew.rb +155 -0
- data/lib/enjine/mechanics/keywords/cycling.rb +58 -0
- data/lib/enjine/mechanics/keywords/decayed.rb +25 -0
- data/lib/enjine/mechanics/keywords/devour.rb +61 -0
- data/lib/enjine/mechanics/keywords/evergreen.rb +54 -0
- data/lib/enjine/mechanics/keywords/extort.rb +26 -0
- data/lib/enjine/mechanics/keywords/flags.rb +46 -0
- data/lib/enjine/mechanics/keywords/foretell.rb +45 -0
- data/lib/enjine/mechanics/keywords/gift.rb +50 -0
- data/lib/enjine/mechanics/keywords/hideaway.rb +28 -0
- data/lib/enjine/mechanics/keywords/increment.rb +31 -0
- data/lib/enjine/mechanics/keywords/job_select.rb +29 -0
- data/lib/enjine/mechanics/keywords/kicker.rb +92 -0
- data/lib/enjine/mechanics/keywords/mobilize_and_firebending.rb +84 -0
- data/lib/enjine/mechanics/keywords/paradigm.rb +91 -0
- data/lib/enjine/mechanics/keywords/power_up.rb +48 -0
- data/lib/enjine/mechanics/keywords/protection.rb +111 -0
- data/lib/enjine/mechanics/keywords/prowess.rb +30 -0
- data/lib/enjine/mechanics/keywords/riot.rb +35 -0
- data/lib/enjine/mechanics/keywords/saddle.rb +115 -0
- data/lib/enjine/mechanics/keywords/speed.rb +89 -0
- data/lib/enjine/mechanics/keywords/station.rb +163 -0
- data/lib/enjine/mechanics/keywords/storied.rb +41 -0
- data/lib/enjine/mechanics/keywords/storm_and_cascade.rb +86 -0
- data/lib/enjine/mechanics/keywords/sunburst.rb +29 -0
- data/lib/enjine/mechanics/keywords/suspend.rb +191 -0
- data/lib/enjine/mechanics/keywords/teamwork.rb +45 -0
- data/lib/enjine/mechanics/keywords/ward.rb +67 -0
- data/lib/enjine/mechanics/linked.rb +67 -0
- data/lib/enjine/mechanics/mana_spent.rb +99 -0
- data/lib/enjine/mechanics/selector.rb +207 -0
- data/lib/enjine/mechanics/subtypes/auras_and_equipment.rb +176 -0
- data/lib/enjine/mechanics/subtypes/basic_land_types.rb +36 -0
- data/lib/enjine/mechanics/subtypes/class_levels.rb +102 -0
- data/lib/enjine/mechanics/subtypes/saga.rb +89 -0
- data/lib/enjine/mechanics/supertypes/basic.rb +17 -0
- data/lib/enjine/mechanics/supertypes/legendary.rb +18 -0
- data/lib/enjine/mechanics/triggers/ability_triggers.rb +51 -0
- data/lib/enjine/mechanics/triggers/any_of.rb +43 -0
- data/lib/enjine/mechanics/triggers/base.rb +228 -0
- data/lib/enjine/mechanics/triggers/casting.rb +219 -0
- data/lib/enjine/mechanics/triggers/combat.rb +123 -0
- data/lib/enjine/mechanics/triggers/damage.rb +130 -0
- data/lib/enjine/mechanics/triggers/dilemma.rb +37 -0
- data/lib/enjine/mechanics/triggers/draw_and_discard.rb +85 -0
- data/lib/enjine/mechanics/triggers/keyword_actions.rb +55 -0
- data/lib/enjine/mechanics/triggers/life.rb +35 -0
- data/lib/enjine/mechanics/triggers/steps.rb +39 -0
- data/lib/enjine/mechanics/triggers/tap_and_counters.rb +111 -0
- data/lib/enjine/mechanics/triggers/zone_changes.rb +249 -0
- data/lib/enjine/mechanics/types/kindred.rb +30 -0
- data/lib/enjine/mechanics/types/land.rb +24 -0
- data/lib/enjine/mechanics/types/permanent_types.rb +67 -0
- data/lib/enjine/mechanics/types/spell_types.rb +38 -0
- data/lib/enjine/mechanics/values.rb +675 -0
- data/lib/enjine/mechanics.rb +74 -0
- data/lib/enjine/player.rb +30 -0
- data/lib/enjine/primitive.rb +61 -0
- data/lib/enjine/replacements.rb +191 -0
- data/lib/enjine/rng.rb +57 -0
- data/lib/enjine/rules_monitor.rb +404 -0
- data/lib/enjine/setup.rb +45 -0
- data/lib/enjine/snapshot/player_checks.rb +86 -0
- data/lib/enjine/snapshot/turn_checks.rb +91 -0
- data/lib/enjine/snapshot.rb +307 -0
- data/lib/enjine/state.rb +173 -0
- data/lib/enjine/steps.rb +117 -0
- data/lib/enjine/tokens.rb +25 -0
- data/lib/enjine/verify.rb +233 -0
- data/lib/enjine/version.rb +7 -0
- data/lib/enjine/view.rb +162 -0
- data/lib/enjine.rb +46 -0
- 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
|