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,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Playing cards from zones other than the hand (CR 601.3, 116.2a, 305.1): a card's own permission
|
|
6
|
+
# to be cast from the graveyard or exile, impulse draw (exiled cards playable for a while),
|
|
7
|
+
# beam me up (CR 702.196) and casting during resolution without paying the mana cost (CR 608.2g,
|
|
8
|
+
# 118.9).
|
|
9
|
+
#
|
|
10
|
+
# A way to play a card is a *method*, plain data carried by the priority option (`via`) and by the
|
|
11
|
+
# cast frame (`"method"`):
|
|
12
|
+
# {"via" => "effect:12" | "self:printed:1" | "static:<permanent>:<ref>" | "beam:<ref>" |
|
|
13
|
+
# "beam_granted:<permanent>:<ref>" | "free",
|
|
14
|
+
# "play" => "cast" | "play", # "play" also allows a land to be played (CR 305.1)
|
|
15
|
+
# "alt" => nil | {"free" => true} | {"beam" => "{3}{W}", "not" => id}, # alternative cost (CR 118.9)
|
|
16
|
+
# "enters_with" => {counter => n}, # "if you cast it this way, it enters with ..."
|
|
17
|
+
# "once" => {"source" => id, "ref" => ref}, # "once during each of your turns"
|
|
18
|
+
# "cost_increase" => n} # riders of a permission effect (PlayPermission), e.g. 601.2f
|
|
19
|
+
#
|
|
20
|
+
# Permissions granted by resolved effects are `state.effects` entries of kind "permission"
|
|
21
|
+
# (see #grant_play_permission!); the others come from abilities (Abilities::PlayFrom, BeamMeUp).
|
|
22
|
+
#
|
|
23
|
+
# Cards outside the game (CR 400.11) aren't objects: a player's are definition ids in
|
|
24
|
+
# PlayerState#outside. Options name one as `"outside:<card id>"` (#outside_key); legality checks use a
|
|
25
|
+
# detached GameObject in the pseudo-zone :outside that is never stored (#outside_object), and the
|
|
26
|
+
# object is created only when the card is brought into the game (#bring_in_from_outside!).
|
|
27
|
+
module Permissions
|
|
28
|
+
FREE = { "free" => true }.freeze
|
|
29
|
+
ZERO = Mana::Cost.new(generic: 0, colored: {}.freeze)
|
|
30
|
+
BEAM_COST_TEXT = "Return a creature you control to its owner's hand"
|
|
31
|
+
OUTSIDE = "outside:"
|
|
32
|
+
# Permission effect fields copied into the play method (see Effects::PlayPermission); "alt" is an
|
|
33
|
+
# alternative cost, {"cost" => "{2}"} (airbend, CR 701.65a, 118.9).
|
|
34
|
+
RIDERS = %w[cost_increase any_mana_type alt exile_instead].freeze
|
|
35
|
+
|
|
36
|
+
# ---- legal actions --------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# Cards `index` might play by a permission or an alternative cost: their hand (alternative costs such
|
|
39
|
+
# as warp, Engine::AltCasting), graveyard, every exiled card and their library's top card (deterministic
|
|
40
|
+
# order: hand, graveyard, exile, library), then the cards they own outside the game (one key per
|
|
41
|
+
# distinct card) while something lets them play those.
|
|
42
|
+
def permission_candidates(index, sources = permission_sources(index))
|
|
43
|
+
pl = player(index)
|
|
44
|
+
ids = alt_cost_hand(pl) + pl.graveyard + state.exile
|
|
45
|
+
ids += [pl.library.first] unless pl.library.empty?
|
|
46
|
+
return ids unless sources.any? { |_, inst| inst.ability.kind == :play_from && inst.ability.outside? }
|
|
47
|
+
|
|
48
|
+
ids + pl.outside.uniq.map { outside_key(it) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Priority options: `{type: "cast", object: id, via: method}` for every castable card and method.
|
|
52
|
+
def permission_cast_options(index)
|
|
53
|
+
sources = permission_sources(index)
|
|
54
|
+
permission_candidates(index, sources).flat_map do |ref|
|
|
55
|
+
obj = candidate_object(index, ref)
|
|
56
|
+
play_methods(index, obj, sources).filter_map do |method|
|
|
57
|
+
{ type: "cast", object: ref, via: method["via"] } if castable_via?(index, obj, method)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Priority options: lands `index` may play from outside their hand (CR 305.1, 305.2).
|
|
63
|
+
def permission_land_options(index)
|
|
64
|
+
sources = permission_sources(index)
|
|
65
|
+
permission_candidates(index, sources).flat_map do |ref|
|
|
66
|
+
obj = candidate_object(index, ref)
|
|
67
|
+
next [] unless face_up(obj.id) { chars(obj).land? } # a face-down card is played face up (CR 406.3a)
|
|
68
|
+
|
|
69
|
+
play_methods(index, obj, sources).filter_map do |method|
|
|
70
|
+
{ type: "play_land", object: ref, via: method["via"] } if method["play"] == "play"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ---- cards outside the game (CR 400.11, 400.11b, 108.3) -------------------------------------
|
|
76
|
+
|
|
77
|
+
def outside_key(card) = "#{OUTSIDE}#{card}"
|
|
78
|
+
def outside_key?(ref) = ref.is_a?(String) && ref.start_with?(OUTSIDE)
|
|
79
|
+
def outside_card(ref) = ref.delete_prefix(OUTSIDE)
|
|
80
|
+
|
|
81
|
+
# A detached object standing for a card `index` owns outside the game, for legality checks only.
|
|
82
|
+
def outside_object(index, card)
|
|
83
|
+
GameObject.new(id: outside_key(card), card: card, owner: index, controller: index, zone: :outside, tapped: false,
|
|
84
|
+
controlled_since: nil, kind: "card", counters: {}, damage: 0, source: nil, ability: nil,
|
|
85
|
+
targets: nil, loyalty_turn: nil, deathtouched: false, modes: nil, memory: {}, attached_to: nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The object an option names: a game object, or the detached stand-in of a card outside the game.
|
|
89
|
+
def candidate_object(index, ref) = outside_key?(ref) ? outside_object(index, outside_card(ref)) : object(ref)
|
|
90
|
+
|
|
91
|
+
# CR 400.11b, 108.3: one copy of `card` leaves `index`'s outside list and enters the game in `to` as a
|
|
92
|
+
# new object they own, through the usual entering replacements (CR 614.12) when it's the battlefield.
|
|
93
|
+
def bring_in_from_outside!(index, card, to, reason:, enters: nil)
|
|
94
|
+
list = player(index).outside
|
|
95
|
+
at = list.index(card) or raise ArgumentError, "#{name(index)} has no #{card} outside the game"
|
|
96
|
+
list.delete_at(at)
|
|
97
|
+
obj = create_object(card, index, to)
|
|
98
|
+
data = { "object" => obj.id, "to" => to, "controller" => index, "reason" => reason }
|
|
99
|
+
if to == :battlefield
|
|
100
|
+
data["enters"] = { "tapped" => enters&.fetch("tapped", false) == true, "counters" => {} }
|
|
101
|
+
data = propose("zone_change", data).data
|
|
102
|
+
merge_enters!(data["enters"], enters.except("tapped")) if enters
|
|
103
|
+
end
|
|
104
|
+
arrive!(obj, obj, data)
|
|
105
|
+
state.zone_list(to, index).unshift(obj.id)
|
|
106
|
+
record_result!(obj.id)
|
|
107
|
+
emit Events::ZoneChanged.new(card: card, old_id: nil, new_id: obj.id, owner: name(index), from: "outside",
|
|
108
|
+
to: to.to_s, reason: reason)
|
|
109
|
+
obj
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# CR 601.2a: a spell cast from outside the game is brought into the game onto the stack.
|
|
113
|
+
def begin_outside_cast!(index, ref, via)
|
|
114
|
+
method = play_method(index, ref, via)
|
|
115
|
+
card = outside_card(ref)
|
|
116
|
+
origin = { "zone" => "outside", "card" => card, "index" => player(index).outside.index(card) }
|
|
117
|
+
obj = bring_in_from_outside!(index, card, :stack, reason: "cast")
|
|
118
|
+
start_casting!(index, obj.id, "spell", origin: origin, extra: { "method" => method })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# CR 305.1: a land played (from a zone or from outside the game) is put onto the battlefield.
|
|
122
|
+
def put_played_land!(index, ref, enters: nil)
|
|
123
|
+
return bring_in_from_outside!(index, outside_card(ref), :battlefield, reason: "play_land", enters: enters) if outside_key?(ref)
|
|
124
|
+
|
|
125
|
+
move!(ref, :battlefield, controller: index, reason: "play_land", enters: enters)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Permanents `index` controls whose static abilities let them play cards from other zones:
|
|
129
|
+
# [[permanent, AbilityInstance], ...].
|
|
130
|
+
def permission_sources(index)
|
|
131
|
+
state.battlefield.flat_map do |id|
|
|
132
|
+
obj = object(id)
|
|
133
|
+
next [] unless obj.controller == index
|
|
134
|
+
|
|
135
|
+
chars(obj).abilities.filter_map do |inst|
|
|
136
|
+
[obj, inst] if inst.ability.kind == :grants_beam_me_up || (inst.ability.kind == :play_from && !inst.ability.self?)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Every method `index` has to play the card `id` (an object, its id or an outside key) now (before
|
|
142
|
+
# timing and cost checks).
|
|
143
|
+
def play_methods(index, id, sources = permission_sources(index))
|
|
144
|
+
obj = id.is_a?(GameObject) ? id : candidate_object(index, id)
|
|
145
|
+
granted_methods(index, obj) + own_methods(index, obj) + static_methods(index, obj, sources) +
|
|
146
|
+
beam_methods(index, obj, sources) + alt_methods(index, obj)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The method named `via`, or an error (the option was offered from this list).
|
|
150
|
+
def play_method(index, id, via)
|
|
151
|
+
return { "via" => "free", "play" => "cast", "alt" => FREE } if via == "free"
|
|
152
|
+
return { "via" => "resolving", "play" => "cast" } if via == "resolving" # CR 608.2g, 707.12: costs paid
|
|
153
|
+
|
|
154
|
+
play_methods(index, id).find { it["via"] == via } or raise ArgumentError, "#{id} can't be played via #{via}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# CR 601.3: whether `index` may begin to cast the card this way now. Timing still applies outside
|
|
158
|
+
# resolution (CR 304.1, 307.1); a card without a mana cost needs an alternative cost (CR 118.6a).
|
|
159
|
+
def castable_via?(index, id, method)
|
|
160
|
+
obj = id.is_a?(GameObject) ? id : candidate_object(index, id)
|
|
161
|
+
return face_up(obj.id) { castable_via?(index, obj, method) } if face_down?(obj) && @face_up != obj.id # CR 406.3a
|
|
162
|
+
|
|
163
|
+
c = chars(obj)
|
|
164
|
+
return false if c.land? || (method["alt"].nil? && c.mana_cost.nil?) # CR 305.9
|
|
165
|
+
return false unless c.type?(:instant) || c.keyword?(:flash) || method["instant"] || sorcery_timing?(index)
|
|
166
|
+
return false if cast_forbidden?(index, obj) # CR 101.2
|
|
167
|
+
return false if returns_creature?(method["alt"]) && return_candidates(index, method["alt"]).empty?
|
|
168
|
+
return false unless additional_costs_payable?(obj) && alt_parts_payable?(obj, method) # CR 601.2f
|
|
169
|
+
|
|
170
|
+
# CR 609.4b; a stand-in for a card outside the game is passed itself (it isn't in state.objects).
|
|
171
|
+
purpose = { "spell" => state.objects.key?(obj.id) ? obj.id : obj, "any_type" => method["any_mana_type"] }.compact
|
|
172
|
+
ability_targetable?(spell_ability_of(obj), index, obj.id) &&
|
|
173
|
+
cost_variant_affordable?(index, spell_cost_list(obj, false), obj, method_cost(obj, method), nil, purpose) do
|
|
174
|
+
harmonize_reduced(total_cost(index, it, spell: obj), best_harmonize_power(index, method)) # CR 601.2b
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# CR 608.2g: whether a card could be cast now during resolution without paying its mana cost
|
|
179
|
+
# (timing doesn't apply; lands can't be cast, CR 305.9; it needs legal targets, CR 601.2c).
|
|
180
|
+
def free_castable?(index, id)
|
|
181
|
+
obj = object(id)
|
|
182
|
+
obj.kind == "card" && !chars(obj).land? && !cast_forbidden?(index, obj) && additional_costs_payable?(obj) &&
|
|
183
|
+
ability_targetable?(spell_ability_of(obj), index, id)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# CR 608.2g, 707.12: whether a card could be cast now during resolution, paying its costs.
|
|
187
|
+
# `any_mana_type`: mana of any type can be spent to cast it (CR 609.4b).
|
|
188
|
+
def castable_while_resolving?(index, id, any_mana_type: nil)
|
|
189
|
+
obj = object(id)
|
|
190
|
+
own = chars(obj).mana_cost or return false # CR 118.6a: no mana cost, can't be cast paying it
|
|
191
|
+
purpose = { "spell" => id, "any_type" => any_mana_type }.compact
|
|
192
|
+
free_castable?(index, id) &&
|
|
193
|
+
cost_variant_affordable?(index, spell_cost_list(obj, false), obj, own, nil, purpose) { total_cost(index, it, spell: obj) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The mana cost paid with a method: the card's own, nothing (CR 118.9) or the beam me up cost, plus a
|
|
197
|
+
# permission's cost increase ("spells cast this way cost {2} more", CR 601.2f).
|
|
198
|
+
def method_cost(obj, method)
|
|
199
|
+
alt = method && method["alt"]
|
|
200
|
+
cost = if alt.nil? then chars(obj).mana_cost
|
|
201
|
+
elsif alt["free"] then ZERO
|
|
202
|
+
else Mana::Cost.parse(alt["beam"] || alt["cost"]) # beam me up, or a keyword's (Engine::AltCasting)
|
|
203
|
+
end
|
|
204
|
+
increase = method && method["cost_increase"]
|
|
205
|
+
increase && cost ? cost.with(generic: cost.generic + increase) : cost
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Costs#base_cost for a spell cast with an alternative cost (CR 118.9c: its mana cost is unchanged).
|
|
209
|
+
def alternative_base_cost(ctx) = method_cost(object(ctx["object"]), ctx["method"])
|
|
210
|
+
|
|
211
|
+
# ---- where methods come from ------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
# Permissions from resolved effects (impulse draw, CR 611.2): the card is still the exiled object. A
|
|
214
|
+
# face-down card only for a player who may look at it (CR 406.3b). `any_mana_type`: mana of any type
|
|
215
|
+
# can be spent to cast it (CR 609.4b).
|
|
216
|
+
def granted_methods(index, obj)
|
|
217
|
+
state.effects.filter_map do |e|
|
|
218
|
+
next unless e["kind"] == "permission" && e["controller"] == index && e["affected"].include?(obj.id)
|
|
219
|
+
next unless obj.zone == :exile && may_look?(obj, index)
|
|
220
|
+
|
|
221
|
+
{ "via" => "effect:#{e["timestamp"]}", "play" => e["play"], **e.slice(*RIDERS) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The card's own permission to be cast from its graveyard or exile (CR 113.6e-f): its owner's.
|
|
226
|
+
def own_methods(index, obj)
|
|
227
|
+
return [] unless obj.owner == index
|
|
228
|
+
|
|
229
|
+
chars(obj).abilities.filter_map do |inst|
|
|
230
|
+
ability = inst.ability
|
|
231
|
+
next unless ability.kind == :play_from && ability.self? && ability.holds?(self, obj)
|
|
232
|
+
|
|
233
|
+
{ "via" => "self:#{inst.ref}", "play" => ability.play, "enters_with" => ability.enters_with }.compact
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Static abilities of permanents `index` controls, for cards they own in the named zone.
|
|
238
|
+
def static_methods(index, obj, sources)
|
|
239
|
+
return [] unless obj.owner == index
|
|
240
|
+
|
|
241
|
+
sources.filter_map do |perm, inst|
|
|
242
|
+
ability = inst.ability
|
|
243
|
+
next unless ability.kind == :play_from && ability.holds?(self, obj) && ability.cards.match?(self, obj)
|
|
244
|
+
next if ability.once && (state.active != index || once_used?(perm, inst.ref))
|
|
245
|
+
|
|
246
|
+
{ "via" => "static:#{perm.id}:#{inst.ref}", "play" => ability.play, "enters_with" => ability.enters_with,
|
|
247
|
+
"once" => ability.once ? { "source" => perm.id, "ref" => inst.ref } : nil }.compact
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# CR 702.196a: beam me up functions from the graveyard, for an instant or sorcery card. A permanent
|
|
252
|
+
# may grant it during its controller's main phase with the card's mana cost as the cost.
|
|
253
|
+
def beam_methods(index, obj, sources)
|
|
254
|
+
c = chars(obj)
|
|
255
|
+
return [] unless obj.zone == :graveyard && obj.owner == index && (c.type?(:instant) || c.type?(:sorcery))
|
|
256
|
+
|
|
257
|
+
own = c.abilities.filter_map do |inst|
|
|
258
|
+
next unless inst.ability.kind == :beam_me_up
|
|
259
|
+
|
|
260
|
+
{ "via" => "beam:#{inst.ref}", "play" => "cast", "alt" => { "beam" => inst.ability.cost.to_s } }
|
|
261
|
+
end
|
|
262
|
+
own + granted_beams(index, c, sources)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def granted_beams(index, chars, sources)
|
|
266
|
+
return [] unless chars.mana_cost && state.active == index && Steps::MAIN_PHASE_STEPS.include?(state.step)
|
|
267
|
+
|
|
268
|
+
sources.filter_map do |perm, inst|
|
|
269
|
+
next unless inst.ability.kind == :grants_beam_me_up
|
|
270
|
+
|
|
271
|
+
{ "via" => "beam_granted:#{perm.id}:#{inst.ref}", "play" => "cast",
|
|
272
|
+
"alt" => { "beam" => chars.mana_cost.to_s, "not" => perm.id } } # not the granting creature itself
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A once-per-turn permission: the permanent remembers the turn it was last used.
|
|
277
|
+
def once_used?(perm, ref) = perm.memory.fetch("play_from_turns", {})[ref] == state.turn
|
|
278
|
+
|
|
279
|
+
def use_play_method!(method)
|
|
280
|
+
once = method && method["once"] or return
|
|
281
|
+
perm = state.objects[once["source"]] or return
|
|
282
|
+
(perm.memory["play_from_turns"] ||= {})[once["ref"]] = state.turn
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# ---- permissions granted by effects -------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
# Permission to play exiled cards for a duration (CR 611.2): a continuous effect for the exiled
|
|
288
|
+
# objects (a card that leaves exile is a new object, CR 400.7, so the permission ends with it).
|
|
289
|
+
# `riders` (RIDERS) change how a card is played with this permission, and only then.
|
|
290
|
+
def grant_play_permission!(index, ids, duration:, play:, **riders)
|
|
291
|
+
return if ids.empty?
|
|
292
|
+
|
|
293
|
+
state.effects << { "kind" => "permission", "timestamp" => next_timestamp!, "controller" => index,
|
|
294
|
+
"affected" => ids, "duration" => duration, "turn" => state.turn, "play" => play,
|
|
295
|
+
**riders.transform_keys(&:to_s).slice(*RIDERS) }
|
|
296
|
+
emit Events::EffectStarted.new(kind: "permission", affected: ids, details: { "duration" => duration, "play" => play })
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# CR 514.2: at cleanup, an end_of_your_next_turn permission ends on its controller's next turn, and a
|
|
300
|
+
# permission whose cards all left exile is gone. (end_of_turn ones end with the other
|
|
301
|
+
# until-end-of-turn effects.)
|
|
302
|
+
def expire_permissions!
|
|
303
|
+
state.effects.reject! do |e|
|
|
304
|
+
next false unless e["kind"] == "permission"
|
|
305
|
+
|
|
306
|
+
(e["duration"] == "end_of_your_next_turn" && state.active == e["controller"] && state.turn > e["turn"]) ||
|
|
307
|
+
e["affected"].none? { state.exile.include?(it) }
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# ---- beam me up: the creature returned as part of the cost (CR 702.196a-b) ---------------------
|
|
312
|
+
|
|
313
|
+
# Creatures `index` could return: not one that can't be beamed up (CR 702.196b), nor `excluded` (a
|
|
314
|
+
# permanent granting beam me up that says it can't be beamed up to cast a spell its way).
|
|
315
|
+
def beam_candidates(index, excluded = nil)
|
|
316
|
+
state.battlefield.select do |id|
|
|
317
|
+
obj = object(id)
|
|
318
|
+
c = chars(obj)
|
|
319
|
+
obj.controller == index && id != excluded && c.creature? && !c.keyword?(:cant_be_beamed_up)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The choices for the cast in progress.
|
|
324
|
+
def beam_choices(ctx) = return_candidates(ctx["player"], ctx.dig("method", "alt"))
|
|
325
|
+
|
|
326
|
+
# True while the returned creature still has to be chosen (or the chosen one is no longer there).
|
|
327
|
+
def beam_choice_pending?(ctx)
|
|
328
|
+
return false unless returns_creature?(ctx.dig("method", "alt"))
|
|
329
|
+
return false if ctx["beam_creature"] && beam_choices(ctx).include?(ctx["beam_creature"])
|
|
330
|
+
|
|
331
|
+
ctx.delete("beam_creature")
|
|
332
|
+
true
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def ask_beam_choice!(ctx)
|
|
336
|
+
text = AltCasting::RETURN_TEXT.fetch(ctx.dig("method", "alt", "returns"), BEAM_COST_TEXT)
|
|
337
|
+
ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => text)
|
|
338
|
+
:wait
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# CR 601.2h: the creature is chosen now and returned when the spell becomes cast, so cancelling
|
|
342
|
+
# never has to bring a permanent back.
|
|
343
|
+
def choose_beam_creature!(id)
|
|
344
|
+
casting["beam_creature"] = id
|
|
345
|
+
ask!(nil)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# As the spell becomes cast (CR 601.2h-i): remember where it was cast from and how (Engine::AltCasting),
|
|
349
|
+
# return the creature an alternative cost returns, remember the riders and use up a "once each turn"
|
|
350
|
+
# permission.
|
|
351
|
+
def finish_cast_method!(obj, ctx)
|
|
352
|
+
record_cast_how!(obj, ctx)
|
|
353
|
+
method = ctx["method"] or return
|
|
354
|
+
returned_creature!(obj, ctx) if ctx["beam_creature"]
|
|
355
|
+
obj.memory["leaves_to_exile"] = true if method.dig("alt", "beam") || method.dig("alt", "exile")
|
|
356
|
+
# "If that spell would be put into a graveyard, exile it instead" (CR 614.1a)
|
|
357
|
+
obj.memory["graveyard_to_exile"] = true if method["exile_instead"] &&
|
|
358
|
+
Mechanics::ExileInstead.applies?(self, method["exile_instead"], obj)
|
|
359
|
+
obj.memory["enters_with"] = method["enters_with"] if method["enters_with"]
|
|
360
|
+
use_play_method!(method)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Replacement effects of how a spell was cast (CR 614.1a, 614.12): a beamed-up or flashed-back spell
|
|
364
|
+
# is exiled whenever it would leave the stack (CR 702.196a, 702.34a), one cast with an `exile_instead`
|
|
365
|
+
# rider whenever it would be put into a graveyard; a permanent spell cast with an
|
|
366
|
+
# "enters with" rider gets those counters as it enters; a permanent remembers how its spell was cast
|
|
367
|
+
# (CR 400.7d, Engine::AltCasting#alt_enters!).
|
|
368
|
+
def cast_replacements!(proposal)
|
|
369
|
+
data = proposal.data
|
|
370
|
+
obj = state.objects[data["object"]]
|
|
371
|
+
return unless obj && obj.zone == :stack
|
|
372
|
+
|
|
373
|
+
data["to"] = :exile if obj.memory["leaves_to_exile"] && data["to"] != :exile
|
|
374
|
+
data["to"] = :exile if obj.memory["graveyard_to_exile"] && data["to"] == :graveyard
|
|
375
|
+
return unless data["to"] == :battlefield
|
|
376
|
+
|
|
377
|
+
alt_enters!(obj, data["enters"])
|
|
378
|
+
extra = obj.memory["enters_with"] or return
|
|
379
|
+
extra.each { |name, n| data["enters"]["counters"][name] = data["enters"]["counters"].fetch(name, 0) + n }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# A cancelled cast (Casting#cancel_casting!): the card goes back where it came from as the same object
|
|
383
|
+
# (CR 734.1), hand, graveyard, exile or library, at its old position. So a permission naming it applies.
|
|
384
|
+
def restore_to_origin!(obj, origin)
|
|
385
|
+
return player(obj.owner).outside.insert(origin["index"] || 0, obj.card) if origin["zone"] == "outside"
|
|
386
|
+
|
|
387
|
+
zone = origin.fetch("zone", "hand").to_sym
|
|
388
|
+
state.objects[origin["id"]] = GameObject.new(
|
|
389
|
+
id: origin["id"], card: card_after_move(obj, zone), owner: obj.owner, controller: obj.owner, zone: zone, tapped: false,
|
|
390
|
+
controlled_since: nil, kind: "card", counters: {}, damage: 0, source: nil, ability: nil, targets: nil,
|
|
391
|
+
loyalty_turn: nil, deathtouched: false, modes: nil, memory: Snapshot.deep_copy(origin.fetch("memory", {})),
|
|
392
|
+
attached_to: nil # a face-down card goes back face down (CR 406.3)
|
|
393
|
+
)
|
|
394
|
+
carry_copy_status!(state.objects[origin["id"]], obj, zone) # a card copy stays one (Engine::Copies)
|
|
395
|
+
list = state.zone_list(zone, obj.owner)
|
|
396
|
+
list.insert([origin["index"], list.size].min, origin["id"])
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# ---- looking at the top card (CR 401.5) ------------------------------------------------
|
|
400
|
+
|
|
401
|
+
# A permanent letting its controller look at the top card of their library at any time.
|
|
402
|
+
def may_look_at_top?(index)
|
|
403
|
+
state.battlefield.any? do |id|
|
|
404
|
+
obj = object(id)
|
|
405
|
+
obj.controller == index && chars(obj).abilities.any? { it.ability.kind == :look_at_top }
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
include Permissions
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# A variable number of targets for one instance of the word "target" (CR 601.2c, 115.1): "X target
|
|
6
|
+
# creatures", "any number of target players", "up to two target creatures". Such a target spec fills
|
|
7
|
+
# one slot of the targets list with a list of objects: the player first announces how many (a
|
|
8
|
+
# choose_target_count decision, unless the number is fixed), then chooses that many different ones
|
|
9
|
+
# (CR 115.3). {"target": n} then refers to each of them. The number never changes afterwards.
|
|
10
|
+
module TargetCounts
|
|
11
|
+
def counted_target?(spec) = spec.respond_to?(:counted?) && spec.counted?
|
|
12
|
+
|
|
13
|
+
# The slot being filled: the open list while choosing its objects, else the next slot.
|
|
14
|
+
def target_slot(ctx) = ctx["counting"] ? ctx["targets"].size - 1 : ctx["targets"].size
|
|
15
|
+
|
|
16
|
+
# Objects already chosen for the open list (CR 115.3: each at most once).
|
|
17
|
+
def open_target_group(ctx) = ctx["counting"] ? ctx["targets"].last : []
|
|
18
|
+
|
|
19
|
+
# Asks for the next target (or how many), true while waiting; false once every slot is filled.
|
|
20
|
+
def ask_targets!(ctx, specs)
|
|
21
|
+
loop do
|
|
22
|
+
return false if !ctx["counting"] && ctx["targets"].size >= specs.size
|
|
23
|
+
|
|
24
|
+
spec = specs[target_slot(ctx)]
|
|
25
|
+
if ctx["counting"] || !counted_target?(spec)
|
|
26
|
+
ask!("kind" => "choose_target", "player" => ctx["player"], "index" => target_slot(ctx))
|
|
27
|
+
return true
|
|
28
|
+
end
|
|
29
|
+
min, max = target_count_range(ctx, spec)
|
|
30
|
+
if min != max
|
|
31
|
+
ask!("kind" => "choose_target_count", "player" => ctx["player"], "index" => target_slot(ctx), "min" => min,
|
|
32
|
+
"max" => max)
|
|
33
|
+
return true
|
|
34
|
+
end
|
|
35
|
+
start_target_group!(ctx, min)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def target_count_options
|
|
40
|
+
pending = state.pending
|
|
41
|
+
(pending["min"]..pending["max"]).map { { type: "count", count: it } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# CR 601.2c: the number is announced before the targets.
|
|
45
|
+
def choose_target_count!(count)
|
|
46
|
+
start_target_group!(casting, count)
|
|
47
|
+
ask!(nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def start_target_group!(ctx, count)
|
|
51
|
+
ctx["targets"] << []
|
|
52
|
+
ctx["counting"] = count if count.positive?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Records a chosen target: into the open list, or as the next slot.
|
|
56
|
+
def add_chosen_target!(ctx, ref)
|
|
57
|
+
return ctx["targets"] << ref unless ctx["counting"]
|
|
58
|
+
|
|
59
|
+
ctx["targets"].last << ref
|
|
60
|
+
ctx["counting"] -= 1
|
|
61
|
+
ctx.delete("counting") if ctx["counting"].zero?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# [min, max] targets to announce: the spec's count (0 up to it with "up to"; any number with "any"),
|
|
65
|
+
# never more than there are different legal targets, nor (dividing, CR 601.2d) more than the amount.
|
|
66
|
+
# Too few legal targets for an exact count makes the cast illegal: only cancelling is left (CR 734.1).
|
|
67
|
+
# A triggered ability can't be rewound: one whose count turns out larger than the legal targets
|
|
68
|
+
# (an X only known once it is put on the stack) gets as many as there are.
|
|
69
|
+
def target_count_range(ctx, spec)
|
|
70
|
+
count_ctx = Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"], ctx["object"], nil, [])
|
|
71
|
+
min, max = spec.count_range(self, count_ctx)
|
|
72
|
+
available = target_candidates.size
|
|
73
|
+
divide = divided_amount(ctx, casting_ability) # Engine::CastChoices
|
|
74
|
+
return [min, min] if min > available && cancellable?
|
|
75
|
+
|
|
76
|
+
max = [max || available, available, divide].compact.min
|
|
77
|
+
min = 1 if divide&.positive? && max.positive? # CR 601.2d: divided among one or more targets
|
|
78
|
+
[[min, max].min, max]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The fewest targets the spec needs, to tell whether it can be cast or activated now (CR 601.2c);
|
|
82
|
+
# an X not announced yet counts as 0.
|
|
83
|
+
def target_minimum(spec, index, source)
|
|
84
|
+
return 0 if optional_target?(spec)
|
|
85
|
+
return 1 unless counted_target?(spec)
|
|
86
|
+
|
|
87
|
+
spec.count_range(self, Mechanics::EffectContext.new(index, source, nil, nil, [])).first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Each target with the spec it was chosen for, flattening counted slots: [[ref, spec], ...].
|
|
91
|
+
def target_slots(targets, specs)
|
|
92
|
+
targets.each_with_index.flat_map do |ref, i|
|
|
93
|
+
ref.is_a?(Array) ? ref.map { [it, specs[i]] } : [[ref, specs[i]]]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The flat list `flat` shaped like `template` (a copy keeps the number of targets, CR 707.10c).
|
|
98
|
+
def regroup_targets(flat, template)
|
|
99
|
+
flat = flat.dup
|
|
100
|
+
template.map { it.is_a?(Array) ? flat.shift(it.size) : flat.shift }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# CR 115.7d: `player` chose new targets for the spell or ability `spell` (shaped like its old ones).
|
|
104
|
+
def change_targets!(spell, targets, player)
|
|
105
|
+
added = targets.flatten.compact - Array(spell.targets).flatten
|
|
106
|
+
spell.targets = targets
|
|
107
|
+
added.each { emit Events::TargetChosen.new(player: name(player), object: spell.id, target: it) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
include TargetCounts
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Waterbend (CR 701.67, Costs::Waterbend): while a waterbend cost is part of what is being paid, each
|
|
6
|
+
# untapped artifact or creature its payer controls may be tapped to pay {1} of it. Only the waterbend
|
|
7
|
+
# cost's own generic mana can be paid that way (CR 701.67b), so the payment frame keeps how much is
|
|
8
|
+
# left ("waterbend"), and affordability counts at most that many permanents (as generic-only mana units,
|
|
9
|
+
# see ManaSources#mana_units). A purpose ({"spell" => id} or {"ability" => id}) carries the same number
|
|
10
|
+
# under "waterbend". Paying the cost, however it was paid, is waterbending (CR 701.67c).
|
|
11
|
+
module Waterbending
|
|
12
|
+
WATERBEND = Mechanics::Costs::Waterbend
|
|
13
|
+
|
|
14
|
+
# The generic mana tapping may pay among `costs`, with X announced.
|
|
15
|
+
def waterbend_generic(costs, x_value = 0) = costs.grep(WATERBEND).sum { it.generic(x_value) }
|
|
16
|
+
|
|
17
|
+
# A purpose's waterbend part for `costs` ({} when there is none).
|
|
18
|
+
def waterbend_purpose(costs, x_value = 0)
|
|
19
|
+
n = waterbend_generic(costs, x_value)
|
|
20
|
+
n.positive? ? { "waterbend" => n } : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def spell_cost_list(obj, kicked)
|
|
24
|
+
abilities = spell_cost_abilities(obj, :additional_cost)
|
|
25
|
+
abilities += spell_cost_abilities(obj, :kicker) if kicked
|
|
26
|
+
abilities.flat_map(&:costs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# CR 601.2f: as the total cost is locked in, so is the waterbend cost's generic part.
|
|
30
|
+
def lock_waterbend!(ctx)
|
|
31
|
+
costs = frame_costs(ctx) # Engine::CostOptions
|
|
32
|
+
ctx["waterbend"] = waterbend_generic(costs, ctx["x"].to_i) if costs.any?(WATERBEND)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def waterbend_candidates(index, excluded = [])
|
|
36
|
+
state.battlefield.select do |id|
|
|
37
|
+
obj = object(id)
|
|
38
|
+
c = chars(obj)
|
|
39
|
+
obj.controller == index && !obj.tapped && !excluded.include?(id) && (c.creature? || c.type?(:artifact))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generic-only mana units (an empty list of types) for the permanents that could be tapped, capped at the
|
|
44
|
+
# waterbend generic. Mana sources are counted as sources instead.
|
|
45
|
+
def waterbend_units(index, purpose, excluded = [])
|
|
46
|
+
n = purpose&.[]("waterbend").to_i
|
|
47
|
+
return [] unless n.positive?
|
|
48
|
+
|
|
49
|
+
sources = all_mana_sources(index).map(&:first)
|
|
50
|
+
Array.new([waterbend_candidates(index, excluded).count { !sources.include?(it) }, n].min) { [] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# pay_cost options: `{type: "waterbend", object: id}` while generic mana of the waterbend cost is left.
|
|
54
|
+
def waterbend_options
|
|
55
|
+
frame = payment
|
|
56
|
+
return [] unless frame["waterbend"].to_i.positive? && frame["remaining"]["generic"].positive?
|
|
57
|
+
|
|
58
|
+
reserved = payment_reserved(frame)
|
|
59
|
+
waterbend_candidates(frame["player"], reserved + [payment_excluded_source(frame)]).filter_map do |id|
|
|
60
|
+
{ type: "waterbend", object: id } if !frame["irreversible"] || waterbend_keeps_payable?(frame, reserved, id)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def waterbend_keeps_payable?(frame, reserved, id)
|
|
65
|
+
pl = player(frame["player"])
|
|
66
|
+
payable_with?(frame.merge("waterbend" => frame["waterbend"] - 1), remaining_cost(after_paying(frame["remaining"], [:generic])),
|
|
67
|
+
pl.mana_pool.dup, reserved + [id], restricted: pl.restricted_mana)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# CR 701.67a: tapping the permanent pays {1}; cancelling untaps it.
|
|
71
|
+
def waterbend!(id)
|
|
72
|
+
frame = payment
|
|
73
|
+
frame["remaining"] = after_paying(frame["remaining"], [:generic])
|
|
74
|
+
frame["waterbend"] -= 1
|
|
75
|
+
tap!(object(id))
|
|
76
|
+
frame["tapped"] << id
|
|
77
|
+
ask!(nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# CR 701.67c: paying a waterbend cost is waterbending, however it was paid.
|
|
81
|
+
def waterbent!(frame)
|
|
82
|
+
return unless frame.key?("waterbend")
|
|
83
|
+
|
|
84
|
+
emit Events::KeywordAction.new(player: name(frame["player"]), action: "waterbend", amount: nil)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
include Waterbending
|
|
89
|
+
end
|
|
90
|
+
end
|