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,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# A static ability that modifies permanents while its source is on the battlefield (CR 604,
|
|
7
|
+
# 611.3, 613): "Creatures you control get +1/+1", "Other Elves you control have reach",
|
|
8
|
+
# a keyword only during your turn, an Aura that sets creature types, creatures that gain a mana
|
|
9
|
+
# ability. Unlike a resolved effect, the set of affected
|
|
10
|
+
# objects is re-evaluated continuously (CR 611.3a).
|
|
11
|
+
class StaticAbility < Primitive
|
|
12
|
+
primitive :ability, :static,
|
|
13
|
+
description: "While its source is on the battlefield (and its `if` condition holds), every permanent " \
|
|
14
|
+
"it `affects` gets `pump` (fixed or computed amounts, layer 7c), the `keywords` and " \
|
|
15
|
+
"granted `abilities` (layer 6, triggered, activated or mana abilities), and with " \
|
|
16
|
+
"`creature_types` has exactly those creature types (layer 4); `loses_abilities: true` " \
|
|
17
|
+
"removes the abilities it had before (layer 6, in timestamp order: later grants and " \
|
|
18
|
+
"keyword counters stay, CR 613.7), `colors` sets its colors (layer 5) and " \
|
|
19
|
+
"`base_power_toughness` its base power and toughness (layer 7b); `types` are card " \
|
|
20
|
+
"types it has in addition " \
|
|
21
|
+
"to its other types (\"this Vehicle is an artifact creature\", layer 4) and `subtypes` subtypes it has in " \
|
|
22
|
+
"addition to its other types (\"is a Wizard in addition to its other types\", layer 4). " \
|
|
23
|
+
"`affects` is " \
|
|
24
|
+
"{\"each\": filter, " \
|
|
25
|
+
"\"controller\": you|opponent|any, \"other\": true} (relative to the source's " \
|
|
26
|
+
"controller), \"self\", or \"attached\" (\"equipped/enchanted creature\").",
|
|
27
|
+
rules: %w[604.1 604.2 611.3a 612.8 613.1c 613.1d 613.1e 613.1f 613.4b 613.4c 613.7a 613.8a 205.1a 205.1b 301.5f
|
|
28
|
+
303.4m],
|
|
29
|
+
params: { "affects" => "{\"each\": filter, \"controller\": ..., \"other\": bool}, \"self\" or \"attached\"",
|
|
30
|
+
"if" => "condition (\"as long as ...\", \"during your turn\")",
|
|
31
|
+
"pump" => "{\"power\": amount, \"toughness\": amount} (\"gets +1/+1 for each ...\")",
|
|
32
|
+
"keywords" => "list of keywords", "creature_types" => "list of creature types (replaces the others)",
|
|
33
|
+
"types" => "list of card types it also has (in addition to its other types)",
|
|
34
|
+
"subtypes" => "list of subtypes it also has (in addition to its other types), or \"chosen\": " \
|
|
35
|
+
"the type chosen as its source entered",
|
|
36
|
+
"abilities" => "list of triggered, activated or mana abilities the affected permanents have",
|
|
37
|
+
"loses_abilities" => "true: it loses all abilities (\"loses all abilities\")",
|
|
38
|
+
"colors" => "list of W, U, B, R, G: its colors are exactly these ([] for colorless)",
|
|
39
|
+
"base_power_toughness" => "{\"power\": n, \"toughness\": n}",
|
|
40
|
+
"set_types" => "list of card types: its card types are exactly these",
|
|
41
|
+
"name" => "its name (\"named Humble Merchant\")" },
|
|
42
|
+
example: { "static" => { "affects" => { "each" => "creature", "controller" => "you" },
|
|
43
|
+
"pump" => { "power" => 1, "toughness" => 1 } } }
|
|
44
|
+
|
|
45
|
+
GRANTABLE = %i[triggered mana activated hexproof_from protection_from tapping_power].freeze
|
|
46
|
+
# Data that would make the layer pass depend on its own result (CR 613.8).
|
|
47
|
+
LIVE = %w[power toughness greatest_power attacking blocking tapped].freeze
|
|
48
|
+
KEYS = %w[affects if pump keywords creature_types abilities types subtypes loses_abilities colors
|
|
49
|
+
base_power_toughness set_types name].freeze
|
|
50
|
+
|
|
51
|
+
attr_reader :filter, :controller, :other, :keywords, :creature_types, :condition, :granted, :card_types, :subtypes,
|
|
52
|
+
:colors, :base_power_toughness, :set_types, :name
|
|
53
|
+
|
|
54
|
+
def self.build(params, errors, path, **)
|
|
55
|
+
Mechanics.check_keys(params, KEYS, errors, path)
|
|
56
|
+
affects = Mechanics.fetch_param(params, :affects)
|
|
57
|
+
filter, controller, other = parse_affects(affects, errors, path)
|
|
58
|
+
raw_if = Mechanics.fetch_param(params, :if)
|
|
59
|
+
errors << "#{path}.if: can't depend on power, toughness or combat status (CR 613.8)" if live?(raw_if)
|
|
60
|
+
condition = raw_if && Condition.build(raw_if, errors, "#{path}.if")
|
|
61
|
+
pump = parse_pump(params, errors, path)
|
|
62
|
+
keywords = parse_keywords(params, errors, path)
|
|
63
|
+
types = parse_subtypes(params, :creature_types, errors, path)
|
|
64
|
+
granted = parse_granted(params, errors, path)
|
|
65
|
+
card_types = Effects::Become.names_param(params, :types, :type, errors, path, allowed: Effects::Become::ADDABLE_TYPES)
|
|
66
|
+
chosen = Mechanics.fetch_param(params, :subtypes) == "chosen" # "is the chosen type in addition" (CR 607.2d)
|
|
67
|
+
subtypes = chosen ? [] : parse_subtypes(params, :subtypes, errors, path) || []
|
|
68
|
+
sets = parse_sets(params, errors, path).merge(chosen_subtype: chosen)
|
|
69
|
+
if pump.empty? && keywords.empty? && types.nil? && granted.empty? && card_types.empty? && subtypes.empty? &&
|
|
70
|
+
sets.values.none?
|
|
71
|
+
errors << "#{path}: needs at least one of #{(KEYS - %w[affects if]).join(", ")}"
|
|
72
|
+
end
|
|
73
|
+
new(filter: filter, controller: controller, other: other, pump: pump, keywords: keywords, creature_types: types,
|
|
74
|
+
condition: condition, granted: granted, card_types: card_types, subtypes: subtypes, **sets)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# "loses all abilities and is a black 1/1" (layers 5, 6 and 7b): the same shapes as Effects::Become.
|
|
78
|
+
def self.parse_sets(params, errors, path)
|
|
79
|
+
loses = Mechanics.fetch_param(params, :loses_abilities)
|
|
80
|
+
errors << "#{path}.loses_abilities: must be true (or absent)" unless [nil, true].include?(loses)
|
|
81
|
+
{ loses_abilities: loses == true,
|
|
82
|
+
colors: Effects::Become.parse_colors(Mechanics.fetch_param(params, :colors), errors, path),
|
|
83
|
+
set_types: parse_set_types(params, errors, path),
|
|
84
|
+
name: parse_name(params, errors, path),
|
|
85
|
+
base_power_toughness: Effects::Become.parse_power_toughness(Mechanics.fetch_param(params, :base_power_toughness),
|
|
86
|
+
errors, path) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# "is a black Skeleton creature" (CR 205.1a): exactly these card types, or nil.
|
|
90
|
+
def self.parse_set_types(params, errors, path)
|
|
91
|
+
return nil unless params.key?("set_types")
|
|
92
|
+
|
|
93
|
+
Effects::Become.names_param(params, :set_types, :type, errors, path, allowed: Effects::Become::ADDABLE_TYPES)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# "named Legitimate Businessperson" (CR 612.8): a non-empty string.
|
|
97
|
+
def self.parse_name(params, errors, path)
|
|
98
|
+
name = Mechanics.fetch_param(params, :name)
|
|
99
|
+
return nil if name.nil?
|
|
100
|
+
|
|
101
|
+
errors << "#{path}.name: must be a non-empty string" unless name.is_a?(String) && !name.strip.empty?
|
|
102
|
+
name.is_a?(String) ? name.freeze : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.parse_affects(affects, errors, path)
|
|
106
|
+
return [affects.to_sym, "any", false] if %w[attached self].include?(affects)
|
|
107
|
+
|
|
108
|
+
unless affects.is_a?(Hash) && affects["each"]
|
|
109
|
+
errors << "#{path}.affects: must be {\"each\": filter, ...}, \"self\" or \"attached\""
|
|
110
|
+
affects = { "each" => "creature" }
|
|
111
|
+
end
|
|
112
|
+
Mechanics.check_keys(affects, %w[each controller other], errors, "#{path}.affects")
|
|
113
|
+
filter = Mechanics.build_entry(:filter, affects["each"], errors, "#{path}.affects.each")
|
|
114
|
+
live = filter ? filter.live_keys : []
|
|
115
|
+
# CR 613.8: these would make the layer pass depend on its own result.
|
|
116
|
+
errors << "#{path}.affects.each: #{live.join(", ")} can't be used in a static ability yet" unless live.empty?
|
|
117
|
+
[filter, affects.fetch("controller", "any"), affects["other"] == true]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.live?(data)
|
|
121
|
+
case data
|
|
122
|
+
when Hash then data.any? { |k, v| LIVE.include?(k.to_s) || live?(v) }
|
|
123
|
+
when Array then data.any? { live?(it) }
|
|
124
|
+
else false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# {"power": amount, "toughness": amount}; an amount is counted during the layer pass, so it can't read
|
|
129
|
+
# power or toughness itself (CR 613.8).
|
|
130
|
+
def self.parse_pump(params, errors, path)
|
|
131
|
+
raw = Mechanics.fetch_param(params, :pump) || {}
|
|
132
|
+
return {} if raw.empty?
|
|
133
|
+
|
|
134
|
+
Mechanics.check_keys(raw, %w[power toughness], errors, "#{path}.pump")
|
|
135
|
+
%w[power toughness].to_h do |key|
|
|
136
|
+
amount = Effects::Pump.signed(raw, key, errors, "#{path}.pump", targets_count: 0)
|
|
137
|
+
errors << "#{path}.pump.#{key}: can't read power or toughness (CR 613.8)" unless Amount.layer_safe?(amount)
|
|
138
|
+
[key, amount]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.parse_keywords(params, errors, path)
|
|
143
|
+
keywords = Array(Mechanics.fetch_param(params, :keywords)).map { it.to_s.downcase.tr(" -", "__") }
|
|
144
|
+
keywords.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
|
|
145
|
+
keywords.map(&:to_sym)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.parse_subtypes(params, key, errors, path)
|
|
149
|
+
raw = Mechanics.fetch_param(params, key)
|
|
150
|
+
return nil if raw.nil?
|
|
151
|
+
|
|
152
|
+
ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
|
|
153
|
+
errors << "#{path}.#{key}: must be a non-empty list of lowercase subtypes" unless ok
|
|
154
|
+
ok ? raw.map(&:to_sym).freeze : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Granted abilities are part of this card's data, referenced as "<this ability's ref>:<j>" (like a
|
|
158
|
+
# station symbol's, see CardDefinition#nested_instances).
|
|
159
|
+
def self.parse_granted(params, errors, path)
|
|
160
|
+
Mechanics.list_param(params, :abilities, errors, path).each_with_index.filter_map do |entry, j|
|
|
161
|
+
ability = Mechanics.build_entry(:ability, entry, errors, "#{path}.abilities[#{j}]")
|
|
162
|
+
next ability if ability.nil? || GRANTABLE.include?(ability.kind)
|
|
163
|
+
|
|
164
|
+
errors << "#{path}.abilities[#{j}]: only #{GRANTABLE.join(", ")} abilities can be granted yet"
|
|
165
|
+
nil
|
|
166
|
+
end.freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def initialize(filter:, controller:, other:, pump:, keywords:, creature_types:, condition:, granted:, card_types: [],
|
|
170
|
+
subtypes: [], loses_abilities: false, colors: nil, base_power_toughness: nil, chosen_subtype: false,
|
|
171
|
+
set_types: nil, name: nil)
|
|
172
|
+
super()
|
|
173
|
+
@set_types = set_types
|
|
174
|
+
@name = name
|
|
175
|
+
@chosen_subtype = chosen_subtype
|
|
176
|
+
@card_types = card_types.freeze
|
|
177
|
+
@subtypes = subtypes.freeze
|
|
178
|
+
@loses_abilities = loses_abilities
|
|
179
|
+
@colors = colors
|
|
180
|
+
@base_power_toughness = base_power_toughness
|
|
181
|
+
@filter = filter
|
|
182
|
+
@controller = controller
|
|
183
|
+
@other = other
|
|
184
|
+
@pump = pump.freeze
|
|
185
|
+
@keywords = keywords.freeze
|
|
186
|
+
@creature_types = creature_types
|
|
187
|
+
@condition = condition
|
|
188
|
+
@granted = granted
|
|
189
|
+
freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def kind = :static
|
|
193
|
+
def attached? = @filter == :attached
|
|
194
|
+
def mana_ability? = false
|
|
195
|
+
def allowed_types = nil
|
|
196
|
+
def try_effects = []
|
|
197
|
+
def nested_abilities = @granted
|
|
198
|
+
def pump? = !@pump.empty?
|
|
199
|
+
def loses_abilities? = @loses_abilities
|
|
200
|
+
def adds_subtypes? = @chosen_subtype || !@subtypes.empty?
|
|
201
|
+
# Whether it changes layers 3-4: the name, card types or subtypes.
|
|
202
|
+
def type_changes? = !@creature_types.nil? || !@card_types.empty? || adds_subtypes? || !@set_types.nil? || !@name.nil?
|
|
203
|
+
|
|
204
|
+
# The subtypes it adds: its own, or the type its source chose as it entered (CR 607.2d, 614.12).
|
|
205
|
+
def added_subtypes(source) = @chosen_subtype ? [source.memory["chosen_type"]].compact.map(&:to_sym) : @subtypes
|
|
206
|
+
# Whether it changes layer 6: abilities removed or added.
|
|
207
|
+
def ability_changes? = @loses_abilities || !@keywords.empty? || !@granted.empty?
|
|
208
|
+
def computed? = @pump.values.any? { !it.is_a?(Amount::Fixed) }
|
|
209
|
+
|
|
210
|
+
# The fixed bonus, for display and for data readers (computed bonuses are Amounts).
|
|
211
|
+
def power = fixed(@pump["power"])
|
|
212
|
+
def toughness = fixed(@pump["toughness"])
|
|
213
|
+
|
|
214
|
+
# The bonus for this layer pass: [power, toughness] (CR 608.2h doesn't apply: statics aren't locked in).
|
|
215
|
+
def bonus(engine, source)
|
|
216
|
+
ctx = context(source)
|
|
217
|
+
[@pump.fetch("power", ZERO).value(engine, ctx), @pump.fetch("toughness", ZERO).value(engine, ctx)]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
ZERO = Amount::Fixed.new(0)
|
|
221
|
+
|
|
222
|
+
# Whether the ability applies now: its "as long as" / "during your turn" condition (CR 604.2).
|
|
223
|
+
def active?(engine, source) = @condition.nil? || @condition.true?(engine, context(source))
|
|
224
|
+
|
|
225
|
+
# Matches on characteristics and counters (Filters::Facts), so the layer pass never depends on its
|
|
226
|
+
# own result.
|
|
227
|
+
def affects?(object, source, facts)
|
|
228
|
+
return source.attached_to == object.id if attached?
|
|
229
|
+
return object.id == source.id if @filter == :self
|
|
230
|
+
return false if @other && object.id == source.id
|
|
231
|
+
return false unless @controller == "any" || (@controller == "you") == (object.controller == source.controller)
|
|
232
|
+
|
|
233
|
+
@filter.matches_facts?(@filter.uses_chosen? ? facts.with(chosen: source.memory) : facts) # CR 607.2d
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def to_s
|
|
237
|
+
bits = []
|
|
238
|
+
bits << "loses all abilities" if loses_abilities?
|
|
239
|
+
bits.concat(set_bits)
|
|
240
|
+
bits << "base #{base_power_toughness.values.join("/")}" if base_power_toughness
|
|
241
|
+
bits << "#{Effects::Pump.signed_text(@pump["power"] || ZERO)}/#{Effects::Pump.signed_text(@pump["toughness"] || ZERO)}" if pump?
|
|
242
|
+
bits << keywords.join(", ") unless keywords.empty?
|
|
243
|
+
bits << "creature type #{creature_types.join(" ")}" if creature_types
|
|
244
|
+
bits << "types #{card_types.join(" ")}" unless card_types.empty?
|
|
245
|
+
bits << "subtypes #{@chosen_subtype ? "the chosen type" : subtypes.join(" ")}" if adds_subtypes?
|
|
246
|
+
bits << "\"#{granted.join("; ")}\"" unless granted.empty?
|
|
247
|
+
"#{who} get#{"s" if %i[attached self].include?(@filter)} #{bits.join(" and ")}#{" as long as #{condition}" if condition}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def fixed(amount) = amount.is_a?(Amount::Fixed) ? amount.n : 0
|
|
253
|
+
|
|
254
|
+
# What it sets: name (layer 3), card types (layer 4) and colors (layer 5).
|
|
255
|
+
def set_bits
|
|
256
|
+
[("named #{name}" if name), ("card types exactly #{set_types.join(" ")}" if set_types),
|
|
257
|
+
("colors #{colors.empty? ? "colorless" : colors.join}" if colors)].compact
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def context(source) = EffectContext.new(source.controller, source.id, source.id, [], [])
|
|
261
|
+
|
|
262
|
+
def who
|
|
263
|
+
return "the attached permanent" if attached?
|
|
264
|
+
return "it" if @filter == :self
|
|
265
|
+
|
|
266
|
+
"#{"other " if other}#{filter.respond_to?(:primitive_name) ? filter.primitive_name : filter}s " \
|
|
267
|
+
"#{{ "you" => "you control ", "opponent" => "opponents control " }[controller]}".strip
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
module Effects
|
|
273
|
+
# "gains flying until end of turn" (CR 611.2, 613.1f): grant keywords to a locked set.
|
|
274
|
+
class Grant < Effect
|
|
275
|
+
primitive :effect, :grant,
|
|
276
|
+
description: "Each referenced permanent (or spell on the stack) gains the `keywords` until end of turn.",
|
|
277
|
+
rules: %w[611.2a 611.2c 613.1f 514.2],
|
|
278
|
+
params: { "keywords" => "list of keywords", "to" => "selector" },
|
|
279
|
+
example: { "grant" => { "keywords" => ["flying"], "to" => { "target" => 0 } } }
|
|
280
|
+
|
|
281
|
+
attr_reader :keywords
|
|
282
|
+
|
|
283
|
+
def self.keys = %w[keywords to if]
|
|
284
|
+
|
|
285
|
+
def setup(params, errors, path)
|
|
286
|
+
@keywords = Array(Mechanics.fetch_param(params, :keywords)).map { it.to_s.downcase.tr(" -", "__") }
|
|
287
|
+
errors << "#{path}.keywords: needs at least one keyword" if @keywords.empty?
|
|
288
|
+
@keywords.each { errors << "#{path}.keywords: unknown keyword #{it.inspect}" unless Enjine.registry.fetch(:keyword, it) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# A spell on the stack can gain keywords too ("those spells gain wither", Engine::SpellGrants).
|
|
292
|
+
def apply(engine, ctx)
|
|
293
|
+
spells = refs(engine, ctx).select { (obj = engine.state.objects[it]) && obj.zone == :stack && !obj.ability? }
|
|
294
|
+
engine.add_continuous_effect!("grant", ctx, objects(engine, ctx) + spells, "keywords" => keywords)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def to_s = "gains #{keywords.join(", ")} until end of turn → #{to}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Setting a creature's creature types until end of turn; its other creature types are lost (CR 205.1a, 613.1d).
|
|
301
|
+
class SetCreatureTypes < Effect
|
|
302
|
+
primitive :effect, :set_creature_types,
|
|
303
|
+
description: "Until end of turn, each referenced creature's creature types are exactly `types` (its " \
|
|
304
|
+
"other subtypes, such as artifact types, stay). The affected set is locked in.",
|
|
305
|
+
rules: %w[205.1a 613.1d 611.2c 514.2],
|
|
306
|
+
params: { "types" => "list of creature types", "to" => "selector" },
|
|
307
|
+
example: { "set_creature_types" => { "types" => ["doctor"], "to" => { "target" => 0 } } }
|
|
308
|
+
|
|
309
|
+
attr_reader :types
|
|
310
|
+
|
|
311
|
+
def self.keys = %w[types to if]
|
|
312
|
+
|
|
313
|
+
def setup(params, errors, path)
|
|
314
|
+
raw = Mechanics.fetch_param(params, :types)
|
|
315
|
+
ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
|
|
316
|
+
errors << "#{path}.types: must be a non-empty list of lowercase creature types" unless ok
|
|
317
|
+
@types = ok ? raw.map(&:to_s).freeze : []
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def apply(engine, ctx)
|
|
321
|
+
affected = objects(engine, ctx).select { engine.chars(it).creature? }
|
|
322
|
+
engine.add_continuous_effect!("creature_types", ctx, affected, "types" => types)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def to_s = "becomes #{types.join(" ")} until end of turn → #{to}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Setting base power and toughness to fixed numbers (CR 613.4b): a setting effect, until end of turn or for
|
|
329
|
+
# as long as the permanent stays on the battlefield (`duration: "indefinite"`, CR 611.2a-b).
|
|
330
|
+
class SetBasePowerToughness < Effect
|
|
331
|
+
DURATIONS = %w[end_of_turn indefinite].freeze
|
|
332
|
+
|
|
333
|
+
primitive :effect, :set_base_power_toughness,
|
|
334
|
+
description: "Each referenced creature's base power and toughness become `power`/`toughness` " \
|
|
335
|
+
"(layer 7b; counters and pumps still apply), until end of turn or, with `duration: " \
|
|
336
|
+
"\"indefinite\"`, for as long as it stays on the battlefield.",
|
|
337
|
+
rules: %w[613.4b 611.2a 611.2b 611.2c],
|
|
338
|
+
params: { "power" => "integer", "toughness" => "integer", "duration" => DURATIONS.join(" | "),
|
|
339
|
+
"to" => "selector" },
|
|
340
|
+
example: { "set_base_power_toughness" => { "power" => 7, "toughness" => 9, "duration" => "indefinite",
|
|
341
|
+
"to" => "self" } }
|
|
342
|
+
|
|
343
|
+
attr_reader :power, :toughness, :duration
|
|
344
|
+
|
|
345
|
+
def self.keys = %w[power toughness duration to if]
|
|
346
|
+
|
|
347
|
+
def setup(params, errors, path)
|
|
348
|
+
@power = Mechanics.integer_param(params, :power, errors, path, min: 0)
|
|
349
|
+
@toughness = Mechanics.integer_param(params, :toughness, errors, path, min: 0)
|
|
350
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
351
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def apply(engine, ctx)
|
|
355
|
+
affected = objects(engine, ctx).select { engine.chars(it).creature? }
|
|
356
|
+
engine.add_continuous_effect!("base_power_toughness", ctx, affected,
|
|
357
|
+
{ "power" => power, "toughness" => toughness,
|
|
358
|
+
"duration" => @duration == "indefinite" ? "permanent" : "end_of_turn" })
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def to_s = "base power and toughness become #{power}/#{toughness}#{" until end of turn" if duration == "end_of_turn"} → #{to}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# "This creature saddles Mounts and crews Vehicles as though its power were 2 greater", "... using its
|
|
7
|
+
# toughness rather than its power", "stations permanents using its toughness rather than its power" (CR
|
|
8
|
+
# 702.122a, 702.171a, 702.184c): a static ability of the creature changing the number it counts when it's
|
|
9
|
+
# tapped to pay a crew or saddle cost or to station a permanent. The costs ask TappingPower.of.
|
|
10
|
+
class TappingPower < Primitive
|
|
11
|
+
ACTIONS = %w[crew saddle station].freeze
|
|
12
|
+
USES = %w[power toughness toughness_if_greater].freeze
|
|
13
|
+
|
|
14
|
+
primitive :ability, :tapping_power,
|
|
15
|
+
description: "When this creature is tapped to pay the cost of the abilities in `for` (crew, saddle, " \
|
|
16
|
+
"station), it counts its `use` (power, default; toughness; toughness_if_greater: its " \
|
|
17
|
+
"toughness when that's greater than its power) plus `plus`. Several of them add up their " \
|
|
18
|
+
"`plus`. A `static` can grant it.",
|
|
19
|
+
rules: %w[702.122a 702.171a 702.184a 702.184c],
|
|
20
|
+
params: { "for" => "list of #{ACTIONS.join(", ")}", "use" => USES.join(" | "), "plus" => "integer (default 0)" },
|
|
21
|
+
example: { "tapping_power" => { "for" => %w[crew saddle], "plus" => 2 } }
|
|
22
|
+
|
|
23
|
+
attr_reader :actions, :use, :plus
|
|
24
|
+
|
|
25
|
+
def self.build(params, errors, path, **)
|
|
26
|
+
Mechanics.check_keys(params, %w[for use plus], errors, path)
|
|
27
|
+
actions = Mechanics.fetch_param(params, :for)
|
|
28
|
+
unless actions.is_a?(Array) && !actions.empty? && (actions - ACTIONS).empty?
|
|
29
|
+
errors << "#{path}.for: must be a non-empty list of #{ACTIONS.join(", ")}"
|
|
30
|
+
actions = []
|
|
31
|
+
end
|
|
32
|
+
use = Mechanics.fetch_param(params, :use) || "power"
|
|
33
|
+
errors << "#{path}.use: must be one of #{USES.join(", ")}" unless USES.include?(use)
|
|
34
|
+
plus = params.key?("plus") ? Mechanics.integer_param(params, :plus, errors, path) : 0
|
|
35
|
+
errors << "#{path}: needs `use` or `plus`" if use == "power" && plus.to_i.zero?
|
|
36
|
+
new(actions.map(&:to_sym), use, plus.to_i)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The number `creature` counts when tapped for `action` (:crew, :saddle or :station): its power unless an
|
|
40
|
+
# ability of it says otherwise.
|
|
41
|
+
def self.of(engine, creature, action)
|
|
42
|
+
mods = engine.chars(creature).abilities.map(&:ability).select { it.kind == :tapping_power && it.actions.include?(action) }
|
|
43
|
+
power, toughness = engine.power_toughness(creature)
|
|
44
|
+
base = if mods.any? { it.use == "toughness" } then toughness
|
|
45
|
+
elsif mods.any? { it.use == "toughness_if_greater" } then [power, toughness].max
|
|
46
|
+
else power
|
|
47
|
+
end
|
|
48
|
+
base + mods.sum(&:plus)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(actions, use, plus)
|
|
52
|
+
super()
|
|
53
|
+
@actions = actions.freeze
|
|
54
|
+
@use = use
|
|
55
|
+
@plus = plus
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kind = :tapping_power
|
|
60
|
+
def mana_ability? = false
|
|
61
|
+
def allowed_types = nil
|
|
62
|
+
def try_effects = []
|
|
63
|
+
|
|
64
|
+
def to_s
|
|
65
|
+
counted = { "power" => "power", "toughness" => "toughness", "toughness_if_greater" => "toughness if greater" }[use]
|
|
66
|
+
"#{actions.join(", ")} using its #{counted}#{" + #{plus}" unless plus.zero?}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Abilities
|
|
6
|
+
# Static abilities that change the rules of a turn for their controller (CR 305.2, 502.3).
|
|
7
|
+
# They take no parameters and do nothing on their own: the engine looks for them by kind
|
|
8
|
+
# (Engine::TurnStructure).
|
|
9
|
+
class TurnRule < Primitive
|
|
10
|
+
def self.build(params, errors, path, **)
|
|
11
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
12
|
+
self::INSTANCE
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mana_ability? = false
|
|
16
|
+
def allowed_types = nil
|
|
17
|
+
def try_effects = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# "You may play an additional land on each of your turns" (CR 305.2): each instance adds one to the
|
|
21
|
+
# number of lands its controller can play each turn (Engine#land_plays_allowed).
|
|
22
|
+
class AdditionalLandPlays < TurnRule
|
|
23
|
+
primitive :ability, :additional_land_plays,
|
|
24
|
+
description: "Its controller may play an additional land on each of their turns. Each instance " \
|
|
25
|
+
"adds one land play (CR 305.2); lands put onto the battlefield by effects don't count.",
|
|
26
|
+
rules: %w[305.2 305.2a 305.2b 604.1], example: "additional_land_plays"
|
|
27
|
+
|
|
28
|
+
INSTANCE = new.freeze
|
|
29
|
+
|
|
30
|
+
def kind = :additional_land_plays
|
|
31
|
+
def to_s = "You may play an additional land on each of your turns"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# "You may choose not to untap [this permanent] during your untap step" (CR 502.3): its controller is
|
|
35
|
+
# asked in their untap step, while it's tapped (decision `untap_choice`).
|
|
36
|
+
class MayNotUntap < TurnRule
|
|
37
|
+
primitive :ability, :may_not_untap,
|
|
38
|
+
description: "Its controller may choose not to untap it during their untap step (decision " \
|
|
39
|
+
"`untap_choice`, asked only while it's tapped).",
|
|
40
|
+
rules: %w[502.3 604.1], example: "may_not_untap"
|
|
41
|
+
|
|
42
|
+
INSTANCE = new.freeze
|
|
43
|
+
|
|
44
|
+
def kind = :may_not_untap
|
|
45
|
+
def to_s = "You may choose not to untap it during your untap step"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# A description of a card by its characteristics, for effects that look at cards in any
|
|
6
|
+
# zone ("a basic land card", "a Swamp card", "an Aura or Equipment card"; CR 701.23a,
|
|
7
|
+
# 702.29e). Data forms:
|
|
8
|
+
#
|
|
9
|
+
# "card" any card
|
|
10
|
+
# {"types": ["land"], "supertypes": ["basic"]} every listed quality must match
|
|
11
|
+
# {"subtypes": ["swamp"]} / {"name": "Card Name"}
|
|
12
|
+
# {"not_types": ["creature", "land"]} "noncreature, nonland card"
|
|
13
|
+
# {"permanent": true} "permanent card": any permanent card type (CR 110.4a)
|
|
14
|
+
# {"mana_value": {"at_most": 3}} also at_least / equal; each bound an amount (CR 202.3)
|
|
15
|
+
# {"colors": ["B"]} a black card: it has any of the listed colors (CR 105.2)
|
|
16
|
+
# {"color_count": {"equal": 1}} monocolored (1), multicolored (at least 2), colorless (0)
|
|
17
|
+
# {"any_of": [spec, spec, ...]} at least one spec must match
|
|
18
|
+
class CardSpec
|
|
19
|
+
KEYS = %w[types not_types subtypes supertypes name any_of permanent mana_value colors color_count].freeze
|
|
20
|
+
SUBTYPE = /\A[a-z][a-z0-9_]*\z/
|
|
21
|
+
# CR 110.4a: the permanent card types (battles have no primitive yet, so none matches that way).
|
|
22
|
+
PERMANENT_TYPES = %i[artifact battle creature enchantment land planeswalker].freeze
|
|
23
|
+
BOUNDS = %w[at_least at_most equal].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :types, :not_types, :subtypes, :supertypes, :name, :any_of, :permanent, :mana_value, :colors, :color_count
|
|
26
|
+
|
|
27
|
+
def self.build(data, errors, path)
|
|
28
|
+
return ANY if data.nil? || data == "card"
|
|
29
|
+
return (errors << "#{path}: must be \"card\" or an object (#{KEYS.join(", ")})") && ANY unless data.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
data = data.transform_keys(&:to_s)
|
|
32
|
+
Mechanics.check_keys(data, KEYS, errors, path)
|
|
33
|
+
errors << "#{path}: needs at least one of #{KEYS.join(", ")}" if data.empty?
|
|
34
|
+
new(types: names(data, "types", :type, errors, path), supertypes: names(data, "supertypes", :supertype, errors, path),
|
|
35
|
+
subtypes: subtype_list(data, errors, path), name: name_param(data, errors, path),
|
|
36
|
+
any_of: alternatives(data, errors, path), not_types: names(data, "not_types", :type, errors, path),
|
|
37
|
+
permanent: permanent_param(data, errors, path), mana_value: bounds_param(data, "mana_value", errors, path),
|
|
38
|
+
colors: colors_param(data, errors, path), color_count: bounds_param(data, "color_count", errors, path))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.colors_param(data, errors, path)
|
|
42
|
+
list(data, "colors", errors, path).filter_map do |raw|
|
|
43
|
+
color = raw.to_s.upcase.to_sym
|
|
44
|
+
next color if Mana::COLORS.include?(color)
|
|
45
|
+
|
|
46
|
+
errors << "#{path}.colors: unknown color #{raw.inspect} (W, U, B, R, G)"
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.names(data, key, category, errors, path)
|
|
52
|
+
list(data, key, errors, path).filter_map do |raw|
|
|
53
|
+
name = raw.to_s.downcase.tr(" -", "__")
|
|
54
|
+
next name.to_sym if Enjine.registry.fetch(category, name)
|
|
55
|
+
|
|
56
|
+
errors << "#{path}.#{key}: unknown #{category} #{raw.inspect}"
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.subtype_list(data, errors, path)
|
|
62
|
+
list(data, "subtypes", errors, path).filter_map do |raw|
|
|
63
|
+
sub = raw.to_s.downcase.tr(" -", "__")
|
|
64
|
+
next sub.to_sym if SUBTYPE.match?(sub)
|
|
65
|
+
|
|
66
|
+
errors << "#{path}.subtypes: invalid subtype #{raw.inspect}"
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.list(data, key, errors, path)
|
|
72
|
+
value = data.fetch(key, [])
|
|
73
|
+
return value if value.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
errors << "#{path}.#{key}: must be a list"
|
|
76
|
+
[]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.name_param(data, errors, path)
|
|
80
|
+
value = data["name"]
|
|
81
|
+
errors << "#{path}.name: must be a card name" unless value.nil? || (value.is_a?(String) && !value.empty?)
|
|
82
|
+
value.is_a?(String) ? value : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.alternatives(data, errors, path)
|
|
86
|
+
return [] unless data.key?("any_of")
|
|
87
|
+
|
|
88
|
+
specs = list(data, "any_of", errors, path)
|
|
89
|
+
errors << "#{path}.any_of: must list at least two specs" if specs.size < 2
|
|
90
|
+
specs.each_with_index.map { |spec, i| build(spec, errors, "#{path}.any_of[#{i}]") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.permanent_param(data, errors, path)
|
|
94
|
+
value = data["permanent"]
|
|
95
|
+
errors << "#{path}.permanent: must be true" unless value.nil? || value == true
|
|
96
|
+
value == true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# {"at_least"|"at_most"|"equal": amount}: each bound is an Amount, computed as the card is looked at.
|
|
100
|
+
def self.bounds_param(data, key, errors, path)
|
|
101
|
+
raw = data[key]
|
|
102
|
+
return {} if raw.nil?
|
|
103
|
+
|
|
104
|
+
unless raw.is_a?(Hash) && !raw.empty? && (raw.keys.map(&:to_s) - BOUNDS).empty?
|
|
105
|
+
errors << "#{path}.#{key}: must be {\"at_least\"|\"at_most\"|\"equal\": amount}"
|
|
106
|
+
return {}
|
|
107
|
+
end
|
|
108
|
+
raw.to_h { |bound, n| [bound.to_s, Amount.build(n, errors, "#{path}.#{key}.#{bound}", targets_count: 0)] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def initialize(types: [], subtypes: [], supertypes: [], name: nil, any_of: [], not_types: [], permanent: false,
|
|
112
|
+
mana_value: {}, colors: [], color_count: {})
|
|
113
|
+
@colors = colors.freeze
|
|
114
|
+
@color_count = color_count.freeze
|
|
115
|
+
@types = types.freeze
|
|
116
|
+
@not_types = not_types.freeze
|
|
117
|
+
@subtypes = subtypes.freeze
|
|
118
|
+
@supertypes = supertypes.freeze
|
|
119
|
+
@name = name
|
|
120
|
+
@any_of = any_of.freeze
|
|
121
|
+
@permanent = permanent
|
|
122
|
+
@mana_value = mana_value.freeze
|
|
123
|
+
freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# True when the spec states no quality: "a card" (CR 701.23d).
|
|
127
|
+
def any?
|
|
128
|
+
types.empty? && not_types.empty? && subtypes.empty? && supertypes.empty? && name.nil? && any_of.empty? &&
|
|
129
|
+
!permanent && mana_value.empty? && colors.empty? && color_count.empty?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether a card object matches, by its current characteristics (CR 109.3). `ctx` (an EffectContext)
|
|
133
|
+
# computes mana value bounds given as amounts; without one, only fixed bounds can be met.
|
|
134
|
+
def match?(engine, object, ctx = nil) = object.kind == "card" && chars_match?(engine, engine.chars(object), ctx)
|
|
135
|
+
|
|
136
|
+
# Whether characteristics match (an object's, or remembered ones such as a mana source's, CR 106.6).
|
|
137
|
+
def chars_match?(engine, chars, ctx = nil)
|
|
138
|
+
types.all? { chars.type?(it) } && not_types.none? { chars.type?(it) } &&
|
|
139
|
+
subtypes.all? { chars.subtypes.include?(it) } && supertypes.all? { chars.supertypes.include?(it) } &&
|
|
140
|
+
(name.nil? || chars.name == name) && (!permanent || chars.types.intersect?(PERMANENT_TYPES)) &&
|
|
141
|
+
(colors.empty? || colors.intersect?(chars.colors)) && bounded?(mana_value, chars.mana_cost&.mana_value.to_i, engine, ctx) &&
|
|
142
|
+
bounded?(color_count, chars.colors.size, engine, ctx) && (any_of.empty? || any_of.any? { it.chars_match?(engine, chars, ctx) })
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def to_s
|
|
146
|
+
return "#{any_of.map { it.to_s.delete_suffix(" card") }.join(" or ")} card" unless any_of.empty?
|
|
147
|
+
return "card named #{name}" if name
|
|
148
|
+
|
|
149
|
+
words = not_types.map { "non#{it}" } + colors + supertypes + subtypes.map { it.to_s.capitalize } + types
|
|
150
|
+
words << "permanent" if permanent
|
|
151
|
+
text = (words.map { it.to_s.tr("_", " ") } + ["card"]).join(" ")
|
|
152
|
+
bounds = { "mana value" => mana_value, "number of colors" => color_count }.reject { |_, b| b.empty? }
|
|
153
|
+
return text if bounds.empty?
|
|
154
|
+
|
|
155
|
+
"#{text} with #{bounds.map { |what, b| "#{what} #{b.map { |bound, n| "#{bound.tr("_", " ")} #{n}" }.join(" and ")}" }.join(", ")}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# CR 202.3: a card's mana value (0 without a mana cost, CR 202.3a), or its number of colors (CR 105.2), within
|
|
161
|
+
# the bounds.
|
|
162
|
+
def bounded?(bounds, value, engine, ctx)
|
|
163
|
+
bounds.all? do |bound, amount|
|
|
164
|
+
n = ctx ? amount.value(engine, ctx) : fixed(amount)
|
|
165
|
+
next false if n.nil?
|
|
166
|
+
|
|
167
|
+
{ "at_least" => value >= n, "at_most" => value <= n, "equal" => value == n }.fetch(bound)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def fixed(amount) = amount.is_a?(Amount::Fixed) ? amount.n : nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# "card": any card.
|
|
175
|
+
CardSpec::ANY = CardSpec.new
|
|
176
|
+
end
|
|
177
|
+
end
|