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,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Casting spells (CR 601) and activating abilities (CR 602), one atomic decision
|
|
6
|
+
# at a time: announce -> choose targets -> pay mana (tapping sources manually)
|
|
7
|
+
# -> cast. Until every cost is paid the whole process can be cancelled and is
|
|
8
|
+
# rewound (CR 734, "Handling Illegal Actions"). The in-progress context lives in
|
|
9
|
+
# casting so a game can be exported mid-cast.
|
|
10
|
+
module Casting
|
|
11
|
+
# ---- what can be started ---------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def castable?(index, id)
|
|
14
|
+
obj = object(id)
|
|
15
|
+
defn = definition(obj)
|
|
16
|
+
return false unless defn.castable?
|
|
17
|
+
return false unless defn.instant_speed? || sorcery_timing?(index)
|
|
18
|
+
return false if cast_forbidden?(index, obj) # CR 101.2 (Engine::Rules)
|
|
19
|
+
return false unless additional_costs_payable?(obj) # CR 601.2f: e.g. "sacrifice a creature"
|
|
20
|
+
|
|
21
|
+
if ability_targetable?(defn.spell_ability, index, id) && # CR 601.2b: some way of paying its costs (Engine::CostOptions)
|
|
22
|
+
cost_variant_affordable?(index, spell_cost_list(obj, false), obj, defn.mana_cost, nil, { "spell" => id }) do
|
|
23
|
+
total_cost(index, it, spell: obj)
|
|
24
|
+
end
|
|
25
|
+
return true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
kicked_castable?(index, obj) # CR 702.33g: only the kicked spell has legal targets
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def activatable?(index, id, ability_ref)
|
|
32
|
+
obj = object(id)
|
|
33
|
+
ability = chars(obj).ability(ability_ref)
|
|
34
|
+
return false unless ability && %i[activated loyalty].include?(ability.kind)
|
|
35
|
+
return false unless obj.zone == ability.zone # CR 113.6: e.g. cycling only from the hand (702.29a)
|
|
36
|
+
return false if ability.sorcery_speed? && !sorcery_timing?(index)
|
|
37
|
+
return false if activation_forbidden?(index, obj) # CR 602.5, 101.2 (Engine::Rules)
|
|
38
|
+
return false unless activation_allowed?(index, obj, ability_ref, ability) # CR 602.5b
|
|
39
|
+
|
|
40
|
+
if (ability.kind == :loyalty) && (obj.loyalty_turn == state.turn || (loyalty(obj) + ability.loyalty_cost).negative?)
|
|
41
|
+
# CR 606.3: once per turn. CR 118.3: a cost can't remove more counters than there are.
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# CR 118.3, 601.2b: some way of paying its costs (Engine::CostOptions); waterbend: CR 701.67a
|
|
46
|
+
ability_targetable?(ability, index, nil, id, ref: ability_ref) &&
|
|
47
|
+
cost_variant_affordable?(index, ability.costs, obj, nil, needs_tap?(ability) ? id : nil, { "ability" => id }) do
|
|
48
|
+
total_cost(index, it, ability: ability, source: id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the ability can get legal targets: for modal abilities, enough modes must (CR 700.2),
|
|
53
|
+
# not counting modes already chosen for the source of a "choose one that hasn't been chosen".
|
|
54
|
+
def ability_targetable?(ability, index, exclude, source = nil, ref: nil)
|
|
55
|
+
return true if ability.nil?
|
|
56
|
+
return targets_available?(ability.targets, index, exclude, source) unless ability.modal?
|
|
57
|
+
|
|
58
|
+
(available_modes(ability, index, exclude, source) - used_modes(ability, source, ref)).size >= ability.choose
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def available_modes(ability, index, exclude, source = nil)
|
|
62
|
+
ability.modes.each_index.select { targets_available?(ability.modes[it].targets, index, exclude, source) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "Up to one target" needs no candidate (CR 601.2c).
|
|
66
|
+
def targets_available?(specs, index, exclude, source = nil)
|
|
67
|
+
specs.all? do |spec|
|
|
68
|
+
need = target_minimum(spec, index, source) # Engine::TargetCounts
|
|
69
|
+
need.zero? || spec.candidates_for(self, index, source).count do
|
|
70
|
+
it != exclude && targetable?(it, index, source, targeter: source || exclude) # a spell's card is `exclude`
|
|
71
|
+
end >= need
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def optional_target?(spec) = spec.respond_to?(:optional?) && spec.optional?
|
|
76
|
+
|
|
77
|
+
# CR 702.11b: hexproof permanents can't be targeted by their controller's opponents. `source`: the
|
|
78
|
+
# permanent whose ability targets (nil for a spell); `targeter`: the spell, or the ability's source (protection,
|
|
79
|
+
# CR 702.16b); see Engine::Rules#targeting_forbidden?.
|
|
80
|
+
def targetable?(ref, index, source = nil, targeter: source)
|
|
81
|
+
return false if targeting_forbidden?(ref, index, source, targeter: targeter) # CR 702.11c, 115.4
|
|
82
|
+
|
|
83
|
+
obj = state.objects[ref]
|
|
84
|
+
return true unless obj && obj.zone == :battlefield && obj.controller != index
|
|
85
|
+
|
|
86
|
+
!chars(obj).keyword?(:hexproof)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The ability an ability object on the stack represents. It keeps existing even if its
|
|
90
|
+
# source has left the battlefield (CR 113.7a), so it's looked up on the source's card.
|
|
91
|
+
def stack_ability(obj) = definition(obj).ability(obj.ability)
|
|
92
|
+
|
|
93
|
+
def needs_tap?(ability) = ability.costs.any?(Mechanics::Costs::Tap)
|
|
94
|
+
|
|
95
|
+
# The spell ability of a spell (printed, CR 113.3a).
|
|
96
|
+
def spell_ability_of(obj) = definition(obj).spell_ability
|
|
97
|
+
|
|
98
|
+
# ---- starting ----------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# CR 601.2a: the card moves from where it is to the stack. `via` names how a card outside the
|
|
101
|
+
# hand is cast (see Engine::Permissions); "free" casts it without paying its mana cost during
|
|
102
|
+
# the resolution of a spell or ability (CR 608.2g, 118.9), after which nobody gets priority.
|
|
103
|
+
# `face`: cast another face of the card (a modal card's back, an Adventure, an Omen; Engine::Faces).
|
|
104
|
+
# `riders`: how an effect casting it while resolving changes the cast ("exile_instead", "any_mana_type").
|
|
105
|
+
def begin_cast!(index, card_id, via: nil, face: nil, riders: {})
|
|
106
|
+
return begin_outside_cast!(index, card_id, via) if outside_key?(card_id) # CR 400.11b
|
|
107
|
+
|
|
108
|
+
card = object(card_id)
|
|
109
|
+
method = via && play_method(index, card_id, via).merge(riders)
|
|
110
|
+
origin = { "id" => card_id, "zone" => card.zone.to_s,
|
|
111
|
+
"index" => state.zone_list(card.zone, card.owner).index(card_id) }
|
|
112
|
+
origin["memory"] = card.memory.slice("face_down", "may_look") if face_down?(card) # turned face up (CR 406.3a)
|
|
113
|
+
origin["memory"] = card.memory.slice("prepared_for") if card.memory["prepared_for"] # a prepare spell (CR 723.3c)
|
|
114
|
+
obj = move!(card_id, :stack, controller: index, reason: "cast")
|
|
115
|
+
turn_spell_face!(obj, face) # CR 712.8b, 716.3b, 721.3b
|
|
116
|
+
extra = method ? { "method" => method } : {}
|
|
117
|
+
extra["no_priority"] = true if %w[free resolving].include?(via) # cast while resolving (CR 608.2g)
|
|
118
|
+
start_casting!(index, obj.id, "spell", origin: origin, extra: extra)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# CR 602.2a: the ability is created on the stack.
|
|
122
|
+
def begin_activation!(index, source_id, ability_ref)
|
|
123
|
+
source = object(source_id)
|
|
124
|
+
# CR 602.2a: activating from a hidden zone reveals the card.
|
|
125
|
+
emit Events::CardRevealed.new(player: name(index), object: source_id, card: source.card) if source.zone == :hand
|
|
126
|
+
card, ref, granter = AbilityLayers.granted_parts(ability_ref) # a granted ability lives in the granting card's data
|
|
127
|
+
obj = create_object(card || source.card, index, :stack)
|
|
128
|
+
obj.kind = "ability"
|
|
129
|
+
obj.source = source_id
|
|
130
|
+
obj.ability = ref || ability_ref
|
|
131
|
+
obj.memory["granter"] = granter if granter # CR 607.1a: "Return Trusty Boomerang" (Selector "granter")
|
|
132
|
+
state.stack.unshift(obj.id)
|
|
133
|
+
start_casting!(index, obj.id, "ability", source: source_id, ability: ability_ref)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# `x` stays nil until announced (CR 601.2b); `remaining` stays nil until the total cost is
|
|
137
|
+
# determined and locked in after targets are chosen (CR 601.2f).
|
|
138
|
+
def start_casting!(index, object_id, kind, origin: nil, source: nil, ability: nil, extra: {})
|
|
139
|
+
state.frames << { "type" => "cast", "player" => index, "object" => object_id, "kind" => kind,
|
|
140
|
+
"origin" => origin, "source" => source, "ability" => ability, "targets" => [], "modes" => [],
|
|
141
|
+
"x" => nil, "remaining" => nil,
|
|
142
|
+
"pool" => player(index).mana_pool.transform_keys(&:to_s), "tapped" => [],
|
|
143
|
+
"restricted" => Snapshot.deep_copy(player(index).restricted_mana),
|
|
144
|
+
"pool_sources" => Snapshot.deep_copy(player(index).pool_sources),
|
|
145
|
+
"triggers_before" => state.pending_triggers.map { it["id"] } }.merge(extra)
|
|
146
|
+
ask!(nil) # advance! now steps the cast frame
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The cast/activation in progress (the top frame while a cast decision is pending).
|
|
150
|
+
def casting
|
|
151
|
+
frame = state.frames.last
|
|
152
|
+
frame if frame && frame["type"] == "cast"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def casting_ability
|
|
156
|
+
ctx = casting
|
|
157
|
+
obj = object(ctx["object"])
|
|
158
|
+
ctx["kind"] == "spell" ? definition(obj).spell_ability : stack_ability(obj)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ---- the in-progress steps --------------------------------------------------
|
|
162
|
+
|
|
163
|
+
# Steps the cast frame: modes and X (601.2b), targets (601.2c), total cost (601.2f), payment.
|
|
164
|
+
def continue_casting!
|
|
165
|
+
ctx = casting
|
|
166
|
+
ability = casting_ability
|
|
167
|
+
return :wait if ask_kicker!(ctx)
|
|
168
|
+
|
|
169
|
+
if ability&.modal? && modes_pending?(ctx, ability)
|
|
170
|
+
min, max = mode_range(ctx, ability)
|
|
171
|
+
ask!({ "kind" => "choose_mode", "player" => ctx["player"], "choose" => max, "chosen" => ctx["modes"].size }
|
|
172
|
+
.merge(min == max ? {} : { "min" => min }))
|
|
173
|
+
return :wait
|
|
174
|
+
end
|
|
175
|
+
return :wait if ask_cost_option!(ctx) # CR 601.2b: which of two costs (Engine::CostOptions)
|
|
176
|
+
return :wait if ask_x!(ctx)
|
|
177
|
+
|
|
178
|
+
specs = casting_targets
|
|
179
|
+
return :wait if ask_targets!(ctx, specs) # Engine::TargetCounts
|
|
180
|
+
return :wait if ask_distribution!(ctx, ability)
|
|
181
|
+
|
|
182
|
+
return ask_harmonize!(ctx) if harmonize_pending?(ctx) # CR 702.180b (Engine::AltCasting)
|
|
183
|
+
|
|
184
|
+
lock_total_cost!(ctx) unless ctx["remaining"]
|
|
185
|
+
if beam_choice_pending?(ctx) then ask_beam_choice!(ctx) # CR 702.196a (Engine::Permissions)
|
|
186
|
+
elsif (cost = next_cost_choice(ctx)) then ask_cost_choice!(ctx, casting_costs(ctx)[cost])
|
|
187
|
+
elsif remaining_left?(ctx["remaining"])
|
|
188
|
+
ask!("kind" => "pay_cost", "player" => ctx["player"], "remaining" => remaining_text)
|
|
189
|
+
:wait
|
|
190
|
+
else
|
|
191
|
+
finish_casting!
|
|
192
|
+
:done
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def casting_targets = casting_ability&.targets_for(casting["modes"], kicked: casting["kicked"]) || []
|
|
197
|
+
|
|
198
|
+
# CR 601.2c: targets, in order; a spell or ability can't target itself (CR 115.5), and the same
|
|
199
|
+
# object can't be chosen twice for one instance of the word "target" (`distinct_from`).
|
|
200
|
+
def target_candidates
|
|
201
|
+
ctx = casting
|
|
202
|
+
spec = casting_targets[target_slot(ctx)] # Engine::TargetCounts: a counted slot's list may be open
|
|
203
|
+
taken = spec.respond_to?(:distinct_from) ? spec.distinct_from.flat_map { Array(ctx["targets"][it]) } : []
|
|
204
|
+
taken += open_target_group(ctx)
|
|
205
|
+
candidates = spec.candidates_for(self, ctx["player"], ctx["source"]).reject do
|
|
206
|
+
it == ctx["object"] || taken.include?(it) || !targetable?(it, ctx["player"], ctx["source"],
|
|
207
|
+
targeter: ctx["source"] || ctx["object"])
|
|
208
|
+
end
|
|
209
|
+
sharing_type(candidates, spec, ctx)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# "Target permanents that share a card type" (`shares_type_with`, CR 205.2a): only candidates sharing one
|
|
213
|
+
# with the earlier target.
|
|
214
|
+
def sharing_type(candidates, spec, ctx)
|
|
215
|
+
index = spec.respond_to?(:shares_type_with) && spec.shares_type_with
|
|
216
|
+
other = index && state.objects[Array(ctx["targets"][index]).first] or return candidates
|
|
217
|
+
|
|
218
|
+
candidates.select { state.objects.key?(it) && chars(it).types.intersect?(chars(other).types) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The target options; an "up to one" target may be left unchosen (`done`).
|
|
222
|
+
def target_options
|
|
223
|
+
options = target_candidates.map { { type: "target", target: it } }
|
|
224
|
+
optional_target?(casting_targets[casting["targets"].size]) && !casting["counting"] ? options + [{ type: "done" }] : options
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# `ref` nil: no target chosen for an optional ("up to one") target. Abilities that trigger when
|
|
228
|
+
# something becomes a target (ward, CR 702.21a) learn which spell or ability targeted it.
|
|
229
|
+
def choose_target!(ref)
|
|
230
|
+
ctx = casting
|
|
231
|
+
add_chosen_target!(ctx, ref)
|
|
232
|
+
if ref
|
|
233
|
+
queued = state.pending_triggers.size
|
|
234
|
+
emit Events::TargetChosen.new(player: name(ctx["player"]), object: ctx["object"], target: ref)
|
|
235
|
+
state.pending_triggers.drop(queued).each { it["memory"] = it["memory"].merge("targeted_by" => ctx["object"]) }
|
|
236
|
+
end
|
|
237
|
+
ask!(nil)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# CR 601.2h-i / 602.2b: remaining costs are paid, the spell becomes cast.
|
|
241
|
+
def finish_casting!
|
|
242
|
+
ctx = casting
|
|
243
|
+
index = ctx["player"]
|
|
244
|
+
obj = object(ctx["object"])
|
|
245
|
+
obj.targets = ctx["targets"].dup
|
|
246
|
+
obj.modes = ctx["modes"].dup unless ctx["modes"].empty?
|
|
247
|
+
obj.mana_spent = ctx["spent"].dup if ctx["spent"]
|
|
248
|
+
obj.memory["mana_from"] = Snapshot.deep_copy(ctx["spent_from"]) if ctx["spent_from"] # the sources (ManaSources)
|
|
249
|
+
record_announcements!(obj, ctx) # kicked (CR 702.33d), division (CR 601.2d), modes chosen before
|
|
250
|
+
record_cast_choices!(obj, ctx) # cost choices; facing a dilemma (CR 700.17)
|
|
251
|
+
waterbent!(ctx) # CR 701.67c (Engine::Waterbending)
|
|
252
|
+
if ctx["kind"] == "trigger"
|
|
253
|
+
emit Events::TriggerPutOnStack.new(player: name(index), object: obj.id, source: ctx["source"], card: obj.card,
|
|
254
|
+
ability: ctx["ability"], targets: obj.targets.flatten)
|
|
255
|
+
return # placing continues in the put_triggers frame; no priority yet
|
|
256
|
+
elsif ctx["kind"] == "ability"
|
|
257
|
+
pay_ability_costs!(ctx, obj)
|
|
258
|
+
record_activation!(ctx) # CR 602.5b
|
|
259
|
+
emit Events::AbilityActivated.new(player: name(index), object: obj.id, card: obj.card, source: ctx["source"],
|
|
260
|
+
ability: ctx["ability"], targets: obj.targets.flatten)
|
|
261
|
+
else
|
|
262
|
+
finish_spell_cast!(ctx, obj)
|
|
263
|
+
end
|
|
264
|
+
return if ctx["no_priority"] # CR 608.2g: no priority; the resolving spell or ability continues
|
|
265
|
+
|
|
266
|
+
action_taken!(index) # CR 117.3c (the frame is popped by advance!)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A spell becomes cast (CR 601.2i) once its other costs are paid.
|
|
270
|
+
def finish_spell_cast!(ctx, obj)
|
|
271
|
+
casting_costs(ctx).each { it.pay!(self, obj) unless it.respond_to?(:choices_needed) } # pay life (CR 119.4)
|
|
272
|
+
pay_chosen_costs!(ctx) # e.g. the creature sacrificed for an additional cost (CR 601.2h)
|
|
273
|
+
finish_cast_method!(obj, ctx) # Engine::Permissions
|
|
274
|
+
cast_prepared_copy!(ctx.dig("origin", "id")) # CR 723.3c (Engine::Faces)
|
|
275
|
+
emit Events::SpellCast.new(player: name(ctx["player"]), object: obj.id, card: obj.card,
|
|
276
|
+
targets: obj.targets.flatten, during_resolution: ctx["no_priority"] == true)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The non-mana costs (CR 602.2b): its own ({T}, sacrifice it, exile it from the graveyard), then
|
|
280
|
+
# the chosen ones (sacrifice another creature, remove counters). A source sacrificed as a cost is
|
|
281
|
+
# remembered as it last existed (its counters, CR 608.2h).
|
|
282
|
+
def pay_ability_costs!(ctx, obj)
|
|
283
|
+
source = object(ctx["source"])
|
|
284
|
+
ability = chars(source).ability(ctx["ability"])
|
|
285
|
+
obj.memory["source_counters"] = source.counters.dup if ability.costs.any?(Mechanics::Costs::SacrificeSelf)
|
|
286
|
+
frame_costs(ctx).reject(&:mana?).each { it.pay!(self, source) } # the chosen option of a one_of (CR 601.2b)
|
|
287
|
+
pay_chosen_costs!(ctx)
|
|
288
|
+
return unless ability.kind == :loyalty
|
|
289
|
+
|
|
290
|
+
source.loyalty_turn = state.turn
|
|
291
|
+
cost = ability.loyalty_cost
|
|
292
|
+
cost.negative? ? remove_counters!(source, "loyalty", -cost) : add_counters!(source, "loyalty", cost)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Rewinds an unfinished cast or activation (CR 734.1). Offered only while nothing irreversible
|
|
296
|
+
# has been paid (see Engine::Payment#cancellable?): tapping is undone, mana abilities' mana
|
|
297
|
+
# leaves the pool, and abilities that triggered meanwhile are dropped.
|
|
298
|
+
def cancel_casting!
|
|
299
|
+
ctx = casting
|
|
300
|
+
index = ctx["player"]
|
|
301
|
+
rewind_payment!(ctx)
|
|
302
|
+
obj = object(ctx["object"])
|
|
303
|
+
state.stack.delete(obj.id)
|
|
304
|
+
state.objects.delete(obj.id)
|
|
305
|
+
restore_to_origin!(obj, ctx["origin"]) if ctx["kind"] == "spell"
|
|
306
|
+
emit Events::CastCancelled.new(player: name(index), object: obj.id, card: obj.card)
|
|
307
|
+
state.frames.pop
|
|
308
|
+
return ask!(nil) if ctx["no_priority"] # cast during resolution: the resolution goes on (CR 608.2g)
|
|
309
|
+
|
|
310
|
+
give_priority!(index)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# (Costs that need a choice, e.g. tapping another creature: see Engine::CastChoices.)
|
|
314
|
+
|
|
315
|
+
# As a spell or ability becomes cast, activated or put on the stack: it keeps the cost choices
|
|
316
|
+
# (CR 702.184a uses the tapped creature's power), and chosen modes mean its controller faced a
|
|
317
|
+
# dilemma (CR 700.17). Recording it here, not as modes are chosen, keeps a cancelled cast from counting.
|
|
318
|
+
def record_cast_choices!(obj, ctx)
|
|
319
|
+
obj.paid = ctx["paid"].map(&:dup) if ctx["paid"]
|
|
320
|
+
emit Events::DilemmaFaced.new(player: name(ctx["player"]), object: obj.id, modes: obj.modes.dup) if obj.modes
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
include Casting
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Choosing between costs (Costs::OneOf, CR 601.2b): which of the listed costs will be paid is announced
|
|
6
|
+
# while casting or activating, before X and the total cost (CR 601.2f, 602.2b). The cast frame keeps the
|
|
7
|
+
# option chosen for each one_of, in order ("cost_options"); from then on the chosen cost stands in its place.
|
|
8
|
+
# Before the cast begins, every combination is tried (`cost_variants`).
|
|
9
|
+
module CostOptions
|
|
10
|
+
ONE_OF = Mechanics::Costs::OneOf
|
|
11
|
+
|
|
12
|
+
# Every way to pay `costs`: each one_of replaced by one of its options.
|
|
13
|
+
def cost_variants(costs)
|
|
14
|
+
costs.reduce([[]]) do |variants, cost|
|
|
15
|
+
cost.is_a?(ONE_OF) ? variants.product(cost.options).map { |v, o| v + [o] } : variants.map { it + [cost] }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `costs` with the n-th one_of replaced by its option chosen[n]; one_ofs not announced yet are left out.
|
|
20
|
+
def with_cost_options(costs, chosen)
|
|
21
|
+
n = -1
|
|
22
|
+
costs.filter_map do |cost|
|
|
23
|
+
next cost unless cost.is_a?(ONE_OF)
|
|
24
|
+
|
|
25
|
+
option = chosen[n += 1]
|
|
26
|
+
option && cost.options[option]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The mana parts of a cost list, as one mana cost.
|
|
31
|
+
def costs_mana(costs) = Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
|
|
32
|
+
|
|
33
|
+
# Whether some way of paying `costs` (on top of the mana cost `base`) is open to `index`: its other parts
|
|
34
|
+
# payable for `payer` (CR 118.3) and the total cost affordable (CR 601.2f). The block turns the mana to pay
|
|
35
|
+
# into the total cost.
|
|
36
|
+
def cost_variant_affordable?(index, costs, payer, base, excluded, purpose)
|
|
37
|
+
variants = costs.any?(ONE_OF) ? cost_variants(costs) : [costs]
|
|
38
|
+
variants.any? do |variant|
|
|
39
|
+
variant.all? { it.mana? || it.payable?(self, payer) } &&
|
|
40
|
+
affordable?(index, yield(variant_mana(base, variant)), excluded, { **purpose, **waterbend_purpose(variant) })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The mana cost `base` plus the mana parts of a cost list.
|
|
45
|
+
def variant_mana(base, costs)
|
|
46
|
+
return base if base && costs.none?(&:mana?)
|
|
47
|
+
|
|
48
|
+
Mana.sum_costs([base, costs_mana(costs)].compact)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The cost list of a cast frame, before the announced options are applied: an ability's costs, a spell's
|
|
52
|
+
# additional costs (and kicker's when kicked).
|
|
53
|
+
def frame_cost_list(ctx)
|
|
54
|
+
case ctx["kind"]
|
|
55
|
+
when "ability" then stack_ability(object(ctx["object"])).costs
|
|
56
|
+
when "spell" then spell_cost_list(object(ctx["object"]), ctx["kicked"])
|
|
57
|
+
else []
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def frame_costs(ctx) = with_cost_options(frame_cost_list(ctx), ctx["cost_options"] || [])
|
|
62
|
+
|
|
63
|
+
# CR 601.2b: true while waiting for the choice of the next one_of.
|
|
64
|
+
def ask_cost_option!(ctx)
|
|
65
|
+
one_of = pending_one_of(ctx) or return false
|
|
66
|
+
ask!("kind" => "choose_cost_option", "player" => ctx["player"], "costs" => one_of.option_texts)
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def pending_one_of(ctx) = frame_cost_list(ctx).grep(ONE_OF)[ctx.fetch("cost_options", []).size]
|
|
71
|
+
|
|
72
|
+
# `{type: "cost_option", option: i}` for each option that could be paid: its own part payable (CR 118.3)
|
|
73
|
+
# and the total cost with it affordable (CR 601.2f).
|
|
74
|
+
def cost_option_options
|
|
75
|
+
ctx = casting
|
|
76
|
+
chosen = ctx.fetch("cost_options", [])
|
|
77
|
+
pending_one_of(ctx).options.each_with_index.filter_map do |cost, i|
|
|
78
|
+
{ type: "cost_option", option: i } if cost_option_payable?(ctx.merge("cost_options" => chosen + [i]), cost)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def cost_option_payable?(trial, cost)
|
|
83
|
+
return false unless cost.mana? || cost.payable?(self, cost_payer(trial))
|
|
84
|
+
|
|
85
|
+
bend = waterbend_generic(frame_costs(trial))
|
|
86
|
+
purpose = payment_purpose(bend.positive? ? trial.merge("waterbend" => bend) : trial)
|
|
87
|
+
affordable?(trial["player"], frame_total_cost(trial, x_value: 0, targets: nil), payment_excluded_source(trial), purpose)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def choose_cost_option!(option)
|
|
91
|
+
ctx = casting
|
|
92
|
+
(ctx["cost_options"] ||= []) << option
|
|
93
|
+
ask!(nil)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
include CostOptions
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# The total cost of a spell or ability (CR 601.2f, 602.2b): the mana cost with X announced
|
|
6
|
+
# (CR 107.3a, 601.2b), plus cost increases, minus cost reductions. It is determined after
|
|
7
|
+
# targets are chosen and then locked in for payment.
|
|
8
|
+
module Costs
|
|
9
|
+
# The mana cost with X announced (CR 107.3a), plus cost increases, minus cost reductions.
|
|
10
|
+
# Changes affect only the generic part (CR 118.7a) and can't take it below {0}. `targets`
|
|
11
|
+
# nil means "not chosen yet": conditions about targets then count as met, so the result
|
|
12
|
+
# is a lower bound on what casting can cost (an upper bound on affordability).
|
|
13
|
+
def total_cost(index, cost, spell: nil, ability: nil, source: nil, x_value: 0, targets: nil)
|
|
14
|
+
return nil if cost.nil?
|
|
15
|
+
|
|
16
|
+
cost = Mechanics::Keywords::PowerUp.reduced(self, cost.with_x(x_value), ability, source) # CR 702.193a
|
|
17
|
+
change = cost_change(index, spell, ability, source, targets)
|
|
18
|
+
change.zero? ? cost : cost.with(generic: [cost.generic + change, 0].max)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Net generic change: + increases - reductions, from the spell's own abilities ("this spell
|
|
22
|
+
# costs {1} less"), the activated ability's own reduction, and static abilities of
|
|
23
|
+
# permanents ("spells you cast cost {1} less").
|
|
24
|
+
def cost_change(index, spell, ability, source, targets)
|
|
25
|
+
if spell
|
|
26
|
+
ctx = Mechanics::EffectContext.new(index, spell.id, spell.id, targets)
|
|
27
|
+
own = chars(spell).abilities.sum do |inst|
|
|
28
|
+
inst.ability.kind == :cost_reduction ? -inst.ability.reduction(self, ctx) : 0
|
|
29
|
+
end
|
|
30
|
+
own + cost_modifiers.sum { |perm, modifier| modifier.change_for(self, perm, spell, index) }
|
|
31
|
+
elsif ability.respond_to?(:cost_reduction) && ability.cost_reduction
|
|
32
|
+
-ability.cost_reduction.reduction(self, Mechanics::EffectContext.new(index, source, nil, targets))
|
|
33
|
+
else
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# [[permanent, CostModifier], ...] on the battlefield.
|
|
39
|
+
def cost_modifiers
|
|
40
|
+
state.battlefield.flat_map do |id|
|
|
41
|
+
obj = object(id)
|
|
42
|
+
chars(obj).abilities.filter_map { [obj, it.ability] if it.ability.kind == :cost_modifier }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The mana cost being paid, before X and cost changes (nil for a triggered ability).
|
|
47
|
+
def base_cost(ctx)
|
|
48
|
+
obj = object(ctx["object"])
|
|
49
|
+
case ctx["kind"]
|
|
50
|
+
when "spell" # plus additional costs, kicker and chosen tiered modes (CR 601.2f, 702.183a); alternative
|
|
51
|
+
# costs (CR 118.9, Permissions)
|
|
52
|
+
cost = ctx["method"] ? alternative_base_cost(ctx) : chars(obj).mana_cost
|
|
53
|
+
modes = spell_ability_of(obj)&.mode_costs(ctx["modes"] || []) || []
|
|
54
|
+
cost && Mana.sum_costs([cost, costs_mana(frame_costs(ctx)), *modes]) # Engine::CostOptions
|
|
55
|
+
when "ability" then costs_mana(frame_costs(ctx))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def frame_total_cost(ctx, x_value: ctx["x"].to_i, targets: ctx["targets"])
|
|
60
|
+
obj = object(ctx["object"])
|
|
61
|
+
spell = ctx["kind"] == "spell" ? obj : nil
|
|
62
|
+
ability = ctx["kind"] == "ability" ? stack_ability(obj) : nil
|
|
63
|
+
total = total_cost(ctx["player"], base_cost(ctx), spell: spell, ability: ability, source: ctx["source"],
|
|
64
|
+
x_value: x_value, targets: targets)
|
|
65
|
+
harmonize_reduced(total, frame_harmonize_power(ctx)) # CR 702.180a (Engine::AltCasting)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# CR 601.2f: the total cost is determined once and locked in.
|
|
69
|
+
def lock_total_cost!(ctx)
|
|
70
|
+
ctx["remaining"] = remaining_hash(frame_total_cost(ctx))
|
|
71
|
+
lock_waterbend!(ctx) # CR 701.67b (Engine::Waterbending)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# CR 601.2b: X is announced (once) before targets are chosen. True while waiting for it. Without {X} in
|
|
75
|
+
# the mana cost or a cost such as "pay X life" or "exile X cards", X is 0.
|
|
76
|
+
def ask_x!(ctx)
|
|
77
|
+
return false unless ctx["x"].nil?
|
|
78
|
+
return (ctx["x"] = 0) && false unless base_cost(ctx)&.x_symbols.to_i.positive? || x_costs(ctx).any?
|
|
79
|
+
|
|
80
|
+
ask!("kind" => "choose_x", "player" => ctx["player"], "max" => x_limit(ctx))
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The costs paid while casting that define X: "pay X life" (Costs::PayLife), "exile X cards from your
|
|
85
|
+
# graveyard" (Costs::CardsCost).
|
|
86
|
+
def x_costs(ctx) = casting_costs(ctx).select { it.respond_to?(:x?) && it.x? }
|
|
87
|
+
|
|
88
|
+
# A {T} ability's own source can't pay for it.
|
|
89
|
+
def payment_excluded_source(ctx)
|
|
90
|
+
ctx["type"] == "cast" && ctx["kind"] == "ability" && needs_tap?(casting_ability) ? ctx["source"] : nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The largest X the player could pay for: the mana available beyond the rest of the cost, and for
|
|
94
|
+
# "pay X life" no more than their life total (CR 119.4), for "exile X cards" no more than there are (CR 118.3).
|
|
95
|
+
def x_limit(ctx)
|
|
96
|
+
limits = x_costs(ctx).map { it.x_limit(self, cost_payer(ctx)) }
|
|
97
|
+
symbols = base_cost(ctx)&.x_symbols.to_i
|
|
98
|
+
return limits.min if symbols.zero?
|
|
99
|
+
|
|
100
|
+
cost = frame_total_cost(ctx, x_value: 0, targets: nil)
|
|
101
|
+
spare = mana_units(ctx["player"], payment_excluded_source(ctx), payment_purpose(ctx)).size - cost.mana_value
|
|
102
|
+
(limits << ([spare, 0].max / symbols)).min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# CR 601.2b / 107.3a: the value of X is announced; the spell or ability keeps it on the stack.
|
|
106
|
+
def announce_x!(value)
|
|
107
|
+
ctx = casting
|
|
108
|
+
ctx["x"] = value
|
|
109
|
+
object(ctx["object"]).x = value
|
|
110
|
+
emit Events::XAnnounced.new(player: name(ctx["player"]), object: ctx["object"], value: value)
|
|
111
|
+
ask!(nil)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
include Costs
|
|
116
|
+
end
|
|
117
|
+
end
|