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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Shared behaviour of "as this enters, choose ..." abilities (CR 614.12, 614.12a): the controller
|
|
7
|
+
# chooses before the permanent enters (an enter_choice decision) and the permanent remembers the
|
|
8
|
+
# choice in its memory, where linked abilities read it (CR 607.2).
|
|
9
|
+
class EnteringChoice < Primitive
|
|
10
|
+
def kind = :entering_choice
|
|
11
|
+
def mana_ability? = false
|
|
12
|
+
def allowed_types = nil
|
|
13
|
+
def try_effects = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# "As this enters, choose a number between 0 and 10."
|
|
17
|
+
class ChooseNumber < EnteringChoice
|
|
18
|
+
primitive :ability, :choose_number,
|
|
19
|
+
description: "As this permanent enters, its controller chooses a number from `min` to `max`; abilities " \
|
|
20
|
+
"of it that say \"the chosen number\" use it.",
|
|
21
|
+
rules: %w[614.12 614.12a 607.2],
|
|
22
|
+
params: { "min" => "integer (default 0)", "max" => "integer" },
|
|
23
|
+
example: { "choose_number" => { "min" => 0, "max" => 10 } }
|
|
24
|
+
|
|
25
|
+
attr_reader :min, :max
|
|
26
|
+
|
|
27
|
+
def self.build(params, errors, path, **)
|
|
28
|
+
Mechanics.check_keys(params, %w[min max], errors, path)
|
|
29
|
+
min = Mechanics.integer_param(params, :min, errors, path, default: 0, min: 0)
|
|
30
|
+
max = Mechanics.integer_param(params, :max, errors, path, min: 0)
|
|
31
|
+
errors << "#{path}.max: must be at least min" if max < min
|
|
32
|
+
new(min, max)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(min, max)
|
|
36
|
+
super()
|
|
37
|
+
@min = min
|
|
38
|
+
@max = max
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s = "as this enters, choose a number from #{min} to #{max}"
|
|
43
|
+
def options(_engine, _controller) = (min..max).map { { type: "number", number: it } }
|
|
44
|
+
def enters_for(answer) = { "memory" => { "chosen_number" => answer[:number] } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# "As this enters, choose a nonland card name." The names offered are those of the cards in the game's
|
|
48
|
+
# decklists (CR 201.4: a player chooses a card name; names of cards outside the game can't matter).
|
|
49
|
+
class ChooseCardName < EnteringChoice
|
|
50
|
+
primitive :ability, :choose_card_name,
|
|
51
|
+
description: "As this permanent enters, its controller chooses a card name among the cards in the " \
|
|
52
|
+
"game (`not`: card types the named card mustn't have, e.g. [\"land\"]); abilities of it " \
|
|
53
|
+
"that say \"the chosen name\" use it.",
|
|
54
|
+
rules: %w[614.12 614.12a 201.4 201.4a 607.2],
|
|
55
|
+
params: { "not" => "list of card types" },
|
|
56
|
+
example: { "choose_card_name" => { "not" => ["land"] } }
|
|
57
|
+
|
|
58
|
+
attr_reader :excluded
|
|
59
|
+
|
|
60
|
+
def self.build(params, errors, path, **)
|
|
61
|
+
Mechanics.check_keys(params, %w[not], errors, path)
|
|
62
|
+
excluded = Mechanics.fetch_param(params, :not) || []
|
|
63
|
+
types = Filters::Restricted::TYPES
|
|
64
|
+
unless excluded.is_a?(Array) && (excluded - types).empty?
|
|
65
|
+
errors << "#{path}.not: must be a list of card types (#{types.join(", ")})"
|
|
66
|
+
excluded = []
|
|
67
|
+
end
|
|
68
|
+
new(excluded.map(&:to_sym))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(excluded)
|
|
72
|
+
super()
|
|
73
|
+
@excluded = excluded.freeze
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s = "as this enters, choose a #{excluded.map { "non#{it}" }.join(" ")} card name".squeeze(" ")
|
|
78
|
+
|
|
79
|
+
def options(engine, _controller)
|
|
80
|
+
names = engine.state.decklists.flatten.uniq.filter_map do |card|
|
|
81
|
+
defn = engine.catalog.fetch(card)
|
|
82
|
+
defn.name unless defn.types.intersect?(excluded)
|
|
83
|
+
end
|
|
84
|
+
names.uniq.sort.map { { type: "name", name: it } }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def enters_for(answer) = { "memory" => { "chosen_name" => answer[:name] } }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "entering_choices"
|
|
4
|
+
require_relative "../copy_except"
|
|
5
|
+
|
|
6
|
+
module Enjine
|
|
7
|
+
module Mechanics
|
|
8
|
+
module Abilities
|
|
9
|
+
# "You may have this creature enter as a copy of any artifact or creature on the battlefield" (CR 707.5,
|
|
10
|
+
# 614.1c, 614.12): as it would enter, its controller chooses a permanent matching `of` (or none); it
|
|
11
|
+
# enters as a copy of it, with the `except` modifications, and with `with_counters: true` also with
|
|
12
|
+
# the same number and kinds of counters the copied permanent has.
|
|
13
|
+
class EnterAsCopy < EnteringChoice
|
|
14
|
+
primitive :ability, :enter_as_copy,
|
|
15
|
+
description: "As this permanent enters, its controller may choose a permanent on the battlefield " \
|
|
16
|
+
"matching `of`; it enters as a copy of that permanent (its copiable values, with the " \
|
|
17
|
+
"`except` modifications) and its enter abilities trigger. `with_counters: true`: it " \
|
|
18
|
+
"also enters with the counters the copied permanent has. Choosing none: it enters as " \
|
|
19
|
+
"itself.",
|
|
20
|
+
rules: %w[707.5 707.9b 614.1c 614.12],
|
|
21
|
+
params: { "of" => "filter (the permanents it may copy)", "except" => Mechanics::CopyExcept::PARAM,
|
|
22
|
+
"with_counters" => "boolean" },
|
|
23
|
+
example: { "enter_as_copy" => { "of" => { "permanent" => { "any_of" => %w[artifact creature] } },
|
|
24
|
+
"except" => { "legendary" => false }, "with_counters" => true } }
|
|
25
|
+
|
|
26
|
+
attr_reader :filter, :except, :with_counters
|
|
27
|
+
|
|
28
|
+
def self.build(params, errors, path, **)
|
|
29
|
+
Mechanics.check_keys(params, %w[of except with_counters], errors, path)
|
|
30
|
+
raw = Mechanics.fetch_param(params, :of)
|
|
31
|
+
filter = raw.nil? ? (errors << "#{path}.of: required") && nil : Mechanics.build_entry(:filter, raw, errors, "#{path}.of")
|
|
32
|
+
except = Mechanics::CopyExcept.build(Mechanics.fetch_param(params, :except), errors, "#{path}.except")
|
|
33
|
+
new(filter, except, Mechanics.flag_param(params, :with_counters, errors, path))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(filter, except, with_counters)
|
|
37
|
+
super()
|
|
38
|
+
@filter = filter
|
|
39
|
+
@except = except
|
|
40
|
+
@with_counters = with_counters
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
what = filter.is_a?(Class) ? filter.primitive_name : filter
|
|
46
|
+
"you may have it enter as a copy of a #{what}#{Mechanics::CopyExcept.to_s(except)}#{" with its counters" if with_counters}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def options(engine, controller)
|
|
50
|
+
candidates = filter ? filter.candidates_for(engine, controller, nil) : []
|
|
51
|
+
candidates.select { engine.state.battlefield.include?(it) }.map { { type: "copy", object: it } } +
|
|
52
|
+
[{ type: "dont_copy" }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What it enters with (Engine#enters_from_answer): the copy, and the copied permanent's counters.
|
|
56
|
+
def enters_with(engine, answer)
|
|
57
|
+
obj = answer[:type] == "copy" && engine.state.objects[answer[:object]]
|
|
58
|
+
return nil unless obj && obj.zone == :battlefield
|
|
59
|
+
|
|
60
|
+
card, except = engine.copiable_values(obj)
|
|
61
|
+
enters = { "copy" => { "card" => card, "except" => Engine::Copies.merge_except(except, @except) } }
|
|
62
|
+
with_counters && !obj.counters.empty? ? enters.merge("counters" => obj.counters.dup) : enters
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enters_for(_answer) = nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# "This permanent enters tapped." A replacement effect on its own entering (CR 614.1c, 614.12).
|
|
7
|
+
class EntersTapped < Primitive
|
|
8
|
+
primitive :ability, :enters_tapped,
|
|
9
|
+
description: "This permanent enters the battlefield tapped (a self-replacement effect).",
|
|
10
|
+
rules: %w[614.1c 614.12],
|
|
11
|
+
example: "enters_tapped"
|
|
12
|
+
|
|
13
|
+
INSTANCE = new.freeze
|
|
14
|
+
|
|
15
|
+
def self.build(params, errors, path, **)
|
|
16
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
17
|
+
INSTANCE
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def kind = :enters_tapped
|
|
21
|
+
def mana_ability? = false
|
|
22
|
+
def allowed_types = nil
|
|
23
|
+
def to_s = "enters tapped"
|
|
24
|
+
def try_effects = []
|
|
25
|
+
|
|
26
|
+
# Called by the replacement pass for the entering object.
|
|
27
|
+
def modify_entering(enters) = enters["tapped"] = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# "As this enters, you may pay N life. If you don't, it enters tapped." (CR 614.12, 119.4).
|
|
31
|
+
# The controller chooses before it enters.
|
|
32
|
+
class EntersTappedUnlessPayLife < Primitive
|
|
33
|
+
primitive :ability, :enters_tapped_unless_pay_life,
|
|
34
|
+
description: "As this permanent enters, its controller may pay `life` life; if they don't, it enters " \
|
|
35
|
+
"tapped. You can't pay more life than you have.",
|
|
36
|
+
rules: %w[614.12 119.4],
|
|
37
|
+
params: { "life" => "integer" },
|
|
38
|
+
example: { "enters_tapped_unless_pay_life" => { "life" => 2 } }
|
|
39
|
+
|
|
40
|
+
attr_reader :life
|
|
41
|
+
|
|
42
|
+
def self.build(params, errors, path, **)
|
|
43
|
+
Mechanics.check_keys(params, %w[life], errors, path)
|
|
44
|
+
new(Mechanics.integer_param(params, :life, errors, path, min: 1))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(life)
|
|
48
|
+
super()
|
|
49
|
+
@life = life
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def kind = :entering_choice
|
|
54
|
+
def mana_ability? = false
|
|
55
|
+
def allowed_types = nil
|
|
56
|
+
def try_effects = []
|
|
57
|
+
def to_s = "as this enters, pay #{life} life or it enters tapped"
|
|
58
|
+
|
|
59
|
+
# Options for the controller (CR 119.4: paying life needs at least that much).
|
|
60
|
+
def options(engine, controller)
|
|
61
|
+
pay = engine.player(controller).life >= life ? [{ type: "pay_life", life: life }] : []
|
|
62
|
+
pay + [{ type: "enter_tapped" }]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enters_for(answer) = { "tapped" => answer[:type] != "pay_life" }
|
|
66
|
+
|
|
67
|
+
# Paid as it enters (CR 119.4).
|
|
68
|
+
def pay!(engine, controller, answer)
|
|
69
|
+
engine.change_life!(controller, -answer["life"]) if answer["type"] == "pay_life"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# "As this land enters, you may behold a Jace. If you don't, this land enters tapped." (CR 614.12,
|
|
74
|
+
# 701.4a). The controller chooses what to behold (or not) before it enters; a card is revealed as it enters.
|
|
75
|
+
class EntersTappedUnlessBehold < Primitive
|
|
76
|
+
primitive :ability, :enters_tapped_unless_behold,
|
|
77
|
+
description: "As this permanent enters, its controller may behold an object matching the card spec " \
|
|
78
|
+
"`card` (reveal such a card from their hand or choose such a permanent they control); " \
|
|
79
|
+
"if they don't, it enters tapped.",
|
|
80
|
+
rules: %w[614.12 701.4a],
|
|
81
|
+
params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"jace\"]}" },
|
|
82
|
+
example: { "enters_tapped_unless_behold" => { "card" => { "subtypes" => ["jace"] } } }
|
|
83
|
+
|
|
84
|
+
attr_reader :card
|
|
85
|
+
|
|
86
|
+
def self.build(params, errors, path, **)
|
|
87
|
+
Mechanics.check_keys(params, %w[card], errors, path)
|
|
88
|
+
raw = Mechanics.fetch_param(params, :card)
|
|
89
|
+
errors << "#{path}.card: required" if raw.nil?
|
|
90
|
+
new(CardSpec.build(raw, errors, "#{path}.card"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(card)
|
|
94
|
+
super()
|
|
95
|
+
@card = card
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def kind = :entering_choice
|
|
100
|
+
def mana_ability? = false
|
|
101
|
+
def allowed_types = nil
|
|
102
|
+
def try_effects = []
|
|
103
|
+
def to_s = "as this enters, you may behold a #{card.to_s.delete_suffix(" card")}; if you don't, it enters tapped"
|
|
104
|
+
|
|
105
|
+
def options(engine, controller)
|
|
106
|
+
Beholding.candidates(engine, controller, card).map { { type: "behold", object: it } } + [{ type: "enter_tapped" }]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def enters_for(answer) = { "tapped" => answer[:type] != "behold" }
|
|
110
|
+
|
|
111
|
+
# The card is revealed as it enters (CR 701.4a).
|
|
112
|
+
def pay!(engine, controller, answer)
|
|
113
|
+
id = answer["object"]
|
|
114
|
+
Beholding.behold!(engine, controller, id) if answer["type"] == "behold" && engine.state.objects.key?(id)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# "This land enters tapped unless you control two or more other lands." A self-replacement effect that
|
|
119
|
+
# checks its condition as the permanent would enter (CR 614.1c, 614.12): it isn't on the battlefield
|
|
120
|
+
# yet, so "you control" counts only the others.
|
|
121
|
+
class EntersTappedUnless < Primitive
|
|
122
|
+
primitive :ability, :enters_tapped_unless,
|
|
123
|
+
description: "This permanent enters tapped unless `condition` is true as it would enter (checked for " \
|
|
124
|
+
"its controller; the permanent itself isn't on the battlefield yet).",
|
|
125
|
+
rules: %w[614.1c 614.12],
|
|
126
|
+
params: { "condition" => "a condition, e.g. {\"controls\": {\"each\": \"land\", \"controller\": \"you\"}, " \
|
|
127
|
+
"\"at_least\": 2}" },
|
|
128
|
+
example: { "enters_tapped_unless" => { "condition" => { "controls" => { "each" => "land", "controller" => "you" },
|
|
129
|
+
"at_least" => 2 } } }
|
|
130
|
+
|
|
131
|
+
attr_reader :condition
|
|
132
|
+
|
|
133
|
+
def self.build(params, errors, path, **)
|
|
134
|
+
Mechanics.check_keys(params, %w[condition], errors, path)
|
|
135
|
+
raw = Mechanics.fetch_param(params, :condition)
|
|
136
|
+
condition = raw.nil? ? ((errors << "#{path}.condition: required") && nil) : Condition.build(raw, errors, "#{path}.condition")
|
|
137
|
+
new(condition)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def initialize(condition)
|
|
141
|
+
super()
|
|
142
|
+
@condition = condition
|
|
143
|
+
freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def kind = :enters_tapped
|
|
147
|
+
def mana_ability? = false
|
|
148
|
+
def allowed_types = nil
|
|
149
|
+
def try_effects = []
|
|
150
|
+
def to_s = "enters tapped unless #{condition}"
|
|
151
|
+
|
|
152
|
+
# Called by the replacement pass for the entering object (Replacements.entering!).
|
|
153
|
+
def modify_entering_for(engine, controller, object_id, enters)
|
|
154
|
+
enters["tapped"] = true unless condition&.true?(engine, EffectContext.new(controller, object_id, nil, []))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Entering with counters: a permanent's own counters as it enters, or extra counters a permanent
|
|
7
|
+
# on the battlefield gives to matching permanents as they enter. Replacement effects that modify
|
|
8
|
+
# how a permanent enters (CR 614.1c, 614.12); counters given as it enters count as counters put
|
|
9
|
+
# on it (CR 122.6).
|
|
10
|
+
class EntersWithCounters < Primitive
|
|
11
|
+
primitive :ability, :enters_with_counters,
|
|
12
|
+
description: "Without `affects`: this permanent enters with the `counters` on it; a count may be an " \
|
|
13
|
+
"amount computed as it enters (e.g. the mana spent to cast it, CR 601.2h). With `affects` " \
|
|
14
|
+
"({\"each\": filter, \"controller\": you|opponent|any, \"other\": true}, relative to " \
|
|
15
|
+
"this permanent's controller): while this permanent is on the battlefield, each " \
|
|
16
|
+
"matching permanent enters with the `counters` in addition to any others.",
|
|
17
|
+
rules: %w[614.1c 614.12 122.6 122.6a],
|
|
18
|
+
params: { "counters" => "{counter name: n} (n >= 1; without `affects`, n may be an amount)",
|
|
19
|
+
"affects" => "optional {\"each\": filter, \"controller\": ..., \"other\": bool}" },
|
|
20
|
+
example: { "enters_with_counters" => { "counters" => { "+1/+1" => 1 },
|
|
21
|
+
"affects" => { "each" => "creature", "controller" => "you",
|
|
22
|
+
"other" => true } } }
|
|
23
|
+
|
|
24
|
+
attr_reader :counters, :filter, :controller, :amounts
|
|
25
|
+
|
|
26
|
+
def self.build(params, errors, path, **)
|
|
27
|
+
Mechanics.check_keys(params, %w[counters affects], errors, path)
|
|
28
|
+
affects = Mechanics.fetch_param(params, :affects)
|
|
29
|
+
counters = read_counters(Mechanics.fetch_param(params, :counters), errors, "#{path}.counters", amounts: affects.nil?)
|
|
30
|
+
counters, amounts = counters.partition { |_, n| n.is_a?(Integer) }.map(&:to_h)
|
|
31
|
+
return new(counters, nil, nil, amounts) if affects.nil?
|
|
32
|
+
|
|
33
|
+
unless affects.is_a?(Hash) && affects["each"]
|
|
34
|
+
errors << "#{path}.affects: must be {\"each\": filter, ...}"
|
|
35
|
+
return new(counters, nil, nil)
|
|
36
|
+
end
|
|
37
|
+
Mechanics.check_keys(affects, %w[each controller other], errors, "#{path}.affects")
|
|
38
|
+
filter = Mechanics.build_entry(:filter, affects["each"], errors, "#{path}.affects.each")
|
|
39
|
+
live = filter ? filter.live_keys : []
|
|
40
|
+
errors << "#{path}.affects.each: #{live.join(", ")} can't be used for an entering permanent" unless live.empty?
|
|
41
|
+
controller = affects.fetch("controller", "any")
|
|
42
|
+
errors << "#{path}.affects.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
|
|
43
|
+
new(counters, filter, controller)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `amounts`: a count may be an amount (Amount), computed as the permanent enters.
|
|
47
|
+
def self.read_counters(raw, errors, path, amounts: false)
|
|
48
|
+
unless raw.is_a?(Hash) && !raw.empty?
|
|
49
|
+
errors << "#{path}: must be a non-empty object {counter name: n}"
|
|
50
|
+
return {}
|
|
51
|
+
end
|
|
52
|
+
raw.to_h do |name, n|
|
|
53
|
+
name = Counters.normalize(name)
|
|
54
|
+
Counters.parse(name, errors, "#{path}.#{name}")
|
|
55
|
+
next [name, Amount.build(n, errors, "#{path}.#{name}", targets_count: 0)] if amounts && !n.is_a?(Integer)
|
|
56
|
+
|
|
57
|
+
errors << "#{path}.#{name}: must be an integer >= 1" unless n.is_a?(Integer) && n >= 1
|
|
58
|
+
[name, n]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(counters, filter, controller, amounts = {})
|
|
63
|
+
super()
|
|
64
|
+
@counters = counters.freeze
|
|
65
|
+
@amounts = amounts.freeze
|
|
66
|
+
@filter = filter
|
|
67
|
+
@controller = controller
|
|
68
|
+
freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def kind = :enters_with_counters
|
|
72
|
+
def mana_ability? = false
|
|
73
|
+
def allowed_types = nil
|
|
74
|
+
def try_effects = []
|
|
75
|
+
|
|
76
|
+
def to_s
|
|
77
|
+
list = (counters.map { |name, n| "#{n} #{name} counter#{"s" if n > 1}" } +
|
|
78
|
+
amounts.map { |name, n| "#{name} counters equal to #{n}" }).join(" and ")
|
|
79
|
+
return "enters with #{list}" unless filter
|
|
80
|
+
|
|
81
|
+
whose = { "you" => " you control", "opponent" => " your opponents control" }[controller]
|
|
82
|
+
label = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
|
|
83
|
+
"#{label}s#{whose} enter with an additional #{list}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The entering permanent's own replacement effect (called by Replacements for it alone).
|
|
87
|
+
def modify_entering(enters) = filter ? nil : add!(enters)
|
|
88
|
+
|
|
89
|
+
# Counts computed as it enters, from the entering object (a resolving spell: what was spent on it).
|
|
90
|
+
def modify_entering_for(engine, controller, object_id, enters)
|
|
91
|
+
ctx = EffectContext.new(controller, object_id, object_id, [])
|
|
92
|
+
amounts.each do |name, amount|
|
|
93
|
+
n = amount.value(engine, ctx)
|
|
94
|
+
enters["counters"][name] = enters["counters"].fetch(name, 0) + n if n.positive?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A permanent on the battlefield modifying another one as it enters.
|
|
99
|
+
def affects_entering?(source, entering_controller, facts)
|
|
100
|
+
return false unless filter
|
|
101
|
+
return false unless controller == "any" || (controller == "you") == (entering_controller == source.controller)
|
|
102
|
+
|
|
103
|
+
filter.matches_facts?(facts)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def modify_other_entering(enters) = add!(enters)
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def add!(enters)
|
|
111
|
+
counters.each { |name, n| enters["counters"][name] = enters["counters"].fetch(name, 0) + n }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../keywords/alternative_costs"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
# "Spend this mana only to cast [quality] spells or activate abilities of [quality] sources" (CR 106.6):
|
|
8
|
+
# a restriction on mana, kept with that mana in the pool. Data:
|
|
9
|
+
# {"spells": card spec, "abilities": card spec} at least one; "card" matches every spell or source
|
|
10
|
+
# The spec is matched against the spell being cast or the source of the ability being activated. Mana
|
|
11
|
+
# paid for a cost while a spell or ability resolves (CR 118.12) is neither, so restricted mana can't pay it.
|
|
12
|
+
# `from` / `not_from` a zone limit the spells to those cast from (not from) it ("spend this mana only to cast
|
|
13
|
+
# a spell from anywhere other than your hand": {"not_from": "hand"}; CR 601.2a); alone, they mean spells.
|
|
14
|
+
class SpendOnly
|
|
15
|
+
KEYS = (%w[spells abilities] + CastFrom::KEYS).freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :data
|
|
18
|
+
|
|
19
|
+
def self.build(data, errors, path)
|
|
20
|
+
unless data.is_a?(Hash) && !data.empty? && (data.keys.map(&:to_s) - KEYS).empty?
|
|
21
|
+
errors << "#{path}: must be {\"spells\": card spec, \"abilities\": card spec, \"from\" | \"not_from\": zone} " \
|
|
22
|
+
"(at least one)"
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
data = data.transform_keys(&:to_s)
|
|
26
|
+
zones = CastFrom.build(data, errors, path)
|
|
27
|
+
specs = data.slice("spells", "abilities").to_h { |key, spec| [key, CardSpec.build(spec, errors, "#{path}.#{key}")] }
|
|
28
|
+
specs["spells"] ||= CardSpec.build("card", errors, path) if data.keys.intersect?(CastFrom::KEYS)
|
|
29
|
+
new(data, specs, zones)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The restriction kept on pool mana (plain data, CR 106.6), rebuilt once per distinct data.
|
|
33
|
+
def self.from_data(data)
|
|
34
|
+
(@cache ||= {})[data] ||= build(data, [], "spend_only")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(data, specs, zones = CastFrom.new(nil, nil))
|
|
38
|
+
@data = Snapshot.deep_copy(data).freeze
|
|
39
|
+
@specs = specs.freeze
|
|
40
|
+
@zones = zones
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `purpose`: {"spell" => id} for a spell being cast, {"ability" => source id} for an ability being
|
|
45
|
+
# activated, nil for anything else.
|
|
46
|
+
def allows?(engine, purpose)
|
|
47
|
+
return false if purpose.nil?
|
|
48
|
+
|
|
49
|
+
spell = purpose.key?("spell")
|
|
50
|
+
spec = spell ? @specs["spells"] : @specs["abilities"]
|
|
51
|
+
ref = purpose["spell"] || purpose["ability"]
|
|
52
|
+
obj = ref.is_a?(GameObject) ? ref : engine.state.objects[ref] # a card outside the game is its stand-in
|
|
53
|
+
!spec.nil? && !obj.nil? && spec.match?(engine, obj) && (!spell || @zones.match?(engine.cast_origin(obj)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
parts = []
|
|
58
|
+
parts << "cast #{"#{@specs["spells"]} " unless @specs["spells"].any?}spells#{@zones}" if @specs["spells"]
|
|
59
|
+
parts << "activate abilities of #{@specs["abilities"]}s" if @specs["abilities"]
|
|
60
|
+
"spend this mana only to #{parts.join(" or ")}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module Abilities
|
|
65
|
+
# Activated mana ability (CR 605). Resolves immediately, never uses the stack. It adds the fixed
|
|
66
|
+
# mana in `produce` (`amount` times), or `amount` mana of colors chosen as it is activated
|
|
67
|
+
# (CR 605.3b, 106.1a): all of one color (`color: "any_one"`) or each separately (`"any"`).
|
|
68
|
+
class ManaAbility < Primitive
|
|
69
|
+
COLORS = Mana::COLORS.map(&:to_s).freeze
|
|
70
|
+
CHOICES = %w[any_one any chosen].freeze
|
|
71
|
+
|
|
72
|
+
primitive :ability, :mana,
|
|
73
|
+
description: "An activated mana ability: pay the cost, add the mana to your mana pool " \
|
|
74
|
+
"immediately. It does not use the stack and can be activated whenever you " \
|
|
75
|
+
"have priority. `produce`: the fixed mana (`amount` times). Or `color`: " \
|
|
76
|
+
"`amount` mana (default 1) of one color you choose as you activate it " \
|
|
77
|
+
"(`\"any_one\"`) or in any combination of colors (`\"any\"`); `colors` limits " \
|
|
78
|
+
"the choice. `\"chosen\"`: of the color chosen as this entered (choose_color). " \
|
|
79
|
+
"`spend_only` restricts how the mana can be spent. `only_if`: \"Activate only if ...\".",
|
|
80
|
+
rules: %w[605.1a 605.3a 605.3b 106.1a 106.4 106.6 607.2d 602.5b],
|
|
81
|
+
params: { "cost" => "list of costs (e.g. [\"tap\"])",
|
|
82
|
+
"produce" => "mana symbols added, e.g. \"{G}\" or \"{C}{C}\"",
|
|
83
|
+
"amount" => "amount (default 1; with produce: how many times)",
|
|
84
|
+
"color" => CHOICES.join(" | "), "colors" => "list of colors to choose from (default all five)",
|
|
85
|
+
"spend_only" => "{\"spells\": card spec, \"abilities\": card spec, \"from\" | \"not_from\": zone}",
|
|
86
|
+
"only_if" => "condition: it can be activated only while it holds (CR 602.5b)" },
|
|
87
|
+
example: { "mana" => { "cost" => ["tap"], "produce" => "{C}" } }
|
|
88
|
+
|
|
89
|
+
attr_reader :costs, :produce, :color, :colors, :amount, :spend_only
|
|
90
|
+
|
|
91
|
+
def self.build(params, errors, path, **)
|
|
92
|
+
Mechanics.check_keys(params, %w[cost produce amount color colors spend_only only_if], errors, path)
|
|
93
|
+
costs = build_costs(Mechanics.fetch_param(params, :cost), errors, path)
|
|
94
|
+
raw = Mechanics.fetch_param(params, :produce)
|
|
95
|
+
color = Mechanics.fetch_param(params, :color)
|
|
96
|
+
errors << "#{path}: needs exactly one of produce, color" unless raw.nil? ^ color.nil?
|
|
97
|
+
produce = raw && Mana.parse_produce(raw, errors, "#{path}.produce")
|
|
98
|
+
errors << "#{path}.color: must be one of #{CHOICES.join(", ")}" if color && !CHOICES.include?(color)
|
|
99
|
+
raw_amount = Mechanics.fetch_param(params, :amount)
|
|
100
|
+
amount = raw_amount.nil? ? nil : Amount.build(raw_amount, errors, "#{path}.amount", targets_count: 0)
|
|
101
|
+
raw_spend = Mechanics.fetch_param(params, :spend_only)
|
|
102
|
+
spend_only = raw_spend.nil? ? nil : SpendOnly.build(raw_spend, errors, "#{path}.spend_only")
|
|
103
|
+
raw_if = Mechanics.fetch_param(params, :only_if)
|
|
104
|
+
only_if = raw_if && Condition.build(raw_if, errors, "#{path}.only_if")
|
|
105
|
+
new(costs.compact.freeze, produce, color: color, colors: build_colors(params, color, errors, path),
|
|
106
|
+
amount: amount, spend_only: spend_only, only_if: only_if)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# CR 605.3a: a mana ability is paid for and resolves at once, so none of its costs can wait for a
|
|
110
|
+
# choice, an announced X or a {2/B} paid one way or the other.
|
|
111
|
+
def self.build_costs(raw, errors, path)
|
|
112
|
+
unless raw.is_a?(Array) && !raw.empty?
|
|
113
|
+
errors << "#{path}.cost: must be a non-empty list"
|
|
114
|
+
return []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raw.each_with_index.map do |c, i|
|
|
118
|
+
cost = Mechanics.build_entry(:cost, c, errors, "#{path}.cost[#{i}]")
|
|
119
|
+
errors << "#{path}.cost[#{i}]: a mana ability's cost can't need a choice, X or {2/B}" if choosing_cost?(cost)
|
|
120
|
+
cost
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.choosing_cost?(cost)
|
|
125
|
+
cost.respond_to?(:choices_needed) || (cost.respond_to?(:x?) && cost.x?) ||
|
|
126
|
+
(cost.respond_to?(:mana_cost) && cost.mana_cost.hybrid.any? { Mana.mono_hybrid?(it) })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.build_colors(params, color, errors, path)
|
|
130
|
+
raw = Mechanics.fetch_param(params, :colors)
|
|
131
|
+
return COLORS if raw.nil?
|
|
132
|
+
if color && color != "chosen" && raw.is_a?(Array) && raw.size >= 2 && (raw - COLORS).empty? && raw.uniq == raw
|
|
133
|
+
return raw.map(&:to_s).freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
errors << "#{path}.colors: must list two or more colors (W U B R G), with color"
|
|
137
|
+
COLORS
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def initialize(costs, produce, color: nil, colors: COLORS, amount: nil, spend_only: nil, only_if: nil)
|
|
141
|
+
super()
|
|
142
|
+
@costs = costs
|
|
143
|
+
@produce = produce
|
|
144
|
+
@color = color
|
|
145
|
+
@colors = colors
|
|
146
|
+
@amount = amount
|
|
147
|
+
@spend_only = spend_only
|
|
148
|
+
@only_if = only_if
|
|
149
|
+
list = costs.select(&:mana?).map(&:mana_cost)
|
|
150
|
+
@mana_cost = list.empty? ? nil : Mana.sum_costs(list)
|
|
151
|
+
freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def mana_ability? = true
|
|
155
|
+
def try_effects = []
|
|
156
|
+
def kind = :mana
|
|
157
|
+
def allowed_types = nil
|
|
158
|
+
|
|
159
|
+
# A mana cost ("{1}, {T}: Add …") is paid from the mana already in the pool (CR 605.3a, 601.2g-h).
|
|
160
|
+
# "Activate only if ..." (CR 602.5b) is checked for its controller.
|
|
161
|
+
def activatable?(engine, object)
|
|
162
|
+
return false if @only_if && !@only_if.true?(engine, EffectContext.new(object.controller, object.id, nil, nil))
|
|
163
|
+
|
|
164
|
+
costs.all? { |c| c.payable?(engine, object) } && (mana_cost.nil? || engine.pool_covers?(object.controller, mana_cost))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The sum of its mana costs, or nil.
|
|
168
|
+
attr_reader :mana_cost
|
|
169
|
+
|
|
170
|
+
# Whether the mana is chosen as it's activated (so each activation option names it).
|
|
171
|
+
def choice? = !color.nil? && color != "chosen"
|
|
172
|
+
|
|
173
|
+
# CR 607.2d: "one mana of the chosen color", chosen as the source entered (choose_color); none, no mana.
|
|
174
|
+
def chosen_color(object) = object.memory["chosen_color"]&.to_sym
|
|
175
|
+
|
|
176
|
+
# How much mana (or how many times `produce`) one activation adds now.
|
|
177
|
+
def count(engine, object)
|
|
178
|
+
return 1 if amount.nil?
|
|
179
|
+
|
|
180
|
+
[amount.value(engine, EffectContext.new(object.controller, object.id, nil, [])), 0].max
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Every mana an activation could add now, each as a list of pool types (CR 605.3b). One entry
|
|
184
|
+
# without a choice; with `any_one` one per color; with `any` one per combination of colors.
|
|
185
|
+
def outcomes(engine, object)
|
|
186
|
+
n = count(engine, object)
|
|
187
|
+
return [Array(chosen_color(object)) * n] if color == "chosen"
|
|
188
|
+
return [(produce || []) * n] unless choice?
|
|
189
|
+
return [[]] if n.zero?
|
|
190
|
+
|
|
191
|
+
picks = colors.map(&:to_sym)
|
|
192
|
+
return picks.map { [it] * n } if color == "any_one"
|
|
193
|
+
|
|
194
|
+
picks.repeated_combination(n).map(&:freeze)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def to_s
|
|
198
|
+
what = if produce then "Add #{produce.map { "{#{it}}" }.join}#{" #{amount} times" if amount}"
|
|
199
|
+
elsif color == "chosen" then "Add #{amount || 1} mana of the chosen color"
|
|
200
|
+
else "Add #{amount || 1} mana of #{color == "any_one" ? "any one color" : "any combination of colors"}" \
|
|
201
|
+
"#{" (#{colors.join("/")})" unless colors == COLORS}"
|
|
202
|
+
end
|
|
203
|
+
"#{costs.join(", ")}: #{what}.#{" #{spend_only.to_s.capitalize}." if spend_only}" \
|
|
204
|
+
"#{" Activate only if #{@only_if}." if @only_if}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|