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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Filters
|
|
6
|
+
# A spell on the stack.
|
|
7
|
+
class Spell < Filter
|
|
8
|
+
primitive :filter, :spell, description: "A spell on the stack.", rules: %w[112.1 115.1], example: "spell"
|
|
9
|
+
def self.zone = :stack
|
|
10
|
+
def self.match_object?(_engine, object, _controller) = object.kind == "card"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# An activated or triggered ability on the stack (CR 113.1b, 115.1): "counter target activated or
|
|
14
|
+
# triggered ability", "copy target triggered ability you control from a creature source". Mana
|
|
15
|
+
# abilities never use the stack (CR 605.3b), so they can't be targeted.
|
|
16
|
+
class StackAbility < Filter
|
|
17
|
+
KINDS = %w[activated triggered].freeze
|
|
18
|
+
CONTROLLERS = %w[you opponent].freeze
|
|
19
|
+
|
|
20
|
+
primitive :filter, :stack_ability,
|
|
21
|
+
description: "An activated or triggered ability on the stack. `kind`: \"activated\" or \"triggered\" " \
|
|
22
|
+
"for only one of them; `controller`: you | opponent; `source`: a filter its source " \
|
|
23
|
+
"matches (e.g. \"creature\", \"artifact\").",
|
|
24
|
+
rules: %w[113.1b 115.1 605.3b 113.7],
|
|
25
|
+
params: { "kind" => KINDS.join(" | "), "controller" => CONTROLLERS.join(" | "), "source" => "filter" },
|
|
26
|
+
example: { "stack_ability" => { "controller" => "you", "source" => "creature" } }
|
|
27
|
+
|
|
28
|
+
def self.zone = :stack
|
|
29
|
+
def self.match_object?(_engine, object, _controller) = object.ability?
|
|
30
|
+
|
|
31
|
+
def self.build(params, errors, path, **)
|
|
32
|
+
return self if params.empty?
|
|
33
|
+
|
|
34
|
+
Mechanics.check_keys(params, %w[kind controller source], errors, path)
|
|
35
|
+
kind, controller, source = params.values_at("kind", "controller", "source")
|
|
36
|
+
errors << "#{path}.kind: must be one of #{KINDS.join(", ")}" unless kind.nil? || KINDS.include?(kind)
|
|
37
|
+
unless controller.nil? || CONTROLLERS.include?(controller)
|
|
38
|
+
errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}"
|
|
39
|
+
end
|
|
40
|
+
source &&= Mechanics.build_entry(:filter, source, errors, "#{path}.source")
|
|
41
|
+
Narrowed.new(kind, controller, source)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Only activated (loyalty included) or only triggered abilities, of a controller, from a kind of source.
|
|
45
|
+
class Narrowed
|
|
46
|
+
def initialize(kind, controller, source)
|
|
47
|
+
@kind = kind
|
|
48
|
+
@controller = controller
|
|
49
|
+
@source = source
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def primitive_name = "stack_ability"
|
|
54
|
+
|
|
55
|
+
def to_s
|
|
56
|
+
whose = { "you" => " you control", "opponent" => " an opponent controls" }[@controller]
|
|
57
|
+
"#{@kind || "activated or triggered"} ability#{whose}#{" from a source: #{Triggers.filter_words(@source)}" if @source}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def candidates(engine, controller) = StackAbility.candidates(engine, controller).select { narrow?(engine, it, controller) }
|
|
61
|
+
|
|
62
|
+
def candidates_for(engine, controller, _source, **) = candidates(engine, controller)
|
|
63
|
+
|
|
64
|
+
def match?(engine, ref, controller) = StackAbility.match?(engine, ref, controller) && narrow?(engine, ref, controller)
|
|
65
|
+
|
|
66
|
+
def match_for?(engine, ref, controller, _source, **) = match?(engine, ref, controller)
|
|
67
|
+
|
|
68
|
+
def matches_types?(_types) = false
|
|
69
|
+
def matches_facts?(_facts) = false
|
|
70
|
+
def live_keys = []
|
|
71
|
+
def uses_chosen? = false
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def narrow?(engine, ref, controller)
|
|
76
|
+
obj = engine.object(ref)
|
|
77
|
+
return false if @kind && (engine.stack_ability(obj).kind == :triggered) != (@kind == "triggered")
|
|
78
|
+
return false if @controller && (@controller == "you") != (obj.controller == controller)
|
|
79
|
+
|
|
80
|
+
@source.nil? || source_ok?(engine, obj)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# CR 113.7: the source of an ability is the object that created it; one that has left the
|
|
84
|
+
# battlefield is matched as it last existed (its card's characteristics).
|
|
85
|
+
def source_ok?(engine, obj)
|
|
86
|
+
source = engine.state.objects[obj.source]
|
|
87
|
+
return @source.match_for?(engine, source.id, obj.controller, nil) if source&.zone == :battlefield
|
|
88
|
+
|
|
89
|
+
card = source&.card || obj.card
|
|
90
|
+
defn = engine.catalog.fetch(card)
|
|
91
|
+
@source.matches_facts?(Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# Effects that happen right after a replacement or prevention effect applies: "prevent that damage and
|
|
6
|
+
# put that many +1/+1 counters on him" (CR 615.5), "instead exile it and you gain 2 life" (CR 614.1a), and
|
|
7
|
+
# "exile it instead. When you do, ..." (a reflexive_trigger among them, CR 603.12). A `then` list of
|
|
8
|
+
# effects that need no choice. They see {"triggering": "amount"} (the damage prevented), "object" (the
|
|
9
|
+
# damage source, or the card exiled instead) and "player" (that object's controller).
|
|
10
|
+
#
|
|
11
|
+
# The FollowUp is one of its card's nested effects, so CardDefinition#ability finds its effects by `ref`;
|
|
12
|
+
# a replacement names it in the event it modifies ("follow_ups") and the engine runs it once the event
|
|
13
|
+
# is done (Engine::FollowUps).
|
|
14
|
+
class FollowUp
|
|
15
|
+
Effects = Data.define(:effects) # what CardDefinition#ability(ref) returns
|
|
16
|
+
|
|
17
|
+
attr_reader :ref, :delayed_ability
|
|
18
|
+
|
|
19
|
+
def self.build(params, errors, path)
|
|
20
|
+
raw = Mechanics.fetch_param(params, :then)
|
|
21
|
+
return nil if raw.nil?
|
|
22
|
+
|
|
23
|
+
effects = Abilities::StackAbility.parse_effects({ "then" => raw }, errors, path, 0, key: :then)
|
|
24
|
+
errors << "#{path}.then: needs at least one effect" if raw.is_a?(Array) && raw.empty?
|
|
25
|
+
new("then:#{path}", effects)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(ref, effects)
|
|
29
|
+
@ref = ref
|
|
30
|
+
@delayed_ability = Effects.new(effects: effects)
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def effects = delayed_ability.effects
|
|
35
|
+
def nested_effects = effects
|
|
36
|
+
def try_effects = []
|
|
37
|
+
def to_s = effects.join(", ")
|
|
38
|
+
|
|
39
|
+
# The entry a replacement adds to the event's "follow_ups": what runs it later, as plain data.
|
|
40
|
+
def entry(card, controller, source, facts)
|
|
41
|
+
{ "card" => card, "ref" => ref, "controller" => controller,
|
|
42
|
+
"source" => source, "facts" => facts.compact }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../effects/delayed_triggers"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Abilities
|
|
8
|
+
# Keyword abilities that are alternative costs (CR 118.9, 601.2b, 601.2f-h): "you may cast this card
|
|
9
|
+
# [from a zone] by paying [cost] rather than paying its mana cost". Each is offered as its own cast
|
|
10
|
+
# option, `{type: "cast", object: id, via: "alt:<ability ref>"}`, while the ability functions (the card
|
|
11
|
+
# is in the right zone, and any other condition holds). `cost` is a mana cost string, or a list of
|
|
12
|
+
# costs like kicker's (mana and/or chosen costs such as `tap_permanents`). How a spell was cast is
|
|
13
|
+
# remembered (the `cast_with` / `cast_from` conditions, CR 400.7d). See Engine::AltCasting.
|
|
14
|
+
class AlternativeCost < Primitive
|
|
15
|
+
attr_reader :costs, :mana_cost, :ref_path
|
|
16
|
+
|
|
17
|
+
# `{"cost": "{2}{R}"}` or `{"cost": [{"mana": "{2}{R}"}, {"tap_permanents": {...}}]}`.
|
|
18
|
+
def self.build(params, errors, path, **)
|
|
19
|
+
Mechanics.check_keys(params, %w[cost], errors, path)
|
|
20
|
+
raw = Mechanics.fetch_param(params, :cost)
|
|
21
|
+
costs = read_costs(raw, errors, path)
|
|
22
|
+
new(costs, path, errors)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.read_costs(raw, errors, path)
|
|
26
|
+
return [] if raw.nil? && optional_cost?
|
|
27
|
+
|
|
28
|
+
if raw.nil?
|
|
29
|
+
errors << "#{path}.cost: required"
|
|
30
|
+
return []
|
|
31
|
+
end
|
|
32
|
+
return [Costs::ManaPayment.build(raw, errors, "#{path}.cost")] if raw.is_a?(String)
|
|
33
|
+
|
|
34
|
+
costs = StackAbility.parse_costs({ "cost" => raw }, errors, path)
|
|
35
|
+
errors << "#{path}.cost: must be a mana cost string or a non-empty list of costs" if raw.is_a?(Array) && raw.empty?
|
|
36
|
+
costs.each_with_index do |cost, i|
|
|
37
|
+
next if cost.mana? || cost.respond_to?(:choices_needed) || cost.respond_to?(:spell_cost?)
|
|
38
|
+
|
|
39
|
+
errors << "#{path}.cost[#{i}]: an alternative cost must be mana, life or a chosen cost (tap, sacrifice, ...)"
|
|
40
|
+
end
|
|
41
|
+
costs
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.optional_cost? = false
|
|
45
|
+
|
|
46
|
+
def initialize(costs, path, _errors)
|
|
47
|
+
super()
|
|
48
|
+
@costs = costs.freeze
|
|
49
|
+
@mana_cost = costs.empty? ? nil : Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
|
|
50
|
+
@ref_path = path
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def kind = :alternative_cost
|
|
55
|
+
def keyword = self.class.primitive_name
|
|
56
|
+
def mana_ability? = false
|
|
57
|
+
def allowed_types = nil
|
|
58
|
+
def try_effects = []
|
|
59
|
+
def choosing_costs = costs.reject(&:mana?)
|
|
60
|
+
|
|
61
|
+
# The zone the ability lets the card be cast from (CR 113.6).
|
|
62
|
+
def zone = :hand
|
|
63
|
+
|
|
64
|
+
# Whether the card `obj` (owned by `index`) may be cast this way now, before timing and cost checks.
|
|
65
|
+
def functions?(_engine, obj, _index) = obj.zone == zone
|
|
66
|
+
|
|
67
|
+
# Paid instead of the mana cost? (Mayhem without a cost only allows playing it.)
|
|
68
|
+
def alternative? = !mana_cost.nil?
|
|
69
|
+
|
|
70
|
+
# "If the [keyword] cost was paid, exile this card instead of putting it anywhere else any time it would
|
|
71
|
+
# leave the stack."
|
|
72
|
+
def exiles? = false
|
|
73
|
+
|
|
74
|
+
# What creature, if any, is returned to its owner's hand as part of the cost: nil, "tapped" (web-slinging)
|
|
75
|
+
# or "unblocked" (sneak).
|
|
76
|
+
def returns = nil
|
|
77
|
+
|
|
78
|
+
# Cast "any time you could cast an instant" (sneak).
|
|
79
|
+
def instant_speed? = false
|
|
80
|
+
|
|
81
|
+
# "play" also lets a land be played (mayhem without a cost).
|
|
82
|
+
def play = "cast"
|
|
83
|
+
|
|
84
|
+
def cost_text = costs.empty? ? "" : " #{costs.join(", ")}"
|
|
85
|
+
def to_s = "#{keyword.split("_").map(&:capitalize).join("-")}#{cost_text}"
|
|
86
|
+
|
|
87
|
+
# The cast method (Engine::Permissions) this ability gives.
|
|
88
|
+
def method_for(ref)
|
|
89
|
+
method = { "via" => "alt:#{ref}", "play" => play, "keyword" => keyword }
|
|
90
|
+
return method unless alternative?
|
|
91
|
+
|
|
92
|
+
alt = { "cost" => mana_cost.to_s, "ref" => ref }
|
|
93
|
+
alt["exile"] = true if exiles?
|
|
94
|
+
alt["returns"] = returns if returns
|
|
95
|
+
alt["harmonize"] = true if is_a?(Harmonize)
|
|
96
|
+
method["instant"] = true if instant_speed?
|
|
97
|
+
method.merge("alt" => alt)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# CR 702.34a: flashback, from the graveyard for an instant or sorcery; exiled whenever it would leave the stack.
|
|
102
|
+
class Flashback < AlternativeCost
|
|
103
|
+
primitive :ability, :flashback,
|
|
104
|
+
description: "Flashback: you may cast this instant or sorcery card from your graveyard by paying " \
|
|
105
|
+
"`cost` rather than its mana cost; then it's exiled whenever it would leave the stack. " \
|
|
106
|
+
"`cost` is a mana cost string or a list of costs (`[{\"mana\": \"{1}{U}\"}, " \
|
|
107
|
+
"{\"tap_permanents\": {\"count\": 3}}]`). Cast option `via: \"alt:<ref>\"`.",
|
|
108
|
+
rules: %w[702.34a 118.9 601.2b 601.2f 601.2h],
|
|
109
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
110
|
+
example: { "flashback" => { "cost" => "{2}{R}" } }
|
|
111
|
+
|
|
112
|
+
def zone = :graveyard
|
|
113
|
+
def exiles? = true
|
|
114
|
+
def allowed_types = %i[instant sorcery]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# CR 702.180a-b: harmonize, from the graveyard, tapping up to one untapped creature you control to reduce
|
|
118
|
+
# the total cost by its power; exiled whenever it would leave the stack.
|
|
119
|
+
class Harmonize < AlternativeCost
|
|
120
|
+
primitive :ability, :harmonize,
|
|
121
|
+
description: "Harmonize: you may cast this card from your graveyard by paying `cost` and tapping up " \
|
|
122
|
+
"to one untapped creature you control rather than its mana cost; the total cost is " \
|
|
123
|
+
"reduced by generic mana equal to that creature's power. Then it's exiled whenever it " \
|
|
124
|
+
"would leave the stack. The creature is a `choose_cost` decision (`{type: \"cost\"}` " \
|
|
125
|
+
"without an object taps none). Cast option `via: \"alt:<ref>\"`.",
|
|
126
|
+
rules: %w[702.180a 702.180b 118.9 601.2b 601.2f 601.2h],
|
|
127
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
128
|
+
example: { "harmonize" => { "cost" => "{4}{G}" } }
|
|
129
|
+
|
|
130
|
+
def zone = :graveyard
|
|
131
|
+
def exiles? = true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# CR 702.187a-c: mayhem, from the graveyard if you discarded the card this turn; without a cost, the card
|
|
135
|
+
# may be played (a land too) for its usual costs.
|
|
136
|
+
class Mayhem < AlternativeCost
|
|
137
|
+
primitive :ability, :mayhem,
|
|
138
|
+
description: "Mayhem: as long as you discarded this card this turn, you may cast it from your " \
|
|
139
|
+
"graveyard by paying `cost` rather than its mana cost. Without `cost` (`\"mayhem\"`), " \
|
|
140
|
+
"you may play it from your graveyard (a land too) if you discarded it this turn. Cast " \
|
|
141
|
+
"option `via: \"alt:<ref>\"`.",
|
|
142
|
+
rules: %w[702.187a 702.187b 702.187c 118.9 601.2b 601.2f 601.2h 305.1],
|
|
143
|
+
params: { "cost" => "mana cost string, or a list of costs (optional)" },
|
|
144
|
+
example: { "mayhem" => { "cost" => "{1}{R}" } }
|
|
145
|
+
|
|
146
|
+
def self.optional_cost? = true
|
|
147
|
+
|
|
148
|
+
def zone = :graveyard
|
|
149
|
+
def play = alternative? ? "cast" : "play"
|
|
150
|
+
def functions?(engine, obj, _index) = obj.zone == zone && engine.discarded_this_turn?(obj)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# CR 702.185a-c: warp, from the hand. The permanent is exiled at the beginning of the next end step; its
|
|
154
|
+
# owner may cast it from exile after this turn for as long as it remains exiled.
|
|
155
|
+
class Warp < AlternativeCost
|
|
156
|
+
primitive :ability, :warp,
|
|
157
|
+
description: "Warp: you may cast this card from your hand by paying `cost` rather than its mana " \
|
|
158
|
+
"cost. If you do, the permanent it becomes is exiled at the beginning of the next end " \
|
|
159
|
+
"step (a delayed triggered ability), and its owner may cast it from exile after this " \
|
|
160
|
+
"turn has ended for as long as it remains exiled (cast option `via: \"warped\"`). " \
|
|
161
|
+
"Conditions `{\"cast_with\": \"warp\"}` and `{\"cast_with_this_turn\": \"warp\"}` read it.",
|
|
162
|
+
rules: %w[702.185a 702.185b 702.185c 603.7 118.9 601.2b 601.2f 601.2h],
|
|
163
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
164
|
+
example: { "warp" => { "cost" => "{1}{R}" } }
|
|
165
|
+
|
|
166
|
+
attr_reader :delayed
|
|
167
|
+
|
|
168
|
+
def initialize(costs, path, errors)
|
|
169
|
+
@delayed = Effects::WarpDelayed.build({}, errors, path)
|
|
170
|
+
super
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def try_effects = [delayed]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# CR 702.74a: evoke, from the hand; "When this permanent enters, if its evoke cost was paid, its controller
|
|
177
|
+
# sacrifices it" is a linked triggered ability.
|
|
178
|
+
class Evoke < AlternativeCost
|
|
179
|
+
primitive :ability, :evoke,
|
|
180
|
+
description: "Evoke: you may cast this card by paying `cost` rather than its mana cost. When the " \
|
|
181
|
+
"permanent enters, if its evoke cost was paid, its controller sacrifices it (a triggered " \
|
|
182
|
+
"ability that comes with the keyword). Cast option `via: \"alt:<ref>\"`.",
|
|
183
|
+
rules: %w[702.74a 118.9 601.2b 601.2f 601.2h 603.4],
|
|
184
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
185
|
+
example: { "evoke" => { "cost" => "{R/W}{R/W}" } }
|
|
186
|
+
|
|
187
|
+
SACRIFICE = { "triggered" => { "when" => { "enters" => {} }, "if" => { "cast_with" => "evoke" },
|
|
188
|
+
"effects" => [{ "sacrifice" => { "to" => "self" } }] } }.freeze
|
|
189
|
+
|
|
190
|
+
attr_reader :linked_abilities
|
|
191
|
+
|
|
192
|
+
def initialize(costs, path, errors)
|
|
193
|
+
@linked_abilities = [Mechanics.build_entry(:ability, SACRIFICE, errors, "#{path}.sacrifice")].compact.freeze
|
|
194
|
+
super
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def allowed_types = %i[artifact creature enchantment planeswalker]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# CR 702.188a: web-slinging, paying `cost` and returning a tapped creature you control to its owner's hand.
|
|
201
|
+
class WebSlinging < AlternativeCost
|
|
202
|
+
primitive :ability, :web_slinging,
|
|
203
|
+
description: "Web-slinging: you may cast this spell by paying `cost` and returning a tapped creature " \
|
|
204
|
+
"you control to its owner's hand rather than its mana cost. The creature is a " \
|
|
205
|
+
"`choose_cost` decision, returned as the spell becomes cast. Cast option " \
|
|
206
|
+
"`via: \"alt:<ref>\"`.",
|
|
207
|
+
rules: %w[702.188a 118.9 601.2b 601.2f 601.2h],
|
|
208
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
209
|
+
example: { "web_slinging" => { "cost" => "{2}{G}" } }
|
|
210
|
+
|
|
211
|
+
def returns = "tapped"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# CR 702.190a-b: sneak, during your declare blockers step at instant speed, paying `cost` and returning an
|
|
215
|
+
# unblocked attacking creature you control to its owner's hand. A permanent spell enters tapped and
|
|
216
|
+
# attacking whatever that creature was attacking.
|
|
217
|
+
class Sneak < AlternativeCost
|
|
218
|
+
primitive :ability, :sneak,
|
|
219
|
+
description: "Sneak: any time you could cast an instant during your declare blockers step, you may " \
|
|
220
|
+
"cast this spell by paying `cost` and returning an unblocked attacking creature you " \
|
|
221
|
+
"control to its owner's hand rather than its mana cost. A permanent spell cast this way " \
|
|
222
|
+
"enters tapped and attacking what that creature was attacking. Cast option " \
|
|
223
|
+
"`via: \"alt:<ref>\"`.",
|
|
224
|
+
rules: %w[702.190a 702.190b 506.3a 118.9 601.2b 601.2f 601.2h],
|
|
225
|
+
params: { "cost" => "mana cost string, or a list of costs" },
|
|
226
|
+
example: { "sneak" => { "cost" => "{1}{B}" } }
|
|
227
|
+
|
|
228
|
+
def returns = "unblocked"
|
|
229
|
+
def instant_speed? = true
|
|
230
|
+
|
|
231
|
+
def functions?(engine, obj, index)
|
|
232
|
+
obj.zone == zone && engine.state.active == index && engine.state.step == :declare_blockers
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
module Effects
|
|
238
|
+
# The delayed triggered ability of warp (CR 702.185a): "exile the permanent this spell becomes at the
|
|
239
|
+
# beginning of the next end step". It is created as the spell becomes cast and watches the spell; the
|
|
240
|
+
# permanent it became remembers the spell ("warp_spell"). Not a card-data primitive.
|
|
241
|
+
class WarpDelayed < DelayedTrigger
|
|
242
|
+
END_STEP = { "step_begins" => { "step" => "end", "whose" => "each" } }.freeze
|
|
243
|
+
|
|
244
|
+
def self.keys = []
|
|
245
|
+
|
|
246
|
+
def setup(_params, errors, path)
|
|
247
|
+
@ref = "delayed:#{path}"
|
|
248
|
+
trigger = Mechanics.build_entry(:trigger, END_STEP, errors, "#{path}.when")
|
|
249
|
+
exile = WarpExile.build({ "to" => "watched" }, errors, "#{path}.exile")
|
|
250
|
+
@delayed_ability = Abilities::TriggeredAbility.new(targets: [].freeze, effects: [exile].freeze, trigger: trigger)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def to_s = "at the beginning of the next end step, exile the permanent this spell became"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Exiles the permanent a warped spell became; the card in exile is "warped" (CR 702.185b) and its owner
|
|
257
|
+
# may cast it after this turn has ended (Engine::AltCasting#warped_methods).
|
|
258
|
+
class WarpExile < Effect
|
|
259
|
+
def self.keys = %w[to]
|
|
260
|
+
|
|
261
|
+
def apply(engine, ctx)
|
|
262
|
+
spells = refs(engine, ctx)
|
|
263
|
+
engine.state.battlefield.dup.each do |id|
|
|
264
|
+
next unless spells.include?(engine.object(id).memory["warp_spell"])
|
|
265
|
+
|
|
266
|
+
fresh = engine.move!(id, :exile, reason: "warp")
|
|
267
|
+
fresh.memory["warped"] = engine.state.turn if fresh&.zone == :exile
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def to_s = "exile the permanent this spell became"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
class Condition
|
|
276
|
+
# How a spell was cast (CR 601.2a, 400.7d): `{"cast_with": "flashback"}` (cast using that keyword's
|
|
277
|
+
# ability: "if its evoke cost was paid", "if this spell's mayhem cost was paid"), `{"cast_from":
|
|
278
|
+
# "graveyard"}` ("if this spell was cast from a graveyard"; `{"not": {"cast_from": "hand"}}` is "from
|
|
279
|
+
# anywhere other than your hand"), and `{"cast_with_this_turn": "warp"}` (any player cast a spell with
|
|
280
|
+
# that keyword's cost this turn, CR 702.185c). The resolving spell, or the permanent it became (and
|
|
281
|
+
# that permanent's triggered abilities), remembers it.
|
|
282
|
+
class CastHow
|
|
283
|
+
KEYS = %w[cast_with cast_from cast_with_this_turn].freeze
|
|
284
|
+
KEYWORDS = %w[flashback harmonize mayhem warp evoke web_slinging sneak foretell].freeze
|
|
285
|
+
# Optional additional costs announced as the spell is cast (Engine::CastChoices#announce_kicker!):
|
|
286
|
+
# "if this spell was cast using teamwork" (CR 702.194b), "if this spell's additional cost was paid" (CR 118.8b),
|
|
287
|
+
# "if the gift was promised" (CR 702.174k).
|
|
288
|
+
OPTIONAL_TEXT = { "teamwork" => "it was cast using teamwork", "additional_cost" => "its additional cost was paid",
|
|
289
|
+
"gift" => "its gift was promised" }.freeze
|
|
290
|
+
OPTIONAL = OPTIONAL_TEXT.keys.freeze
|
|
291
|
+
ZONES = %w[hand graveyard exile library outside].freeze
|
|
292
|
+
|
|
293
|
+
def self.build(key, data, errors, path)
|
|
294
|
+
Mechanics.check_keys(data, [key], errors, path)
|
|
295
|
+
value = data[key]
|
|
296
|
+
allowed = { "cast_from" => ZONES, "cast_with" => KEYWORDS + OPTIONAL }.fetch(key, KEYWORDS)
|
|
297
|
+
errors << "#{path}.#{key}: must be one of #{allowed.join(", ")}" unless allowed.include?(value)
|
|
298
|
+
new(key, value)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def initialize(key, value)
|
|
302
|
+
@key = key
|
|
303
|
+
@value = value
|
|
304
|
+
freeze
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def true?(engine, ctx)
|
|
308
|
+
return engine.cast_with_this_turn?(@value) if @key == "cast_with_this_turn"
|
|
309
|
+
|
|
310
|
+
holder = engine.state.objects[ctx.object || ctx.source]
|
|
311
|
+
return holder&.memory&.[]("kicked_with") == @value if OPTIONAL.include?(@value)
|
|
312
|
+
|
|
313
|
+
holder&.memory&.[](@key) == @value
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def to_s
|
|
317
|
+
case @key
|
|
318
|
+
when "cast_with" then OPTIONAL.include?(@value) ? OPTIONAL_TEXT.fetch(@value) : "its #{@value.tr("_", "-")} cost was paid"
|
|
319
|
+
when "cast_from" then "it was cast from #{@value == "hand" ? "a hand" : "a #{@value}"}"
|
|
320
|
+
else "a spell was cast with #{@value.tr("_", "-")} this turn"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Where a spell is cast from (CR 601.2a), as a filter of the `cast` trigger and of `cost_modifier`:
|
|
327
|
+
# `from` / `not_from` a zone ("spells you cast from anywhere other than your hand" is
|
|
328
|
+
# {"not_from": "hand"}). Engine#cast_origin knows the zone while the spell is being cast and after.
|
|
329
|
+
class CastFrom
|
|
330
|
+
KEYS = %w[from not_from].freeze
|
|
331
|
+
|
|
332
|
+
def self.build(params, errors, path)
|
|
333
|
+
from, not_from = KEYS.map { Mechanics.fetch_param(params, it) }
|
|
334
|
+
KEYS.zip([from, not_from]).each do |key, zone|
|
|
335
|
+
next if zone.nil? || Condition::CastHow::ZONES.include?(zone)
|
|
336
|
+
|
|
337
|
+
errors << "#{path}.#{key}: must be one of #{Condition::CastHow::ZONES.join(", ")}"
|
|
338
|
+
end
|
|
339
|
+
new(from, not_from)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def initialize(from, not_from)
|
|
343
|
+
@from = from
|
|
344
|
+
@not_from = not_from
|
|
345
|
+
freeze
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def match?(zone) = (@from.nil? || zone == @from) && (@not_from.nil? || zone != @not_from)
|
|
349
|
+
|
|
350
|
+
WORDS = { "hand" => "your hand", "graveyard" => "a graveyard", "exile" => "exile", "library" => "a library",
|
|
351
|
+
"outside" => "outside the game" }.freeze
|
|
352
|
+
|
|
353
|
+
def to_s
|
|
354
|
+
return " from #{WORDS.fetch(@from)}" if @from
|
|
355
|
+
return " from anywhere other than #{WORDS.fetch(@not_from)}" if @not_from
|
|
356
|
+
|
|
357
|
+
""
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Beam me up [cost] (CR 702.196a): "You may cast this card from your graveyard if the
|
|
7
|
+
# resulting spell is an instant or sorcery spell by paying [cost] and returning a creature you control
|
|
8
|
+
# to its owner's hand rather than paying its mana cost" and "If the beam me up cost was paid, exile this
|
|
9
|
+
# card instead of putting it anywhere else any time it would leave the stack." The creature is chosen
|
|
10
|
+
# while casting (a `choose_cost` decision) and returned as the spell becomes cast (CR 601.2h).
|
|
11
|
+
class BeamMeUp < Primitive
|
|
12
|
+
primitive :ability, :beam_me_up,
|
|
13
|
+
description: "You may cast this instant or sorcery card from your graveyard for `cost` plus " \
|
|
14
|
+
"returning a creature you control to its owner's hand, instead of its mana cost. " \
|
|
15
|
+
"If you do, it's exiled whenever it would leave the stack. Offered as a cast option " \
|
|
16
|
+
"with `via: \"beam:<ref>\"`.",
|
|
17
|
+
rules: %w[702.196a 702.196b 118.9 601.2b 601.2f 601.2h],
|
|
18
|
+
params: { "cost" => "mana cost string, e.g. \"{3}{W}\"" },
|
|
19
|
+
example: { "beam_me_up" => { "cost" => "{3}{W}" } }
|
|
20
|
+
|
|
21
|
+
attr_reader :cost
|
|
22
|
+
|
|
23
|
+
def self.build(params, errors, path, **)
|
|
24
|
+
Mechanics.check_keys(params, %w[cost], errors, path)
|
|
25
|
+
new(Mana.parse_cost(Mechanics.fetch_param(params, :cost), errors, "#{path}.cost") || Mana::Cost.new(generic: 0, colored: {}))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(cost)
|
|
29
|
+
super()
|
|
30
|
+
@cost = cost
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def kind = :beam_me_up
|
|
35
|
+
def mana_ability? = false
|
|
36
|
+
def allowed_types = %i[instant sorcery]
|
|
37
|
+
def try_effects = []
|
|
38
|
+
def to_s = "Beam me up #{cost}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A static ability of a permanent giving beam me up (CR 702.196a), during its controller's main phase,
|
|
42
|
+
# to the instant and sorcery cards with a mana cost in their graveyard, each card's mana cost being its
|
|
43
|
+
# beam me up cost. The permanent itself can't be returned to cast a spell this way (CR 702.196b).
|
|
44
|
+
class GrantsBeamMeUp < Primitive
|
|
45
|
+
primitive :ability, :grants_beam_me_up,
|
|
46
|
+
description: "While this permanent is on the battlefield, during your main phase each instant and " \
|
|
47
|
+
"sorcery card in your graveyard has beam me up with its mana cost as the beam me up " \
|
|
48
|
+
"cost; this permanent can't be the creature returned to cast a spell this way. " \
|
|
49
|
+
"Offered as a cast option with `via: \"beam_granted:<permanent>:<ref>\"`.",
|
|
50
|
+
rules: %w[702.196a 702.196b 604.1 611.3a],
|
|
51
|
+
example: "grants_beam_me_up"
|
|
52
|
+
|
|
53
|
+
INSTANCE = new.freeze
|
|
54
|
+
|
|
55
|
+
def self.build(params, errors, path, **)
|
|
56
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
57
|
+
INSTANCE
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def kind = :grants_beam_me_up
|
|
61
|
+
def mana_ability? = false
|
|
62
|
+
def allowed_types = nil
|
|
63
|
+
def try_effects = []
|
|
64
|
+
def to_s = "During your main phase, each card in your graveyard has beam me up (its mana cost)"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module Keywords
|
|
69
|
+
# CR 702.196b: "If a creature can't be beamed up, its controller can't return it to its owner's hand to
|
|
70
|
+
# pay the portion of a beam me up cost that requires them to return a creature." A flag like a keyword.
|
|
71
|
+
class CantBeBeamedUp < Primitive
|
|
72
|
+
primitive :keyword, :cant_be_beamed_up,
|
|
73
|
+
description: "This creature can't be returned to its owner's hand to pay a beam me up cost.",
|
|
74
|
+
rules: %w[702.196b], example: { "keywords" => ["cant_be_beamed_up"] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Keywords
|
|
6
|
+
# Changeling (CR 702.73a): a characteristic-defining ability, "this object is
|
|
7
|
+
# every creature type". It works in every zone (CR 604.3, 113.6a), so it is applied with the object's
|
|
8
|
+
# copiable values (Engine::Characteristics#copy_base): the object's subtypes then include every
|
|
9
|
+
# creature type, and subtype filters, "creature types among" counts and type-changing effects
|
|
10
|
+
# (layer 4) see them like printed ones.
|
|
11
|
+
class Changeling < Primitive
|
|
12
|
+
primitive :keyword, :changeling,
|
|
13
|
+
description: "This object is every creature type (a characteristic-defining ability that works " \
|
|
14
|
+
"in all zones).",
|
|
15
|
+
rules: %w[702.73a 604.3 205.3m 113.6a], example: { "keywords" => ["changeling"] }
|
|
16
|
+
|
|
17
|
+
# CR 205.3m: every creature type, written as card data subtypes (lowercase, "-" and
|
|
18
|
+
# spaces become "_", apostrophes dropped).
|
|
19
|
+
CREATURE_TYPES = <<~TYPES.split.map(&:to_sym).freeze
|
|
20
|
+
time_lord advisor aenar aetherborn alien ally andorian android angel antelope ape archer archon armadillo
|
|
21
|
+
army artificer assassin assembly_worker astartes atog aurochs avatar azra badger bajoran balloon barbarian
|
|
22
|
+
bard basilisk bat bear beast beaver beeble beholder berserker betazoid bird bison blinkmoth boar bolian
|
|
23
|
+
borg brikar bringer brushwagg caitian camarid camel capybara cardassian caribou carrier cat centaur child
|
|
24
|
+
chimera citizen cleric clown cockatrice construct coward coyote crab crocodile ctan custodes cyberman
|
|
25
|
+
cyclops dalek dauthi demigod demon denobulan deserter detective devil dinosaur djinn doctor dog dragon
|
|
26
|
+
drake dreadnought drix drone druid dryad dwarf echidna edosian efreet egg el_aurian elder eldrazi
|
|
27
|
+
elemental elephant elf elk employee eternal excalbian eye faerie ferengi ferret fish flagbearer fox
|
|
28
|
+
fractal frog fungus gamer gamma gargoyle germ giant giraffe gith glimmer gnoll gnome goat goblin god golem
|
|
29
|
+
gorgon gorn graveborn gremlin griffin guest hag halfling hamster harpy hedgehog hellion hero hippo
|
|
30
|
+
hippogriff hirogen homarid homunculus horror horse human hydra hyena illusion illyrian imp incarnation
|
|
31
|
+
inhuman inkling inquisitor insect jackal jellyfish jemhadar juggernaut kangaroo kavu kelpien kirin kithkin
|
|
32
|
+
klingon knight kobold kor kraken kree kwejian llama lamia lammasu lanthanite leech lemur leviathan
|
|
33
|
+
lhurgoyf licid lizard lobster manticore masticore medusan melkotian mercenary merfolk metathran minion
|
|
34
|
+
minotaur mite mole monger mongoose monk monkey moogle moonfolk mount mouse mutant myr mystic nakuhl
|
|
35
|
+
nautilus necron nephilim nightmare nightstalker ninja noble noggle nomad nymph ocampa octopus officer ogre
|
|
36
|
+
ooze orb orc orgg orion otter ouphe ox oyster pangolin peasant pegasus pentavite performer pest
|
|
37
|
+
phelddagrif phoenix phyrexian pilot pincher pirate plant platypus porcupine possum praetor primarch prism
|
|
38
|
+
processor q qu rabbit raccoon ranger rat rebel reflection rhino rigger robot rogue romulan sable
|
|
39
|
+
salamander samurai sand saproling satyr scarecrow scientist scion scorpion scout sculpture seal serf
|
|
40
|
+
serpent servo shade shaman shapeshifter shark sheep shiar siren skeleton skrull skunk slith sliver sloth
|
|
41
|
+
slug snail snake soldier soltari sorcerer spawn specter spellshaper sphinx spider spike spirit splinter
|
|
42
|
+
sponge spy squid squirrel starfish surrakar survivor symbiote synth talaxian talosian tamarian tellarite
|
|
43
|
+
tentacle tetravite thalakos tholian thopter thrull tiefling tosk toy treefolk tribble trill trilobite
|
|
44
|
+
triskelavite troll turtle tyranid unicorn utrom vampire varmint vau_nakat vedalken villain volver vorta
|
|
45
|
+
vulcan wall walrus warlock warrior weasel weird werewolf whale wizard wolf wolverine wombat worm wraith
|
|
46
|
+
wurm xindi yeti zombie zubera
|
|
47
|
+
TYPES
|
|
48
|
+
|
|
49
|
+
# The characteristics with every creature type added, if the object has changeling.
|
|
50
|
+
def self.apply(chars)
|
|
51
|
+
return chars unless chars.keywords.include?(:changeling)
|
|
52
|
+
|
|
53
|
+
chars.with(subtypes: (chars.subtypes | CREATURE_TYPES).freeze)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|