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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Auras and Equipment on the battlefield (CR 301.5, 303.4, 701.3): attaching, and the
|
|
6
|
+
# state-based actions that clean up illegal attachments (CR 704.5m, 704.5n, 704.5p).
|
|
7
|
+
# An attachment remembers the id it is attached to; when that object leaves the
|
|
8
|
+
# battlefield it becomes a new object (CR 400.7), so the stale id is cleaned up here.
|
|
9
|
+
module Attachments
|
|
10
|
+
def aura?(obj) = chars(obj).subtypes.include?(:aura)
|
|
11
|
+
def equipment?(obj) = chars(obj).subtypes.include?(:equipment)
|
|
12
|
+
|
|
13
|
+
# The objects attached to `id`, in battlefield order.
|
|
14
|
+
def attachments_of(id) = state.battlefield.select { object(it).attached_to == id }
|
|
15
|
+
|
|
16
|
+
# CR 700.9: equipped, or enchanted by an Aura its controller controls, makes a permanent modified.
|
|
17
|
+
def modifying_attachment?(obj)
|
|
18
|
+
attachments_of(obj.id).any? do
|
|
19
|
+
attachment = object(it)
|
|
20
|
+
equipment?(attachment) || (aura?(attachment) && attachment.controller == obj.controller)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether `attachment` could legally be attached to the permanent `target_id` (CR 701.3a): not to a
|
|
25
|
+
# permanent with protection from it (CR 702.16c-d).
|
|
26
|
+
def can_attach?(attachment, target_id)
|
|
27
|
+
return false if target_id == attachment.id || !state.battlefield.include?(target_id) # CR 301.5c, 303.4d
|
|
28
|
+
return false if protected_from?(object(target_id), attachment)
|
|
29
|
+
|
|
30
|
+
if aura?(attachment)
|
|
31
|
+
enchantable?(attachment, target_id)
|
|
32
|
+
elsif equipment?(attachment)
|
|
33
|
+
!chars(attachment).creature? && chars(target_id).creature? # CR 301.5, 301.5c
|
|
34
|
+
else
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# CR 702.5a, 702.5c, 303.4d: what the Aura's enchant abilities allow (an Aura creature can't enchant).
|
|
40
|
+
def enchantable?(aura, target_id)
|
|
41
|
+
return false if chars(aura).creature?
|
|
42
|
+
|
|
43
|
+
enchants = chars(aura).abilities.map(&:ability).select { it.kind == :enchant }
|
|
44
|
+
!enchants.empty? && enchants.all? { it.filter.match_for?(self, target_id, aura.controller, aura.id) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# CR 701.3b-c: an illegal attachment doesn't move; reattaching to the same object does nothing.
|
|
48
|
+
def attach!(attachment, target_id)
|
|
49
|
+
return false if attachment.attached_to == target_id || !can_attach?(attachment, target_id)
|
|
50
|
+
|
|
51
|
+
attachment.attached_to = target_id
|
|
52
|
+
attachment.memory["attached_at"] = next_timestamp! # CR 613.7e: a new timestamp (AbilityLayers#timestamp_of)
|
|
53
|
+
emit Events::Attached.new(object: attachment.id, to: target_id)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# CR 701.3d: unattach (the permanent stays on the battlefield).
|
|
58
|
+
def unattach!(attachment)
|
|
59
|
+
from = attachment.attached_to or return
|
|
60
|
+
attachment.attached_to = nil
|
|
61
|
+
emit Events::Unattached.new(object: attachment.id, from: from)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# CR 704.5m: an Aura attached to an illegal object, or to nothing, goes to the graveyard.
|
|
65
|
+
def illegal_aura?(obj)
|
|
66
|
+
return false unless aura?(obj)
|
|
67
|
+
|
|
68
|
+
to = obj.attached_to
|
|
69
|
+
to.nil? || !can_attach?(obj, to)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# CR 704.5n (Equipment attached to an illegal permanent) and 704.5p (anything that's neither
|
|
73
|
+
# an Aura nor an Equipment, e.g. a permanent that stopped being one): it becomes unattached.
|
|
74
|
+
# Returns true when anything was unattached.
|
|
75
|
+
def unattach_illegal_attachments!
|
|
76
|
+
loose = state.battlefield.select do |id|
|
|
77
|
+
obj = object(id)
|
|
78
|
+
obj.attached_to && !aura?(obj) && !can_attach?(obj, obj.attached_to)
|
|
79
|
+
end
|
|
80
|
+
loose.each { unattach!(object(it)) }
|
|
81
|
+
!loose.empty?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
include Attachments
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Alternative costs given by keyword abilities (CR 118.9, Abilities::AlternativeCost): flashback, harmonize,
|
|
6
|
+
# mayhem, warp, evoke, web-slinging and sneak, plus convoke (CR 702.51), which changes how the total cost
|
|
7
|
+
# is paid. Each keyword is a cast *method* of Engine::Permissions:
|
|
8
|
+
# {"via" => "alt:<ref>", "play" => "cast" | "play", "keyword" => "flashback", "instant" => true (sneak),
|
|
9
|
+
# "alt" => {"cost" => "{2}{R}", "ref" => ref, "exile" => true, "returns" => "tapped" | "unblocked",
|
|
10
|
+
# "harmonize" => true}}
|
|
11
|
+
# and a warped card in exile has the method {"via" => "warped", "play" => "cast"} (CR 702.185a).
|
|
12
|
+
#
|
|
13
|
+
# How a spell was cast is remembered on the spell (memory "cast_from": the zone, "cast_with": the keyword)
|
|
14
|
+
# and on the permanent it becomes (CR 400.7d), and each player's turn log keeps the keywords of the
|
|
15
|
+
# spells they cast this turn ("cast_with"), for "if a spell was warped this turn" (CR 702.185c).
|
|
16
|
+
module AltCasting
|
|
17
|
+
HARMONIZE_TEXT = "Tap up to one untapped creature you control (harmonize)"
|
|
18
|
+
RETURN_TEXT = { "tapped" => "Return a tapped creature you control to its owner's hand",
|
|
19
|
+
"unblocked" => "Return an unblocked attacking creature you control to its owner's hand" }.freeze
|
|
20
|
+
|
|
21
|
+
# ---- methods ------------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# The cards in a hand with an alternative cost (only those can be cast from it with a method).
|
|
24
|
+
def alt_cost_hand(player) = player.hand.select { |id| chars(id).abilities.any? { it.ability.kind == :alternative_cost } }
|
|
25
|
+
|
|
26
|
+
# The alternative-cost methods of `obj` (a card `index` owns) that function now. A face-down card its owner
|
|
27
|
+
# may look at is seen face up (a foretold card, CR 702.143a, 406.3a).
|
|
28
|
+
def alt_methods(index, obj)
|
|
29
|
+
return [] unless obj.owner == index && obj.kind == "card" && may_look?(obj, index)
|
|
30
|
+
|
|
31
|
+
own = face_up(obj.id) { chars(obj).abilities }.filter_map do |inst|
|
|
32
|
+
ability = inst.ability
|
|
33
|
+
ability.method_for(inst.ref) if ability.kind == :alternative_cost && ability.functions?(self, obj, index)
|
|
34
|
+
end
|
|
35
|
+
own + warped_methods(index, obj)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CR 702.185a: "Its owner may cast this card after the current turn has ended for as long as it remains
|
|
39
|
+
# exiled." The exiled card remembers the turn it was warped in.
|
|
40
|
+
def warped_methods(_index, obj)
|
|
41
|
+
turn = obj.zone == :exile && obj.memory["warped"]
|
|
42
|
+
turn && state.turn > turn ? [{ "via" => "warped", "play" => "cast" }] : []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# CR 702.187b: mayhem needs the card to have been discarded this turn (TurnHistory stamps it).
|
|
46
|
+
def discarded_this_turn?(obj) = !turned_memory(obj, "discarded").nil?
|
|
47
|
+
|
|
48
|
+
# The non-mana parts of the alternative cost being paid (a list of costs, CR 601.2h).
|
|
49
|
+
def alt_cost_parts(obj, method)
|
|
50
|
+
ref = method&.dig("alt", "ref") or return []
|
|
51
|
+
chars(obj).ability(ref).choosing_costs
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def alt_parts_payable?(obj, method) = alt_cost_parts(obj, method).all? { it.payable?(self, obj) }
|
|
55
|
+
|
|
56
|
+
# ---- returning a creature (beam me up, web-slinging, sneak) -------------------------------------
|
|
57
|
+
|
|
58
|
+
# Whether an alternative cost returns a creature to its owner's hand (Engine::Permissions#beam_choices).
|
|
59
|
+
def returns_creature?(alt) = !alt.nil? && (alt.key?("beam") || alt.key?("returns"))
|
|
60
|
+
|
|
61
|
+
# The creatures `index` could return for the alternative cost `alt`: any (beam me up, CR 702.196a), a
|
|
62
|
+
# tapped one (web-slinging, CR 702.188a) or an unblocked attacking one (sneak, CR 702.190a).
|
|
63
|
+
def return_candidates(index, alt)
|
|
64
|
+
case alt["returns"]
|
|
65
|
+
when "tapped" then state.battlefield.select { (o = object(it)).controller == index && o.tapped && chars(o).creature? }
|
|
66
|
+
when "unblocked"
|
|
67
|
+
attackers.filter_map { |id, a| id if !a["blocked"] && object(id).controller == index && chars(id).creature? }
|
|
68
|
+
else beam_candidates(index, alt["not"])
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ---- harmonize (CR 702.180a-b) --------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def harmonize_candidates(index)
|
|
75
|
+
state.battlefield.select { (o = object(it)).controller == index && !o.tapped && chars(o).creature? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The largest reduction harmonize could give `index` now (for whether the card is castable).
|
|
79
|
+
def best_harmonize_power(index, method)
|
|
80
|
+
return 0 unless method.dig("alt", "harmonize")
|
|
81
|
+
|
|
82
|
+
harmonize_candidates(index).map { [chars(it).power.to_i, 0].max }.max || 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CR 702.180a: the total cost is reduced by generic mana equal to the tapped creature's power.
|
|
86
|
+
def harmonize_reduced(cost, power)
|
|
87
|
+
return cost if cost.nil? || !power.positive?
|
|
88
|
+
|
|
89
|
+
cost.with(generic: [cost.generic - power, 0].max)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Costs#frame_total_cost: the cast in progress's reduction.
|
|
93
|
+
def frame_harmonize_power(ctx) = ctx.dig("harmonize", "power").to_i
|
|
94
|
+
|
|
95
|
+
# CR 702.180b: the creature is chosen as the harmonize cost is chosen, before the total cost is locked.
|
|
96
|
+
def harmonize_pending?(ctx) = ctx["kind"] == "spell" && ctx.dig("method", "alt", "harmonize") && !ctx.key?("harmonize")
|
|
97
|
+
|
|
98
|
+
def ask_harmonize!(ctx)
|
|
99
|
+
ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => HARMONIZE_TEXT)
|
|
100
|
+
:wait
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Options: each untapped creature, or none (`{type: "cost"}`).
|
|
104
|
+
def harmonize_options(ctx) = harmonize_candidates(ctx["player"]).map { { object: it } } + [{}]
|
|
105
|
+
|
|
106
|
+
# The creature is tapped now (cancelling untaps it); its power is the reduction.
|
|
107
|
+
def choose_harmonize!(ctx, id)
|
|
108
|
+
ctx["harmonize"] = { "creature" => id, "power" => id ? [chars(id).power.to_i, 0].max : 0 }
|
|
109
|
+
if id
|
|
110
|
+
tap!(object(id))
|
|
111
|
+
ctx["tapped"] << id
|
|
112
|
+
end
|
|
113
|
+
ask!(nil)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The zone a spell is (being) cast from (CR 601.2a): remembered once cast, the cast frame's origin
|
|
117
|
+
# while it's being cast, or the zone a card is in before it's cast (when its cost is estimated).
|
|
118
|
+
def cast_origin(spell)
|
|
119
|
+
return spell.zone.to_s unless spell.zone == :stack
|
|
120
|
+
|
|
121
|
+
spell.memory["cast_from"] || state.frames.rfind { it["object"] == spell.id }&.dig("origin", "zone") || "hand"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ---- as the spell becomes cast --------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
# Where the spell was cast from and with which keyword (CR 601.2a, 400.7d); a warp spell's delayed
|
|
127
|
+
# trigger (CR 702.185a); the creatures that convoked it (CR 702.51c).
|
|
128
|
+
def record_cast_how!(obj, ctx)
|
|
129
|
+
obj.memory["cast_from"] = ctx.dig("origin", "zone") || "hand"
|
|
130
|
+
obj.memory["convoked"] = ctx["convoked"].dup if ctx["convoked"]
|
|
131
|
+
return unless ctx.dig("method", "alt") # mayhem without a cost: no keyword cost was paid
|
|
132
|
+
|
|
133
|
+
keyword = ctx.dig("method", "keyword") or return
|
|
134
|
+
obj.memory["cast_with"] = keyword
|
|
135
|
+
(turn_log(ctx["player"])["cast_with"] ||= []) << keyword
|
|
136
|
+
warp_delayed!(obj, ctx) if keyword == "warp"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def warp_delayed!(obj, ctx)
|
|
140
|
+
ability = chars(obj).ability(ctx.dig("method", "alt", "ref"))
|
|
141
|
+
create_delayed_trigger!("controller" => ctx["player"], "source" => obj.id, "card" => obj.card,
|
|
142
|
+
"ability" => ability.delayed.ref, "watched" => [obj.id])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The creature returned for beam me up, web-slinging or sneak goes to its owner's hand as the spell
|
|
146
|
+
# becomes cast (CR 601.2h); a sneak spell remembers what it was attacking (CR 702.190b).
|
|
147
|
+
def returned_creature!(obj, ctx)
|
|
148
|
+
creature = ctx["beam_creature"]
|
|
149
|
+
alt = ctx.dig("method", "alt")
|
|
150
|
+
obj.memory["sneak_attack"] = attackers.dig(creature, "target") if alt["returns"] == "unblocked"
|
|
151
|
+
move!(creature, :hand, reason: alt.key?("beam") ? "beam_me_up" : ctx.dig("method", "keyword"))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The permanent a spell becomes remembers how it was cast; a warp spell's permanent is watched by the
|
|
155
|
+
# delayed trigger; a sneak spell's enters tapped and attacking (CR 702.190b, 506.3a). Cards its costs
|
|
156
|
+
# exiled stay linked to it ("behold a Goblin and exile it", CR 607.2a).
|
|
157
|
+
def alt_enters!(spell, enters)
|
|
158
|
+
how = spell.memory.slice("cast_from", "cast_with", "mana_from", Mechanics::Linked::EXILED, "haste_while_controlled_by")
|
|
159
|
+
how["warp_spell"] = spell.id if spell.memory["cast_with"] == "warp"
|
|
160
|
+
(enters["memory"] ||= {}).merge!(how) unless how.empty?
|
|
161
|
+
target = spell.memory["sneak_attack"] or return
|
|
162
|
+
enters["tapped"] = true
|
|
163
|
+
enters["attacking"] = target if target.start_with?("player:") || state.battlefield.include?(target)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---- convoke (CR 702.51) and improvise (CR 702.126) ------------------------------------------
|
|
167
|
+
|
|
168
|
+
# Who helps pay: convoke taps creatures (for {1} or one mana of their color), improvise artifacts
|
|
169
|
+
# (for {1} only). Neither is an additional or alternative cost (CR 702.51b, 702.126b).
|
|
170
|
+
HELPERS = { "convoke" => :creature, "improvise" => :artifact }.freeze
|
|
171
|
+
|
|
172
|
+
def convoke?(spell) = helper_keywords(spell).include?("convoke")
|
|
173
|
+
def helper_keywords(spell) = spell.nil? ? [] : HELPERS.keys.select { chars(spell).keyword?(it.to_sym) } | granted_helpers(spell)
|
|
174
|
+
|
|
175
|
+
# "Noncreature spells you cast have improvise" (Abilities::SpellsHave, CR 611.3a).
|
|
176
|
+
def granted_helpers(spell)
|
|
177
|
+
board_abilities(:spells_have).flat_map { |perm, ability| ability.keywords_for(self, spell, perm.controller) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Mana units (Engine::ManaSources) the permanents `index` could convoke or improvise with add for
|
|
181
|
+
# `purpose`: one per untapped helper that isn't counted as a mana source, paying {1} or (convoke) one
|
|
182
|
+
# of its colors; an improvise-only unit has no type, so it pays only generic mana.
|
|
183
|
+
def convoke_units(index, purpose, excluded = [])
|
|
184
|
+
spell = purpose&.[]("spell")
|
|
185
|
+
spell = state.objects[spell] unless spell.is_a?(GameObject)
|
|
186
|
+
kinds = helper_keywords(spell)
|
|
187
|
+
return [] if kinds.empty?
|
|
188
|
+
|
|
189
|
+
sources = all_mana_sources(index).map(&:first)
|
|
190
|
+
kinds.flat_map { convoke_candidates(index, it) }.uniq.reject { excluded.include?(it) || sources.include?(it) }.map do |id|
|
|
191
|
+
kinds.include?("convoke") && chars(id).creature? ? chars(id).colors : []
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def convoke_candidates(index, kind = "convoke")
|
|
196
|
+
state.battlefield.select { (o = object(it)).controller == index && !o.tapped && chars(o).type?(HELPERS[kind]) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# pay_cost options: `{type: "convoke" | "improvise", object: id}` for each helper that can pay part of what is left.
|
|
200
|
+
def convoke_options
|
|
201
|
+
frame = payment
|
|
202
|
+
return [] unless frame["type"] == "cast" && frame["kind"] == "spell"
|
|
203
|
+
|
|
204
|
+
helper_keywords(object(frame["object"])).flat_map do |kind|
|
|
205
|
+
convoke_candidates(frame["player"], kind).filter_map do |id|
|
|
206
|
+
{ type: kind, object: id } if convoke_slot(frame, id, kind)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The part of the remaining cost the permanent pays: for convoke a colored or hybrid symbol of one of its
|
|
212
|
+
# colors, else generic (or half of a {2/B} paid as {2}, CR 107.4e, 601.2b); for improvise only generic (one
|
|
213
|
+
# that keeps the rest payable once the payment is irreversible).
|
|
214
|
+
def convoke_slot(frame, id, kind = "convoke")
|
|
215
|
+
rem = frame["remaining"]
|
|
216
|
+
colors = kind == "convoke" ? chars(id).colors : []
|
|
217
|
+
slots = colors.flat_map { pay_slots(rem, it.to_s) }.select { %i[color hybrid].include?(it.first) }.uniq
|
|
218
|
+
slots << [:generic] if rem["generic"].positive?
|
|
219
|
+
slots += pay_slots(rem, "C").select { it.first == :two_generic }
|
|
220
|
+
return slots.first unless frame["irreversible"]
|
|
221
|
+
|
|
222
|
+
pl = player(frame["player"])
|
|
223
|
+
slots.find do |slot|
|
|
224
|
+
payable_with?(frame, remaining_cost(after_paying(rem, slot)), pl.mana_pool.dup, payment_reserved(frame) + [id],
|
|
225
|
+
restricted: pl.restricted_mana)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# CR 702.51a, 702.126a: tapping the permanent pays that mana; cancelling untaps it.
|
|
230
|
+
def convoke!(id, kind = "convoke")
|
|
231
|
+
frame = payment
|
|
232
|
+
frame["remaining"] = after_paying(frame["remaining"], convoke_slot(frame, id, kind))
|
|
233
|
+
tap!(object(id))
|
|
234
|
+
frame["tapped"] << id
|
|
235
|
+
(frame["convoked"] ||= []) << id if kind == "convoke" # CR 702.51c
|
|
236
|
+
ask!(nil)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# ---- history ------------------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# CR 702.187b: a card discarded this turn is stamped in the graveyard (TurnHistory#object_memory!).
|
|
242
|
+
def discard_recorded!(event)
|
|
243
|
+
return unless event.reason == "discard" && event.to == "graveyard" && state.objects.key?(event.new_id)
|
|
244
|
+
|
|
245
|
+
object_memory!(event.new_id, "discarded")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Whether any player cast a spell with `keyword`'s cost this turn (CR 702.185c: "a spell was warped").
|
|
249
|
+
def cast_with_this_turn?(keyword) = state.players.each_index.any? { Array(turn_log(it)["cast_with"]).include?(keyword) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
include AltCasting
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Choices made while casting or activating, besides targets and mana (CR 601.2b-d, 601.2h, 602.2b):
|
|
6
|
+
# kicker and other additional costs, how many modes, dividing an amount among targets, costs that
|
|
7
|
+
# need a choice (tap or sacrifice a permanent, remove counters), and "activate only once each turn".
|
|
8
|
+
module CastChoices
|
|
9
|
+
# ---- additional costs of spells (CR 601.2b, 601.2f, 702.33) ----------------------------
|
|
10
|
+
|
|
11
|
+
def spell_cost_abilities(obj, kind) = chars(obj).abilities.map(&:ability).select { it.kind == kind }
|
|
12
|
+
|
|
13
|
+
# CR 601.2f / 118.3: the spell's mandatory non-mana additional costs can be paid.
|
|
14
|
+
def additional_costs_payable?(obj) = spell_cost_abilities(obj, :additional_cost).all? { it.payable?(self, obj) }
|
|
15
|
+
|
|
16
|
+
# The costs being paid, in order: an ability's own costs; a spell's additional costs (and kicker's).
|
|
17
|
+
def casting_costs(ctx)
|
|
18
|
+
case ctx["kind"]
|
|
19
|
+
when "ability" then frame_costs(ctx) # with the chosen option of each one_of (Engine::CostOptions)
|
|
20
|
+
when "spell" then frame_costs(ctx).reject(&:mana?) + alt_cost_parts(object(ctx["object"]), ctx["method"]) # Engine::AltCasting
|
|
21
|
+
else []
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# CR 601.2b / 702.33a: true while waiting for the kicker announcement.
|
|
26
|
+
def ask_kicker!(ctx)
|
|
27
|
+
return false unless ctx["kind"] == "spell" && ctx["kicked"].nil?
|
|
28
|
+
|
|
29
|
+
kicker = spell_cost_abilities(object(ctx["object"]), :kicker).first
|
|
30
|
+
return ctx["kicked"] = false unless kicker
|
|
31
|
+
|
|
32
|
+
ask!("kind" => "choose_kicker", "player" => ctx["player"], "cost" => kicker.costs.join(", "))
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# "Kick" when the kicked total could be paid (CR 118.3, 601.2f) and the kicked spell has targets;
|
|
37
|
+
# "don't kick" when the spell has targets without it (CR 702.33g).
|
|
38
|
+
def kicker_options
|
|
39
|
+
ctx = casting
|
|
40
|
+
obj = object(ctx["object"])
|
|
41
|
+
spell = spell_ability_of(obj)
|
|
42
|
+
kick = kicked_castable?(ctx["player"], obj)
|
|
43
|
+
plain = ability_targetable?(spell, ctx["player"], obj.id)
|
|
44
|
+
(kick ? [{ type: "kick" }] : []) + (plain ? [{ type: "no_kick" }] : [])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the spell could be cast kicked: kicker payable, the kicked total affordable and its
|
|
48
|
+
# kicked targets (or normal ones) available.
|
|
49
|
+
def kicked_castable?(index, obj)
|
|
50
|
+
return false if spell_cost_abilities(obj, :kicker).empty?
|
|
51
|
+
|
|
52
|
+
spell = spell_ability_of(obj)
|
|
53
|
+
targetable = if spell.nil? || spell.modal?
|
|
54
|
+
ability_targetable?(spell, index,
|
|
55
|
+
obj.id)
|
|
56
|
+
else
|
|
57
|
+
targets_available?(spell.targets_for([], kicked: true),
|
|
58
|
+
index, obj.id)
|
|
59
|
+
end
|
|
60
|
+
targetable && cost_variant_affordable?(index, spell_cost_list(obj, true), obj, chars(obj).mana_cost, nil, { "spell" => obj.id }) do
|
|
61
|
+
total_cost(index, it, spell: obj)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The spell remembers it at once, so a "choose both if ..." mode count sees it (CR 601.2b, 702.194b);
|
|
66
|
+
# `kicked_with` names the optional cost paid (kicker, teamwork, an optional additional cost); only kicker
|
|
67
|
+
# makes the spell kicked (CR 702.33d).
|
|
68
|
+
def announce_kicker!(kicked)
|
|
69
|
+
ctx = casting
|
|
70
|
+
ctx["kicked"] = kicked
|
|
71
|
+
if kicked
|
|
72
|
+
obj = object(ctx["object"])
|
|
73
|
+
optional = spell_cost_abilities(obj, :kicker).first
|
|
74
|
+
obj.memory["kicked_with"] = optional.keyword
|
|
75
|
+
obj.memory["kicked"] = true if optional.kicks?
|
|
76
|
+
end
|
|
77
|
+
ask!(nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# ---- modes (CR 700.2) ---------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# [min, max], determined once as modes are first chosen.
|
|
83
|
+
def mode_range(ctx, ability)
|
|
84
|
+
ctx["choose"] ||= ability.choice_range(self, Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"],
|
|
85
|
+
ctx["object"], nil))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def modes_pending?(ctx, ability)
|
|
89
|
+
min, max = mode_range(ctx, ability)
|
|
90
|
+
chosen = ctx["modes"].size
|
|
91
|
+
chosen < min || (chosen < max && !ctx["modes_done"] && !choosable_modes(ctx, ability).empty?)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# CR 601.2b / 700.2: modes still choosable (distinct, with legal targets, and for "choose one that
|
|
95
|
+
# hasn't been chosen" not chosen before for this object).
|
|
96
|
+
def choosable_modes(ctx, ability)
|
|
97
|
+
modes = available_modes(ability, ctx["player"], ctx["object"], ctx["source"]) - ctx["modes"]
|
|
98
|
+
(modes - used_modes(ability, ctx["source"], ctx["ability"])).select { mode_affordable?(ctx, ability.modes[it]) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A spell's mode with a cost (tiered, CR 702.183a) is offered only if the spell could still be paid for
|
|
102
|
+
# with it (CR 601.2f, 118.3).
|
|
103
|
+
def mode_affordable?(ctx, mode)
|
|
104
|
+
base = mode.cost && base_cost(ctx) or return true
|
|
105
|
+
|
|
106
|
+
obj = object(ctx["object"])
|
|
107
|
+
total = total_cost(ctx["player"], Mana.sum_costs([base, mode.cost]), spell: obj)
|
|
108
|
+
affordable?(ctx["player"], total, nil, { "spell" => obj.id })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Modes chosen before for this source's "choose one that hasn't been chosen" ability `ref`.
|
|
112
|
+
def used_modes(ability, source, ref)
|
|
113
|
+
return [] unless ability.extras.unchosen
|
|
114
|
+
|
|
115
|
+
state.objects[source]&.memory&.dig("chosen_modes", ref) || []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def mode_options
|
|
119
|
+
ctx = casting
|
|
120
|
+
ability = casting_ability
|
|
121
|
+
options = choosable_modes(ctx, ability).map { { type: "mode", mode: it } }
|
|
122
|
+
ctx["modes"].size >= mode_range(ctx, ability).first ? options + [{ type: "done" }] : options
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def choose_mode!(index)
|
|
126
|
+
casting["modes"] << index
|
|
127
|
+
ask!(nil)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def modes_done!
|
|
131
|
+
casting["modes_done"] = true
|
|
132
|
+
ask!(nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ---- dividing among targets (CR 601.2d) ------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
# Each chosen target gets one; the rest is assigned one at a time when there's a choice.
|
|
138
|
+
def ask_distribution!(ctx, ability)
|
|
139
|
+
amount = divided_amount(ctx, ability) or return false
|
|
140
|
+
chosen = ctx["targets"].flatten.compact
|
|
141
|
+
ctx["division"] ||= chosen.to_h { [it, 1] }
|
|
142
|
+
left = amount - ctx["division"].values.sum
|
|
143
|
+
return false unless left.positive? && chosen.any?
|
|
144
|
+
|
|
145
|
+
if chosen.size == 1
|
|
146
|
+
ctx["division"][chosen.first] += left
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
ask!("kind" => "distribute", "player" => ctx["player"], "left" => left)
|
|
151
|
+
true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The amount divided (CR 601.2d), an amount such as "X damage, where X is the number of lands you
|
|
155
|
+
# control": computed once, as the spell or ability is put on the stack, and kept in the frame.
|
|
156
|
+
def divided_amount(ctx, ability)
|
|
157
|
+
divide = ability&.extras&.divide or return nil
|
|
158
|
+
ctx["divide"] ||= [divide.value(self, Mechanics::EffectContext.new(ctx["player"], ctx["source"] || ctx["object"],
|
|
159
|
+
ctx["object"], nil, [])), 0].max
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def distribute_options = casting["division"].keys.map { { type: "assign", target: it } }
|
|
163
|
+
|
|
164
|
+
def distribute!(ref)
|
|
165
|
+
casting["division"][ref] += 1
|
|
166
|
+
ask!(nil)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# ---- costs that need a choice (CR 601.2h, 602.2b) -----------------------------------------
|
|
170
|
+
|
|
171
|
+
# The index (in casting_costs) of the next cost that still needs a choice.
|
|
172
|
+
def next_cost_choice(ctx, _ability = nil)
|
|
173
|
+
made = (ctx["paid"] || []).group_by { it["cost"] }
|
|
174
|
+
casting_costs(ctx).each_with_index.find do |cost, i|
|
|
175
|
+
cost.respond_to?(:choices_needed) && !cost_chosen?(ctx, cost, i, made.fetch(i, []))
|
|
176
|
+
end&.last
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A cost with a fixed number of choices is chosen once it has them; one of "any number" (crew, CR
|
|
180
|
+
# 702.122a) says itself, given whether the player said they're done.
|
|
181
|
+
def cost_chosen?(ctx, cost, index, entries)
|
|
182
|
+
return entries.size >= ctx["x"].to_i if cost.respond_to?(:x?) && cost.x? # "exile X cards" (CR 601.2b)
|
|
183
|
+
return entries.size >= cost.choices_needed unless cost.respond_to?(:chosen?)
|
|
184
|
+
|
|
185
|
+
cost.chosen?(self, cost_payer(ctx), entries, ctx.fetch("costs_done", []).include?(index))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Whether the player may stop choosing for the current cost ("any number", once enough is chosen).
|
|
189
|
+
def cost_choice_finishable?
|
|
190
|
+
ctx = casting
|
|
191
|
+
return false if harmonize_pending?(ctx) || beam_choice_pending?(ctx)
|
|
192
|
+
|
|
193
|
+
index = next_cost_choice(ctx)
|
|
194
|
+
cost = casting_costs(ctx)[index]
|
|
195
|
+
cost.respond_to?(:finishable?) && cost.finishable?(cost_entries(ctx, index))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def finish_cost_choice!
|
|
199
|
+
ctx = casting
|
|
200
|
+
(ctx["costs_done"] ||= []) << next_cost_choice(ctx)
|
|
201
|
+
ask!(nil)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def ask_cost_choice!(ctx, cost)
|
|
205
|
+
ask!("kind" => "choose_cost", "player" => ctx["player"], "cost" => cost.to_s)
|
|
206
|
+
:wait
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The object a cost is paid for: the ability's source, or the spell itself.
|
|
210
|
+
def cost_payer(ctx) = object(ctx["kind"] == "ability" ? ctx["source"] : ctx["object"])
|
|
211
|
+
|
|
212
|
+
def cost_entries(ctx, index) = (ctx["paid"] || []).select { it["cost"] == index }
|
|
213
|
+
|
|
214
|
+
def cost_choice_candidates
|
|
215
|
+
ctx = casting
|
|
216
|
+
return harmonize_options(ctx) if harmonize_pending?(ctx) # Engine::AltCasting
|
|
217
|
+
return beam_choices(ctx).map { { object: it } } if beam_choice_pending?(ctx) # Engine::Permissions
|
|
218
|
+
|
|
219
|
+
index = next_cost_choice(ctx)
|
|
220
|
+
casting_costs(ctx)[index].choice_options(self, cost_payer(ctx), cost_entries(ctx, index))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Records (and for tapping, pays) the chosen part of the cost (costs may be paid in any order,
|
|
224
|
+
# CR 601.2h). Cancelling the cast untaps what was tapped (ctx "tapped").
|
|
225
|
+
def choose_cost!(option)
|
|
226
|
+
ctx = casting
|
|
227
|
+
return choose_harmonize!(ctx, option[:object]) if harmonize_pending?(ctx) # Engine::AltCasting
|
|
228
|
+
return choose_beam_creature!(option[:object]) if beam_choice_pending?(ctx) # Engine::Permissions
|
|
229
|
+
|
|
230
|
+
index = next_cost_choice(ctx)
|
|
231
|
+
entry = casting_costs(ctx)[index].choose!(self, option).merge("cost" => index)
|
|
232
|
+
ctx["tapped"] << entry["object"] if entry["tapped"]
|
|
233
|
+
(ctx["paid"] ||= []) << entry
|
|
234
|
+
ask!(nil)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# As the spell or ability becomes cast: sacrifices and counter removals chosen earlier happen now.
|
|
238
|
+
def pay_chosen_costs!(ctx)
|
|
239
|
+
payer = state.objects[ctx["kind"] == "ability" ? ctx["source"] : ctx["object"]]
|
|
240
|
+
casting_costs(ctx).each_with_index do |cost, i|
|
|
241
|
+
cost.pay_chosen!(self, payer, cost_entries(ctx, i)) if cost.respond_to?(:pay_chosen!)
|
|
242
|
+
end
|
|
243
|
+
object(ctx["object"]).paid = Snapshot.deep_copy(ctx["paid"]) if ctx["paid"] # with what was sacrificed
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ---- once each turn (CR 602.5b) ----------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def activations_this_turn(obj, ref)
|
|
249
|
+
entry = obj.memory.dig("activations", ref)
|
|
250
|
+
entry && entry["turn"] == state.turn ? entry["count"] : 0
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def activation_allowed?(index, obj, ref, ability)
|
|
254
|
+
return true unless ability.respond_to?(:restriction_met?)
|
|
255
|
+
return false if ability.per_turn && activations_this_turn(obj, ref) >= ability.per_turn
|
|
256
|
+
return false if ability.once? && obj.memory.fetch("exhausted", []).include?(ref) # CR 602.5b, 702.177a, 702.193a
|
|
257
|
+
|
|
258
|
+
ability.restriction_met?(self, index, obj.id)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Kept on the source object, so the limit applies even if its controller changes (CR 602.5b). An exhaust or
|
|
262
|
+
# power-up ability is remembered for as long as the object exists (CR 702.177a, 702.193a; a new object can
|
|
263
|
+
# again, CR 400.7).
|
|
264
|
+
def record_activation!(ctx)
|
|
265
|
+
source = state.objects[ctx["source"]] or return
|
|
266
|
+
ability = chars(source).ability(ctx["ability"])
|
|
267
|
+
(source.memory["exhausted"] ||= []) << ctx["ability"] if ability.respond_to?(:once?) && ability.once?
|
|
268
|
+
return unless ability.respond_to?(:per_turn) && ability.per_turn
|
|
269
|
+
|
|
270
|
+
(source.memory["activations"] ||= {})[ctx["ability"]] =
|
|
271
|
+
{ "turn" => state.turn, "count" => activations_this_turn(source, ctx["ability"]) + 1 }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# What was announced while casting stays with the spell or ability (CR 702.33d, 601.2d).
|
|
275
|
+
def record_announcements!(obj, ctx)
|
|
276
|
+
obj.memory["kicked"] = true if ctx["kicked"] && spell_cost_abilities(obj, :kicker).first&.kicks? # CR 702.33d
|
|
277
|
+
obj.memory["division"] = ctx["division"].dup if ctx["division"]
|
|
278
|
+
record_chosen_modes!(ctx, casting_ability)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# "Choose one that hasn't been chosen": the source remembers the modes chosen for it.
|
|
282
|
+
def record_chosen_modes!(ctx, ability)
|
|
283
|
+
return unless ability&.modal? && ability.extras.unchosen
|
|
284
|
+
|
|
285
|
+
source = state.objects[ctx["source"]] or return
|
|
286
|
+
((source.memory["chosen_modes"] ||= {})[ctx["ability"]] ||= []).concat(ctx["modes"])
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
include CastChoices
|
|
291
|
+
end
|
|
292
|
+
end
|