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,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Keywords
|
|
6
|
+
# Increment (CR 702.191a): "Whenever you cast a spell, if this permanent is a creature and the amount of
|
|
7
|
+
# mana spent to cast that spell is greater than this creature's power or this creature's toughness, put a
|
|
8
|
+
# +1/+1 counter on this creature." Each instance triggers separately (CR 702.191b).
|
|
9
|
+
class Increment < Primitive
|
|
10
|
+
primitive :keyword, :increment,
|
|
11
|
+
description: "Whenever you cast a spell, if this is a creature and the mana spent to cast that " \
|
|
12
|
+
"spell is greater than its power or its toughness, put a +1/+1 counter on it.",
|
|
13
|
+
rules: %w[702.191a 702.191b 603.4], example: { "keywords" => ["increment"] }
|
|
14
|
+
|
|
15
|
+
# "greater than this creature's [stat]": mana spent - stat >= 1.
|
|
16
|
+
def self.exceeds(stat)
|
|
17
|
+
{ "amount" => { "plus" => [{ "mana_spent" => { "of" => { "triggering" => "object" } } },
|
|
18
|
+
{ "times" => [{ stat => "self" }, -1] }] }, "at_least" => 1 }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ABILITY = { "triggered" => {
|
|
22
|
+
"when" => { "cast" => { "controller" => "you" } },
|
|
23
|
+
"if" => { "all" => [{ "source" => "creature" }, { "any" => [exceeds("power"), exceeds("toughness")] }] },
|
|
24
|
+
"effects" => [{ "add_counters" => { "counter" => "+1/+1", "amount" => 1, "to" => "self" } }]
|
|
25
|
+
} }.freeze
|
|
26
|
+
|
|
27
|
+
def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "increment")].freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Keywords
|
|
6
|
+
# Job select (CR 702.182a): "When this Equipment enters, create a 1/1 colorless Hero creature token,
|
|
7
|
+
# then attach this Equipment to it."
|
|
8
|
+
class JobSelect < Primitive
|
|
9
|
+
primitive :keyword, :job_select,
|
|
10
|
+
description: "When this Equipment enters, create a 1/1 colorless Hero creature token, then " \
|
|
11
|
+
"attach this Equipment to it.",
|
|
12
|
+
rules: %w[702.182a], example: { "keywords" => ["job_select"] }
|
|
13
|
+
|
|
14
|
+
HERO = { "name" => "Hero", "types" => ["creature"], "subtypes" => ["hero"], "colors" => [], "power" => 1,
|
|
15
|
+
"toughness" => 1 }.freeze
|
|
16
|
+
ABILITY = { "triggered" => { "when" => { "enters" => {} },
|
|
17
|
+
"effects" => [{ "create_token" => { "token" => HERO } },
|
|
18
|
+
{ "attach" => { "to" => { "result" => 0 } } }] } }.freeze
|
|
19
|
+
|
|
20
|
+
def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "job_select")].freeze
|
|
21
|
+
|
|
22
|
+
# CR 702.182a: only an Equipment can be attached this way.
|
|
23
|
+
def self.validate(card, errors)
|
|
24
|
+
errors << "keywords: job_select is only for Equipment" unless Array(card["subtypes"]).include?("equipment")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Additional costs of a spell (CR 601.2b, 601.2f-h, 118.8): paid as the spell is cast, on top of its
|
|
7
|
+
# mana cost. Mana parts add to the total cost; other parts (sacrifice a creature) are chosen while
|
|
8
|
+
# casting and paid as it becomes cast.
|
|
9
|
+
class SpellCost < Primitive
|
|
10
|
+
attr_reader :costs
|
|
11
|
+
|
|
12
|
+
def self.build(params, errors, path, **)
|
|
13
|
+
Mechanics.check_keys(params, %w[cost], errors, path)
|
|
14
|
+
costs = StackAbility.parse_costs(params, errors, path)
|
|
15
|
+
errors << "#{path}.cost: must be a non-empty list" if costs.empty? && errors.none? { it.start_with?("#{path}.cost") }
|
|
16
|
+
costs.each_with_index do |cost, i|
|
|
17
|
+
next if spell_cost?(cost)
|
|
18
|
+
|
|
19
|
+
errors << "#{path}.cost[#{i}]: a spell's additional cost must be mana, life or a chosen cost (sacrifice, tap, ...)"
|
|
20
|
+
end
|
|
21
|
+
new(costs)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.spell_cost?(cost)
|
|
25
|
+
return cost.options.all? { spell_cost?(it) } if cost.is_a?(Costs::OneOf)
|
|
26
|
+
|
|
27
|
+
cost.mana? || cost.respond_to?(:choices_needed) || cost.respond_to?(:spell_cost?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(costs, optional: false)
|
|
31
|
+
super()
|
|
32
|
+
@costs = costs
|
|
33
|
+
@optional = optional
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mana_ability? = false
|
|
38
|
+
def allowed_types = nil
|
|
39
|
+
def try_effects = []
|
|
40
|
+
def mana_cost = Mana.sum_costs(costs.select(&:mana?).map(&:mana_cost))
|
|
41
|
+
def choosing_costs = costs.reject(&:mana?)
|
|
42
|
+
# Recorded as the spell's "kicked_with" when this optional cost is paid.
|
|
43
|
+
def keyword = self.class.primitive_name.to_s
|
|
44
|
+
# Whether paying it makes the spell kicked (CR 702.33d).
|
|
45
|
+
def kicks? = false
|
|
46
|
+
|
|
47
|
+
# Whether the non-mana parts can be paid by the spell's controller (CR 118.3).
|
|
48
|
+
def payable?(engine, spell) = choosing_costs.all? { it.payable?(engine, spell) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Kicker (CR 702.33a): "You may pay an additional [cost] as you cast this spell." The choice is
|
|
52
|
+
# announced while casting (CR 601.2b); a kicked spell remembers it (condition {"kicked": true},
|
|
53
|
+
# CR 702.33d) and may have other targets (`kicked_targets` of its spell ability, CR 702.33g).
|
|
54
|
+
class Kicker < SpellCost
|
|
55
|
+
primitive :ability, :kicker,
|
|
56
|
+
description: "Kicker: as you cast this spell you may also pay `cost` (mana and/or chosen costs " \
|
|
57
|
+
"such as `sacrifice_permanent`). The spell is then kicked: `{\"kicked\": true}` " \
|
|
58
|
+
"conditions hold and its `kicked_targets` are used.",
|
|
59
|
+
rules: %w[702.33a 702.33d 702.33g 601.2b 601.2f 601.2h],
|
|
60
|
+
params: { "cost" => "list of costs, e.g. [{\"mana\": \"{1}{U}\"}]" },
|
|
61
|
+
example: { "kicker" => { "cost" => [{ "mana" => "{1}{U}" }] } }
|
|
62
|
+
|
|
63
|
+
def kind = :kicker
|
|
64
|
+
def kicks? = true
|
|
65
|
+
def to_s = "Kicker #{costs.join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# An additional cost: "As an additional cost to cast this spell, sacrifice a creature." (CR 601.2b,
|
|
69
|
+
# 601.2f-h, 118.8). An optional one ("you may behold a Dragon", CR 118.8b) is announced like kicker (the
|
|
70
|
+
# `choose_kicker` decision) without making the spell kicked: {"cast_with": "additional_cost"} holds if it
|
|
71
|
+
# was paid.
|
|
72
|
+
class AdditionalCost < SpellCost
|
|
73
|
+
primitive :ability, :additional_cost,
|
|
74
|
+
description: "As an additional cost to cast this spell, pay `cost` too (mana and/or chosen costs " \
|
|
75
|
+
"such as `sacrifice_permanent`). The spell can't be cast without it. `optional: true`: " \
|
|
76
|
+
"you may pay it (announced with the `choose_kicker` decision); the condition " \
|
|
77
|
+
"`{\"cast_with\": \"additional_cost\"}` then holds (the spell isn't kicked).",
|
|
78
|
+
rules: %w[601.2b 601.2f 601.2h 118.8 118.8b],
|
|
79
|
+
params: { "cost" => "list of costs", "optional" => "true: you may pay it" },
|
|
80
|
+
example: { "additional_cost" => { "cost" => [{ "sacrifice_permanent" => { "filter" => "creature" } }] } }
|
|
81
|
+
|
|
82
|
+
def self.build(params, errors, path, **)
|
|
83
|
+
optional = Mechanics.flag_param(params, :optional, errors, path)
|
|
84
|
+
new(super(params.except("optional", :optional), errors, path).costs, optional: optional)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def kind = @optional ? :kicker : :additional_cost
|
|
88
|
+
def to_s = "As an additional cost to cast this spell#{", you may" if @optional}: #{costs.join(", ")}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# A keyword ability with a number that stands for a triggered ability written in the card DSL
|
|
7
|
+
# ("Mobilize 2", "Firebending X"). Its `amount` is any amount; it's written into the generated
|
|
8
|
+
# effects, which validate it.
|
|
9
|
+
class NumberedTrigger < TriggeredAbility
|
|
10
|
+
def self.build(params, errors, path, **)
|
|
11
|
+
Mechanics.check_keys(params, %w[amount], errors, path)
|
|
12
|
+
amount = Mechanics.fetch_param(params, :amount)
|
|
13
|
+
errors << "#{path}.amount: required" if amount.nil?
|
|
14
|
+
data = expansion(amount || 0)
|
|
15
|
+
trigger = Mechanics.build_entry(:trigger, data["when"], errors, "#{path}.when")
|
|
16
|
+
body = parse_body(data.except("when"), errors, path)
|
|
17
|
+
new(**body, effects: (body[:effects] + extra_effects).freeze, trigger: trigger, amount: amount)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Effects the keyword adds after its printed ones (none by default).
|
|
21
|
+
def self.extra_effects = []
|
|
22
|
+
|
|
23
|
+
def initialize(amount: nil, **)
|
|
24
|
+
@amount = amount
|
|
25
|
+
super(**)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s = "#{self.class.primitive_name.to_s.tr("_", " ").capitalize} #{@amount.is_a?(Integer) ? @amount : "X"}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Mobilize N (CR 702.181a): "Whenever this creature attacks, create N 1/1 red Warrior creature tokens.
|
|
32
|
+
# Those tokens enter tapped and attacking. Sacrifice them at the beginning of the next end step."
|
|
33
|
+
class Mobilize < NumberedTrigger
|
|
34
|
+
primitive :ability, :mobilize,
|
|
35
|
+
description: "Mobilize `amount`: whenever this creature attacks, create that many 1/1 red Warrior " \
|
|
36
|
+
"creature tokens tapped and attacking; sacrifice them at the beginning of the next end step.",
|
|
37
|
+
rules: %w[702.181a 508.4],
|
|
38
|
+
params: { "amount" => "amount" },
|
|
39
|
+
example: { "mobilize" => { "amount" => 2 } }
|
|
40
|
+
|
|
41
|
+
WARRIOR = { "name" => "Warrior", "types" => ["creature"], "subtypes" => ["warrior"], "colors" => ["R"], "power" => 1,
|
|
42
|
+
"toughness" => 1 }.freeze
|
|
43
|
+
|
|
44
|
+
def self.expansion(amount)
|
|
45
|
+
{ "when" => { "attacks" => {} },
|
|
46
|
+
"effects" => [{ "create_token" => { "token" => WARRIOR, "amount" => amount, "tapped" => true, "attacking" => true } },
|
|
47
|
+
{ "delayed_trigger" => { "when" => { "step_begins" => { "step" => "end", "whose" => "each" } },
|
|
48
|
+
"effects" => [{ "sacrifice" => { "to" => { "result" => 0 } } }] } }] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Firebending N (CR 702.189a): "Whenever this creature attacks, add N {R}. Until end of combat, you
|
|
53
|
+
# don't lose this mana as steps and phases end."
|
|
54
|
+
class Firebending < NumberedTrigger
|
|
55
|
+
primitive :ability, :firebending,
|
|
56
|
+
description: "Firebending `amount`: whenever this creature attacks, add that much {R}; you don't " \
|
|
57
|
+
"lose this mana as steps end until end of combat.",
|
|
58
|
+
rules: %w[702.189a 500.5],
|
|
59
|
+
params: { "amount" => "amount" },
|
|
60
|
+
example: { "firebending" => { "amount" => 2 } }
|
|
61
|
+
|
|
62
|
+
def self.expansion(amount)
|
|
63
|
+
{ "when" => { "attacks" => {} },
|
|
64
|
+
"effects" => [{ "add_mana" => { "produce" => "{R}", "amount" => amount, "until" => "end_of_combat" } }] }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# CR 702.189b: the ability resolving is firebending ("whenever you firebend").
|
|
68
|
+
def self.extra_effects = @extra_effects ||= [Effects::Firebent.new(to: nil, params: {}, errors: [], path: "firebending")].freeze
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module Effects
|
|
73
|
+
# The last step of a firebending ability: it has resolved, so its controller firebended (CR 702.189b).
|
|
74
|
+
class Firebent < Effect
|
|
75
|
+
def self.keys = []
|
|
76
|
+
def to_s = "(you firebend)"
|
|
77
|
+
|
|
78
|
+
def apply(engine, ctx)
|
|
79
|
+
engine.emit Events::KeywordAction.new(player: engine.name(ctx.controller), action: "firebend", amount: nil)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../effects/delayed_triggers"
|
|
4
|
+
require_relative "../abilities/play_permissions"
|
|
5
|
+
|
|
6
|
+
module Enjine
|
|
7
|
+
module Mechanics
|
|
8
|
+
module Keywords
|
|
9
|
+
# Paradigm (CR 702.192a): two spell abilities that follow the spell's own effects. "If this is the first
|
|
10
|
+
# time a spell you control with this spell's name has resolved this game, at the beginning of each of
|
|
11
|
+
# your precombat main phases for the rest of the game, create a copy of this object in exile. You may
|
|
12
|
+
# cast the copy without paying its mana cost" and "Exile this spell." The engine applies them as the
|
|
13
|
+
# spell finishes resolving (Engine::Resolution#finish_resolution!); the delayed triggered ability is
|
|
14
|
+
# "delayed:keyword:paradigm" of the spell's card and lasts for the rest of the game.
|
|
15
|
+
class Paradigm < Primitive
|
|
16
|
+
primitive :keyword, :paradigm,
|
|
17
|
+
description: "As this instant or sorcery finishes resolving: if it's the first time a spell you " \
|
|
18
|
+
"control with its name has resolved this game, at the beginning of each of your " \
|
|
19
|
+
"precombat main phases for the rest of the game you create a copy of the card in exile " \
|
|
20
|
+
"and may cast it without paying its mana cost. Then the spell is exiled.",
|
|
21
|
+
rules: %w[702.192a 707.12 603.7 608.2g], example: { "keywords" => ["paradigm"] }
|
|
22
|
+
|
|
23
|
+
REF = "delayed:keyword:paradigm"
|
|
24
|
+
|
|
25
|
+
# Carries the delayed triggered ability, so the card's data names it (CardDefinition.keyword_delayed).
|
|
26
|
+
class Rule < Primitive
|
|
27
|
+
def kind = :paradigm
|
|
28
|
+
def mana_ability? = false
|
|
29
|
+
def allowed_types = %i[instant sorcery]
|
|
30
|
+
def try_effects = [Paradigm.delayed]
|
|
31
|
+
def to_s = "Paradigm"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.intrinsic_abilities = @intrinsic_abilities ||= [Rule.new.freeze].freeze
|
|
35
|
+
def self.delayed = @delayed ||= Effects::ParadigmDelayed.build({}, [], "keyword:paradigm")
|
|
36
|
+
|
|
37
|
+
def self.validate(card, errors)
|
|
38
|
+
types = Array(card["types"]).map(&:to_s)
|
|
39
|
+
errors << "keywords: paradigm is only for instants and sorceries" unless types.intersect?(%w[instant sorcery])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# As the spell finishes resolving: the delayed trigger the first time a spell with its name resolved
|
|
43
|
+
# for its controller (remembered in their game log), then it's exiled instead of going to the
|
|
44
|
+
# graveyard. False if the spell doesn't have paradigm.
|
|
45
|
+
def self.resolved!(engine, spell)
|
|
46
|
+
chars = engine.chars(spell)
|
|
47
|
+
return false unless chars.keyword?(:paradigm)
|
|
48
|
+
|
|
49
|
+
names = engine.game_log(spell.controller)["paradigm"] ||= []
|
|
50
|
+
unless names.include?(chars.name)
|
|
51
|
+
names << chars.name
|
|
52
|
+
engine.create_delayed_trigger!("controller" => spell.controller, "source" => spell.id, "card" => spell.card,
|
|
53
|
+
"ability" => REF, "game" => true)
|
|
54
|
+
end
|
|
55
|
+
engine.move!(spell.id, :exile, reason: "paradigm")
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module Effects
|
|
62
|
+
# Paradigm's delayed triggered ability (CR 702.192a): at the beginning of each of your precombat main
|
|
63
|
+
# phases, create a copy of the card in exile; you may cast it without paying its mana cost (a copy not
|
|
64
|
+
# cast ceases to exist, CR 707.12). Not a card-data primitive.
|
|
65
|
+
class ParadigmDelayed < DelayedTrigger
|
|
66
|
+
MAIN = { "step_begins" => { "step" => "precombat_main", "whose" => "your" } }.freeze
|
|
67
|
+
|
|
68
|
+
def self.keys = []
|
|
69
|
+
|
|
70
|
+
def setup(_params, errors, path)
|
|
71
|
+
@ref = "delayed:#{path}"
|
|
72
|
+
trigger = Mechanics.build_entry(:trigger, MAIN, errors, "#{path}.when")
|
|
73
|
+
cast = CastFree.build({ "to" => { "result" => 0 } }, errors, "#{path}.cast", results_count: 1)
|
|
74
|
+
@delayed_ability = Abilities::TriggeredAbility.new(targets: [].freeze, effects: [ParadigmCopy.new, cast].freeze,
|
|
75
|
+
trigger: trigger)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_s = "at the beginning of each of your precombat main phases, copy this card in exile; you may cast the copy free"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Creates a copy of the paradigm card (the delayed trigger's card) in exile, owned by its controller.
|
|
82
|
+
class ParadigmCopy < Effect
|
|
83
|
+
def self.keys = []
|
|
84
|
+
|
|
85
|
+
def initialize = super(to: nil, params: {}, errors: [], path: "paradigm")
|
|
86
|
+
def apply(engine, ctx) = engine.create_card_copy!([engine.object(ctx.object).card, {}], ctx.controller, :exile)
|
|
87
|
+
def to_s = "create a copy of this card in exile"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Keywords
|
|
6
|
+
# Power-up (CR 702.193): "Power-up — [Cost]: [Effect]" is an activated ability written with
|
|
7
|
+
# `"power_up": true` (Abilities::ActivatedAbility). It can be activated only once, and if its permanent
|
|
8
|
+
# entered this turn its cost is reduced by the permanent's mana cost (CR 702.193a). Generic mana reduces
|
|
9
|
+
# generic mana; colored and colorless mana reduce mana of the same type, and any excess reduces generic
|
|
10
|
+
# mana (CR 702.193b, 118.7b-d). A hybrid symbol reduces by one of its halves (CR 118.7e): the half the cost
|
|
11
|
+
# needs (a matching colored symbol, then a hybrid symbol that half can pay), otherwise one generic mana.
|
|
12
|
+
module PowerUp
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The total cost of `ability` of `source` after the power-up reduction.
|
|
16
|
+
def reduced(engine, cost, ability, source)
|
|
17
|
+
return cost unless ability.respond_to?(:power_up?) && ability.power_up?
|
|
18
|
+
|
|
19
|
+
obj = engine.state.objects[source]
|
|
20
|
+
return cost unless obj && engine.entered_this_turn?(obj)
|
|
21
|
+
|
|
22
|
+
mana_cost = engine.chars(obj).mana_cost
|
|
23
|
+
mana_cost ? reduce(cost, mana_cost) : cost
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `cost` reduced by the mana cost `reduction` (CR 118.7).
|
|
27
|
+
def reduce(cost, reduction)
|
|
28
|
+
colored = cost.colored.dup
|
|
29
|
+
hybrid = cost.hybrid.dup
|
|
30
|
+
generic = cost.generic
|
|
31
|
+
halves = reduction.colored.flat_map { |type, n| Array.new(n) { [type] } } + reduction.hybrid
|
|
32
|
+
halves.each { generic -= 1 unless reduce_symbol!(colored, hybrid, it) }
|
|
33
|
+
cost.with(generic: [generic - reduction.generic, 0].max, colored: colored.reject { |_, n| n.zero? }.freeze,
|
|
34
|
+
hybrid: hybrid.freeze)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Takes one mana of one of `types` off the colored part, else a hybrid symbol one of them pays.
|
|
38
|
+
def reduce_symbol!(colored, hybrid, types)
|
|
39
|
+
type = types.find { colored.fetch(it, 0).positive? }
|
|
40
|
+
return colored[type] -= 1 if type
|
|
41
|
+
|
|
42
|
+
index = hybrid.index { it.intersect?(types) }
|
|
43
|
+
index && hybrid.delete_at(index)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# "Hexproof from [quality]" and "protection from [quality]" (CR 702.11d, 702.16): static abilities of a
|
|
7
|
+
# permanent whose quality is a card spec matched against the spell, or the source of the ability or
|
|
8
|
+
# damage, as it is now ({"colors": ["B"]} "from black", {"types": ["instant"]} "from instants",
|
|
9
|
+
# {"color_count": {"equal": 1}} "from monocolored"); "everything" is protection from everything (CR
|
|
10
|
+
# 702.16j). "From A and from B" is two abilities (CR 702.11f, 702.16g). Two qualities depend on what has
|
|
11
|
+
# the ability: "each_opponent" (protection from each of your opponents: from everything they control, or own
|
|
12
|
+
# and nobody controls, CR 702.16i, 702.16k) and "its_colors" ("hexproof from each of its colors": anything
|
|
13
|
+
# sharing a color with it, CR 702.11g, 702.16h). The engine asks for them by kind
|
|
14
|
+
# (Engine::Rules#protected_from?).
|
|
15
|
+
class QualityAbility < Primitive
|
|
16
|
+
HOLDER_QUALITIES = %w[each_opponent its_colors].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :quality
|
|
19
|
+
|
|
20
|
+
# Its value may be the string "everything", "each_opponent" or "its_colors".
|
|
21
|
+
def self.scalar_params? = true
|
|
22
|
+
|
|
23
|
+
def self.build(params, errors, path, **)
|
|
24
|
+
return new(CardSpec::ANY) if params == "everything"
|
|
25
|
+
return new(params.to_sym) if HOLDER_QUALITIES.include?(params)
|
|
26
|
+
|
|
27
|
+
if params.is_a?(String)
|
|
28
|
+
errors << "#{path}: must be a card spec, \"everything\", \"each_opponent\" or \"its_colors\""
|
|
29
|
+
return new(CardSpec::ANY)
|
|
30
|
+
end
|
|
31
|
+
new(CardSpec.build(params, errors, path))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(quality)
|
|
35
|
+
super()
|
|
36
|
+
@quality = quality
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mana_ability? = false
|
|
41
|
+
def allowed_types = nil
|
|
42
|
+
def try_effects = []
|
|
43
|
+
def everything? = quality.equal?(CardSpec::ANY)
|
|
44
|
+
|
|
45
|
+
# Whether `object` (a spell, or the source of an ability or of damage) has the quality, for `holder` (the
|
|
46
|
+
# permanent with the ability, or the index of the player who has it).
|
|
47
|
+
def from?(engine, object, holder = nil)
|
|
48
|
+
return false if object.nil?
|
|
49
|
+
|
|
50
|
+
case quality
|
|
51
|
+
when :each_opponent then QualityAbility.opponents?(object, holder)
|
|
52
|
+
when :its_colors then holder.is_a?(GameObject) && engine.chars(object).colors.intersect?(engine.chars(holder).colors)
|
|
53
|
+
else quality.match?(engine, object)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# CR 702.16k: the object's controller (or its owner, if nobody controls it) is an opponent of the holder's
|
|
58
|
+
# controller.
|
|
59
|
+
def self.opponents?(object, holder)
|
|
60
|
+
return false if holder.nil?
|
|
61
|
+
|
|
62
|
+
(object.controller || object.owner) != (holder.is_a?(Integer) ? holder : holder.controller)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def quality_text
|
|
66
|
+
case quality
|
|
67
|
+
when :each_opponent then "each of your opponents"
|
|
68
|
+
when :its_colors then "each of its colors"
|
|
69
|
+
else everything? ? "everything" : quality.to_s.delete_suffix(" card")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Hexproof from [quality] (CR 702.11d): it can't be the target of [quality] spells its controller's
|
|
75
|
+
# opponents control, or of abilities they control from [quality] sources.
|
|
76
|
+
class HexproofFrom < QualityAbility
|
|
77
|
+
primitive :ability, :hexproof_from,
|
|
78
|
+
description: "Hexproof from the quality: this permanent can't be the target of spells with the " \
|
|
79
|
+
"quality, or abilities from sources with it, that its controller's opponents control. " \
|
|
80
|
+
"The quality is a card spec (`{\"colors\": [\"B\"]}`, `{\"types\": [\"instant\"]}`, " \
|
|
81
|
+
"`{\"color_count\": {\"equal\": 1}}` for monocolored), or \"its_colors\" (hexproof " \
|
|
82
|
+
"from each of its colors: whatever shares a color with it).",
|
|
83
|
+
rules: %w[702.11d 702.11f 702.11g 115.4],
|
|
84
|
+
params: { "(value)" => "card spec of the quality, or \"its_colors\"" },
|
|
85
|
+
example: { "hexproof_from" => { "colors" => ["B"] } }
|
|
86
|
+
|
|
87
|
+
def kind = :hexproof_from
|
|
88
|
+
def to_s = "Hexproof from #{quality_text}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Protection from [quality] (CR 702.16b-f, "DEBT"): it can't be targeted by spells with the quality or
|
|
92
|
+
# abilities from sources with it, enchanted or equipped by Auras and Equipment with it, or blocked by
|
|
93
|
+
# creatures with it, and damage sources with it would deal to it is prevented.
|
|
94
|
+
class ProtectionFrom < QualityAbility
|
|
95
|
+
primitive :ability, :protection_from,
|
|
96
|
+
description: "Protection from the quality (a card spec, \"everything\", \"each_opponent\": from " \
|
|
97
|
+
"each of your opponents, or \"its_colors\"): damage from sources " \
|
|
98
|
+
"with the quality is prevented, and this permanent can't be enchanted or equipped " \
|
|
99
|
+
"by, blocked by or targeted by anything with the quality (Auras and Equipment " \
|
|
100
|
+
"already attached fall off as a state-based action).",
|
|
101
|
+
rules: %w[702.16a 702.16b 702.16c 702.16d 702.16e 702.16f 702.16g 702.16h 702.16i 702.16j 702.16k
|
|
102
|
+
704.5m 704.5n],
|
|
103
|
+
params: { "(value)" => "card spec of the quality, \"everything\", \"each_opponent\" or \"its_colors\"" },
|
|
104
|
+
example: { "protection_from" => { "colors" => ["W"] } }
|
|
105
|
+
|
|
106
|
+
def kind = :protection_from
|
|
107
|
+
def to_s = "Protection from #{quality_text}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Keywords
|
|
6
|
+
# Builds the ability a keyword stands for from its card-DSL form, so it behaves exactly like the same
|
|
7
|
+
# ability printed on a card. The data is fixed, so an error here is a bug.
|
|
8
|
+
def self.ability_from_data(data, keyword)
|
|
9
|
+
errors = []
|
|
10
|
+
ability = Mechanics.build_entry(:ability, data, errors, "keyword:#{keyword}")
|
|
11
|
+
raise ArgumentError, errors.join("; ") unless errors.empty?
|
|
12
|
+
|
|
13
|
+
ability
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Prowess (CR 702.108a): "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of
|
|
17
|
+
# turn." An effect that grants prowess grants this triggered ability too (layer 6, CR 613.1f).
|
|
18
|
+
class Prowess < Primitive
|
|
19
|
+
primitive :keyword, :prowess,
|
|
20
|
+
description: "Whenever you cast a noncreature spell, this creature gets +1/+1 until end of turn.",
|
|
21
|
+
rules: %w[702.108a], example: { "keywords" => ["prowess"] }
|
|
22
|
+
|
|
23
|
+
ABILITY = { "triggered" => { "when" => { "cast" => { "controller" => "you", "spell" => "noncreature" } },
|
|
24
|
+
"effects" => [{ "pump" => { "power" => 1, "toughness" => 1, "to" => "self" } }] } }.freeze
|
|
25
|
+
|
|
26
|
+
def self.intrinsic_abilities = @intrinsic_abilities ||= [Keywords.ability_from_data(ABILITY, "prowess")].freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../abilities/chosen_values"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Abilities
|
|
8
|
+
# Riot's "as this enters" choice (CR 702.136a): "counter" (it enters with an additional +1/+1 counter) or
|
|
9
|
+
# "haste". The choice is remembered like choose_option's ({"chosen": "haste"}).
|
|
10
|
+
class RiotChoice < ChooseOption
|
|
11
|
+
INSTANCE = new(%w[counter haste].freeze)
|
|
12
|
+
|
|
13
|
+
def to_s = "as this enters, choose a +1/+1 counter or haste"
|
|
14
|
+
def enters_for(answer) = answer[:option] == "counter" ? super.merge("counters" => { "+1/+1" => 1 }) : super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Keywords
|
|
19
|
+
# Riot (CR 702.136a): "You may have this permanent enter with an additional +1/+1 counter on it. If you
|
|
20
|
+
# don't, it gains haste." Only one "as this enters" choice of a permanent is supported.
|
|
21
|
+
class Riot < Primitive
|
|
22
|
+
primitive :keyword, :riot,
|
|
23
|
+
description: "As this permanent enters, its controller chooses an additional +1/+1 counter or haste " \
|
|
24
|
+
"(an enter_choice: `{type: \"option\", option: \"counter\" | \"haste\"}`).",
|
|
25
|
+
rules: %w[702.136a 614.12], example: { "keywords" => ["riot"] }
|
|
26
|
+
|
|
27
|
+
HASTE = { "static" => { "affects" => "self", "if" => { "chosen" => "haste" }, "keywords" => ["haste"] } }.freeze
|
|
28
|
+
|
|
29
|
+
def self.intrinsic_abilities
|
|
30
|
+
@intrinsic_abilities ||= [Abilities::RiotChoice::INSTANCE, Keywords.ability_from_data(HASTE, "riot")].freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|