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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever a creature you control attacking causes a triggered ability of that creature to trigger" (CR 603.2):
|
|
7
|
+
# another ability triggering is the event. `cause: "attacks"`: the other ability triggered on its own source
|
|
8
|
+
# attacking (CR 508.1). "Copy that ability" is copy_spell of {"triggering": "ability"}: the triggered ability
|
|
9
|
+
# once it's on the stack (CR 707.10); the controller orders the two abilities (CR 603.3b).
|
|
10
|
+
class AbilityTriggers < Trigger
|
|
11
|
+
CAUSES = %w[attacks].freeze
|
|
12
|
+
|
|
13
|
+
primitive :trigger, :ability_triggers,
|
|
14
|
+
description: "A triggered ability of the subject triggers. `cause: \"attacks\"` (required): its trigger " \
|
|
15
|
+
"event was the subject attacking. {\"triggering\": \"ability\"} is that triggered ability " \
|
|
16
|
+
"while it's on the stack.",
|
|
17
|
+
rules: %w[603.2 603.3b 508.1 707.10],
|
|
18
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, \"controller\": ..., \"other\": true}",
|
|
19
|
+
"cause" => CAUSES.join(" | ") },
|
|
20
|
+
example: { "ability_triggers" => { "subject" => { "each" => "creature", "controller" => "you" },
|
|
21
|
+
"cause" => "attacks" } }
|
|
22
|
+
|
|
23
|
+
def self.keys = %w[subject cause]
|
|
24
|
+
def event_class = Events::AbilityTriggered
|
|
25
|
+
|
|
26
|
+
def setup(params, errors, path)
|
|
27
|
+
@cause = params["cause"]
|
|
28
|
+
errors << "#{path}.cause: must be one of #{CAUSES.join(", ")}" unless CAUSES.include?(@cause)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def match?(engine, event, source)
|
|
32
|
+
pending = engine.state.pending_triggers.find { it["id"] == event.trigger } or return false
|
|
33
|
+
cause = pending["event"] || {}
|
|
34
|
+
return false unless cause["type"] == "attacker_declared" && cause["object"] == event.source # CR 508.1
|
|
35
|
+
|
|
36
|
+
obj = engine.state.objects[event.source] or return false
|
|
37
|
+
@subject.match?(engine, event.source, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def occurrences(engine, event, source)
|
|
41
|
+
return [] unless match?(engine, event, source)
|
|
42
|
+
|
|
43
|
+
[{ "object" => event.source, "player" => engine.object(event.source).controller, "card" => event.card,
|
|
44
|
+
"ability" => event.trigger }]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s = "Whenever #{@subject} attacking causes a triggered ability of it to trigger"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever ~ enters or attacks": one ability with several trigger events. It triggers once
|
|
7
|
+
# per event that matches any of them (CR 603.2c).
|
|
8
|
+
class AnyOf < Trigger
|
|
9
|
+
primitive :trigger, :any_of,
|
|
10
|
+
description: "Triggers when any of the listed trigger conditions is met.",
|
|
11
|
+
rules: %w[603.2 603.2c],
|
|
12
|
+
params: { "(list)" => "two or more trigger conditions" },
|
|
13
|
+
example: { "any_of" => [{ "enters" => {} }, { "attacks" => {} }] }
|
|
14
|
+
|
|
15
|
+
attr_reader :parts
|
|
16
|
+
|
|
17
|
+
def self.scalar_params? = true
|
|
18
|
+
|
|
19
|
+
def self.build(params, errors, path, **)
|
|
20
|
+
unless params.is_a?(Array) && params.size >= 2
|
|
21
|
+
errors << "#{path}: must be a list of at least two trigger conditions"
|
|
22
|
+
params = []
|
|
23
|
+
end
|
|
24
|
+
parts = params.each_with_index.filter_map { |entry, i| Mechanics.build_entry(:trigger, entry, errors, "#{path}[#{i}]") }
|
|
25
|
+
new(parts)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(parts) # rubocop:disable Lint/MissingSuper -- no subject or params of its own
|
|
29
|
+
@parts = parts.freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def watches?(event) = parts.any? { it.watches?(event) }
|
|
34
|
+
def leaves_battlefield? = parts.any?(&:leaves_battlefield?)
|
|
35
|
+
def match?(engine, event, source) = parts.any? { it.watches?(event) && it.match?(engine, event, source) }
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
parts.map(&:to_s).each_with_index.map { |text, i| i.zero? ? text : text.sub(/\A(When|Whenever) /, "") }.join(" or ")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# Trigger conditions for triggered abilities (CR 603). A trigger watches one kind of
|
|
7
|
+
# event and decides whether it matches for a given source permanent:
|
|
8
|
+
# match?(engine, event, source) -> true/false
|
|
9
|
+
# `source` is a Hash with "id", "card", "controller" (for a permanent that just left
|
|
10
|
+
# the battlefield it describes the object as it last existed, CR 603.10a).
|
|
11
|
+
# What an event tells a triggered ability about itself (CR 603.2): the object and player it is
|
|
12
|
+
# about and a number ("that many"). Effects name them with {"triggering": "object" | "player"}
|
|
13
|
+
# and the amount {"triggering": "amount"}; "card" is the object's card, for conditions.
|
|
14
|
+
def self.event_facts(engine, event)
|
|
15
|
+
facts = {}
|
|
16
|
+
case event
|
|
17
|
+
when Events::ZoneChanged then facts.merge!("object" => event.new_id, "player" => engine.names.index(event.owner))
|
|
18
|
+
when Events::SpellCast, Events::DilemmaFaced, Events::Tapped, Events::AttackerDeclared, Events::BecameBlocked,
|
|
19
|
+
Events::BlockerDeclared
|
|
20
|
+
facts["object"] = event.object
|
|
21
|
+
when Events::AbilityActivated then facts.merge!("object" => event.source, "ability" => event.object)
|
|
22
|
+
when Events::TriggerPutOnStack then facts["ability"] = event.object
|
|
23
|
+
when Events::DamageDealt
|
|
24
|
+
facts.merge!("object" => event.source, "amount" => event.amount, "player" => Filters.player_index(engine, event.target))
|
|
25
|
+
when Events::LifeChanged then facts["amount"] = [event.gain, event.loss].max
|
|
26
|
+
when Events::CountersChanged then facts.merge!("object" => event.object, "amount" => event.to - event.from)
|
|
27
|
+
when Events::StepBegan then facts["player"] = engine.state.active # "that player": whose step it is
|
|
28
|
+
end
|
|
29
|
+
cast_facts(engine, event, facts) if event.is_a?(Events::SpellCast)
|
|
30
|
+
facts["player"] ||= engine.names.index(event.player) if event.respond_to?(:player)
|
|
31
|
+
facts["card"] = event.respond_to?(:card) ? event.card : engine.state.objects[facts["object"]]&.card
|
|
32
|
+
facts.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# CR 601.2h: the mana spent to cast "that spell" and the sources of what artifacts made, as it was cast;
|
|
36
|
+
# storm's "that many" (CR 702.40a).
|
|
37
|
+
def self.cast_facts(engine, event, facts)
|
|
38
|
+
spell = engine.state.objects[event.object]
|
|
39
|
+
facts["mana_spent"] = spell&.mana_spent&.dup
|
|
40
|
+
facts["mana_from"] = Snapshot.deep_copy(spell&.memory&.[]("mana_from"))
|
|
41
|
+
facts["amount"] = Triggers.spells_before(engine)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.filter_words(filter)
|
|
45
|
+
return nil if filter.nil?
|
|
46
|
+
|
|
47
|
+
filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One entry (the event facts) per occurrence of the trigger event in `event` (CR 603.2c). Works for
|
|
51
|
+
# any trigger condition, including ones that only answer match? (a Saga's chapter trigger).
|
|
52
|
+
def self.occurrences(trigger, engine, event, source)
|
|
53
|
+
return trigger.occurrences(engine, event, source) if trigger.respond_to?(:occurrences)
|
|
54
|
+
|
|
55
|
+
trigger.match?(engine, event, source) ? [event_facts(engine, event)] : []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Who a trigger is about: "self" (default), "attached" (the creature the source equips or
|
|
59
|
+
# enchants, CR 301.5f / 303.4m), "watched" (the objects a delayed triggered ability was created
|
|
60
|
+
# to watch, CR 603.7c: "when that creature dies this turn") or {"each": filter, "controller": ...,
|
|
61
|
+
# "other": true}.
|
|
62
|
+
class Subject
|
|
63
|
+
def self.build(data, errors, path)
|
|
64
|
+
data = "self" if data.nil?
|
|
65
|
+
return new(data.to_sym) if %w[self attached watched].include?(data)
|
|
66
|
+
|
|
67
|
+
unless data.is_a?(Hash) && data["each"]
|
|
68
|
+
errors << "#{path}: must be \"self\", \"attached\", \"watched\" or " \
|
|
69
|
+
"{\"each\": filter, \"controller\": you|opponent|any, \"other\": bool}"
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
Mechanics.check_keys(data, %w[each controller other], errors, path)
|
|
73
|
+
filter = Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each")
|
|
74
|
+
controller = data.fetch("controller", "any")
|
|
75
|
+
errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
|
|
76
|
+
new(:each, filter, controller, other: data["other"] == true)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initialize(kind, filter = nil, controller = nil, other: false)
|
|
80
|
+
@kind = kind
|
|
81
|
+
@filter = filter
|
|
82
|
+
@controller = controller
|
|
83
|
+
@other = other
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Does the object `object_id` (controlled by `object_controller`, of card `card`) count?
|
|
88
|
+
# `types`: its card types at the time (current or last known, CR 603.10a); printed if omitted.
|
|
89
|
+
def match?(engine, object_id, object_controller, card, source, types: nil)
|
|
90
|
+
return object_id == source["id"] if @kind == :self
|
|
91
|
+
return object_id == engine.state.objects[source["id"]]&.attached_to if @kind == :attached
|
|
92
|
+
return Array(source["watched"]).include?(object_id) if @kind == :watched
|
|
93
|
+
return false if @other && object_id == source["id"]
|
|
94
|
+
return false unless controller_ok?(object_controller, source["controller"])
|
|
95
|
+
|
|
96
|
+
obj = engine.state.objects[object_id]
|
|
97
|
+
return @filter.match_for?(engine, object_id, source["controller"], source["id"]) if obj&.zone == :battlefield
|
|
98
|
+
|
|
99
|
+
# Left the battlefield: as it last existed (CR 603.10a), from its card, last known types and counters.
|
|
100
|
+
defn = engine.catalog.fetch(card)
|
|
101
|
+
chars = types ? defn.printed_characteristics.with(types: types) : defn.printed_characteristics
|
|
102
|
+
lki = engine.last_known(object_id) || {}
|
|
103
|
+
token = lki.fetch("token") { defn.token? } # a token copy of a card (CR 111.1)
|
|
104
|
+
@filter.matches_facts?(Filters::Facts.new(chars: chars, counters: lki.fetch("counters", nil) || {}, token: token,
|
|
105
|
+
chosen: engine.state.objects[source["id"]]&.memory))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def controller_ok?(object_controller, you)
|
|
109
|
+
@controller == "any" || (@controller == "you") == (object_controller == you)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_s
|
|
113
|
+
return "this" if @kind == :self
|
|
114
|
+
return "the attached creature" if @kind == :attached
|
|
115
|
+
return "that object" if @kind == :watched
|
|
116
|
+
|
|
117
|
+
"#{"another " if @other}#{@filter.primitive_name.tr("_",
|
|
118
|
+
" ")}#{{ "you" => " you control",
|
|
119
|
+
"opponent" => " an opponent controls" }[@controller]}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Base class for trigger conditions.
|
|
124
|
+
class Trigger < Primitive
|
|
125
|
+
def self.keys = %w[subject]
|
|
126
|
+
|
|
127
|
+
def self.build(params, errors, path, **)
|
|
128
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
129
|
+
new(params.transform_keys(&:to_s), errors, path)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def initialize(params, errors, path)
|
|
133
|
+
super()
|
|
134
|
+
@subject = Subject.build(params["subject"], errors, "#{path}.subject") if self.class.keys.include?("subject")
|
|
135
|
+
setup(params, errors, path)
|
|
136
|
+
freeze
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def setup(_params, _errors, _path) = nil
|
|
140
|
+
def leaves_battlefield? = false
|
|
141
|
+
|
|
142
|
+
# Whether this trigger looks at `event` at all (one event class by default).
|
|
143
|
+
def watches?(event) = event.is_a?(event_class)
|
|
144
|
+
|
|
145
|
+
# "One or more ...": all occurrences in one event batch trigger it once (CR 603.2c).
|
|
146
|
+
def batched? = false
|
|
147
|
+
|
|
148
|
+
# "When you cast this spell": the trigger functions while its card is a spell on the stack.
|
|
149
|
+
def own_spell? = false
|
|
150
|
+
|
|
151
|
+
# The event facts of each occurrence this trigger matches (CR 603.2c); see Triggers.event_facts.
|
|
152
|
+
def occurrences(engine, event, source) = match?(engine, event, source) ? [Triggers.event_facts(engine, event)] : []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Matching the source's "as this enters" choice (CR 614.12, 607.2): `"name": "chosen"` is "with the
|
|
156
|
+
# chosen name" (CR 201.4) and `"mana_value": "chosen"` is "with mana value equal to the chosen number".
|
|
157
|
+
module ChosenMatch
|
|
158
|
+
def setup_chosen(params, errors, path)
|
|
159
|
+
@chosen = %w[name mana_value].select { params.key?(it) }
|
|
160
|
+
@chosen.each { errors << "#{path}.#{it}: must be \"chosen\"" unless params[it] == "chosen" }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `card`: the card the spell or source is; `x_value`: the X announced for a spell on the stack (CR 202.3e).
|
|
164
|
+
def chosen_ok?(engine, source, card, x_value = 0)
|
|
165
|
+
memory = engine.state.objects[source["id"]]&.memory || {}
|
|
166
|
+
defn = engine.catalog.fetch(card)
|
|
167
|
+
@chosen.all? do |key|
|
|
168
|
+
if key == "name" then !memory["chosen_name"].nil? && defn.name == memory["chosen_name"]
|
|
169
|
+
else
|
|
170
|
+
cost = defn.mana_cost
|
|
171
|
+
!memory["chosen_number"].nil? && (cost ? cost.mana_value + (cost.x_symbols * x_value.to_i) : 0) == memory["chosen_number"]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def chosen_words = @chosen.map { it == "name" ? " with the chosen name" : " with mana value equal to the chosen number" }.join
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
PLAYERS = %w[you opponent any].freeze
|
|
180
|
+
|
|
181
|
+
# Base for triggers about a player ("whenever you gain life", "whenever you attack"):
|
|
182
|
+
# `controller` is you (default) | opponent | any, relative to the source's controller.
|
|
183
|
+
class PlayerTrigger < Trigger
|
|
184
|
+
def self.keys = %w[controller]
|
|
185
|
+
|
|
186
|
+
def setup(params, errors, path)
|
|
187
|
+
@controller = params.fetch("controller", "you")
|
|
188
|
+
errors << "#{path}.controller: must be one of #{PLAYERS.join(", ")}" unless PLAYERS.include?(@controller)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def player_ok?(engine, player_name, source)
|
|
192
|
+
return true if @controller == "any"
|
|
193
|
+
|
|
194
|
+
(@controller == "you") == (engine.names.index(player_name) == source["controller"])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def who = { "you" => "you", "opponent" => "an opponent", "any" => "a player" }.fetch(@controller)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A `batched` flag: "one or more ..." triggers once per event batch (CR 603.2c), and its "that many"
|
|
201
|
+
# ({"triggering": "amount"}) is how many occurrences the batch had (Engine::Triggering#queue_trigger).
|
|
202
|
+
module Batched
|
|
203
|
+
def setup_batched(params, errors, path)
|
|
204
|
+
@batched = params.fetch("batched", false)
|
|
205
|
+
errors << "#{path}.batched: must be true or false" unless [true, false].include?(@batched)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def batched? = @batched
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Matching a card (not a permanent) by its printed characteristics.
|
|
212
|
+
module CardMatch
|
|
213
|
+
def setup_card(params, errors, path)
|
|
214
|
+
@card = params["card"] && Mechanics.build_entry(:filter, params["card"], errors, "#{path}.card")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def card_ok?(engine, card)
|
|
218
|
+
return true unless @card
|
|
219
|
+
|
|
220
|
+
defn = engine.catalog.fetch(card)
|
|
221
|
+
@card.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def card_words = @card ? Triggers.filter_words(@card) : "card"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever you cast a spell" (optionally only matching spells); "when you cast this spell" (`self`).
|
|
7
|
+
# It triggers once the spell becomes cast (CR 601.2i), so a cast that is cancelled never triggers it.
|
|
8
|
+
class Cast < Trigger
|
|
9
|
+
include ChosenMatch
|
|
10
|
+
|
|
11
|
+
primitive :trigger, :cast,
|
|
12
|
+
description: "A player casts a spell. `controller`: you (default) | opponent | any; `spell`: creature | " \
|
|
13
|
+
"noncreature | instant_or_sorcery, or any spell filter, e.g. {\"spell\": {\"subtypes\": " \
|
|
14
|
+
"[\"saga\"]}} or {\"one_of\": [...]} for \"creature or Spacecraft spell\"; `name` / " \
|
|
15
|
+
"`mana_value`: \"chosen\" (the source's as-it-entered choice); `self: true`: \"when you cast " \
|
|
16
|
+
"this spell\" (it functions on the stack). `targets`: only a spell that targets something " \
|
|
17
|
+
"matching this filter (\"self\": this permanent), e.g. {\"creature\": {\"controller\": " \
|
|
18
|
+
"\"you\"}}. `from` / `not_from`: only a spell cast from (not from) that zone: hand | " \
|
|
19
|
+
"graveyard | exile | library | outside. `single_target: true`: only a spell with exactly " \
|
|
20
|
+
"one target.",
|
|
21
|
+
rules: %w[603.2 601.2i 113.6 202.3e 201.4 607.2 115.1 601.2a 601.2c],
|
|
22
|
+
params: { "controller" => "you | opponent | any", "spell" => "creature | noncreature | instant_or_sorcery | filter",
|
|
23
|
+
"name" => "\"chosen\"", "mana_value" => "\"chosen\"", "self" => "true",
|
|
24
|
+
"targets" => "\"self\" or a target filter", "from" => "zone", "not_from" => "zone",
|
|
25
|
+
"single_target" => "true" },
|
|
26
|
+
example: { "cast" => { "controller" => "you", "spell" => "noncreature" } }
|
|
27
|
+
|
|
28
|
+
SPELLS = %w[creature noncreature instant_or_sorcery].freeze
|
|
29
|
+
|
|
30
|
+
def self.keys = %w[controller spell name mana_value self targets from not_from single_target]
|
|
31
|
+
def event_class = Events::SpellCast
|
|
32
|
+
def own_spell? = @self
|
|
33
|
+
|
|
34
|
+
def setup(params, errors, path)
|
|
35
|
+
@controller = params.fetch("controller", "you")
|
|
36
|
+
@self = params["self"] == true
|
|
37
|
+
raw = params["spell"]
|
|
38
|
+
errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(@controller)
|
|
39
|
+
errors << "#{path}.self: must be true" unless params["self"].nil? || @self
|
|
40
|
+
if raw.is_a?(String) && SPELLS.include?(raw) then @spell = raw
|
|
41
|
+
elsif !raw.nil? && !raw.is_a?(String) then @filter = Mechanics.build_entry(:filter, raw, errors, "#{path}.spell")
|
|
42
|
+
elsif raw then errors << "#{path}.spell: must be one of #{SPELLS.join(", ")} or a spell filter"
|
|
43
|
+
end
|
|
44
|
+
setup_chosen(params, errors, path)
|
|
45
|
+
setup_targets(params, errors, path)
|
|
46
|
+
@single = params["single_target"] == true
|
|
47
|
+
errors << "#{path}.single_target: must be true" unless params["single_target"].nil? || @single
|
|
48
|
+
@zones = CastFrom.build(params, errors, path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def match?(engine, event, source)
|
|
52
|
+
return event.object == source["id"] if @self
|
|
53
|
+
|
|
54
|
+
caster = engine.names.index(event.player)
|
|
55
|
+
return false unless @controller == "any" || (@controller == "you") == (caster == source["controller"])
|
|
56
|
+
return false unless spell_ok?(engine, event, caster, source) && targets_ok?(engine, event, source)
|
|
57
|
+
return false unless @zones.match?((spell = engine.state.objects[event.object]) && engine.cast_origin(spell))
|
|
58
|
+
|
|
59
|
+
chosen_ok?(engine, source, event.card, engine.state.objects[event.object]&.x)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "Those creatures" ({"triggering": "targets"}): the spell's targets that matched `targets` (all of them
|
|
63
|
+
# without it), as they were chosen (CR 601.2c).
|
|
64
|
+
def occurrences(engine, event, source)
|
|
65
|
+
return [] unless match?(engine, event, source)
|
|
66
|
+
|
|
67
|
+
[Triggers.event_facts(engine, event).merge("targets" => matched_targets(engine, event, source))]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# "A spell that targets this creature / a creature you control" (CR 115.1, 601.2c): one of the
|
|
71
|
+
# targets chosen as it was cast matches, relative to the trigger's source.
|
|
72
|
+
def setup_targets(params, errors, path)
|
|
73
|
+
raw = params["targets"]
|
|
74
|
+
@targets = raw == "self" ? :self : raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.targets")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def targets_ok?(engine, event, source)
|
|
78
|
+
return false if @single && Array(event.targets).flatten.compact.uniq.size != 1 # "with a single target"
|
|
79
|
+
|
|
80
|
+
!@targets || matched_targets(engine, event, source).any?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def matched_targets(engine, event, source)
|
|
84
|
+
Array(event.targets).flatten.compact.uniq.select do |ref|
|
|
85
|
+
next true unless @targets
|
|
86
|
+
|
|
87
|
+
@targets == :self ? ref == source["id"] : @targets.match_for?(engine, ref, source["controller"], source["id"])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def spell_ok?(engine, event, caster, source)
|
|
92
|
+
return @filter.match_for?(engine, event.object, caster, source["id"]) if @filter # "of the chosen color"
|
|
93
|
+
|
|
94
|
+
types = engine.catalog.fetch(event.card).types
|
|
95
|
+
case @spell
|
|
96
|
+
when "creature" then types.include?(:creature)
|
|
97
|
+
when "noncreature" then !types.include?(:creature)
|
|
98
|
+
when "instant_or_sorcery" then types.intersect?(%i[instant sorcery])
|
|
99
|
+
else true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_s
|
|
104
|
+
return "When you cast this spell" if @self
|
|
105
|
+
|
|
106
|
+
kind = Triggers.filter_words(@filter) || @spell || "spell"
|
|
107
|
+
"Whenever #{{ "you" => "you cast", "opponent" => "an opponent casts" }.fetch(@controller, "a player casts")} a #{kind}" \
|
|
108
|
+
"#{@zones}#{target_words}#{chosen_words}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def target_words
|
|
112
|
+
return "" unless @targets || @single
|
|
113
|
+
|
|
114
|
+
return " with a single target" if @single && !@targets
|
|
115
|
+
|
|
116
|
+
" that targets #{@targets == :self ? "this" : Triggers.filter_words(@targets)}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# "Whenever a creature you control becomes the target of a spell or ability an opponent controls"
|
|
121
|
+
# (CR 115.1, 603.2): checked once the spell is cast, the ability activated or the triggered ability
|
|
122
|
+
# put on the stack with its targets, so a cancelled cast never triggers it. Each matching object
|
|
123
|
+
# that became a target is one occurrence.
|
|
124
|
+
class BecomesTarget < Trigger
|
|
125
|
+
BY = %w[opponent you any].freeze
|
|
126
|
+
EVENTS = %i[SpellCast AbilityActivated TriggerPutOnStack].freeze
|
|
127
|
+
|
|
128
|
+
primitive :trigger, :becomes_target,
|
|
129
|
+
description: "The subject becomes the target of a spell or ability (controlled by `by`: opponent | you | " \
|
|
130
|
+
"any (default), relative to the source's controller). One trigger per matching object " \
|
|
131
|
+
"targeted.",
|
|
132
|
+
rules: %w[115.1 601.2c 603.2 603.2c],
|
|
133
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "by" => BY.join(" | ") },
|
|
134
|
+
example: { "becomes_target" => { "subject" => { "each" => "creature", "controller" => "you" },
|
|
135
|
+
"by" => "opponent" } }
|
|
136
|
+
|
|
137
|
+
def self.keys = %w[subject by]
|
|
138
|
+
def watches?(event) = EVENTS.any? { event.is_a?(Events.const_get(it)) }
|
|
139
|
+
|
|
140
|
+
def setup(params, errors, path)
|
|
141
|
+
@by = params.fetch("by", "any")
|
|
142
|
+
errors << "#{path}.by: must be one of #{BY.join(", ")}" unless BY.include?(@by)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def occurrences(engine, event, source)
|
|
146
|
+
player = engine.names.index(event.player)
|
|
147
|
+
return [] unless @by == "any" || (@by == "you") == (player == source["controller"])
|
|
148
|
+
|
|
149
|
+
Array(event.targets).uniq.filter_map do |ref|
|
|
150
|
+
obj = engine.state.objects[ref]
|
|
151
|
+
next unless obj && obj.zone == :battlefield
|
|
152
|
+
next unless @subject.match?(engine, ref, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
153
|
+
|
|
154
|
+
{ "object" => ref, "player" => player, "card" => obj.card }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def match?(engine, event, source) = !occurrences(engine, event, source).empty?
|
|
159
|
+
|
|
160
|
+
def to_s
|
|
161
|
+
"Whenever #{@subject} becomes the target of a spell or ability#{{ "you" => " you control",
|
|
162
|
+
"opponent" => " an opponent controls" }[@by]}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# "Whenever a player activates an ability of a source with ..." (CR 602.2, 605.1): non-mana activated
|
|
167
|
+
# and loyalty abilities only (mana abilities aren't activated through the stack and aren't watched).
|
|
168
|
+
class ActivatesAbility < PlayerTrigger
|
|
169
|
+
include ChosenMatch
|
|
170
|
+
|
|
171
|
+
primitive :trigger, :activates_ability,
|
|
172
|
+
description: "A player (`controller`: you (default) | opponent | any) activates an ability that isn't a " \
|
|
173
|
+
"mana ability. `source`: optional filter on the source's card; `name: \"chosen\"`: only a " \
|
|
174
|
+
"source with the name chosen as this permanent entered. `exhaust: true`: only an exhaust " \
|
|
175
|
+
"ability (\"whenever you activate an exhaust ability\", CR 702.177). `boast: true`: only a " \
|
|
176
|
+
"boast ability (\"whenever a creature you control boasts\", CR 702.142b).",
|
|
177
|
+
rules: %w[602.2 605.1a 603.2 201.4 702.177a 702.142b],
|
|
178
|
+
params: { "controller" => PLAYERS.join(" | "), "source" => "card filter", "name" => "\"chosen\"",
|
|
179
|
+
"exhaust" => "true: only exhaust abilities", "boast" => "true: only boast abilities" },
|
|
180
|
+
example: { "activates_ability" => { "controller" => "opponent" } }
|
|
181
|
+
|
|
182
|
+
def self.keys = %w[controller source name exhaust boast]
|
|
183
|
+
KINDS = { "exhaust" => :exhaust?, "boast" => :boast? }.freeze
|
|
184
|
+
def event_class = Events::AbilityActivated
|
|
185
|
+
|
|
186
|
+
def setup(params, errors, path)
|
|
187
|
+
super
|
|
188
|
+
@source = params["source"] && Mechanics.build_entry(:filter, params["source"], errors, "#{path}.source")
|
|
189
|
+
setup_chosen(params, errors, path)
|
|
190
|
+
@kinds = KINDS.select { |key, _| params[key] == true }.values
|
|
191
|
+
KINDS.each_key { errors << "#{path}.#{it}: must be true or false" unless [nil, true, false].include?(params[it]) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def match?(engine, event, source)
|
|
195
|
+
return false unless player_ok?(engine, event.player, source) && chosen_ok?(engine, source, event.card)
|
|
196
|
+
return false unless @kinds.all? { kind?(engine, event, it) }
|
|
197
|
+
return true unless @source
|
|
198
|
+
|
|
199
|
+
defn = engine.catalog.fetch(event.card)
|
|
200
|
+
@source.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def to_s
|
|
204
|
+
what = "#{@kinds.map { "#{it.to_s.delete("?")} " }.join}ability"
|
|
205
|
+
"Whenever #{who} activate#{"s" unless @controller == "you"} #{what.start_with?("b") ? "a" : "an"} #{what}#{chosen_words}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# The activated ability on the stack is an exhaust ability (CR 702.177a) / a boast ability (CR 702.142b).
|
|
211
|
+
def kind?(engine, event, flag)
|
|
212
|
+
obj = engine.state.objects[event.object]
|
|
213
|
+
ability = obj && engine.stack_ability(obj)
|
|
214
|
+
ability.respond_to?(flag) && ability.public_send(flag)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever ~ attacks" / "Whenever ~ attacks for the first time each turn".
|
|
7
|
+
class Attacks < Trigger
|
|
8
|
+
primitive :trigger, :attacks,
|
|
9
|
+
description: "The subject is declared as an attacker. `first_time_this_turn: true`: only the first " \
|
|
10
|
+
"time that creature attacks this turn (later combats of the turn don't trigger it).",
|
|
11
|
+
rules: %w[508.3a 603.2],
|
|
12
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "first_time_this_turn" => "boolean" },
|
|
13
|
+
example: { "attacks" => { "subject" => "self", "first_time_this_turn" => true } }
|
|
14
|
+
|
|
15
|
+
def self.keys = %w[subject first_time_this_turn]
|
|
16
|
+
def event_class = Events::AttackerDeclared
|
|
17
|
+
|
|
18
|
+
def setup(params, errors, path)
|
|
19
|
+
@first_time = params["first_time_this_turn"] == true
|
|
20
|
+
errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(params["first_time_this_turn"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def match?(engine, event, source)
|
|
24
|
+
obj = engine.state.objects[event.object] or return false
|
|
25
|
+
return false if @first_time && engine.attacks_this_turn(event.object) != 1
|
|
26
|
+
|
|
27
|
+
@subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_s = "Whenever #{@subject} attacks#{" for the first time each turn" if @first_time}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# "Whenever you attack" (CR 508.3d): once per combat, if one or more creatures attack.
|
|
34
|
+
# "Whenever you attack with creatures with total power 12 or greater for the first time each turn".
|
|
35
|
+
class PlayerAttacks < PlayerTrigger
|
|
36
|
+
primitive :trigger, :player_attacks,
|
|
37
|
+
description: "A player declares one or more attackers (once per combat, not per creature). " \
|
|
38
|
+
"`controller`: you (default) | opponent | any. `attacking_power` ({\"at_least\": n}): " \
|
|
39
|
+
"only when the attacking creatures' total power is at least n as they're declared; " \
|
|
40
|
+
"with `first_time_this_turn: true`, only the first combat this turn in which it was.",
|
|
41
|
+
rules: %w[508.3d 508.1 603.2],
|
|
42
|
+
params: { "controller" => PLAYERS.join(" | "), "attacking_power" => "{\"at_least\": n}",
|
|
43
|
+
"first_time_this_turn" => "boolean" },
|
|
44
|
+
example: { "player_attacks" => { "controller" => "you", "attacking_power" => { "at_least" => 12 },
|
|
45
|
+
"first_time_this_turn" => true } }
|
|
46
|
+
|
|
47
|
+
def self.keys = %w[controller attacking_power first_time_this_turn]
|
|
48
|
+
def event_class = Events::AttacksDeclared
|
|
49
|
+
|
|
50
|
+
def setup(params, errors, path)
|
|
51
|
+
super
|
|
52
|
+
power = params["attacking_power"]
|
|
53
|
+
@at_least = power.is_a?(Hash) && power.keys == ["at_least"] && power["at_least"].is_a?(Integer) ? power["at_least"] : nil
|
|
54
|
+
errors << "#{path}.attacking_power: must be {\"at_least\": n}" if power && @at_least.nil?
|
|
55
|
+
@first_time = params["first_time_this_turn"] == true
|
|
56
|
+
errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(params["first_time_this_turn"])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The attackers' total power was recorded as they were declared (Engine::TurnHistory, "attack_powers").
|
|
60
|
+
def match?(engine, event, source)
|
|
61
|
+
return false unless player_ok?(engine, event.player, source)
|
|
62
|
+
|
|
63
|
+
powers = engine.turn_log(engine.names.index(event.player)).fetch("attack_powers", [])
|
|
64
|
+
wanted = ->(power) { power >= (@at_least || 0) }
|
|
65
|
+
return false unless wanted.call(powers.last.to_i)
|
|
66
|
+
|
|
67
|
+
!@first_time || powers[0...-1].none?(&wanted)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_s
|
|
71
|
+
power = " with total power #{@at_least} or greater" if @at_least
|
|
72
|
+
"Whenever #{who} attack#{"s" unless @controller == "you"}#{power}#{" for the first time each turn" if @first_time}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# "Whenever ~ blocks" / "Whenever this creature blocks a creature with flying" (CR 509.3a-b, 509.3f).
|
|
77
|
+
class Blocks < Trigger
|
|
78
|
+
primitive :trigger, :blocks,
|
|
79
|
+
description: "The subject is declared as a blocker. With `blocking`, only when the attacking creature " \
|
|
80
|
+
"it blocks matches that filter (\"blocks a creature with flying\"). {\"triggering\": " \
|
|
81
|
+
"\"object\"} is the blocking creature.",
|
|
82
|
+
rules: %w[509.3a 509.3b 509.3f 603.2],
|
|
83
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "blocking" => "filter" },
|
|
84
|
+
example: { "blocks" => { "blocking" => { "creature" => { "keywords" => ["flying"] } } } }
|
|
85
|
+
|
|
86
|
+
def self.keys = %w[subject blocking]
|
|
87
|
+
def event_class = Events::BlockerDeclared
|
|
88
|
+
|
|
89
|
+
def setup(params, errors, path)
|
|
90
|
+
@blocking = params["blocking"] && Mechanics.build_entry(:filter, params["blocking"], errors, "#{path}.blocking")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def match?(engine, event, source)
|
|
94
|
+
obj = engine.state.objects[event.object] or return false
|
|
95
|
+
return false if @blocking && !@blocking.match_for?(engine, event.attacker, source["controller"], source["id"])
|
|
96
|
+
|
|
97
|
+
@subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_s = "Whenever #{@subject} blocks#{" a #{Triggers.filter_words(@blocking)}" if @blocking}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# "Whenever ~ becomes blocked" (CR 509.3c): once per combat, however many creatures block it.
|
|
104
|
+
class BecomesBlocked < Trigger
|
|
105
|
+
primitive :trigger, :becomes_blocked,
|
|
106
|
+
description: "The subject becomes blocked: once per combat when blockers are declared, even if " \
|
|
107
|
+
"several creatures block it.",
|
|
108
|
+
rules: %w[509.3c 509.1h 603.2e],
|
|
109
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}" },
|
|
110
|
+
example: { "becomes_blocked" => { "subject" => "self" } }
|
|
111
|
+
|
|
112
|
+
def event_class = Events::BecameBlocked
|
|
113
|
+
|
|
114
|
+
def match?(engine, event, source)
|
|
115
|
+
obj = engine.state.objects[event.object] or return false
|
|
116
|
+
@subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_s = "Whenever #{@subject} becomes blocked"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|