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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Keywords a spell on the stack has besides its own (CR 113.6, 611.3a): granted by a resolved effect ("those
|
|
6
|
+
# spells gain wither", a `grant` effect on the spell) or by a static ability of a permanent ("Instant and
|
|
7
|
+
# sorcery spells you cast from your hand have cascade", ability `spell_keywords`). A keyword that stands for
|
|
8
|
+
# an ability brings that ability (CR 702.85a: a granted cascade triggers as the spell is cast).
|
|
9
|
+
module SpellGrants
|
|
10
|
+
def with_spell_grants(obj, base)
|
|
11
|
+
return base if obj.ability? || @granting&.include?(obj.id) # a static's filter reads the spell's own keywords
|
|
12
|
+
|
|
13
|
+
keywords = granting(obj.id) { granted_spell_keywords(obj) } - base.keywords
|
|
14
|
+
return base if keywords.empty?
|
|
15
|
+
|
|
16
|
+
base.with(keywords: (base.keywords + keywords).freeze,
|
|
17
|
+
abilities: (base.abilities + keywords.flat_map { CardDefinition.keyword_instances(it) }).freeze)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def granting(id)
|
|
23
|
+
(@granting ||= []) << id
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
@granting.delete(id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def granted_spell_keywords(obj)
|
|
30
|
+
from_effects = state.effects.select { it["kind"] == "grant" && it["affected"].include?(obj.id) }
|
|
31
|
+
.flat_map { it["keywords"].map(&:to_sym) }
|
|
32
|
+
from_statics = state.battlefield.flat_map do |id|
|
|
33
|
+
source = object(id)
|
|
34
|
+
next [] unless source.controller == obj.controller
|
|
35
|
+
|
|
36
|
+
chars(source).abilities.flat_map do |inst|
|
|
37
|
+
inst.ability.kind == :spell_keywords && inst.ability.applies_to?(self, obj, source) ? inst.ability.keywords : []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
(from_effects + from_statics).uniq
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
include SpellGrants
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# State-based actions (CR 704) and the end of the game (CR 104).
|
|
6
|
+
module StateBasedActions
|
|
7
|
+
def check_state_based_actions!
|
|
8
|
+
loop do
|
|
9
|
+
check_player_state_based_actions!
|
|
10
|
+
break if state.over? || !check_permanent_state_based_actions!
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def check_player_state_based_actions!
|
|
15
|
+
state.players.each_with_index do |pl, i|
|
|
16
|
+
next if pl.lost
|
|
17
|
+
|
|
18
|
+
reason = if loses_for_life?(i) then "life" # CR 704.5a (unless an effect says otherwise)
|
|
19
|
+
elsif pl.drew_from_empty then "empty_library" # CR 704.5b
|
|
20
|
+
elsif pl.poison >= 10 then "poison" # CR 704.5c
|
|
21
|
+
end
|
|
22
|
+
pl.drew_from_empty = false
|
|
23
|
+
lose!(i, reason) if reason
|
|
24
|
+
end
|
|
25
|
+
end_game_if_decided!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def concede!(index)
|
|
29
|
+
lose!(index, "concede") # CR 104.3a
|
|
30
|
+
end_game_if_decided!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def lose!(index, reason)
|
|
34
|
+
player(index).lost = reason
|
|
35
|
+
emit Events::PlayerLost.new(player: name(index), reason: reason)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CR 104.2a: last player standing wins; CR 104.4a: all players losing at once is a draw.
|
|
39
|
+
def end_game_if_decided!
|
|
40
|
+
return if state.over?
|
|
41
|
+
|
|
42
|
+
losers = state.players.select(&:lost)
|
|
43
|
+
return if losers.empty?
|
|
44
|
+
|
|
45
|
+
survivors = state.players.reject(&:lost)
|
|
46
|
+
winner = survivors.size == 1 ? survivors.first.name : nil
|
|
47
|
+
reason = losers.map(&:lost).uniq.join(",")
|
|
48
|
+
state.result = { "winner" => winner, "reason" => reason }
|
|
49
|
+
ask!(nil)
|
|
50
|
+
state.frames.clear
|
|
51
|
+
emit Events::GameEnded.new(winner: winner, reason: reason)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# CR 704.5d, 704.5f, 704.5g, 704.5i, 704.5m, 704.5n, 704.5q. All applicable actions happen
|
|
55
|
+
# simultaneously (CR 704.3). Returns true when anything happened, so the check repeats.
|
|
56
|
+
def check_permanent_state_based_actions!
|
|
57
|
+
ceased = remove_tokens_outside_battlefield! # CR 704.5d
|
|
58
|
+
ceased = annihilate_counters! || ceased # CR 704.5q
|
|
59
|
+
ceased = unattach_illegal_attachments! || ceased # CR 704.5n, 704.5p
|
|
60
|
+
ceased = start_engines! || ceased # CR 704.5aa (Engine::Speed)
|
|
61
|
+
ceased = tell_enduring_stories! || ceased # CR 702.195a (Engine::Designations)
|
|
62
|
+
doomed = nil
|
|
63
|
+
state.battlefield.each do |id|
|
|
64
|
+
obj = object(id)
|
|
65
|
+
defn = chars(obj)
|
|
66
|
+
reason = if defn.creature? then creature_sba(obj)
|
|
67
|
+
elsif defn.planeswalker? && loyalty(obj) <= 0 then "sba_loyalty"
|
|
68
|
+
end
|
|
69
|
+
reason ||= "sba_aura" if illegal_aura?(obj) # CR 704.5m
|
|
70
|
+
reason ||= "sacrifice" if saga_complete?(obj) # CR 704.5s: its controller sacrifices it
|
|
71
|
+
(doomed ||= []) << [id, reason] if reason
|
|
72
|
+
end
|
|
73
|
+
return ceased unless doomed
|
|
74
|
+
|
|
75
|
+
simultaneously { doomed.each { |id, reason| move!(id, :graveyard, reason: reason) } } # CR 704.3, 603.10a
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# CR 704.5q, 122.3: N +1/+1 and N -1/-1 counters are removed, N the smaller count.
|
|
80
|
+
def annihilate_counters!
|
|
81
|
+
hit = state.battlefield.filter_map do |id|
|
|
82
|
+
obj = object(id)
|
|
83
|
+
n = [obj.counters.fetch("+1/+1", 0), obj.counters.fetch("-1/-1", 0)].min
|
|
84
|
+
[obj, n] if n.positive?
|
|
85
|
+
end
|
|
86
|
+
hit.each do |obj, n|
|
|
87
|
+
remove_counters!(obj, "+1/+1", n)
|
|
88
|
+
remove_counters!(obj, "-1/-1", n)
|
|
89
|
+
end
|
|
90
|
+
!hit.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# CR 704.5d / 111.7: a token in a zone other than the battlefield ceases to exist. Phased-out tokens are
|
|
94
|
+
# still on the battlefield (CR 702.26d), see Engine::Copies#ceases_to_exist?.
|
|
95
|
+
def remove_tokens_outside_battlefield!
|
|
96
|
+
gone = state.objects.values.select { ceases_to_exist?(it) } # and copies, CR 707.10a (Engine::Copies)
|
|
97
|
+
gone.each do |obj|
|
|
98
|
+
state.zone_list(obj.zone, obj.owner).delete(obj.id)
|
|
99
|
+
state.objects.delete(obj.id)
|
|
100
|
+
emit Events::TokenCeased.new(object: obj.id, card: obj.card, zone: obj.zone.to_s)
|
|
101
|
+
end
|
|
102
|
+
!gone.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# CR 704.5j: the legend rule needs a choice, so it runs as a frame before priority.
|
|
106
|
+
def legend_rule_first!(index)
|
|
107
|
+
return false if state.frames.any? { it["type"] == "legend_rule" }
|
|
108
|
+
|
|
109
|
+
conflict = legend_conflicts.first or return false
|
|
110
|
+
state.frames << { "type" => "legend_rule", "then" => index, "player" => conflict[0], "name" => conflict[1] }
|
|
111
|
+
ask!(nil)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# [[controller, card name, [ids]]] for every legend-rule violation.
|
|
116
|
+
def legend_conflicts
|
|
117
|
+
groups = state.battlefield.group_by do |id|
|
|
118
|
+
c = chars(id)
|
|
119
|
+
c.legendary? ? [object(id).controller, c.name] : nil
|
|
120
|
+
end
|
|
121
|
+
groups.filter_map { |key, ids| [key[0], key[1], ids] if key && ids.size > 1 }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def legend_candidates(frame)
|
|
125
|
+
legend_conflicts.find { |player, name, _| player == frame["player"] && name == frame["name"] }&.last || []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def step_legend_rule!(frame)
|
|
129
|
+
candidates = legend_candidates(frame)
|
|
130
|
+
if candidates.size > 1 && !frame["keep"]
|
|
131
|
+
ask!("kind" => "legend_rule", "player" => frame["player"], "name" => frame["name"])
|
|
132
|
+
return :wait
|
|
133
|
+
end
|
|
134
|
+
(candidates - [frame["keep"]]).each { move!(it, :graveyard, reason: "sba_legend_rule") }
|
|
135
|
+
give_priority!(frame["then"]) # state-based actions are checked again (CR 704.3)
|
|
136
|
+
:done
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def keep_legend!(id)
|
|
140
|
+
state.frames.rfind { it["type"] == "legend_rule" }["keep"] = id
|
|
141
|
+
ask!(nil)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def creature_sba(obj)
|
|
145
|
+
toughness = toughness(obj)
|
|
146
|
+
return "sba_toughness" if toughness <= 0 # CR 704.5f (indestructible doesn't help)
|
|
147
|
+
return nil if chars(obj).keyword?(:indestructible) # CR 702.12b
|
|
148
|
+
|
|
149
|
+
if obj.damage.positive? && obj.damage >= toughness then "sba_lethal_damage" # CR 704.5g
|
|
150
|
+
elsif obj.deathtouched then "sba_deathtouch" # CR 704.5h (wither damage is dealt but not marked, CR 702.80a)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
include StateBasedActions
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class Engine
|
|
5
|
+
# Triggered abilities (CR 603): matching events, the pending queue, and putting
|
|
6
|
+
# triggers on the stack before a player receives priority (CR 117.5, 603.3).
|
|
7
|
+
module Triggering
|
|
8
|
+
# The object as it last existed on the battlefield, while its zone-change event is
|
|
9
|
+
# being matched (CR 603.10a). Nil otherwise.
|
|
10
|
+
# A permanent that left earlier in the same simultaneous zone change is remembered too (#simultaneously).
|
|
11
|
+
def last_known(id) = @last_known&.[](id) || @departed_together&.dig(id, "lki")
|
|
12
|
+
|
|
13
|
+
# What a permanent looked like as it last existed on the battlefield (CR 603.10a, 608.2h):
|
|
14
|
+
# controller and current characteristics, so "a creature you control dies" checks the
|
|
15
|
+
# types it had then, not its printed ones.
|
|
16
|
+
def last_known_info(obj)
|
|
17
|
+
current = chars(obj)
|
|
18
|
+
{ "controller" => obj.controller, "card" => obj.card, "types" => current.types, "subtypes" => current.subtypes,
|
|
19
|
+
"power" => current.power, "toughness" => current.toughness, "counters" => obj.counters.dup,
|
|
20
|
+
"keywords" => current.keywords.map(&:to_s), # deathtouch, lifelink, wither (CR 702.2e, 702.15c, 702.80b)
|
|
21
|
+
"token" => token?(obj), "except" => copy_exceptions(obj), "abilities" => current.abilities }
|
|
22
|
+
.merge(obj.memory.slice(Mechanics::Linked::EXILED)) # the cards exiled with it (CR 607.2a)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_last_known(id, info)
|
|
26
|
+
(@last_known ||= {})[id] = info
|
|
27
|
+
yield
|
|
28
|
+
ensure
|
|
29
|
+
@last_known&.delete(id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The activated or triggered ability on the stack named by `ref`: its object id, or the id of the pending
|
|
33
|
+
# trigger it was put on the stack from ("copy that ability", CR 707.10). Nil once it has left the stack.
|
|
34
|
+
def stack_object_for(ref) = state.stack.find { it == ref || object(it).memory["trigger_id"] == ref }
|
|
35
|
+
|
|
36
|
+
# Called for every event: queue every triggered ability whose condition matches. Only an ability_triggers
|
|
37
|
+
# trigger watches another ability triggering (Events::AbilityTriggered).
|
|
38
|
+
def watch_triggers!(event)
|
|
39
|
+
return if state.step.nil? # no triggers during the pregame
|
|
40
|
+
|
|
41
|
+
record_counter_placement!(event)
|
|
42
|
+
|
|
43
|
+
trigger_sources.each do |id, instances|
|
|
44
|
+
obj = state.objects[id] or next
|
|
45
|
+
source = { "id" => id, "card" => obj.card, "controller" => obj.controller }
|
|
46
|
+
instances.each { |inst| queue_trigger(inst, event, source) }
|
|
47
|
+
end
|
|
48
|
+
return if event.is_a?(Events::AbilityTriggered) # only abilities on the battlefield watch it (Triggers::AbilityTriggers)
|
|
49
|
+
|
|
50
|
+
watch_leaving!(event)
|
|
51
|
+
watch_own_cast!(event) if event.is_a?(Events::SpellCast)
|
|
52
|
+
watch_graveyards!(event)
|
|
53
|
+
watch_delayed!(event) unless state.delayed_triggers.empty?
|
|
54
|
+
watch_speed!(event) # CR 702.179d (Engine::Speed)
|
|
55
|
+
Mechanics::Keywords::Suspend.watch!(self, event) # CR 702.62a: suspended cards in exile
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# "For the first time each turn" (trigger counters_placed with first_time_this_turn): every placement of
|
|
59
|
+
# counters on a permanent is counted per kind in its memory, stamped with the turn, whether or not any
|
|
60
|
+
# ability triggers on it, so a later placement is never the first (CR 603.2, 122.6). Entering with
|
|
61
|
+
# counters is a placement (CR 122.6).
|
|
62
|
+
def record_counter_placement!(event)
|
|
63
|
+
kinds = case event
|
|
64
|
+
when Events::CountersChanged then event.to > event.from ? [event.counter] : []
|
|
65
|
+
when Events::ZoneChanged
|
|
66
|
+
obj = event.to == "battlefield" && state.objects[event.new_id]
|
|
67
|
+
obj ? obj.counters.select { |_, n| n.positive? }.keys : []
|
|
68
|
+
else []
|
|
69
|
+
end
|
|
70
|
+
return if kinds.empty?
|
|
71
|
+
|
|
72
|
+
id = event.is_a?(Events::CountersChanged) ? event.object : event.new_id
|
|
73
|
+
return unless state.objects.key?(id)
|
|
74
|
+
|
|
75
|
+
placed = object_memory!(id, "counters_placed")
|
|
76
|
+
(kinds + ["*"]).each { placed[it] = placed.fetch(it, 0) + 1 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# How many times counters of `kind` (nil: any kind) were put on `id` this turn.
|
|
80
|
+
def counter_placements_this_turn(id, kind)
|
|
81
|
+
turned_memory(state.objects[id], "counters_placed")&.fetch(kind || "*", 0).to_i
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# CR 603.7: creates a delayed triggered ability. `entry` names the card and the ability ref
|
|
85
|
+
# (see Effects::DelayedTrigger) plus what it remembers ("results", "watched" objects) and, for
|
|
86
|
+
# one with a duration ("this turn"), the turn it lasts for ("turn").
|
|
87
|
+
def create_delayed_trigger!(entry)
|
|
88
|
+
state.delayed_triggers << entry.merge("id" => "d#{next_timestamp!}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# CR 603.7b: a delayed triggered ability triggers only once, the next time its event occurs, unless
|
|
92
|
+
# it has a stated duration ("whenever ... this turn"): then it triggers each time until the turn ends.
|
|
93
|
+
def watch_delayed!(event)
|
|
94
|
+
state.delayed_triggers.reject! { it["turn"] && it["turn"] != state.turn } # "this turn" has ended
|
|
95
|
+
fired = []
|
|
96
|
+
state.delayed_triggers.each do |entry|
|
|
97
|
+
trigger = catalog.fetch(entry["card"]).ability(entry["ability"]).trigger
|
|
98
|
+
next unless trigger.watches?(event)
|
|
99
|
+
|
|
100
|
+
found = Mechanics::Triggers.occurrences(trigger, self, event, delayed_source(entry))
|
|
101
|
+
found = found.first(1) unless repeats?(entry)
|
|
102
|
+
fired << entry unless found.empty?
|
|
103
|
+
found.each do
|
|
104
|
+
queue_pending_trigger!(delayed_source(entry).merge("ability" => entry["ability"]), event, delayed_memory(entry, it))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
state.delayed_triggers -= fired.reject { repeats?(it) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# "Whenever ... this turn" triggers each time; "the next ... this turn" ("once") only the first time; one
|
|
111
|
+
# "for the rest of the game" ("game", paradigm, CR 702.192a) each time, forever.
|
|
112
|
+
def repeats?(entry) = (entry["turn"] && !entry["once"]) || entry["game"] == true
|
|
113
|
+
|
|
114
|
+
def delayed_memory(entry, facts)
|
|
115
|
+
{ "results" => entry["results"], "watched" => entry["watched"], "triggering" => facts }.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# CR 603.7d-e: its source and controller are those of the spell or ability that created it.
|
|
119
|
+
def delayed_source(entry)
|
|
120
|
+
{ "id" => entry["source"], "card" => entry["card"], "controller" => entry["controller"], "watched" => entry["watched"] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Battlefield permanents with triggered abilities, cached with the layer pass
|
|
124
|
+
# (it changes only on the same events that change characteristics).
|
|
125
|
+
def trigger_sources
|
|
126
|
+
return @trigger_sources if @trigger_sources && @trigger_epoch == @epoch && @trigger_state.equal?(state)
|
|
127
|
+
|
|
128
|
+
@trigger_epoch = @epoch
|
|
129
|
+
@trigger_state = state
|
|
130
|
+
@trigger_sources = (state.battlefield + (state.command || [])).filter_map do |id| # emblems too (CR 114.4)
|
|
131
|
+
instances = chars(id).triggered_abilities.select { it.ability.zone == :battlefield }
|
|
132
|
+
[id, instances] unless instances.empty?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# "When ~ dies" / "leaves": the leaving object's own abilities, as it last existed. Permanents that left
|
|
137
|
+
# earlier in the same simultaneous event look back too (CR 603.10a: an artifact destroyed along with
|
|
138
|
+
# creatures sees them die), see #simultaneously.
|
|
139
|
+
def watch_leaving!(event)
|
|
140
|
+
return unless event.is_a?(Events::ZoneChanged) && event.from == "battlefield"
|
|
141
|
+
|
|
142
|
+
lki = last_known(event.old_id) or return
|
|
143
|
+
@departed_together&.each_value { queue_leaving_triggers(it, event) }
|
|
144
|
+
gone = { "id" => event.old_id, "card" => event.card, "controller" => lki["controller"], "lki" => lki }
|
|
145
|
+
queue_leaving_triggers(gone, event)
|
|
146
|
+
@departed_together[event.old_id] = gone if @departed_together
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The abilities it had as it last existed (CR 603.10a): not those it had lost, but those it was granted.
|
|
150
|
+
def queue_leaving_triggers(gone, event)
|
|
151
|
+
source = gone.slice("id", "card", "controller")
|
|
152
|
+
gone["lki"]["abilities"].each do |inst|
|
|
153
|
+
ability = inst.ability
|
|
154
|
+
queue_trigger(inst, event, source) if ability.kind == :triggered && ability.zone == :battlefield &&
|
|
155
|
+
ability.trigger.leaves_battlefield?
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Zone changes made inside the block happen at once (one effect, or state-based actions, CR 704.3):
|
|
160
|
+
# leaves-the-battlefield abilities of permanents that left look back in time at the others (CR 603.10a).
|
|
161
|
+
def simultaneously
|
|
162
|
+
outer = @departed_together
|
|
163
|
+
@departed_together ||= {}
|
|
164
|
+
yield
|
|
165
|
+
ensure
|
|
166
|
+
@departed_together = outer
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# "When you cast this spell" (CR 601.2i): the spell's own ability, functioning on the stack.
|
|
170
|
+
def watch_own_cast!(event)
|
|
171
|
+
obj = state.objects[event.object] or return
|
|
172
|
+
source = { "id" => obj.id, "card" => obj.card, "controller" => obj.controller }
|
|
173
|
+
chars(obj).triggered_abilities.each do |inst|
|
|
174
|
+
queue_trigger(inst, event, source) if inst.ability.trigger.respond_to?(:own_spell?) && inst.ability.trigger.own_spell?
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# CR 113.6k: triggered abilities that function from a graveyard, controlled by the card's owner.
|
|
179
|
+
def watch_graveyards!(event)
|
|
180
|
+
state.players.each_with_index do |pl, owner|
|
|
181
|
+
pl.graveyard.each do |id|
|
|
182
|
+
instances = graveyard_triggers(object(id).card)
|
|
183
|
+
next if instances.empty?
|
|
184
|
+
|
|
185
|
+
source = { "id" => id, "card" => object(id).card, "controller" => owner }
|
|
186
|
+
instances.each { queue_trigger(it, event, source) }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def graveyard_triggers(card)
|
|
192
|
+
(@graveyard_triggers ||= {})[card] ||= catalog.fetch(card).ability_instances.select do |inst|
|
|
193
|
+
inst.ability.kind == :triggered && inst.ability.zone == :graveyard
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def queue_trigger(inst, event, source)
|
|
198
|
+
ability = inst.ability
|
|
199
|
+
trigger = ability.trigger
|
|
200
|
+
return unless trigger.watches?(event)
|
|
201
|
+
|
|
202
|
+
found = Mechanics::Triggers.occurrences(trigger, self, event, source)
|
|
203
|
+
return if found.empty?
|
|
204
|
+
|
|
205
|
+
# CR 603.4: an intervening "if" is checked for each trigger event, and may ask about it ("that spell")
|
|
206
|
+
found = found.select { ability.condition_met?(self, source["controller"], source["id"], facts: it) }
|
|
207
|
+
return if found.empty?
|
|
208
|
+
return if once_each_turn_used!(ability, inst.ref, source)
|
|
209
|
+
|
|
210
|
+
memory = trigger_memory(source)
|
|
211
|
+
batch = trigger.respond_to?(:batched?) && trigger.batched? ? event_batch : nil
|
|
212
|
+
times = 1 + additional_triggers(event, source)
|
|
213
|
+
card, ref, granter = AbilityLayers.granted_parts(inst.ref) # a granted ability lives in the granting card's data (CR 613.1f)
|
|
214
|
+
queued = source.merge("ability" => ref || inst.ref, "card" => card || source["card"])
|
|
215
|
+
memory = memory.merge("granter" => granter) if granter # CR 607.1a (Selector "granter")
|
|
216
|
+
found.first(limited?(ability) ? 1 : found.size).each do |facts|
|
|
217
|
+
next if batch && batched_pending!(source, queued["ability"], batch)
|
|
218
|
+
|
|
219
|
+
facts = facts.merge("amount" => 1) if batch # "that many": the occurrences so far (batched_pending!)
|
|
220
|
+
times.times { queue_pending_trigger!(queued, event, memory.merge("triggering" => facts), batch: batch) }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A once-each-turn limit: remembered on the source object, so the same card
|
|
225
|
+
# as a new object (CR 400.7) may trigger again.
|
|
226
|
+
def limited?(ability) = ability.respond_to?(:limit) && !ability.limit.nil?
|
|
227
|
+
|
|
228
|
+
def once_each_turn_used!(ability, ref, source)
|
|
229
|
+
return false unless limited?(ability)
|
|
230
|
+
|
|
231
|
+
obj = state.objects[source["id"]] or return false
|
|
232
|
+
used = (obj.memory["triggered_turn"] ||= {})
|
|
233
|
+
return true if used[ref] == state.turn
|
|
234
|
+
|
|
235
|
+
used[ref] = state.turn
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# CR 603.2d: static abilities such as "if you gaining life causes a triggered ability of a permanent
|
|
240
|
+
# you control to trigger, that ability triggers an additional time". Only abilities of permanents;
|
|
241
|
+
# never delayed or reflexive triggered abilities.
|
|
242
|
+
# Resolved effects do the same for the triggered abilities of the objects they affect
|
|
243
|
+
# (Effects::AdditionalTriggersEffect, "for as long as [its source] remains tapped", CR 611.2b).
|
|
244
|
+
def additional_triggers(event, source)
|
|
245
|
+
return 0 unless state.battlefield.include?(source["id"])
|
|
246
|
+
|
|
247
|
+
statics = state.battlefield.sum do |id|
|
|
248
|
+
obj = object(id)
|
|
249
|
+
chars(obj).abilities.count do |inst|
|
|
250
|
+
inst.ability.respond_to?(:additional_trigger?) && inst.ability.additional_trigger?(self, event, obj, source)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
statics + state.effects.count do |effect|
|
|
254
|
+
effect["kind"] == "additional_triggers" && effect["affected"].include?(source["id"]) && while_source_holds?(effect)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The event batch an event belongs to, for "one or more" triggers (CR 603.2c): everything one effect
|
|
259
|
+
# of a resolving spell or ability does, or one cast or activation (its costs). Outside those, each
|
|
260
|
+
# event is its own batch.
|
|
261
|
+
def event_batch
|
|
262
|
+
frame = state.frames.last or return "event:#{state.next_id}"
|
|
263
|
+
"#{frame["type"]}:#{frame["object"]}:#{frame["pc"]}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Whether a "one or more" trigger already waits for this batch. If so, this occurrence joins it: its
|
|
267
|
+
# "that many" counts the occurrences in the batch (CR 603.2c).
|
|
268
|
+
def batched_pending!(source, ref, batch)
|
|
269
|
+
waiting = state.pending_triggers.select { it["source"] == source["id"] && it["ability"] == ref && it["batch"] == batch }
|
|
270
|
+
waiting.each do |trigger|
|
|
271
|
+
facts = trigger["memory"]["triggering"]
|
|
272
|
+
facts["amount"] = facts.fetch("amount", 1) + 1
|
|
273
|
+
end
|
|
274
|
+
!waiting.empty?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# `memory` travels to the ability on the stack (its source's last known status, a delayed
|
|
278
|
+
# trigger's remembered objects, the event facts it may refer to). `event` is nil for a reflexive
|
|
279
|
+
# triggered ability (CR 603.12), which triggers on something done during a resolution.
|
|
280
|
+
def queue_pending_trigger!(source, event, memory, batch: nil)
|
|
281
|
+
state.pending_triggers << { "id" => "t#{next_timestamp!}", "controller" => source["controller"],
|
|
282
|
+
"source" => source["id"], "card" => source["card"], "ability" => source["ability"],
|
|
283
|
+
"event" => event.to_h, "memory" => memory, "batch" => batch }.compact
|
|
284
|
+
emit Events::AbilityTriggered.new(player: name(source["controller"]), source: source["id"],
|
|
285
|
+
card: source["card"], ability: source["ability"],
|
|
286
|
+
trigger: state.pending_triggers.last["id"])
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# CR 117.5 / 603.3: before a player receives priority, pending triggers go on the stack.
|
|
290
|
+
# Returns true when that process was started (priority is given afterwards).
|
|
291
|
+
def put_triggers_first!(index)
|
|
292
|
+
return false if state.pending_triggers.empty?
|
|
293
|
+
return true if state.frames.any? { it["type"] == "put_triggers" }
|
|
294
|
+
|
|
295
|
+
state.frames << { "type" => "put_triggers", "then" => index, "next" => nil }
|
|
296
|
+
ask!(nil)
|
|
297
|
+
true
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Steps the put_triggers frame: APNAP order (CR 603.3b); a player with several
|
|
301
|
+
# triggers picks which goes on the stack next.
|
|
302
|
+
def step_put_triggers!(frame)
|
|
303
|
+
player_index = turn_order_from_active.find { |i| state.pending_triggers.any? { it["controller"] == i } }
|
|
304
|
+
unless player_index
|
|
305
|
+
give_priority!(frame["then"])
|
|
306
|
+
return :done
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
mine = pending_triggers_for(player_index)
|
|
310
|
+
chosen = mine.find { it["id"] == frame["next"] } || (mine.first if mine.size == 1)
|
|
311
|
+
unless chosen
|
|
312
|
+
ask!("kind" => "order_triggers", "player" => player_index, "count" => mine.size)
|
|
313
|
+
return :wait
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
frame["next"] = nil
|
|
317
|
+
place_trigger!(chosen)
|
|
318
|
+
:push
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def turn_order_from_active
|
|
322
|
+
Array.new(state.players.size) { |i| (state.active + i) % state.players.size }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def pending_triggers_for(index) = state.pending_triggers.select { it["controller"] == index }
|
|
326
|
+
|
|
327
|
+
def choose_trigger!(id)
|
|
328
|
+
state.frames.rfind { it["type"] == "put_triggers" }["next"] = id
|
|
329
|
+
ask!(nil)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# CR 107.3m: an enters-the-battlefield trigger uses the X its permanent was cast with.
|
|
333
|
+
def entered_x(trigger)
|
|
334
|
+
event = trigger["event"]
|
|
335
|
+
return nil unless event["type"] == "zone_changed" && event["to"] == "battlefield" &&
|
|
336
|
+
event["new_id"] == trigger["source"]
|
|
337
|
+
|
|
338
|
+
state.objects[trigger["source"]]&.x
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Creates the triggered ability on the stack and starts choosing its targets.
|
|
342
|
+
# CR 603.3d: a trigger without legal targets is removed.
|
|
343
|
+
def place_trigger!(trigger)
|
|
344
|
+
state.pending_triggers.delete(trigger)
|
|
345
|
+
ability = catalog.fetch(trigger["card"]).ability(trigger["ability"])
|
|
346
|
+
unless ability_targetable?(ability, trigger["controller"], nil, trigger["source"], ref: trigger["ability"])
|
|
347
|
+
emit Events::TriggerRemoved.new(player: name(trigger["controller"]), source: trigger["source"],
|
|
348
|
+
card: trigger["card"], reason: "no legal targets")
|
|
349
|
+
return
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
obj = create_object(trigger["card"], trigger["controller"], :stack)
|
|
353
|
+
obj.kind = "ability"
|
|
354
|
+
obj.source = trigger["source"]
|
|
355
|
+
obj.ability = trigger["ability"]
|
|
356
|
+
obj.memory = Snapshot.deep_copy(trigger.fetch("memory", {})).merge("trigger_id" => trigger["id"])
|
|
357
|
+
obj.x = entered_x(trigger)
|
|
358
|
+
state.stack.unshift(obj.id)
|
|
359
|
+
start_casting!(trigger["controller"], obj.id, "trigger", source: trigger["source"], ability: trigger["ability"])
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
include Triggering
|
|
363
|
+
end
|
|
364
|
+
end
|