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,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# Which damage a damage trigger counts: `combat: true` only combat damage, `noncombat: true` only
|
|
7
|
+
# noncombat damage (CR 120.2a-b).
|
|
8
|
+
module DamageKind
|
|
9
|
+
def setup_kind(params, errors, path)
|
|
10
|
+
@combat = params.fetch("combat", false)
|
|
11
|
+
@noncombat = params.fetch("noncombat", false)
|
|
12
|
+
%w[combat noncombat].each do |key|
|
|
13
|
+
errors << "#{path}.#{key}: must be true or false" unless [true, false].include?(params.fetch(key, false))
|
|
14
|
+
end
|
|
15
|
+
errors << "#{path}: combat and noncombat can't both be true" if @combat == true && @noncombat == true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def kind_ok?(event) = !(@combat && !event.combat) && !(@noncombat && event.combat)
|
|
19
|
+
def kind_words = ("combat " if @combat) || ("noncombat " if @noncombat)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# "Whenever ~ deals combat damage to a player" / "Whenever a source you control deals noncombat damage
|
|
23
|
+
# to an opponent".
|
|
24
|
+
class DealsDamage < Trigger
|
|
25
|
+
include DamageKind
|
|
26
|
+
|
|
27
|
+
RECIPIENTS = %w[player opponent creature any].freeze
|
|
28
|
+
|
|
29
|
+
primitive :trigger, :deals_damage,
|
|
30
|
+
description: "The subject deals damage. `combat: true` only counts combat damage, `noncombat: true` " \
|
|
31
|
+
"only noncombat damage; `to`: player | opponent (of the source's controller) | creature " \
|
|
32
|
+
"| any (default). Triggers once per damage event (each combat damage step is one, so " \
|
|
33
|
+
"double strike can trigger twice).",
|
|
34
|
+
rules: %w[510.2 603.2 603.2c 120.2a 120.2b 120.3],
|
|
35
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "combat" => "true | false",
|
|
36
|
+
"noncombat" => "true | false", "to" => RECIPIENTS.join(" | ") },
|
|
37
|
+
example: { "deals_damage" => { "combat" => true, "to" => "player" } }
|
|
38
|
+
|
|
39
|
+
def self.keys = %w[subject combat noncombat to]
|
|
40
|
+
def event_class = Events::DamageDealt
|
|
41
|
+
|
|
42
|
+
def setup(params, errors, path)
|
|
43
|
+
setup_kind(params, errors, path)
|
|
44
|
+
@to = params.fetch("to", "any")
|
|
45
|
+
errors << "#{path}.to: must be one of #{RECIPIENTS.join(", ")}" unless RECIPIENTS.include?(@to)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def match?(engine, event, source)
|
|
49
|
+
return false unless kind_ok?(event) && recipient_ok?(engine, event.target, source)
|
|
50
|
+
|
|
51
|
+
obj = engine.state.objects[event.source] or return false
|
|
52
|
+
@subject.match?(engine, event.source, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recipient_ok?(engine, ref, source)
|
|
56
|
+
index = Filters.player_index(engine, ref)
|
|
57
|
+
case @to
|
|
58
|
+
when "player" then !index.nil?
|
|
59
|
+
when "opponent" then !index.nil? && index != source["controller"]
|
|
60
|
+
when "creature" then engine.state.objects.key?(ref) && engine.chars(ref).creature?
|
|
61
|
+
else true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s = "Whenever #{@subject} deals #{kind_words}damage#{" to a#{"n" if @to == "opponent"} #{@to}" unless @to == "any"}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# "Whenever this creature is dealt damage", "Whenever enchanted creature is dealt damage", "Whenever an
|
|
69
|
+
# opponent is dealt noncombat damage" (CR 120.3, 603.2). "That creature" is the one dealt damage and
|
|
70
|
+
# "that much" the damage dealt.
|
|
71
|
+
class DealtDamage < Trigger
|
|
72
|
+
include DamageKind
|
|
73
|
+
include Batched
|
|
74
|
+
|
|
75
|
+
primitive :trigger, :dealt_damage,
|
|
76
|
+
description: "Damage is dealt to the `subject` (a permanent: \"self\" by default, \"attached\" or " \
|
|
77
|
+
"{\"each\": filter, ...}) or, with `player` (you | opponent | any), to a player. " \
|
|
78
|
+
"`combat` / `noncombat`: only that damage. {\"triggering\": \"object\"} is the permanent " \
|
|
79
|
+
"dealt damage, {\"triggering\": \"player\"} the player, {\"triggering\": \"amount\"} how " \
|
|
80
|
+
"much. `batched: true`: \"one or more ...\" (once per damage event, CR 603.2c).",
|
|
81
|
+
rules: %w[120.3 603.2 603.2c 120.2a 120.2b],
|
|
82
|
+
params: { "subject" => "\"self\" (default), \"attached\" or {\"each\": filter, ...}",
|
|
83
|
+
"player" => PLAYERS.join(" | "), "combat" => "true | false", "noncombat" => "true | false",
|
|
84
|
+
"batched" => "true | false" },
|
|
85
|
+
example: { "dealt_damage" => { "subject" => "self" } }
|
|
86
|
+
|
|
87
|
+
def self.keys = %w[subject player combat noncombat batched]
|
|
88
|
+
def event_class = Events::DamageDealt
|
|
89
|
+
|
|
90
|
+
def setup(params, errors, path)
|
|
91
|
+
setup_kind(params, errors, path)
|
|
92
|
+
setup_batched(params, errors, path)
|
|
93
|
+
@player = params["player"]
|
|
94
|
+
errors << "#{path}.player: must be one of #{PLAYERS.join(", ")}" unless @player.nil? || PLAYERS.include?(@player)
|
|
95
|
+
errors << "#{path}: use subject or player, not both" if @player && params.key?("subject")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def match?(engine, event, source)
|
|
99
|
+
return false unless kind_ok?(event)
|
|
100
|
+
|
|
101
|
+
index = Filters.player_index(engine, event.target)
|
|
102
|
+
return player_ok?(index, source) if index
|
|
103
|
+
return false if @player
|
|
104
|
+
|
|
105
|
+
obj = engine.state.objects[event.target] or return false
|
|
106
|
+
@subject.match?(engine, event.target, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def player_ok?(index, source)
|
|
110
|
+
return false unless @player
|
|
111
|
+
|
|
112
|
+
@player == "any" || (@player == "you") == (index == source["controller"])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def occurrences(engine, event, source)
|
|
116
|
+
return [] unless match?(engine, event, source)
|
|
117
|
+
|
|
118
|
+
facts = { "amount" => event.amount }
|
|
119
|
+
index = Filters.player_index(engine, event.target)
|
|
120
|
+
[index ? facts.merge("player" => index) : facts.merge("object" => event.target, "card" => engine.object(event.target).card)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_s
|
|
124
|
+
who = @player ? { "you" => "you are", "opponent" => "an opponent is", "any" => "a player is" }[@player] : "#{@subject} is"
|
|
125
|
+
"Whenever #{who} dealt #{kind_words}damage"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever you face a dilemma" (CR 700.17): a player faces a dilemma whenever they
|
|
7
|
+
# choose one or more modes for a spell or ability. The engine records it when the spell or ability
|
|
8
|
+
# with chosen modes is cast, activated or put on the stack, so a cancelled cast never counts.
|
|
9
|
+
class FacesDilemma < Trigger
|
|
10
|
+
primitive :trigger, :faces_dilemma,
|
|
11
|
+
description: "A player faces a dilemma: they chose one or more modes for a spell or ability " \
|
|
12
|
+
"(cast, activated, or a triggered ability put on the stack). " \
|
|
13
|
+
"`controller`: you (default) | opponent | any. CR 700.17.",
|
|
14
|
+
rules: %w[700.17 700.2 601.2b 603.3c],
|
|
15
|
+
params: { "controller" => "you | opponent | any" },
|
|
16
|
+
example: { "faces_dilemma" => {} }
|
|
17
|
+
|
|
18
|
+
CONTROLLERS = %w[you opponent any].freeze
|
|
19
|
+
|
|
20
|
+
def self.keys = %w[controller]
|
|
21
|
+
def event_class = Events::DilemmaFaced
|
|
22
|
+
|
|
23
|
+
def setup(params, errors, path)
|
|
24
|
+
@controller = params.fetch("controller", "you")
|
|
25
|
+
errors << "#{path}.controller: must be one of #{CONTROLLERS.join(", ")}" unless CONTROLLERS.include?(@controller)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def match?(engine, event, source)
|
|
29
|
+
who = engine.names.index(event.player)
|
|
30
|
+
@controller == "any" || (@controller == "you") == (who == source["controller"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s = "Whenever #{{ "you" => "you face", "opponent" => "an opponent faces" }.fetch(@controller, "a player faces")} a dilemma"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever you draw a card" (CR 121.2): once for each card drawn.
|
|
7
|
+
class Draws < PlayerTrigger
|
|
8
|
+
primitive :trigger, :draws,
|
|
9
|
+
description: "A player draws a card; triggers once per card (draws happen one at a time). " \
|
|
10
|
+
"`controller`: you (default) | opponent | any.",
|
|
11
|
+
rules: %w[121.1 121.2],
|
|
12
|
+
params: { "controller" => PLAYERS.join(" | ") },
|
|
13
|
+
example: { "draws" => { "controller" => "you" } }
|
|
14
|
+
|
|
15
|
+
def event_class = Events::ZoneChanged
|
|
16
|
+
|
|
17
|
+
def match?(engine, event, source)
|
|
18
|
+
event.from == "library" && event.to == "hand" && event.reason == "draw" && player_ok?(engine, event.owner, source)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s = "Whenever #{who} draw#{"s" unless @controller == "you"} a card"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# "Whenever you discard a card" / "one or more cards" (CR 701.9a). A cycled card is discarded too
|
|
25
|
+
# (CR 702.29d).
|
|
26
|
+
class Discards < PlayerTrigger
|
|
27
|
+
include Batched
|
|
28
|
+
include CardMatch
|
|
29
|
+
|
|
30
|
+
primitive :trigger, :discards,
|
|
31
|
+
description: "A player (`controller`: you (default) | opponent | any) discards a card (optional `card` " \
|
|
32
|
+
"filter). `batched: true`: \"one or more cards\", once per event batch; then \"that many\" " \
|
|
33
|
+
"is how many were discarded.",
|
|
34
|
+
rules: %w[701.9a 603.2c 702.29d],
|
|
35
|
+
params: { "controller" => PLAYERS.join(" | "), "card" => "card filter", "batched" => "true | false" },
|
|
36
|
+
example: { "discards" => { "controller" => "you", "batched" => true } }
|
|
37
|
+
|
|
38
|
+
def self.keys = %w[controller card batched]
|
|
39
|
+
def event_class = Events::ZoneChanged
|
|
40
|
+
|
|
41
|
+
def setup(params, errors, path)
|
|
42
|
+
super
|
|
43
|
+
setup_batched(params, errors, path)
|
|
44
|
+
setup_card(params, errors, path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def match?(engine, event, source)
|
|
48
|
+
event.reason == "discard" && event.from == "hand" && player_ok?(engine, event.owner, source) && card_ok?(engine, event.card)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
"Whenever #{who} discard#{"s" unless @controller == "you"} #{@batched ? "one or more #{card_words}s" : "a #{card_words}"}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# "Whenever you cycle a card" (CR 702.29a): a player activates a cycling (or typecycling) ability.
|
|
57
|
+
class Cycles < PlayerTrigger
|
|
58
|
+
include CardMatch
|
|
59
|
+
|
|
60
|
+
primitive :trigger, :cycles,
|
|
61
|
+
description: "A player (`controller`: you (default) | opponent | any) cycles a card (activates its " \
|
|
62
|
+
"cycling or typecycling ability; optional `card` filter).",
|
|
63
|
+
rules: %w[702.29a 702.29e],
|
|
64
|
+
params: { "controller" => PLAYERS.join(" | "), "card" => "card filter" },
|
|
65
|
+
example: { "cycles" => { "controller" => "you" } }
|
|
66
|
+
|
|
67
|
+
def self.keys = %w[controller card]
|
|
68
|
+
def event_class = Events::AbilityActivated
|
|
69
|
+
|
|
70
|
+
def setup(params, errors, path)
|
|
71
|
+
super
|
|
72
|
+
setup_card(params, errors, path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def match?(engine, event, source)
|
|
76
|
+
ability = engine.catalog.fetch(event.card).ability(event.ability)
|
|
77
|
+
ability.respond_to?(:cycling?) && ability.cycling? && player_ok?(engine, event.player, source) &&
|
|
78
|
+
card_ok?(engine, event.card)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_s = "Whenever #{who} cycle#{"s" unless @controller == "you"} a #{card_words}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever you scry" / "Whenever you surveil" (CR 701.22, 701.25): once the whole action is done
|
|
7
|
+
# (CR 701.25d); scry 0 / surveil 0 isn't one (CR 701.22b, 701.25c). "Whenever you scry or surveil" is
|
|
8
|
+
# `any_of` both.
|
|
9
|
+
class KeywordActionTrigger < PlayerTrigger
|
|
10
|
+
def event_class = Events::KeywordAction
|
|
11
|
+
def match?(engine, event, source) = event.action == self.class.action && player_ok?(engine, event.player, source)
|
|
12
|
+
def to_s = "Whenever #{who} #{self.class.action}#{"s" unless @controller == "you"}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# "Whenever you scry".
|
|
16
|
+
class Scries < KeywordActionTrigger
|
|
17
|
+
primitive :trigger, :scries,
|
|
18
|
+
description: "A player (`controller`: you (default) | opponent | any) scries (not scry 0). Triggers " \
|
|
19
|
+
"after the scry is complete.",
|
|
20
|
+
rules: %w[701.22a 701.22b],
|
|
21
|
+
params: { "controller" => PLAYERS.join(" | ") },
|
|
22
|
+
example: { "any_of" => [{ "scries" => {} }, { "surveils" => {} }] }
|
|
23
|
+
|
|
24
|
+
def self.action = "scry"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# "Whenever you surveil".
|
|
28
|
+
class Surveils < KeywordActionTrigger
|
|
29
|
+
primitive :trigger, :surveils,
|
|
30
|
+
description: "A player (`controller`: you (default) | opponent | any) surveils (not surveil 0). " \
|
|
31
|
+
"Triggers after the surveil is complete.",
|
|
32
|
+
rules: %w[701.25a 701.25c 701.25d],
|
|
33
|
+
params: { "controller" => PLAYERS.join(" | ") },
|
|
34
|
+
example: { "surveils" => { "controller" => "you" } }
|
|
35
|
+
|
|
36
|
+
def self.action = "surveil"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# "Whenever you earthbend / waterbend / airbend / firebend" (CR 701.66b, 701.67c, 701.65b, 702.189b): the
|
|
40
|
+
# bending happened (the earthbend delayed trigger was created, a waterbend cost was paid, objects were
|
|
41
|
+
# exiled by an airbend, a firebending ability resolved). "Whenever you waterbend, earthbend, firebend, or
|
|
42
|
+
# airbend" is `any_of` the four.
|
|
43
|
+
{
|
|
44
|
+
earthbends: ["earthbend", %w[701.66b]], waterbends: ["waterbend", %w[701.67c]],
|
|
45
|
+
airbends: ["airbend", %w[701.65b]], firebends: ["firebend", %w[702.189b]]
|
|
46
|
+
}.each do |name, (verb, rules)|
|
|
47
|
+
klass = Class.new(KeywordActionTrigger) { define_singleton_method(:action) { verb } }
|
|
48
|
+
Triggers.const_set(name.to_s.capitalize, klass)
|
|
49
|
+
klass.primitive :trigger, name,
|
|
50
|
+
description: "A player (`controller`: you (default) | opponent | any) #{verb}s.",
|
|
51
|
+
rules: rules, params: { "controller" => PLAYERS.join(" | ") }, example: { name.to_s => {} }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever you gain life" (CR 119.9): once per life gain event; gaining 0 life isn't one.
|
|
7
|
+
class GainsLife < PlayerTrigger
|
|
8
|
+
primitive :trigger, :gains_life,
|
|
9
|
+
description: "A player gains life (once per life gain event; lifelink combat damage is one event " \
|
|
10
|
+
"per source). `controller`: you (default) | opponent | any.",
|
|
11
|
+
rules: %w[119.9 120.3f],
|
|
12
|
+
params: { "controller" => PLAYERS.join(" | ") },
|
|
13
|
+
example: { "gains_life" => { "controller" => "you" } }
|
|
14
|
+
|
|
15
|
+
def event_class = Events::LifeChanged
|
|
16
|
+
def match?(engine, event, source) = event.gain.positive? && player_ok?(engine, event.player, source)
|
|
17
|
+
def to_s = "Whenever #{who} gain#{"s" unless @controller == "you"} life"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# "Whenever an opponent loses life".
|
|
21
|
+
class LosesLife < PlayerTrigger
|
|
22
|
+
primitive :trigger, :loses_life,
|
|
23
|
+
description: "A player loses life (damage to a player is life loss, CR 120.3a). " \
|
|
24
|
+
"`controller`: you (default) | opponent | any.",
|
|
25
|
+
rules: %w[119.3 120.3a],
|
|
26
|
+
params: { "controller" => PLAYERS.join(" | ") },
|
|
27
|
+
example: { "loses_life" => { "controller" => "opponent" } }
|
|
28
|
+
|
|
29
|
+
def event_class = Events::LifeChanged
|
|
30
|
+
def match?(engine, event, source) = event.loss.positive? && player_ok?(engine, event.player, source)
|
|
31
|
+
def to_s = "Whenever #{who} lose#{"s" unless @controller == "you"} life"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "At the beginning of your upkeep" / "... of each end step".
|
|
7
|
+
class StepBegins < Trigger
|
|
8
|
+
STEPS = %w[untap upkeep draw precombat_main beginning_of_combat declare_attackers declare_blockers
|
|
9
|
+
combat_damage end_of_combat postcombat_main end cleanup].freeze
|
|
10
|
+
WHOSE = %w[your each opponent].freeze
|
|
11
|
+
|
|
12
|
+
primitive :trigger, :step_begins,
|
|
13
|
+
description: "A step begins. `whose`: \"your\" (default), \"each\" or \"opponent\".",
|
|
14
|
+
rules: %w[603.6b 503.1a 513.1a],
|
|
15
|
+
params: { "step" => "upkeep | draw | precombat_main | end | ...", "whose" => WHOSE.join(" | ") },
|
|
16
|
+
example: { "step_begins" => { "step" => "upkeep", "whose" => "your" } }
|
|
17
|
+
|
|
18
|
+
def self.keys = %w[step whose]
|
|
19
|
+
def event_class = Events::StepBegan
|
|
20
|
+
|
|
21
|
+
def setup(params, errors, path)
|
|
22
|
+
@step = params["step"]
|
|
23
|
+
@whose = params.fetch("whose", "your")
|
|
24
|
+
errors << "#{path}.step: must be one of #{STEPS.join(", ")}" unless STEPS.include?(@step)
|
|
25
|
+
errors << "#{path}.whose: must be one of #{WHOSE.join(", ")}" unless WHOSE.include?(@whose)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def match?(engine, event, source)
|
|
29
|
+
return false unless event.step == @step
|
|
30
|
+
|
|
31
|
+
active = engine.state.active
|
|
32
|
+
@whose == "each" || (@whose == "your") == (active == source["controller"])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s = "At the beginning of #{@whose == "each" ? "each" : @whose} #{@step.tr("_", " ")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Triggers
|
|
6
|
+
# "Whenever ~ becomes untapped" (CR 603.2e): not a permanent that enters untapped.
|
|
7
|
+
class BecomesUntapped < Trigger
|
|
8
|
+
primitive :trigger, :becomes_untapped,
|
|
9
|
+
description: "The subject becomes untapped (in the untap step or by an effect). A permanent that " \
|
|
10
|
+
"enters untapped doesn't become untapped.",
|
|
11
|
+
rules: %w[603.2e 701.26b],
|
|
12
|
+
params: { "subject" => "\"self\" (default), \"attached\" or {\"each\": filter, ...}" },
|
|
13
|
+
example: { "becomes_untapped" => { "subject" => "attached" } }
|
|
14
|
+
|
|
15
|
+
def event_class = Events::Untapped
|
|
16
|
+
|
|
17
|
+
def match?(engine, event, source)
|
|
18
|
+
obj = engine.state.objects[event.object] or return false
|
|
19
|
+
@subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s = "Whenever #{@subject} becomes untapped"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# "Whenever ~ becomes tapped" (CR 603.2e): only a permanent already on the battlefield changing from
|
|
26
|
+
# untapped to tapped; entering tapped isn't becoming tapped. `for: "teamwork"`: "becomes tapped to pay a
|
|
27
|
+
# teamwork cost" (CR 702.194a), known once the spell is cast.
|
|
28
|
+
class BecomesTapped < Trigger
|
|
29
|
+
COSTS = %w[teamwork].freeze
|
|
30
|
+
|
|
31
|
+
primitive :trigger, :becomes_tapped,
|
|
32
|
+
description: "The subject becomes tapped (for any reason: a cost, attacking, an effect). A " \
|
|
33
|
+
"permanent that enters tapped doesn't become tapped. `for: \"teamwork\"`: only when " \
|
|
34
|
+
"it's tapped to pay a teamwork cost (it triggers once the spell is cast).",
|
|
35
|
+
rules: %w[603.2e 701.26a 702.194a],
|
|
36
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "for" => COSTS.join(" | ") },
|
|
37
|
+
example: { "becomes_tapped" => { "subject" => "self" } }
|
|
38
|
+
|
|
39
|
+
def self.keys = %w[subject for]
|
|
40
|
+
def event_class = @for ? Events::TappedForCost : Events::Tapped
|
|
41
|
+
|
|
42
|
+
def setup(params, errors, path)
|
|
43
|
+
@for = params["for"]
|
|
44
|
+
errors << "#{path}.for: must be one of #{COSTS.join(", ")}" unless @for.nil? || COSTS.include?(@for)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def match?(engine, event, source)
|
|
48
|
+
obj = engine.state.objects[event.object] or return false
|
|
49
|
+
@subject.match?(engine, event.object, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s = "Whenever #{@subject} becomes tapped#{" to pay a #{@for} cost" if @for}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "Whenever one or more [kind] counters are put on ~" (CR 122.6): counters put on a permanent, including
|
|
56
|
+
# the counters it enters with. Once per counter-placing event.
|
|
57
|
+
class CountersPlaced < Trigger
|
|
58
|
+
primitive :trigger, :counters_placed,
|
|
59
|
+
description: "One or more counters (only `counter`, if given) are put on the subject, including the " \
|
|
60
|
+
"counters it enters the battlefield with. Triggers once per event, however many counters.",
|
|
61
|
+
rules: %w[122.6 603.2 603.2c],
|
|
62
|
+
params: { "subject" => "\"self\" (default) or {\"each\": filter, ...}", "counter" => "counter name",
|
|
63
|
+
"first_time_this_turn" => "true: only the first time each turn such counters are put on " \
|
|
64
|
+
"that permanent (an earlier placement counts even if nothing " \
|
|
65
|
+
"could trigger then)" },
|
|
66
|
+
example: { "counters_placed" => { "counter" => "+1/+1" } }
|
|
67
|
+
|
|
68
|
+
def self.keys = %w[subject counter first_time_this_turn]
|
|
69
|
+
def watches?(event) = event.is_a?(Events::CountersChanged) || event.is_a?(Events::ZoneChanged)
|
|
70
|
+
|
|
71
|
+
def setup(params, errors, path)
|
|
72
|
+
@counter = params["counter"] && Counters.parse(params["counter"], errors, "#{path}.counter")
|
|
73
|
+
@first_time = params["first_time_this_turn"]
|
|
74
|
+
errors << "#{path}.first_time_this_turn: must be true or false" unless [nil, true, false].include?(@first_time)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def occurrences(engine, event, source)
|
|
78
|
+
id, amount = placed(engine, event)
|
|
79
|
+
return [] unless amount&.positive?
|
|
80
|
+
return [] if @first_time && engine.counter_placements_this_turn(id, @counter) != 1
|
|
81
|
+
|
|
82
|
+
obj = engine.object(id)
|
|
83
|
+
return [] unless @subject.match?(engine, id, obj.controller, obj.card, source, types: engine.chars(obj).types)
|
|
84
|
+
|
|
85
|
+
[{ "object" => id, "player" => obj.controller, "amount" => amount, "card" => obj.card }]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def match?(engine, event, source) = !occurrences(engine, event, source).empty?
|
|
89
|
+
|
|
90
|
+
def to_s
|
|
91
|
+
first = " for the first time each turn" if @first_time
|
|
92
|
+
"Whenever one or more #{"#{@counter} " if @counter}counters are put on #{@subject}#{first}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# [object id, how many matching counters were put on it] for this event, or nil.
|
|
98
|
+
def placed(engine, event)
|
|
99
|
+
if event.is_a?(Events::CountersChanged)
|
|
100
|
+
return nil if @counter && event.counter != @counter
|
|
101
|
+
|
|
102
|
+
[event.object, event.to - event.from]
|
|
103
|
+
elsif event.to == "battlefield" && (obj = engine.state.objects[event.new_id])
|
|
104
|
+
counts = @counter ? [obj.counters.fetch(@counter, 0)] : obj.counters.values
|
|
105
|
+
[obj.id, counts.sum]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|