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,542 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# One target of a spell or ability with options (CR 601.2c, 115.1d): {"filter": f, "optional": true}
|
|
7
|
+
# is "up to one target f" (the player may choose none), and "distinct_from": [i, ...] keeps it
|
|
8
|
+
# different from earlier targets of the same "target" word ("one or two target creatures").
|
|
9
|
+
# "shares_type_with": i keeps it sharing a card type with earlier target i ("two target permanents that
|
|
10
|
+
# share a card type", CR 115.1, 205.2a).
|
|
11
|
+
# "count" is a variable number of targets for one "target" word (CR 601.2c, 115.3; Engine::TargetCounts):
|
|
12
|
+
# an amount ("x": "X target creatures", 2: "two target creatures"; with "optional": "up to") or "any"
|
|
13
|
+
# ("any number of target ..."). Its slot then holds a list, and {"target": n} means each of them.
|
|
14
|
+
class TargetSpec
|
|
15
|
+
KEYS = %w[filter optional distinct_from count shares_type_with].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :filter, :distinct_from, :count, :shares_type_with
|
|
18
|
+
|
|
19
|
+
def self.build(entry, errors, path, index)
|
|
20
|
+
Mechanics.check_keys(entry, KEYS, errors, path)
|
|
21
|
+
filter = Mechanics.build_entry(:filter, entry["filter"], errors, "#{path}.filter")
|
|
22
|
+
distinct = Array(entry["distinct_from"])
|
|
23
|
+
errors << "#{path}.distinct_from: must list earlier targets" unless distinct.all? { it.is_a?(Integer) && it >= 0 && it < index }
|
|
24
|
+
optional = Mechanics.flag_param(entry, :optional, errors, path)
|
|
25
|
+
shares = entry["shares_type_with"]
|
|
26
|
+
unless shares.nil? || (shares.is_a?(Integer) && shares >= 0 && shares < index)
|
|
27
|
+
errors << "#{path}.shares_type_with: must name an earlier target"
|
|
28
|
+
end
|
|
29
|
+
filter && new(filter, optional, distinct, build_count(entry, errors, path), shares_type_with: shares)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_count(entry, errors, path)
|
|
33
|
+
return nil unless entry.key?("count")
|
|
34
|
+
return :any if entry["count"] == "any"
|
|
35
|
+
|
|
36
|
+
Amount.build(entry["count"], errors, "#{path}.count", targets_count: 0, min: 1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(filter, optional, distinct_from, count = nil, shares_type_with: nil)
|
|
40
|
+
@filter = filter
|
|
41
|
+
@optional = optional
|
|
42
|
+
@distinct_from = distinct_from.freeze
|
|
43
|
+
@count = count
|
|
44
|
+
@shares_type_with = shares_type_with
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def optional? = @optional
|
|
49
|
+
def counted? = !@count.nil?
|
|
50
|
+
|
|
51
|
+
# [min, max] objects to choose (max nil: any number).
|
|
52
|
+
def count_range(engine, ctx)
|
|
53
|
+
return [0, nil] if @count == :any
|
|
54
|
+
|
|
55
|
+
n = [@count.value(engine, ctx), 0].max
|
|
56
|
+
[optional? ? 0 : n, n]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def candidates_for(...) = filter.candidates_for(...)
|
|
60
|
+
def match_for?(...) = filter.match_for?(...)
|
|
61
|
+
def candidates(...) = filter.candidates(...)
|
|
62
|
+
def match?(...) = filter.match?(...)
|
|
63
|
+
def primitive_name = filter.primitive_name
|
|
64
|
+
def to_s = "#{count_words}#{filter.is_a?(Class) ? primitive_name : filter}"
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def count_words
|
|
69
|
+
return "any number of " if @count == :any
|
|
70
|
+
return "#{"up to " if optional?}#{@count.is_a?(Amount::Fixed) ? @count.n : "X"} " if counted?
|
|
71
|
+
|
|
72
|
+
"up to one " if optional?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Shared parsing for abilities that use the stack: targets and effects.
|
|
77
|
+
class StackAbility < Primitive
|
|
78
|
+
# One mode of a modal ability ("Choose one —", CR 700.2): its own targets and effects, and for a spell
|
|
79
|
+
# the additional mana cost paid when it's chosen (tiered, CR 702.183a), or nil.
|
|
80
|
+
Mode = Data.define(:targets, :effects, :cost) do
|
|
81
|
+
def initialize(targets:, effects:, cost: nil) = super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Optional parts of the body: how many modes may be chosen and when more may (CR 700.2), modes
|
|
85
|
+
# not chosen before for the same object, targets of a kicked spell (CR 702.33g), and an amount
|
|
86
|
+
# divided among the targets as it's cast (CR 601.2d).
|
|
87
|
+
Extras = Data.define(:choose_max, :choose_if, :unchosen, :kicked_targets, :divide) do
|
|
88
|
+
def initialize(choose_max: nil, choose_if: nil, unchosen: false, kicked_targets: nil, divide: nil) = super
|
|
89
|
+
end
|
|
90
|
+
NO_EXTRAS = Extras.new
|
|
91
|
+
|
|
92
|
+
attr_reader :targets, :effects, :costs, :modes, :choose, :extras
|
|
93
|
+
|
|
94
|
+
# Parses either plain targets/effects or {"choose": n, "modes": [{targets, effects}, ...]}.
|
|
95
|
+
# `choose` may be {"min": a, "max": b} ("choose one or more"), and `choose_if` {"if": condition,
|
|
96
|
+
# "choose": ...} replaces it while the condition holds.
|
|
97
|
+
# `mode_costs`: modes may have a `cost` (a spell's tiered modes, CR 702.183a).
|
|
98
|
+
def self.parse_body(params, errors, path, mode_costs: false)
|
|
99
|
+
modes_data = Mechanics.fetch_param(params, :modes)
|
|
100
|
+
extras = parse_extras(params, errors, path)
|
|
101
|
+
if modes_data.nil?
|
|
102
|
+
targets = parse_targets(params, errors, path)
|
|
103
|
+
check_kicked_targets(extras, targets, errors, path)
|
|
104
|
+
check_divide(extras, targets, errors, path)
|
|
105
|
+
return { targets: targets, effects: parse_effects(params, errors, path, targets.size), modes: [].freeze,
|
|
106
|
+
choose: 0, extras: extras }
|
|
107
|
+
end
|
|
108
|
+
unless modes_data.is_a?(Array) && modes_data.size >= 2
|
|
109
|
+
errors << "#{path}.modes: must be a list of at least two modes"
|
|
110
|
+
modes_data = []
|
|
111
|
+
end
|
|
112
|
+
errors << "#{path}: use either modes or targets/effects, not both" if params.key?("targets") || params.key?("effects")
|
|
113
|
+
modes = modes_data.each_with_index.map do |mode, i|
|
|
114
|
+
mode_path = "#{path}.modes[#{i}]"
|
|
115
|
+
mode = mode.is_a?(Hash) ? mode.transform_keys(&:to_s) : {}
|
|
116
|
+
Mechanics.check_keys(mode, %w[targets effects] + (mode_costs ? %w[cost] : []), errors, mode_path)
|
|
117
|
+
mode_targets = parse_targets(mode, errors, mode_path)
|
|
118
|
+
cost = mode.key?("cost") ? Mana.parse_cost(mode["cost"], errors, "#{mode_path}.cost") : nil
|
|
119
|
+
Mode.new(targets: mode_targets, effects: parse_effects(mode, errors, mode_path, mode_targets.size), cost: cost)
|
|
120
|
+
end
|
|
121
|
+
min, max = parse_choose(Mechanics.fetch_param(params, :choose), errors, "#{path}.choose", modes.size)
|
|
122
|
+
extras = extras.with(choose_max: max) if max != min
|
|
123
|
+
{ targets: [].freeze, effects: [].freeze, modes: modes.freeze, choose: min, extras: extras }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
BODY_KEYS = %w[targets effects modes choose choose_if unchosen kicked_targets divide].freeze
|
|
127
|
+
|
|
128
|
+
# [min, max] modes from 1, n or {"min": a, "max": b}.
|
|
129
|
+
def self.parse_choose(raw, errors, path, mode_count)
|
|
130
|
+
raw = 1 if raw.nil?
|
|
131
|
+
min, max = raw.is_a?(Hash) ? [raw["min"], raw["max"]] : [raw, raw]
|
|
132
|
+
unless min.is_a?(Integer) && max.is_a?(Integer) && min >= 1 && max >= min && (raw.is_a?(Integer) || raw.size == 2)
|
|
133
|
+
errors << "#{path}: must be an integer >= 1 or {\"min\": a, \"max\": b}"
|
|
134
|
+
return [1, 1]
|
|
135
|
+
end
|
|
136
|
+
errors << "#{path}: can't exceed the number of modes" if max > mode_count && mode_count.positive?
|
|
137
|
+
[min, max]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.parse_extras(params, errors, path)
|
|
141
|
+
extras = {}
|
|
142
|
+
if (raw = Mechanics.fetch_param(params, :choose_if))
|
|
143
|
+
extras[:choose_if] = parse_choose_if(raw, errors, "#{path}.choose_if", params)
|
|
144
|
+
end
|
|
145
|
+
extras[:unchosen] = Mechanics.flag_param(params, :unchosen, errors, path)
|
|
146
|
+
raw = Mechanics.fetch_param(params, :kicked_targets)
|
|
147
|
+
extras[:kicked_targets] = parse_targets(params, errors, path, key: :kicked_targets) unless raw.nil?
|
|
148
|
+
extras[:divide] = Amount.build(params["divide"], errors, "#{path}.divide", targets_count: 0, min: 1) if params.key?("divide")
|
|
149
|
+
Extras.new(**extras)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.parse_choose_if(raw, errors, path, params)
|
|
153
|
+
unless raw.is_a?(Hash) && raw.key?("if") && raw.key?("choose")
|
|
154
|
+
errors << "#{path}: must be {\"if\": condition, \"choose\": n or {\"min\": a, \"max\": b}}"
|
|
155
|
+
return nil
|
|
156
|
+
end
|
|
157
|
+
Mechanics.check_keys(raw, %w[if choose], errors, path)
|
|
158
|
+
count = Array(Mechanics.fetch_param(params, :modes)).size
|
|
159
|
+
[Condition.build(raw["if"], errors, "#{path}.if"), *parse_choose(raw["choose"], errors, "#{path}.choose", count)]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# CR 601.2d: each target gets at least one, so there must be a first, required target. A counted one
|
|
163
|
+
# ("one, two, or three targets": count 3, optional) needs at least one target when dividing.
|
|
164
|
+
def self.check_divide(extras, targets, errors, path)
|
|
165
|
+
first = targets.first
|
|
166
|
+
return if extras.divide.nil? || (first && (!first.respond_to?(:optional?) || !first.optional? || first.counted?))
|
|
167
|
+
|
|
168
|
+
errors << "#{path}.divide: needs a first target that isn't optional"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# CR 702.33g: the kicked spell's targets replace the normal ones, one for one.
|
|
172
|
+
def self.check_kicked_targets(extras, targets, errors, path)
|
|
173
|
+
return if extras.kicked_targets.nil? || extras.kicked_targets.size == targets.size
|
|
174
|
+
|
|
175
|
+
errors << "#{path}.kicked_targets: must list as many targets as targets"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.parse_targets(params, errors, path, key: :targets)
|
|
179
|
+
Mechanics.list_param(params, key, errors, path).each_with_index.filter_map do |entry, i|
|
|
180
|
+
entry_path = "#{path}.#{key}[#{i}]"
|
|
181
|
+
if entry.is_a?(Hash) && entry.key?("filter")
|
|
182
|
+
TargetSpec.build(entry, errors, entry_path, i)
|
|
183
|
+
else
|
|
184
|
+
Mechanics.build_entry(:filter, entry, errors, entry_path)
|
|
185
|
+
end
|
|
186
|
+
end.freeze
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Effect i may name the results of effects 0...i ({"result": n}). A nested list (`results_count`
|
|
190
|
+
# given) sees only its parent's earlier results.
|
|
191
|
+
def self.parse_effects(params, errors, path, targets_count, key: :effects, results_count: nil)
|
|
192
|
+
list = Mechanics.list_param(params, key, errors, path)
|
|
193
|
+
list.each_with_index.filter_map do |entry, i|
|
|
194
|
+
Mechanics.build_entry(:effect, entry, errors, "#{path}.#{key}[#{i}]", targets_count: targets_count,
|
|
195
|
+
results_count: results_count || i)
|
|
196
|
+
end.freeze
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.parse_costs(params, errors, path)
|
|
200
|
+
Mechanics.list_param(params, :cost, errors, path).each_with_index.filter_map do |entry, i|
|
|
201
|
+
Mechanics.build_entry(:cost, entry, errors, "#{path}.cost[#{i}]")
|
|
202
|
+
end.freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def initialize(targets:, effects:, costs: [].freeze, modes: [].freeze, choose: 0, extras: NO_EXTRAS)
|
|
206
|
+
super()
|
|
207
|
+
@targets = targets
|
|
208
|
+
@effects = effects
|
|
209
|
+
@costs = costs
|
|
210
|
+
@modes = modes
|
|
211
|
+
@choose = choose
|
|
212
|
+
@extras = extras
|
|
213
|
+
freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def modal? = !modes.empty?
|
|
217
|
+
def try_effects = effects
|
|
218
|
+
|
|
219
|
+
# The additional mana costs of the chosen modes (a spell's tiered modes, CR 702.183a).
|
|
220
|
+
def mode_costs(chosen) = modal? ? chosen.filter_map { modes[it].cost } : []
|
|
221
|
+
|
|
222
|
+
# Targets in order for the chosen modes (or the ability's own targets); a kicked spell uses its
|
|
223
|
+
# kicked targets (CR 702.33g).
|
|
224
|
+
def targets_for(chosen, kicked: false)
|
|
225
|
+
return chosen.sort.flat_map { modes[it].targets } if modal?
|
|
226
|
+
|
|
227
|
+
kicked && extras.kicked_targets ? extras.kicked_targets : targets
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# [min, max] modes to choose now (CR 700.2): `choose_if` applies while its condition holds.
|
|
231
|
+
def choice_range(engine, ctx)
|
|
232
|
+
cond, min, max = extras.choose_if
|
|
233
|
+
return [min, max] if cond&.true?(engine, ctx)
|
|
234
|
+
|
|
235
|
+
[choose, extras.choose_max || choose]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# [[effect, target offset, target count, index in its list], ...] in resolution order (modes in
|
|
239
|
+
# printed order, CR 700.2a). The index lets {"result": n} find results within the same list.
|
|
240
|
+
def effect_plan(chosen)
|
|
241
|
+
return effects.each_with_index.map { |e, i| [e, 0, targets.size, i] } unless modal?
|
|
242
|
+
|
|
243
|
+
offset = 0
|
|
244
|
+
chosen.sort.flat_map do |index|
|
|
245
|
+
mode = modes[index]
|
|
246
|
+
plan = mode.effects.each_with_index.map { |e, i| [e, offset, mode.targets.size, i] }
|
|
247
|
+
offset += mode.targets.size
|
|
248
|
+
plan
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def mana_ability? = false
|
|
253
|
+
def loyalty_cost = nil
|
|
254
|
+
|
|
255
|
+
# The zone the ability functions in (CR 113.6): a cost that moves the card out of a zone
|
|
256
|
+
# (e.g. discarding it from hand) says where it can be activated (CR 113.6m).
|
|
257
|
+
def zone = costs.find { it.respond_to?(:zone) }&.zone || :battlefield
|
|
258
|
+
|
|
259
|
+
def effects_text
|
|
260
|
+
return effects.join("; ") unless modal?
|
|
261
|
+
|
|
262
|
+
"choose #{choose} — " + modes.map { it.effects.join("; ") }.join(" • ")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def to_s = costs.empty? ? effects_text : "#{costs.join(", ")}: #{effects_text}"
|
|
266
|
+
def sorcery_speed? = false
|
|
267
|
+
def allowed_types = nil
|
|
268
|
+
|
|
269
|
+
# Total mana cost to pay during activation.
|
|
270
|
+
def mana_cost
|
|
271
|
+
Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The spell ability of an instant or sorcery (CR 113.3a): what happens when it resolves.
|
|
276
|
+
class SpellAbility < StackAbility
|
|
277
|
+
primitive :ability, :spell,
|
|
278
|
+
description: "The spell ability of an instant or sorcery: its targets are chosen while casting " \
|
|
279
|
+
"and its effects happen in order when it resolves. If every target is illegal on " \
|
|
280
|
+
"resolution, it doesn't resolve. A mode of a modal spell may have a `cost`: a mana " \
|
|
281
|
+
"cost paid in addition when that mode is chosen (tiered: \"Choose one additional " \
|
|
282
|
+
"cost\").",
|
|
283
|
+
rules: %w[113.3a 601.2c 608.2b 608.2c 702.183a 601.2f],
|
|
284
|
+
params: { "targets" => "list of target filters", "effects" => "list of effects",
|
|
285
|
+
"modes" => "list of {targets, effects, cost} (with `choose`)" },
|
|
286
|
+
example: { "spell" => { "targets" => ["any_target"],
|
|
287
|
+
"effects" => [{ "deal_damage" => { "amount" => 3, "to" => { "target" => 0 } } }] } }
|
|
288
|
+
|
|
289
|
+
def self.build(params, errors, path, **)
|
|
290
|
+
Mechanics.check_keys(params, BODY_KEYS, errors, path)
|
|
291
|
+
new(**parse_body(params, errors, path, mode_costs: true))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def kind = :spell
|
|
295
|
+
def allowed_types = %i[instant sorcery]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A non-mana activated ability "[Cost]: [Effect]" (CR 602).
|
|
299
|
+
class ActivatedAbility < StackAbility
|
|
300
|
+
primitive :ability, :activated,
|
|
301
|
+
description: "An activated ability: announce it, choose targets, pay its costs, then it goes on " \
|
|
302
|
+
"the stack. `timing: \"sorcery\"` restricts it to sorcery speed, `\"your_turn\"` to your " \
|
|
303
|
+
"turns; `limit: \"once_each_turn\"` to one activation per turn, `\"once\"` to one " \
|
|
304
|
+
"activation for as long as the object exists; `only_if` to when a " \
|
|
305
|
+
"condition holds. `zone` (\"exile\", \"graveyard\" or \"hand\") makes it function only " \
|
|
306
|
+
"while its card is in that zone (\"activate only if this card is exiled\"). `exhaust: " \
|
|
307
|
+
"true` makes it an exhaust ability: it can be activated only once (CR 702.177a). " \
|
|
308
|
+
"`power_up: true` makes it a power-up ability: activate only once, and if the " \
|
|
309
|
+
"permanent entered this turn its cost is reduced by the permanent's mana cost " \
|
|
310
|
+
"(CR 702.193). `boast: true`: activate only if this creature attacked this turn and " \
|
|
311
|
+
"only once each turn (CR 702.142a).",
|
|
312
|
+
rules: %w[602.1 602.2 602.5b 602.5d 702.177a 702.193a 702.193b 702.142a],
|
|
313
|
+
params: { "cost" => "list of costs (tap, {\"mana\": \"{1}\"}, {\"mana\": \"{X}\"})",
|
|
314
|
+
"targets" => "list of target filters", "effects" => "list of effects",
|
|
315
|
+
"timing" => "\"instant\" (default), \"sorcery\" or \"your_turn\"",
|
|
316
|
+
"limit" => "\"once_each_turn\" | \"once\" (CR 602.5b)", "only_if" => "condition (\"activate only if ...\")",
|
|
317
|
+
"exhaust" => "true: \"Exhaust — [cost]: [effect]\", activate only once (CR 702.177a)",
|
|
318
|
+
"power_up" => "true: \"Power-up — [cost]: [effect]\" (CR 702.193a)",
|
|
319
|
+
"boast" => "true: \"Boast — [cost]: [effect]\" (CR 702.142a)",
|
|
320
|
+
"zone" => "\"exile\" | \"graveyard\" | \"hand\" (CR 113.6b; default: the battlefield, or the " \
|
|
321
|
+
"zone a cost moves the card from)",
|
|
322
|
+
"cost_reduction" => "{\"amount\": amount, \"if\": condition}: \"this ability costs {N} less " \
|
|
323
|
+
"to activate\" (CR 601.2f, 602.2b)" },
|
|
324
|
+
example: { "activated" => { "cost" => ["tap"], "targets" => ["any_target"],
|
|
325
|
+
"effects" => [{ "deal_damage" => { "amount" => 1, "to" => { "target" => 0 } } }] } }
|
|
326
|
+
|
|
327
|
+
def self.build(params, errors, path, **)
|
|
328
|
+
Mechanics.check_keys(params, %w[cost timing cost_reduction limit only_if zone exhaust power_up boast] + BODY_KEYS, errors, path)
|
|
329
|
+
timing = Mechanics.fetch_param(params, :timing) || "instant"
|
|
330
|
+
errors << "#{path}.timing: must be one of #{TIMINGS.join(", ")}" unless TIMINGS.include?(timing)
|
|
331
|
+
restriction = parse_restriction(params, errors, path)
|
|
332
|
+
costs = parse_costs(params, errors, path)
|
|
333
|
+
errors << "#{path}.cost: must be a non-empty list" if costs.empty? && errors.none? { it.start_with?("#{path}.cost") }
|
|
334
|
+
raw = Mechanics.fetch_param(params, :cost_reduction)
|
|
335
|
+
reduction = raw && Reduction.build(raw, errors, "#{path}.cost_reduction")
|
|
336
|
+
check_cost_zones(costs, errors, path)
|
|
337
|
+
zone = parse_zone(params, costs, errors, path)
|
|
338
|
+
body = parse_body(params, errors, path)
|
|
339
|
+
check_mana_ability(body, errors, path)
|
|
340
|
+
new(**body, costs: costs, sorcery: timing == "sorcery", cost_reduction: reduction, zone: zone,
|
|
341
|
+
restriction: restriction.merge(your_turn: timing == "your_turn"))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
ZONES = %w[exile graveyard hand].freeze
|
|
345
|
+
|
|
346
|
+
# CR 113.6b: "activate only if this card is exiled" (an ability that functions only from a zone).
|
|
347
|
+
def self.parse_zone(params, costs, errors, path)
|
|
348
|
+
zone = Mechanics.fetch_param(params, :zone) or return nil
|
|
349
|
+
return (errors << "#{path}.zone: must be one of #{ZONES.join(", ")}") && nil unless ZONES.include?(zone)
|
|
350
|
+
|
|
351
|
+
own = costs.filter_map { it.zone if it.respond_to?(:zone) }.first
|
|
352
|
+
errors << "#{path}.zone: its costs are paid from the #{own}" if own && own.to_s != zone
|
|
353
|
+
if costs.any? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
|
|
354
|
+
errors << "#{path}.zone: an ability of a card in the #{zone} can't tap or sacrifice it"
|
|
355
|
+
end
|
|
356
|
+
zone.to_sym
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
TIMINGS = %w[instant sorcery your_turn].freeze
|
|
360
|
+
LIMITS = { "once_each_turn" => 1, "once" => nil }.freeze
|
|
361
|
+
|
|
362
|
+
# CR 602.5b: "Activate only once each turn" and "Activate only if [condition]".
|
|
363
|
+
def self.parse_restriction(params, errors, path)
|
|
364
|
+
limit = Mechanics.fetch_param(params, :limit)
|
|
365
|
+
errors << "#{path}.limit: must be one of #{LIMITS.keys.join(", ")}" unless limit.nil? || LIMITS.key?(limit)
|
|
366
|
+
raw = Mechanics.fetch_param(params, :only_if)
|
|
367
|
+
flags = %i[exhaust power_up boast].to_h { [it, Mechanics.flag_param(params, it, errors, path)] }
|
|
368
|
+
errors << "#{path}: exhaust and power_up can't be used together" if flags[:exhaust] && flags[:power_up]
|
|
369
|
+
errors << "#{path}.boast: can't be combined with limit" if flags[:boast] && limit
|
|
370
|
+
{ per_turn: flags[:boast] ? 1 : LIMITS[limit], only_if: raw && Condition.build(raw, errors, "#{path}.only_if"),
|
|
371
|
+
once: limit == "once", **flags }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# CR 605.1a: an activated ability without targets that could add mana is a mana ability,
|
|
375
|
+
# which doesn't use the stack.
|
|
376
|
+
def self.check_mana_ability(body, errors, path)
|
|
377
|
+
plans = body[:modes].empty? ? [body] : body[:modes].map(&:to_h)
|
|
378
|
+
return unless plans.any? { it[:targets].empty? && it[:effects].any?(Effects::AddMana) }
|
|
379
|
+
|
|
380
|
+
errors << "#{path}: an activated ability without targets that adds mana is a mana ability (CR 605.1a); " \
|
|
381
|
+
"use a \"mana\" ability"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# CR 113.6m: an ability can't both function from the hand (or graveyard) and tap or sacrifice its
|
|
385
|
+
# permanent, nor function from two zones.
|
|
386
|
+
def self.check_cost_zones(costs, errors, path)
|
|
387
|
+
zones = costs.filter_map { it.zone if it.respond_to?(:zone) }.uniq
|
|
388
|
+
errors << "#{path}.cost: costs can't be paid from both #{zones.join(" and ")}" if zones.size > 1
|
|
389
|
+
return if zones.empty? || costs.none? { it.is_a?(Costs::Tap) || it.is_a?(Costs::SacrificeSelf) }
|
|
390
|
+
|
|
391
|
+
errors << "#{path}.cost: a cost paid from the #{zones.first} can't be combined with tap or sacrifice"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
attr_reader :cost_reduction
|
|
395
|
+
|
|
396
|
+
def initialize(sorcery:, cost_reduction: nil, restriction: {}, zone: nil, **)
|
|
397
|
+
@sorcery = sorcery
|
|
398
|
+
@cost_reduction = cost_reduction
|
|
399
|
+
@restriction = restriction
|
|
400
|
+
@zone = zone
|
|
401
|
+
super(**)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def kind = :activated
|
|
405
|
+
def zone = @zone || super
|
|
406
|
+
def sorcery_speed? = @sorcery
|
|
407
|
+
|
|
408
|
+
# How many times it may be activated each turn (nil: no limit, CR 602.5b).
|
|
409
|
+
def per_turn = @restriction[:per_turn]
|
|
410
|
+
|
|
411
|
+
# An exhaust ability: "Activate only once" (CR 702.177a), see Engine::CastChoices#activation_allowed?.
|
|
412
|
+
def exhaust? = @restriction[:exhaust] == true
|
|
413
|
+
|
|
414
|
+
# A power-up ability (CR 702.193a): activate only once; cheaper the turn its permanent entered
|
|
415
|
+
# (Keywords::PowerUp).
|
|
416
|
+
def power_up? = @restriction[:power_up] == true
|
|
417
|
+
|
|
418
|
+
# A boast ability (CR 702.142a): only if this creature attacked this turn, and only once each turn.
|
|
419
|
+
def boast? = @restriction[:boast] == true
|
|
420
|
+
|
|
421
|
+
# "Activate only once" for as long as the object exists (CR 602.5b, per object, CR 400.7): exhaust,
|
|
422
|
+
# power-up, or `limit: "once"`.
|
|
423
|
+
def once? = exhaust? || power_up? || @restriction[:once] == true
|
|
424
|
+
|
|
425
|
+
KEYWORD_WORDS = { exhaust?: "Exhaust", power_up?: "Power-up", boast?: "Boast" }.freeze
|
|
426
|
+
|
|
427
|
+
def to_s
|
|
428
|
+
word = KEYWORD_WORDS.find { |flag, _| public_send(flag) }&.last
|
|
429
|
+
text = word ? "#{word} — #{super}" : super
|
|
430
|
+
@restriction[:once] ? "#{text} (activate only once)" : text
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# CR 602.5b: restrictions on when it may be activated, besides timing and costs.
|
|
434
|
+
def restriction_met?(engine, index, source)
|
|
435
|
+
return false if boast? && !engine.attacked_this_turn?(source) # CR 702.142a
|
|
436
|
+
return false if @restriction[:your_turn] && engine.state.active != index
|
|
437
|
+
return false if @restriction[:only_if] && !@restriction[:only_if].true?(engine, EffectContext.new(index, source, nil, nil))
|
|
438
|
+
|
|
439
|
+
true
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# A triggered ability "When/Whenever/At [trigger], [effect]" (CR 603).
|
|
444
|
+
class TriggeredAbility < StackAbility
|
|
445
|
+
primitive :ability, :triggered,
|
|
446
|
+
description: "A triggered ability: when its trigger condition happens, it is put on the stack the " \
|
|
447
|
+
"next time a player would receive priority (in APNAP order), with targets chosen then.",
|
|
448
|
+
rules: %w[603.2 603.3 603.3b 603.3d],
|
|
449
|
+
params: { "when" => "a trigger condition, e.g. {\"enters\": {}}", "targets" => "list of target filters",
|
|
450
|
+
"effects" => "list of effects",
|
|
451
|
+
"if" => "condition: an intervening \"if\", checked when it triggers and on resolution (CR 603.4)",
|
|
452
|
+
"while" => "condition checked only when it triggers (\"whenever you attack while ...\", CR 603.2)",
|
|
453
|
+
"limit" => "\"once_each_turn\": it triggers at most once each turn (per object)",
|
|
454
|
+
"zone" => "\"graveyard\": the ability functions only while its card is in a graveyard (CR 113.6k)" },
|
|
455
|
+
example: { "triggered" => { "when" => { "enters" => {} }, "effects" => [{ "draw" => { "amount" => 1 } }] } }
|
|
456
|
+
|
|
457
|
+
attr_reader :trigger, :condition, :while_condition, :limit
|
|
458
|
+
|
|
459
|
+
LIMITS = %w[once_each_turn].freeze
|
|
460
|
+
ZONES = %w[battlefield graveyard].freeze
|
|
461
|
+
|
|
462
|
+
def self.build(params, errors, path, **)
|
|
463
|
+
Mechanics.check_keys(params, %w[when if while limit zone] + BODY_KEYS, errors, path)
|
|
464
|
+
raw = Mechanics.fetch_param(params, :when)
|
|
465
|
+
trigger = raw.nil? ? ((errors << "#{path}.when: required") && nil) : Mechanics.build_entry(:trigger, raw, errors, "#{path}.when")
|
|
466
|
+
raw_if = Mechanics.fetch_param(params, :if)
|
|
467
|
+
condition = raw_if && Condition.build(raw_if, errors, "#{path}.if")
|
|
468
|
+
raw_while = Mechanics.fetch_param(params, :while)
|
|
469
|
+
during = raw_while && Condition.build(raw_while, errors, "#{path}.while")
|
|
470
|
+
limit = Mechanics.fetch_param(params, :limit)
|
|
471
|
+
errors << "#{path}.limit: must be one of #{LIMITS.join(", ")}" unless limit.nil? || LIMITS.include?(limit)
|
|
472
|
+
zone = Mechanics.fetch_param(params, :zone) || "battlefield"
|
|
473
|
+
errors << "#{path}.zone: must be one of #{ZONES.join(", ")}" unless ZONES.include?(zone)
|
|
474
|
+
new(**parse_body(params, errors, path), trigger: trigger, condition: condition, while_condition: during,
|
|
475
|
+
limit: limit, zone: zone.to_sym)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def initialize(trigger:, condition: nil, while_condition: nil, limit: nil, zone: :battlefield, **)
|
|
479
|
+
@trigger = trigger
|
|
480
|
+
@condition = condition
|
|
481
|
+
@while_condition = while_condition
|
|
482
|
+
@limit = limit
|
|
483
|
+
@zone = zone
|
|
484
|
+
super(**)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# CR 113.6k: a trigger condition that can't trigger from the battlefield functions where it can.
|
|
488
|
+
attr_reader :zone
|
|
489
|
+
|
|
490
|
+
# CR 603.4: an intervening "if" must be true when the trigger event happens and on resolution.
|
|
491
|
+
# A "while" condition is part of the trigger event (CR 603.2): only checked as it triggers, which is
|
|
492
|
+
# when no resolving `object` is given.
|
|
493
|
+
# `facts`: the trigger event being checked (no ability object exists yet), see Selector.memory.
|
|
494
|
+
def condition_met?(engine, controller, source, object = nil, facts: nil)
|
|
495
|
+
ctx = EffectContext.new(controller, source, object, [], nil, nil, facts && { "triggering" => facts })
|
|
496
|
+
return false if object.nil? && while_condition && !while_condition.true?(engine, ctx)
|
|
497
|
+
|
|
498
|
+
condition.nil? || condition.true?(engine, ctx)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def kind = :triggered
|
|
502
|
+
|
|
503
|
+
def to_s
|
|
504
|
+
"#{trigger}#{" while #{while_condition}" if while_condition}, #{"if #{condition}, " if condition}#{effects_text}" \
|
|
505
|
+
"#{" (only once each turn)" if limit}#{" (from a graveyard)" if zone == :graveyard}"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# A planeswalker loyalty ability "+N: ..." / "-N: ..." (CR 606).
|
|
510
|
+
class LoyaltyAbility < StackAbility
|
|
511
|
+
primitive :ability, :loyalty,
|
|
512
|
+
description: "A loyalty ability. Its cost adds (positive) or removes (negative) loyalty counters " \
|
|
513
|
+
"and is paid on activation. Sorcery speed, and only one loyalty ability per " \
|
|
514
|
+
"planeswalker per turn.",
|
|
515
|
+
rules: %w[606.3 606.4 606.5 602.2b],
|
|
516
|
+
params: { "cost" => "integer, e.g. 1 for +1 or -2", "targets" => "list of target filters",
|
|
517
|
+
"effects" => "list of effects" },
|
|
518
|
+
example: { "loyalty" => { "cost" => -3, "targets" => ["creature"],
|
|
519
|
+
"effects" => [{ "deal_damage" => { "amount" => 3, "to" => { "target" => 0 } } }] } }
|
|
520
|
+
|
|
521
|
+
def self.build(params, errors, path, **)
|
|
522
|
+
Mechanics.check_keys(params, %w[cost] + BODY_KEYS, errors, path)
|
|
523
|
+
cost = Mechanics.fetch_param(params, :cost)
|
|
524
|
+
errors << "#{path}.cost: must be an integer loyalty change" unless cost.is_a?(Integer)
|
|
525
|
+
new(**parse_body(params, errors, path), loyalty: cost.to_i)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def initialize(loyalty:, **)
|
|
529
|
+
@loyalty = loyalty
|
|
530
|
+
super(**)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def kind = :loyalty
|
|
534
|
+
def loyalty_cost = @loyalty
|
|
535
|
+
def sorcery_speed? = true
|
|
536
|
+
def allowed_types = [:planeswalker]
|
|
537
|
+
|
|
538
|
+
def to_s = "#{"+" if @loyalty.positive?}#{@loyalty}: #{effects_text}"
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|