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,527 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# A card filter for cards outside the battlefield: a CardSpec (`for`) plus card types the card must
|
|
6
|
+
# not have (`not_types`, "a nonland card", "a noncreature spell").
|
|
7
|
+
class CardFilter
|
|
8
|
+
def self.build(params, errors, path)
|
|
9
|
+
spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
|
|
10
|
+
raw = Mechanics.fetch_param(params, :not_types) || []
|
|
11
|
+
unless raw.is_a?(Array) && raw.all? { Enjine.registry.fetch(:type, it.to_s) }
|
|
12
|
+
errors << "#{path}.not_types: must be a list of card types"
|
|
13
|
+
raw = []
|
|
14
|
+
end
|
|
15
|
+
new(spec, raw.map(&:to_sym))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(spec, not_types)
|
|
19
|
+
@spec = spec
|
|
20
|
+
@not_types = not_types.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def match?(engine, obj) = @spec.match?(engine, obj) && !engine.chars(obj).types.intersect?(@not_types)
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
words = @not_types.map { "non#{it}" }
|
|
28
|
+
words.empty? ? @spec.to_s : "#{words.join(" ")} #{@spec}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# "If that spell would be put into a graveyard, exile it instead" (CR 614.1a), a rider of a permission or of
|
|
33
|
+
# casting while an effect resolves: `"exile_instead": true`, or a card spec the spell must match ("if an
|
|
34
|
+
# instant or sorcery spell cast this way would be put into your graveyard"). Kept as plain data in the play
|
|
35
|
+
# method and checked as the spell becomes cast (Engine::Permissions#finish_cast_method!).
|
|
36
|
+
module ExileInstead
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# The rider's data from `params` (true or a card spec), or nil without one.
|
|
40
|
+
def build(params, errors, path)
|
|
41
|
+
raw = Mechanics.fetch_param(params, :exile_instead)
|
|
42
|
+
return nil if raw.nil? || raw == false
|
|
43
|
+
return true if raw == true
|
|
44
|
+
|
|
45
|
+
CardSpec.build(raw, errors, "#{path}.exile_instead")
|
|
46
|
+
raw
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def applies?(engine, data, spell) = data == true || CardSpec.build(data, [], "exile_instead").match?(engine, spell)
|
|
50
|
+
def words(data) = "exile it#{" (#{CardSpec.build(data, [], "")})" unless data == true} instead of a graveyard"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module Abilities
|
|
54
|
+
# A card's own permission to be cast from a zone other than the hand (CR 113.6e-f, 601.3), or a
|
|
55
|
+
# permanent's static permission for its controller's cards in a zone, possibly limited to once per
|
|
56
|
+
# turn (CR 604.1). Offered as cast / play_land options with a `via`.
|
|
57
|
+
class PlayFrom < Primitive
|
|
58
|
+
ZONES = %w[graveyard exile library_top outside].freeze
|
|
59
|
+
PLAYS = %w[cast play].freeze
|
|
60
|
+
|
|
61
|
+
primitive :ability, :play_from,
|
|
62
|
+
description: "With `cards: \"self\"` (default): its owner may cast this card from `zone` (the " \
|
|
63
|
+
"ability functions there). With a card spec: while this permanent is on the " \
|
|
64
|
+
"battlefield, its controller may play their own matching cards from `zone`, or from " \
|
|
65
|
+
"any of `zones` (\"outside\": cards they own outside the game, the deck's `outside` " \
|
|
66
|
+
"list, CR 400.11a). `play`: \"cast\" (spells only, default) or \"play\" (lands too, " \
|
|
67
|
+
"using a land play). `once_each_turn`: once during each of your turns, one use shared " \
|
|
68
|
+
"by all its zones. `enters_with`: a permanent spell cast this way enters with these " \
|
|
69
|
+
"counters. Timing and costs are the usual ones.",
|
|
70
|
+
rules: %w[601.3 113.6e 113.6f 305.1 401.5 122.1 614.12 400.11a 400.11b 108.3],
|
|
71
|
+
params: { "zone" => ZONES.join(" | "), "zones" => "list of zones (instead of `zone`)",
|
|
72
|
+
"cards" => "\"self\" or a card spec", "play" => PLAYS.join(" | "),
|
|
73
|
+
"once_each_turn" => "boolean", "enters_with" => "{counter name: n}" },
|
|
74
|
+
example: { "play_from" => { "zone" => "graveyard", "enters_with" => { "finality" => 1 } } }
|
|
75
|
+
|
|
76
|
+
attr_reader :zones, :cards, :play, :once, :enters_with
|
|
77
|
+
|
|
78
|
+
def self.build(params, errors, path, **)
|
|
79
|
+
Mechanics.check_keys(params, %w[zone zones cards play once_each_turn enters_with], errors, path)
|
|
80
|
+
zones = zones_param(params, errors, path)
|
|
81
|
+
play = Mechanics.fetch_param(params, :play) || "cast"
|
|
82
|
+
errors << "#{path}.play: must be one of #{PLAYS.join(", ")}" unless PLAYS.include?(play)
|
|
83
|
+
raw = Mechanics.fetch_param(params, :cards) || "self"
|
|
84
|
+
cards = raw == "self" ? :self : CardSpec.build(raw, errors, "#{path}.cards")
|
|
85
|
+
# CR 400.11c: a card outside the game has no abilities that work there; a permanent brings cards in.
|
|
86
|
+
errors << "#{path}.zones: \"outside\" needs a card spec in `cards`" if cards == :self && zones.include?("outside")
|
|
87
|
+
once = Mechanics.flag_param(params, :once_each_turn, errors, path)
|
|
88
|
+
extra = Mechanics.fetch_param(params, :enters_with)
|
|
89
|
+
extra &&= EntersWithCounters.read_counters(extra, errors, "#{path}.enters_with")
|
|
90
|
+
new(zones, cards, play, once, extra)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `zone: "graveyard"` or `zones: ["outside", "graveyard"]`, one of the two.
|
|
94
|
+
def self.zones_param(params, errors, path)
|
|
95
|
+
one = Mechanics.fetch_param(params, :zone)
|
|
96
|
+
many = Mechanics.fetch_param(params, :zones)
|
|
97
|
+
if many.nil?
|
|
98
|
+
errors << "#{path}.zone: must be one of #{ZONES.join(", ")}" unless ZONES.include?(one)
|
|
99
|
+
return [one]
|
|
100
|
+
end
|
|
101
|
+
errors << "#{path}: give `zone` or `zones`, not both" if one
|
|
102
|
+
return many.uniq if many.is_a?(Array) && !many.empty? && many.all? { ZONES.include?(it) }
|
|
103
|
+
|
|
104
|
+
errors << "#{path}.zones: must be a non-empty list of #{ZONES.join(", ")}"
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def initialize(zones, cards, play, once, enters_with)
|
|
109
|
+
super()
|
|
110
|
+
@zones = zones.freeze
|
|
111
|
+
@cards = cards
|
|
112
|
+
@play = play
|
|
113
|
+
@once = once
|
|
114
|
+
@enters_with = enters_with&.freeze
|
|
115
|
+
freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self? = @cards == :self
|
|
119
|
+
def kind = :play_from
|
|
120
|
+
def mana_ability? = false
|
|
121
|
+
def allowed_types = nil
|
|
122
|
+
def try_effects = []
|
|
123
|
+
|
|
124
|
+
def outside? = zones.include?("outside")
|
|
125
|
+
|
|
126
|
+
# Whether the card is in a zone this permission names. A card outside the game is a detached object
|
|
127
|
+
# in the pseudo-zone :outside (Engine::Permissions#outside_object; outside the game isn't a zone).
|
|
128
|
+
def holds?(engine, obj)
|
|
129
|
+
zones.any? do |zone|
|
|
130
|
+
next obj.zone == :library && engine.player(obj.owner).library.first == obj.id if zone == "library_top"
|
|
131
|
+
|
|
132
|
+
obj.zone.to_s == zone
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
WHERE = { "library_top" => "the top of your library", "outside" => "outside the game" }.freeze
|
|
137
|
+
|
|
138
|
+
def to_s
|
|
139
|
+
what = self? ? "this card" : "#{cards}s"
|
|
140
|
+
where = zones.map { WHERE.fetch(it) { |zone| "your #{zone}" } }.join(" or from ")
|
|
141
|
+
text = "#{"Once during each of your turns, " if once}you may #{play} #{what} from #{where}"
|
|
142
|
+
enters_with ? "#{text}; it enters with #{enters_with.map { |k, n| "#{n} #{k}" }.join(", ")} counter(s)" : text
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Looking at the top card of your library at any time (CR 401.5).
|
|
147
|
+
class LookAtTop < Primitive
|
|
148
|
+
primitive :ability, :look_at_top,
|
|
149
|
+
description: "While this permanent is on the battlefield, its controller sees the top card of " \
|
|
150
|
+
"their library (the `library_top` of their view).",
|
|
151
|
+
rules: %w[401.5 604.1], example: "look_at_top"
|
|
152
|
+
|
|
153
|
+
INSTANCE = new.freeze
|
|
154
|
+
|
|
155
|
+
def self.build(params, errors, path, **)
|
|
156
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
157
|
+
INSTANCE
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def kind = :look_at_top
|
|
161
|
+
def mana_ability? = false
|
|
162
|
+
def allowed_types = nil
|
|
163
|
+
def try_effects = []
|
|
164
|
+
def to_s = "Its controller sees the top card of their library (CR 401.5)"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
module Effects
|
|
169
|
+
# (exile_top, exiling the top N cards: see effects/library.rb.)
|
|
170
|
+
|
|
171
|
+
# Exiling cards from the top of a library one at a time until a nonland card is exiled (CR 701.13).
|
|
172
|
+
class ExileUntil < Effect
|
|
173
|
+
UNTIL = %w[nonland].freeze
|
|
174
|
+
|
|
175
|
+
primitive :effect, :exile_until,
|
|
176
|
+
description: "Each referenced player exiles cards from the top of their library until they exile a " \
|
|
177
|
+
"card matching `until` (or the library is empty). All exiled cards are the result.",
|
|
178
|
+
rules: %w[406.1 701.13a 400.7j], params: { "until" => UNTIL.join(" | "), "to" => "selector" },
|
|
179
|
+
example: { "exile_until" => { "until" => "nonland", "to" => { "target" => 0 } } }
|
|
180
|
+
|
|
181
|
+
def self.keys = %w[until to if]
|
|
182
|
+
def self.default_to = "you"
|
|
183
|
+
|
|
184
|
+
def setup(params, errors, path)
|
|
185
|
+
@until = Mechanics.fetch_param(params, :until)
|
|
186
|
+
errors << "#{path}.until: must be one of #{UNTIL.join(", ")}" unless UNTIL.include?(@until)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def to_s = "exile from the top until a #{@until} card → #{to}"
|
|
190
|
+
|
|
191
|
+
def apply(engine, ctx)
|
|
192
|
+
players(engine, ctx).each do |index|
|
|
193
|
+
library = engine.player(index).library
|
|
194
|
+
until library.empty?
|
|
195
|
+
exiled = engine.move!(library.first, :exile, reason: "exile")
|
|
196
|
+
break if exiled.nil? || !engine.chars(exiled).land?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Permission to play exiled cards for a duration (CR 611.2, 601.3; see Engine::Permissions). With
|
|
203
|
+
# `choose`, the controller first chooses one matching card per owner among them.
|
|
204
|
+
#
|
|
205
|
+
# Who may play them and how (riders):
|
|
206
|
+
# `to_player` "controller" (default), "owner" (each card's owner, CR 108.3) or a player selector
|
|
207
|
+
# `cost_increase` generic mana added to the total cost of a spell cast with this permission (601.2f)
|
|
208
|
+
# `any_mana_type` mana of any type may be spent to cast it (CR 609.4b)
|
|
209
|
+
# Riders like these are stored on the permission (`state.effects` entry) and copied into the play
|
|
210
|
+
# method, so only casts that use this permission (`via: "effect:<timestamp>"`) see them.
|
|
211
|
+
class PlayPermission < ChoiceEffect
|
|
212
|
+
DURATIONS = %w[end_of_turn end_of_your_next_turn while_exiled].freeze
|
|
213
|
+
PLAYERS = %w[controller owner].freeze
|
|
214
|
+
|
|
215
|
+
primitive :effect, :play_permission,
|
|
216
|
+
description: "A player may play (`play: \"play\"`, default) or cast (`\"cast\"`) each referenced " \
|
|
217
|
+
"exiled card matching `for`/`not_types`, paying its costs and following timing rules, " \
|
|
218
|
+
"for `duration`: end_of_turn (default), end_of_your_next_turn or while_exiled. With " \
|
|
219
|
+
"`choose: \"one_per_owner\"` only one chosen card per owner. `to_player`: who may " \
|
|
220
|
+
"play it, \"controller\" (default, the effect's controller), \"owner\" (the card's " \
|
|
221
|
+
"owner) or a player selector (a face-down card: only someone who may look at it). " \
|
|
222
|
+
"`cost_increase`: spells cast this way cost that much generic mana more. " \
|
|
223
|
+
"`any_mana_type: true`: mana of any type can be spent to cast those spells.",
|
|
224
|
+
rules: %w[611.2a 601.3 305.1 400.7 514.2 601.2f 108.3 406.3b 609.4b],
|
|
225
|
+
params: { "to" => "selector (usually {\"result\": n})", "duration" => DURATIONS.join(" | "),
|
|
226
|
+
"play" => "play | cast", "for" => "card spec", "not_types" => "list of card types",
|
|
227
|
+
"choose" => "one_per_owner", "to_player" => "controller | owner | player selector",
|
|
228
|
+
"cost_increase" => "integer >= 1", "any_mana_type" => "boolean",
|
|
229
|
+
"exile_instead" => "true or a card spec: if a spell cast this way (matching it) would be put " \
|
|
230
|
+
"into a graveyard, it's exiled instead (CR 614.1a)" },
|
|
231
|
+
example: { "play_permission" => { "to" => { "result" => 0 }, "duration" => "end_of_your_next_turn" } }
|
|
232
|
+
|
|
233
|
+
def self.keys = %w[to duration play for not_types choose to_player cost_increase any_mana_type exile_instead if]
|
|
234
|
+
|
|
235
|
+
def setup(params, errors, path)
|
|
236
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
237
|
+
durations = DURATIONS
|
|
238
|
+
errors << "#{path}.duration: must be one of #{durations.join(", ")}" unless durations.include?(@duration)
|
|
239
|
+
@play = Mechanics.fetch_param(params, :play) || "play"
|
|
240
|
+
errors << "#{path}.play: must be play or cast" unless %w[play cast].include?(@play)
|
|
241
|
+
@choose = Mechanics.fetch_param(params, :choose)
|
|
242
|
+
errors << "#{path}.choose: must be one_per_owner" unless [nil, "one_per_owner"].include?(@choose)
|
|
243
|
+
@filter = CardFilter.build(params, errors, path)
|
|
244
|
+
@to_player = player_param(Mechanics.fetch_param(params, :to_player) || "controller", errors, path)
|
|
245
|
+
@riders = riders(params, errors, path)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def to_s
|
|
249
|
+
who = { "controller" => "you", "owner" => "its owner" }.fetch(@to_player) { @to_player.to_s }
|
|
250
|
+
more = @riders["cost_increase"] ? ", costs {#{@riders["cost_increase"]}} more" : ""
|
|
251
|
+
more += ", mana of any type" if @riders["any_mana_type"]
|
|
252
|
+
more += ", #{ExileInstead.words(@riders["exile_instead"])}" if @riders["exile_instead"]
|
|
253
|
+
"#{who} may #{@play} #{@filter} (#{@duration.tr("_", " ")}#{more}) → #{to}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def run(engine, ctx, locals)
|
|
257
|
+
return :done unless locals["cards"] || condition.nil? || condition.true?(engine, ctx)
|
|
258
|
+
|
|
259
|
+
cards = (locals["cards"] ||= refs(engine, ctx).select { exiled_match?(engine, it) })
|
|
260
|
+
picks = @choose ? choose(engine, ctx, locals, cards) : cards
|
|
261
|
+
return :wait if picks == :wait
|
|
262
|
+
|
|
263
|
+
picks.flat_map { |id| players_for(engine, ctx, id).map { [it, id] } }.group_by(&:first).each do |index, pairs|
|
|
264
|
+
engine.grant_play_permission!(index, pairs.map(&:last), duration: @duration, play: @play, **@riders)
|
|
265
|
+
end
|
|
266
|
+
:done
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def player_param(raw, errors, path)
|
|
272
|
+
return raw if PLAYERS.include?(raw)
|
|
273
|
+
|
|
274
|
+
Selector.build(raw, errors, "#{path}.to_player", targets_count: @targets_count, results_count: @results_count)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def riders(params, errors, path)
|
|
278
|
+
increase = Mechanics.fetch_param(params, :cost_increase)
|
|
279
|
+
ok = increase.nil? || (increase.is_a?(Integer) && increase.positive?)
|
|
280
|
+
errors << "#{path}.cost_increase: must be an integer >= 1" unless ok
|
|
281
|
+
any_type = Mechanics.flag_param(params, :any_mana_type, errors, path)
|
|
282
|
+
{ "cost_increase" => increase, "any_mana_type" => (true if any_type),
|
|
283
|
+
"exile_instead" => ExileInstead.build(params, errors, path) }.compact
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The players who may play `id`: the effect's controller, the card's owner, or the selected players.
|
|
287
|
+
def players_for(engine, ctx, id)
|
|
288
|
+
case @to_player
|
|
289
|
+
when "controller" then [ctx.controller]
|
|
290
|
+
when "owner" then [engine.object(id).owner]
|
|
291
|
+
else @to_player.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.uniq
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# A face-down card is judged face up (the permission is to play it face up, CR 406.3a).
|
|
296
|
+
def exiled_match?(engine, id)
|
|
297
|
+
obj = engine.state.objects[id]
|
|
298
|
+
obj && obj.zone == :exile && engine.face_up(id) { @filter.match?(engine, obj) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# One card per owner, in seat order; asked only when there's a choice.
|
|
302
|
+
def choose(engine, ctx, locals, cards)
|
|
303
|
+
asked = 0
|
|
304
|
+
cards.group_by { engine.object(it).owner }.sort.map do |_owner, group|
|
|
305
|
+
next group.first if group.size == 1
|
|
306
|
+
|
|
307
|
+
answer = answers(locals)[asked]
|
|
308
|
+
asked += 1
|
|
309
|
+
next answer["object"] if answer
|
|
310
|
+
|
|
311
|
+
return ask(engine, locals, ctx.controller, "Choose a card (#{@filter}) you may play",
|
|
312
|
+
group.map { { "type" => "choose", "object" => it } })
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Optional casting without paying mana costs during resolution (CR 608.2g, 118.9, 601.2a):
|
|
318
|
+
# the controller picks a card, casts it right away (a cast frame on top of the resolving one; nobody
|
|
319
|
+
# gets priority), then the resolution goes on. `count: "any"` repeats until they stop.
|
|
320
|
+
class CastFree < ChoiceEffect
|
|
321
|
+
primitive :effect, :cast_free,
|
|
322
|
+
description: "The controller may cast referenced cards without paying their mana costs while this " \
|
|
323
|
+
"resolves. `to` names the cards ({\"result\": n}), or a player for the cards in their " \
|
|
324
|
+
"hand (default: your hand). Only cards matching `for`/`not_types` with mana value up to " \
|
|
325
|
+
"`max_mana_value` (optional amount) that could be cast now (legal targets). `count`: 1 " \
|
|
326
|
+
"(default) or \"any\" (any number, one at a time). X is 0 (CR 107.3b). `play: true` " \
|
|
327
|
+
"(\"you may play\"): a land among them may be played, on your turn with a land play " \
|
|
328
|
+
"left (`{type: \"play_land\"}`). A face-down card is judged face up.",
|
|
329
|
+
rules: %w[608.2g 118.9 601.2a 107.3b 118.6a 305.2a 305.3 406.3a],
|
|
330
|
+
params: { "to" => "selector", "for" => "card spec", "not_types" => "list of card types",
|
|
331
|
+
"max_mana_value" => "amount", "count" => "1 or \"any\"", "play" => "boolean",
|
|
332
|
+
"exile_instead" => "true or a card spec: if a spell cast this way (matching it) would be put " \
|
|
333
|
+
"into a graveyard, it's exiled instead (CR 614.1a)",
|
|
334
|
+
"any_mana_type" => "boolean: mana of any type can be spent to cast it (CR 609.4b)" },
|
|
335
|
+
example: { "cast_free" => { "to" => { "result" => 0 }, "max_mana_value" => 3, "count" => "any" } }
|
|
336
|
+
|
|
337
|
+
def self.keys = %w[to for not_types max_mana_value count play exile_instead any_mana_type if]
|
|
338
|
+
def self.default_to = "you"
|
|
339
|
+
|
|
340
|
+
def setup(params, errors, path)
|
|
341
|
+
@filter = CardFilter.build(params, errors, path)
|
|
342
|
+
any_type = Mechanics.flag_param(params, :any_mana_type, errors, path)
|
|
343
|
+
@riders = { "exile_instead" => ExileInstead.build(params, errors, path), "any_mana_type" => (true if any_type) }.compact
|
|
344
|
+
raw = Mechanics.fetch_param(params, :max_mana_value)
|
|
345
|
+
@max = raw.nil? ? nil : Amount.build(raw, errors, "#{path}.max_mana_value", targets_count: @targets_count)
|
|
346
|
+
@count = Mechanics.fetch_param(params, :count) || 1
|
|
347
|
+
errors << "#{path}.count: must be 1 or \"any\"" unless [1, "any"].include?(@count)
|
|
348
|
+
@play = Mechanics.flag_param(params, :play, errors, path)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def to_s
|
|
352
|
+
more = @riders["any_mana_type"] ? ", mana of any type" : ""
|
|
353
|
+
more += ", #{ExileInstead.words(@riders["exile_instead"])}" if @riders["exile_instead"]
|
|
354
|
+
"you may cast #{@count == "any" ? "any number of" : "a"} #{@filter} free#{more} → #{to}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def run(engine, ctx, locals)
|
|
358
|
+
return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
|
|
359
|
+
|
|
360
|
+
index = ctx.controller
|
|
361
|
+
return :wait if locals["playing"] && play_land!(engine, index, locals) == :wait
|
|
362
|
+
|
|
363
|
+
settle_cast!(engine, locals)
|
|
364
|
+
answer = answers(locals)[locals.fetch("seen", 0)]
|
|
365
|
+
if answer
|
|
366
|
+
locals["seen"] = locals.fetch("seen", 0) + 1
|
|
367
|
+
return :done if answer["type"] == "done"
|
|
368
|
+
return start_cast!(engine, index, answer["object"], locals) unless answer["type"] == "play_land"
|
|
369
|
+
|
|
370
|
+
engine.object(answer["object"]).memory.delete("face_down") # turned face up to be played (CR 406.3a)
|
|
371
|
+
locals["playing"] = answer["object"]
|
|
372
|
+
return :wait if play_land!(engine, index, locals) == :wait
|
|
373
|
+
end
|
|
374
|
+
return :done if @count == 1 && locals["cast"].to_i >= 1
|
|
375
|
+
|
|
376
|
+
choices = candidates(engine, ctx, locals)
|
|
377
|
+
return :done if choices.empty?
|
|
378
|
+
|
|
379
|
+
ask(engine, locals, index, prompt, choices + [{ "type" => "done" }])
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# How the cards are cast (Engine::Permissions#play_method); Effects::Cast pays the costs instead.
|
|
383
|
+
def via = "free"
|
|
384
|
+
def prompt = "You may cast a spell without paying its mana cost"
|
|
385
|
+
def castable_now?(engine, index, id) = engine.free_castable?(index, id)
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
# After the cast frame: the card left its zone (it was cast), or it's back (cancelled: declined).
|
|
390
|
+
def settle_cast!(engine, locals)
|
|
391
|
+
id = locals.delete("casting") or return
|
|
392
|
+
engine.state.objects.key?(id) ? (locals["declined"] ||= []) << id : locals["cast"] = locals["cast"].to_i + 1
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# `{"type" => "cast" | "play", "object" => id}` for each card that may be cast (or, with `play`, a land
|
|
396
|
+
# played) now; a face-down card is judged face up (CR 406.3a).
|
|
397
|
+
def candidates(engine, ctx, locals)
|
|
398
|
+
limit = @max&.value(engine, ctx)
|
|
399
|
+
index = ctx.controller
|
|
400
|
+
cards(engine, ctx).filter_map do |id|
|
|
401
|
+
next if locals.fetch("declined", []).include?(id)
|
|
402
|
+
|
|
403
|
+
engine.face_up(id) do
|
|
404
|
+
obj = engine.object(id)
|
|
405
|
+
next unless @filter.match?(engine, obj) && (limit.nil? || engine.chars(obj).mana_cost&.mana_value.to_i <= limit)
|
|
406
|
+
|
|
407
|
+
if engine.chars(obj).land? then { "type" => "play_land", "object" => id } if @play && land_play?(engine, index)
|
|
408
|
+
elsif castable_now?(engine, index, id) then { "type" => "cast", "object" => id }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def start_cast!(engine, index, id, locals)
|
|
415
|
+
locals["casting"] = id
|
|
416
|
+
engine.begin_cast!(index, id, via: via, riders: @riders)
|
|
417
|
+
:wait
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# CR 305.2a, 305.3: a land is played during resolution only on your turn, with a land play left.
|
|
421
|
+
def land_play?(engine, index) = engine.state.active == index && engine.land_play_available?(index)
|
|
422
|
+
|
|
423
|
+
# Plays the land (CR 305.1) after its "as this enters" choice (CR 614.12); it counts as a played card.
|
|
424
|
+
def play_land!(engine, index, locals)
|
|
425
|
+
id = locals["playing"]
|
|
426
|
+
return :wait if engine.ask_entering_choices!(locals, [[id, index]]) == :wait
|
|
427
|
+
|
|
428
|
+
locals.delete("playing")
|
|
429
|
+
locals["cast"] = locals["cast"].to_i + 1
|
|
430
|
+
engine.player(index).lands_played += 1
|
|
431
|
+
engine.put_played_land!(index, id, enters: engine.with_entering_answer(locals, id, index, nil))
|
|
432
|
+
:done
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Referenced cards still in the zone they were put in; a player stands for their hand.
|
|
436
|
+
def cards(engine, ctx)
|
|
437
|
+
refs(engine, ctx).flat_map do |ref|
|
|
438
|
+
index = Filters.player_index(engine, ref)
|
|
439
|
+
next engine.player(index).hand if index
|
|
440
|
+
next [] unless engine.state.objects.key?(ref)
|
|
441
|
+
|
|
442
|
+
%i[stack battlefield].include?(engine.object(ref).zone) ? [] : [ref]
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Discover N (CR 701.57a): exile from the top until a nonland card with mana value N or less; cast it
|
|
448
|
+
# without paying its mana cost or put it into your hand; the rest go on the bottom in a random order.
|
|
449
|
+
class Discover < ChoiceEffect
|
|
450
|
+
primitive :effect, :discover,
|
|
451
|
+
description: "Discover `amount`: exile cards from the top of your library until you exile a nonland " \
|
|
452
|
+
"card with mana value `amount` or less. You may cast it without paying its mana cost; " \
|
|
453
|
+
"if you don't, it goes into your hand. The other exiled cards go on the bottom of " \
|
|
454
|
+
"your library in a random order.",
|
|
455
|
+
rules: %w[701.57a 701.57b 701.57c 608.2g 118.9],
|
|
456
|
+
params: { "amount" => "amount" },
|
|
457
|
+
example: { "discover" => { "amount" => 5 } }
|
|
458
|
+
|
|
459
|
+
def self.keys = %w[amount if]
|
|
460
|
+
def self.default_to = "you"
|
|
461
|
+
|
|
462
|
+
def run(engine, ctx, locals)
|
|
463
|
+
return :done unless locals["exiled"] || condition.nil? || condition.true?(engine, ctx)
|
|
464
|
+
|
|
465
|
+
index = ctx.controller
|
|
466
|
+
exile_until_hit!(engine, ctx, locals) unless locals["exiled"]
|
|
467
|
+
hit = locals["hit"]
|
|
468
|
+
return :wait if hit && decide(engine, index, locals, hit) == :wait
|
|
469
|
+
|
|
470
|
+
rest = locals["exiled"].select { engine.state.exile.include?(it) } # all but a card cast or kept
|
|
471
|
+
engine.state.rng.shuffle!(rest)
|
|
472
|
+
rest.each { engine.move!(it, :library, position: :bottom, reason: "discover") }
|
|
473
|
+
:done
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
private
|
|
477
|
+
|
|
478
|
+
# The greatest mana value a hit may have.
|
|
479
|
+
def limit(engine, ctx) = amount_for(engine, ctx)
|
|
480
|
+
|
|
481
|
+
# What happens to a hit that isn't cast: an option type, a prompt, and the move.
|
|
482
|
+
def miss_type = "hand"
|
|
483
|
+
def prompt = "Discover: cast it without paying its mana cost, or put it into your hand"
|
|
484
|
+
def miss!(engine, hit) = engine.move!(hit, :hand, reason: "discover")
|
|
485
|
+
|
|
486
|
+
def exile_until_hit!(engine, ctx, locals)
|
|
487
|
+
n = limit(engine, ctx)
|
|
488
|
+
library = engine.player(ctx.controller).library
|
|
489
|
+
locals["exiled"] = []
|
|
490
|
+
until library.empty?
|
|
491
|
+
card = engine.move!(library.first, :exile, reason: "discover") or break
|
|
492
|
+
locals["exiled"] << card.id
|
|
493
|
+
c = engine.chars(card)
|
|
494
|
+
next if c.land? || c.mana_cost&.mana_value.to_i > n
|
|
495
|
+
|
|
496
|
+
locals["hit"] = card.id
|
|
497
|
+
break
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Cast it (a frame), or not (`miss!`); a cancelled cast counts as not casting it.
|
|
502
|
+
def decide(engine, index, locals, hit)
|
|
503
|
+
return if locals["decided"]
|
|
504
|
+
|
|
505
|
+
if locals.key?("casting")
|
|
506
|
+
locals.delete("casting")
|
|
507
|
+
else
|
|
508
|
+
answer = answers(locals).first
|
|
509
|
+
unless answer
|
|
510
|
+
choices = [{ "type" => miss_type, "object" => hit }]
|
|
511
|
+
choices.unshift({ "type" => "cast", "object" => hit }) if engine.free_castable?(index, hit)
|
|
512
|
+
return ask(engine, locals, index, prompt, choices)
|
|
513
|
+
end
|
|
514
|
+
if answer["type"] == "cast"
|
|
515
|
+
locals["casting"] = hit
|
|
516
|
+
engine.begin_cast!(index, hit, via: "free")
|
|
517
|
+
return :wait
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
locals["decided"] = true
|
|
521
|
+
miss!(engine, hit) if engine.state.objects.key?(hit)
|
|
522
|
+
nil
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|