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,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Paying mana one decision at a time (CR 601.2g-h, 605.3a), shared by casting/activating (a
|
|
6
|
+
# "cast" frame) and costs paid while a spell or ability resolves (a "pay" frame, CR 118.12).
|
|
7
|
+
#
|
|
8
|
+
# The frame holds what is left to pay ("remaining": generic, one key per color, and "hybrid":
|
|
9
|
+
# the hybrid symbols still unpaid, CR 107.4e), the pool before payment started ("pool") and the
|
|
10
|
+
# sources tapped for it ("tapped"), so an unfinished payment can be rewound (CR 734.1). Once a
|
|
11
|
+
# mana ability with a cost other than {T} has been activated (a Treasure's sacrifice), the payment
|
|
12
|
+
# is "irreversible": it can't be cancelled any more, and only choices that keep the rest payable
|
|
13
|
+
# are offered, so the player can't get stuck.
|
|
14
|
+
module Payment
|
|
15
|
+
# The payment in progress: the top frame (a cast or a pay frame while a casting decision is pending).
|
|
16
|
+
def payment = state.frames.last
|
|
17
|
+
|
|
18
|
+
# Whether the process on top can still be rewound with a "cancel" option.
|
|
19
|
+
def cancellable?
|
|
20
|
+
frame = payment
|
|
21
|
+
!frame.nil? && frame["kind"] != "trigger" && !frame["irreversible"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ---- the remaining cost ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def remaining_hash(cost)
|
|
27
|
+
rem = { "generic" => cost&.generic.to_i }
|
|
28
|
+
cost&.colored&.each { |color, n| rem[color.to_s] = n }
|
|
29
|
+
rem["hybrid"] = cost.hybrid.map { |pair| pair.map(&:to_s) } unless cost.nil? || cost.hybrid.empty?
|
|
30
|
+
rem
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remaining_cost(rem)
|
|
34
|
+
colored = rem.except("generic", "hybrid").transform_keys(&:to_sym).reject { |_, n| n.zero? }
|
|
35
|
+
Mana::Cost.new(generic: rem["generic"], colored: colored,
|
|
36
|
+
hybrid: rem.fetch("hybrid", []).map { |pair| pair.map(&:to_sym) })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def remaining_left?(rem) = remaining_cost(rem).mana_value.positive?
|
|
40
|
+
|
|
41
|
+
# What is still to pay, as mana symbols.
|
|
42
|
+
def remaining_text(frame = payment) = remaining_cost(frame["remaining"]).to_s
|
|
43
|
+
|
|
44
|
+
# ---- paying from the pool ----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
# What the payment is for, deciding where restricted mana may be spent (CR 106.6) and whether any
|
|
47
|
+
# mana may pay any symbol (CR 609.4b, a permission's `any_mana_type`): {"spell" => id} or
|
|
48
|
+
# {"ability" => source id}, plus "any_type"; nil for a cost paid while resolving (CR 118.12).
|
|
49
|
+
def payment_purpose(frame)
|
|
50
|
+
waterbend = frame["waterbend"] ? { "waterbend" => frame["waterbend"] } : {} # CR 701.67b (Engine::Waterbending)
|
|
51
|
+
return (waterbend unless waterbend.empty?) unless frame["type"] == "cast"
|
|
52
|
+
|
|
53
|
+
purpose = frame["kind"] == "spell" ? { "spell" => frame["object"] } : { "ability" => frame["source"] }
|
|
54
|
+
purpose["any_type"] = true if frame.dig("method", "any_mana_type")
|
|
55
|
+
purpose.merge(waterbend)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Pool mana that can pay part of the remaining cost (CR 601.2h): `{type: "pay_mana", mana: "R"}`, and
|
|
59
|
+
# `restricted: i` for restricted mana (entry i of the restricted pool) that may be spent here.
|
|
60
|
+
def payable_mana_options
|
|
61
|
+
frame = payment
|
|
62
|
+
pl = player(frame["player"])
|
|
63
|
+
any = payment_purpose(frame)&.[]("any_type") == true
|
|
64
|
+
# Once a mana can go somewhere, some place keeps the rest payable (pay_mana! picks it), so no
|
|
65
|
+
# pool mana can strand an irreversible payment.
|
|
66
|
+
plain = pl.mana_pool.filter_map do |type, n|
|
|
67
|
+
{ type: "pay_mana", mana: type.to_s } if n.positive? && !pay_slots(frame["remaining"], type.to_s, any: any).empty?
|
|
68
|
+
end
|
|
69
|
+
plain + sourced_mana_options(pl, plain) + restricted_mana_options(frame, pl, any)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# When pool mana of a type both remembers a source (an artifact's, CR 106.6) and doesn't, which one is spent
|
|
73
|
+
# matters ("if mana from a Treasure was spent"): `from: i` spends the mana entry i of pool_sources stands for.
|
|
74
|
+
# (Plain `pay_mana` spends mana without a source first.)
|
|
75
|
+
def sourced_mana_options(player, plain)
|
|
76
|
+
types = plain.map { it[:mana] }.select { unsourced_mana(player, it).positive? }
|
|
77
|
+
seen = []
|
|
78
|
+
(player.pool_sources || []).each_with_index.filter_map do |entry, i|
|
|
79
|
+
next if seen.include?(entry) || !types.include?(entry["mana"])
|
|
80
|
+
|
|
81
|
+
seen << entry
|
|
82
|
+
{ type: "pay_mana", mana: entry["mana"], from: i }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One option per distinct restricted mana that may be spent on this payment.
|
|
87
|
+
def restricted_mana_options(frame, player, any)
|
|
88
|
+
purpose = payment_purpose(frame)
|
|
89
|
+
seen = []
|
|
90
|
+
player.restricted_mana.each_with_index.filter_map do |entry, i|
|
|
91
|
+
next if seen.include?(entry) || !spendable?(entry, purpose) ||
|
|
92
|
+
pay_slots(frame["remaining"], entry["mana"], any: any).empty?
|
|
93
|
+
|
|
94
|
+
seen << entry
|
|
95
|
+
{ type: "pay_mana", mana: entry["mana"], restricted: i }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Where one mana of `type` can go: a colored symbol (its own, or any with "any_type"), a hybrid symbol
|
|
100
|
+
# that includes it, generic mana, or half of a {2/B}. [[:color, key], [:hybrid, i], [:generic], [:two_generic, i]].
|
|
101
|
+
def pay_slots(rem, type, any: false)
|
|
102
|
+
slots = (any ? Mana::TYPES.map(&:to_s) : [type]).filter_map { [:color, it] if rem.fetch(it, 0).positive? }
|
|
103
|
+
hybrid = rem.fetch("hybrid", [])
|
|
104
|
+
hybrid.each_with_index { |pair, i| slots << [:hybrid, i] if any || pair.include?(type) }
|
|
105
|
+
slots << [:generic] if rem["generic"].positive?
|
|
106
|
+
# CR 107.4e: other mana pays {2/B} as two generic mana: one now, {1} still to pay.
|
|
107
|
+
hybrid.each_with_index { |pair, i| slots << [:two_generic, i] if Mana.mono_hybrid?(pair) }
|
|
108
|
+
slots
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Spends one mana (restricted entry `restricted`, or a plain one); it pays the first slot that keeps
|
|
112
|
+
# the rest payable (a hybrid symbol the other color could also pay waits for it), deterministically.
|
|
113
|
+
# `from`: the pool_sources entry of the pool mana spent (#sourced_mana_options).
|
|
114
|
+
def pay_mana!(type, restricted = nil, from = nil)
|
|
115
|
+
frame = payment
|
|
116
|
+
pl = player(frame["player"])
|
|
117
|
+
slots = pay_slots(frame["remaining"], type, any: payment_purpose(frame)&.[]("any_type") == true)
|
|
118
|
+
slot = slots.find { completable_after_paying?(frame, type, it, restricted) } || slots.first
|
|
119
|
+
entry = restricted ? pl.restricted_mana.delete_at(restricted) : spend_pool_mana!(pl, type, from)
|
|
120
|
+
frame["remaining"] = after_paying(frame["remaining"], slot)
|
|
121
|
+
(frame["spent"] ||= []) << type # what was spent matters later (sunburst, CR 702.44a; 609.4b: its real type)
|
|
122
|
+
(frame["spent_from"] ||= []) << entry["from"] if entry&.key?("from") # "if mana from a Treasure was spent"
|
|
123
|
+
emit Events::ManaPaid.new(player: name(frame["player"]), mana: "{#{type}}", object: frame["object"])
|
|
124
|
+
ask!(nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Spends one pool mana of `type`: the one pool_sources entry `from` stands for, else one without a source, else
|
|
128
|
+
# one with. Returns the entry of its source, or nil.
|
|
129
|
+
def spend_pool_mana!(player, type, from)
|
|
130
|
+
from ||= (player.pool_sources || []).index { it["mana"] == type } unless unsourced_mana(player, type).positive?
|
|
131
|
+
player.mana_pool[type.to_sym] -= 1
|
|
132
|
+
from && player.pool_sources.delete_at(from)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def after_paying(rem, slot)
|
|
136
|
+
rem = Snapshot.deep_copy(rem)
|
|
137
|
+
case slot.first
|
|
138
|
+
when :color then rem[slot.last] -= 1
|
|
139
|
+
when :hybrid then rem["hybrid"].delete_at(slot.last)
|
|
140
|
+
when :two_generic
|
|
141
|
+
rem["hybrid"].delete_at(slot.last)
|
|
142
|
+
rem["generic"] += 1
|
|
143
|
+
else rem["generic"] -= 1
|
|
144
|
+
end
|
|
145
|
+
rem.delete("hybrid") if rem["hybrid"] && rem["hybrid"].empty?
|
|
146
|
+
rem
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def completable_after_paying?(frame, type, slot, restricted)
|
|
150
|
+
pl = player(frame["player"])
|
|
151
|
+
pool = pl.mana_pool.dup
|
|
152
|
+
rest = pl.restricted_mana.dup
|
|
153
|
+
restricted ? rest.delete_at(restricted) : pool[type.to_sym] -= 1
|
|
154
|
+
payable_with?(frame, remaining_cost(after_paying(frame["remaining"], slot)), pool, payment_reserved(frame),
|
|
155
|
+
restricted: rest)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ---- mana abilities during payment (CR 601.2g, 605.3a) ---------------------------------
|
|
159
|
+
|
|
160
|
+
# Mana ability activation options usable during payment: not a source the spell or ability still
|
|
161
|
+
# needs for one of its own costs ({T}, or a cost that sacrifices or exiles it), unless the mana
|
|
162
|
+
# ability only taps it and the cost doesn't; not a source of restricted mana that can't be spent
|
|
163
|
+
# here; and, once the payment is irreversible, only activations that keep the rest payable.
|
|
164
|
+
def payment_mana_options
|
|
165
|
+
frame = payment
|
|
166
|
+
purpose = payment_purpose(frame)
|
|
167
|
+
sources = all_mana_sources(frame["player"]).reject do |id, ref|
|
|
168
|
+
spend_only = chars(id).ability(ref).spend_only
|
|
169
|
+
reserved_for_cost?(frame, id, ref) || (spend_only && !spend_only.allows?(self, purpose))
|
|
170
|
+
end
|
|
171
|
+
mana_activation_options(sources).select do |option|
|
|
172
|
+
id = option[:object]
|
|
173
|
+
next !frame["irreversible"] && !irreversible_mana_ability?(id, option[:ability]) if chars(id).ability(option[:ability]).mana_cost
|
|
174
|
+
|
|
175
|
+
(!frame["irreversible"] && !irreversible_mana_ability?(id, option[:ability])) ||
|
|
176
|
+
completable_after_activating?(frame, option)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A mana ability whose costs include more than {T} and mana (a sacrifice, life, a discard) can't be undone.
|
|
181
|
+
def irreversible_mana_ability?(id, ref) = chars(id).ability(ref).costs.any? { !it.is_a?(Mechanics::Costs::Tap) && !it.mana? }
|
|
182
|
+
|
|
183
|
+
def reserved_for_cost?(frame, id, ref)
|
|
184
|
+
return true if id == payment_excluded_source(frame)
|
|
185
|
+
|
|
186
|
+
payment_reserved(frame).include?(id) && chars(id).ability(ref).costs.any? { !it.is_a?(Mechanics::Costs::Tap) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Permanents a cost of the spell or ability will still use: its source when it has a non-mana cost
|
|
190
|
+
# about itself, and permanents chosen for a cost paid when it becomes cast (CR 601.2h).
|
|
191
|
+
def payment_reserved(frame)
|
|
192
|
+
return [] unless frame["type"] == "cast"
|
|
193
|
+
|
|
194
|
+
reserved = (frame["paid"] || []).map { it["object"] }
|
|
195
|
+
reserved << frame["source"] if frame["kind"] == "ability" && casting_ability.costs.any? { it.respond_to?(:uses_source?) }
|
|
196
|
+
reserved
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def completable_after_activating?(frame, option)
|
|
200
|
+
id = option[:object]
|
|
201
|
+
ability = chars(id).ability(option[:ability])
|
|
202
|
+
# A mana ability that would add nothing (mana: "") produces none.
|
|
203
|
+
produced = (option[:mana] ? Mana.parse_produce(option[:mana], [], "mana") : ability.outcomes(self, object(id)).first) || []
|
|
204
|
+
pool = player(frame["player"]).mana_pool.dup
|
|
205
|
+
rest = player(frame["player"]).restricted_mana
|
|
206
|
+
if ability.spend_only
|
|
207
|
+
rest += produced.map { { "mana" => it.to_s, "spend_only" => ability.spend_only.data } }
|
|
208
|
+
else
|
|
209
|
+
produced.each { pool[it] += 1 }
|
|
210
|
+
end
|
|
211
|
+
payable_with?(frame, remaining_cost(frame["remaining"]), pool, payment_reserved(frame) + [id], restricted: rest)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# CR 605.3: activating a mana ability while paying. One whose costs include more than {T} (a
|
|
215
|
+
# sacrifice, life, a discard) can't be undone, so the payment stops being cancellable.
|
|
216
|
+
def tap_for_payment!(id, ability_ref, mana = nil)
|
|
217
|
+
frame = payment
|
|
218
|
+
frame["irreversible"] = true if irreversible_mana_ability?(id, ability_ref)
|
|
219
|
+
activate_mana_ability!(frame["player"], id, ability_ref, mana)
|
|
220
|
+
frame["tapped"] << id
|
|
221
|
+
ask!(nil)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Whether `cost` can be paid from `pool` (and the `restricted` pool) plus the player's untapped
|
|
225
|
+
# sources, minus `excluded` and a source the ability taps for its own cost, for the payment `frame`.
|
|
226
|
+
def payable_with?(frame, cost, pool, excluded, restricted:)
|
|
227
|
+
return true if cost.mana_value.zero?
|
|
228
|
+
|
|
229
|
+
index = frame["player"]
|
|
230
|
+
excluded = [*excluded, payment_excluded_source(frame)].compact
|
|
231
|
+
purpose = payment_purpose(frame)
|
|
232
|
+
units = pool_units(index, pool, purpose, restricted) + source_units(index, excluded, purpose) +
|
|
233
|
+
convoke_units(index, purpose, excluded) + # CR 702.51a (Engine::AltCasting)
|
|
234
|
+
waterbend_units(index, purpose, excluded) # CR 701.67a (Engine::Waterbending)
|
|
235
|
+
covers?(cost, units)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ---- cancelling ----------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
# The "cancel" option of a payment decision: rewinds a cast, or declines a resolution payment.
|
|
241
|
+
def cancel_payment!
|
|
242
|
+
payment["type"] == "cast" ? cancel_casting! : decline_payment!
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Undoes the mana abilities activated for the payment (CR 734.1): untaps their sources and restores
|
|
246
|
+
# the pool. Abilities that triggered meanwhile didn't trigger (CR 734.1).
|
|
247
|
+
def rewind_payment!(frame)
|
|
248
|
+
frame["tapped"].select { state.objects.key?(it) }.each do |id|
|
|
249
|
+
object(id).tapped = false
|
|
250
|
+
emit Events::Untapped.new(object: id)
|
|
251
|
+
end
|
|
252
|
+
player(frame["player"]).mana_pool = Mana::TYPES.to_h { |t| [t, frame["pool"].fetch(t.to_s, 0)] }
|
|
253
|
+
player(frame["player"]).restricted_mana = Snapshot.deep_copy(frame["restricted"]) if frame["restricted"]
|
|
254
|
+
player(frame["player"]).pool_sources = Snapshot.deep_copy(frame["pool_sources"]) if frame.key?("pool_sources")
|
|
255
|
+
state.pending_triggers.select! { frame["triggers_before"].include?(it["id"]) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ---- countering (CR 701.6) ----------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
# CR 701.6a: a countered spell goes to its owner's graveyard; a countered ability ceases to exist.
|
|
261
|
+
# "This spell can't be countered" and "Spells [and abilities] you control can't be countered" win (CR 101.2).
|
|
262
|
+
# Returns whether it was countered.
|
|
263
|
+
def counter!(id)
|
|
264
|
+
obj = object(id)
|
|
265
|
+
return false if obj.ability? && player_rule?(obj.controller, "abilities_cant_be_countered")
|
|
266
|
+
return false if !obj.ability? && (chars(obj).keyword?(:cant_be_countered) ||
|
|
267
|
+
player_rule?(obj.controller, "spells_cant_be_countered", obj))
|
|
268
|
+
|
|
269
|
+
if obj.ability?
|
|
270
|
+
emit Events::AbilityCountered.new(object: id, card: obj.card, source: obj.source)
|
|
271
|
+
remove_stack_object!(id)
|
|
272
|
+
else
|
|
273
|
+
move!(id, :graveyard, reason: "countered")
|
|
274
|
+
end
|
|
275
|
+
true
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# ---- paying while resolving (CR 118.12) -------------------------------------------------
|
|
279
|
+
|
|
280
|
+
# Starts paying `cost` for an effect that is resolving; Effects::Payment learns the outcome as an
|
|
281
|
+
# answer ("paid" or "declined") when the pay frame is done.
|
|
282
|
+
# `waterbend`: the generic mana of a waterbend cost that tapping may pay (CR 701.67a, Engine::Waterbending).
|
|
283
|
+
# `special`: the cost of a special action ({"object", "ability"}, Actions::SpecialAction), taken once paid.
|
|
284
|
+
def begin_payment!(index, cost, object, waterbend: nil, special: nil)
|
|
285
|
+
state.frames << { "type" => "pay", "player" => index, "object" => object, "remaining" => remaining_hash(cost),
|
|
286
|
+
"pool" => player(index).mana_pool.transform_keys(&:to_s), "tapped" => [],
|
|
287
|
+
"restricted" => Snapshot.deep_copy(player(index).restricted_mana),
|
|
288
|
+
"pool_sources" => Snapshot.deep_copy(player(index).pool_sources),
|
|
289
|
+
"triggers_before" => state.pending_triggers.map { it["id"] },
|
|
290
|
+
"waterbend" => waterbend, "special" => special }.compact
|
|
291
|
+
ask!(nil)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def continue_payment!(frame)
|
|
295
|
+
if remaining_left?(frame["remaining"])
|
|
296
|
+
ask!("kind" => "pay_cost", "player" => frame["player"], "remaining" => remaining_text(frame))
|
|
297
|
+
return :wait
|
|
298
|
+
end
|
|
299
|
+
finish_payment!(frame, "paid")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def decline_payment!
|
|
303
|
+
frame = payment
|
|
304
|
+
rewind_payment!(frame)
|
|
305
|
+
state.frames.delete(frame)
|
|
306
|
+
ask!(nil)
|
|
307
|
+
finish_payment!(frame, "declined")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def finish_payment!(frame, outcome)
|
|
311
|
+
waterbent!(frame) if outcome == "paid" # CR 701.67c (Engine::Waterbending)
|
|
312
|
+
emit Events::PaymentFinished.new(player: name(frame["player"]), object: frame["object"], outcome: outcome)
|
|
313
|
+
return finish_special_action!(frame, outcome) if frame["special"]
|
|
314
|
+
|
|
315
|
+
resolving = state.frames.rfind { it["type"] == "resolve" }
|
|
316
|
+
locals = resolving["locals"][resolving["pc"].to_s]
|
|
317
|
+
locals = locals["child"] while locals["child"]
|
|
318
|
+
(locals["answers"] ||= []) << { "type" => outcome }
|
|
319
|
+
:done
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# CR 116.2f, 116.2h, 116.3: the special action is taken once its cost is paid; either way the player
|
|
323
|
+
# receives priority again.
|
|
324
|
+
def finish_special_action!(frame, outcome)
|
|
325
|
+
index = frame["player"]
|
|
326
|
+
special = frame["special"]
|
|
327
|
+
if outcome == "paid" && state.objects.key?(special["object"])
|
|
328
|
+
obj = object(special["object"])
|
|
329
|
+
chars(obj).ability(special["ability"]).special!(self, obj, index)
|
|
330
|
+
end
|
|
331
|
+
action_taken!(index)
|
|
332
|
+
:done
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
include Payment
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
module Events
|
|
340
|
+
PaymentFinished = define(:payment_finished, :player, :object, :outcome) # CR 118.12: paid or declined
|
|
341
|
+
AbilityCountered = define(:ability_countered, :object, :card, :source) # CR 701.6a
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
module Frames
|
|
345
|
+
# Paying a cost while a spell or ability resolves (CR 118.12, 701.6), e.g. "counter it unless its
|
|
346
|
+
# controller pays {2}". The payer may activate mana abilities (CR 605.3a).
|
|
347
|
+
class Pay < Base
|
|
348
|
+
def self.step(engine, frame) = engine.continue_payment!(frame)
|
|
349
|
+
def self.decisions = %w[pay_cost]
|
|
350
|
+
end
|
|
351
|
+
register("pay", Pay)
|
|
352
|
+
end
|
|
353
|
+
end
|