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,675 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# A number an effect uses, computed when the effect resolves (CR 608.2h):
|
|
6
|
+
# 3 a fixed number
|
|
7
|
+
# "x" the X announced for this spell or ability (CR 107.3)
|
|
8
|
+
# {"count": selector} how many objects/players match ({"count": {"result": 0}}:
|
|
9
|
+
# how many the first effect moved or created, e.g. cards drawn)
|
|
10
|
+
# {"power": selector} / {"toughness": ...} total power / toughness ("Picard's power": {"power": "self"})
|
|
11
|
+
# {"greatest_power" | "greatest_toughness": selector} the greatest power / toughness among them (0 for none)
|
|
12
|
+
# {"hand_size" | "life" | "graveyard_size": player selector}
|
|
13
|
+
# {"distinct_names": selector} how many different names they have (strange new worlds)
|
|
14
|
+
# {"mana_value": selector} their total mana value (a spell's X counts on the stack; a spell
|
|
15
|
+
# countered by the resolving one: as it last existed, CR 608.2h)
|
|
16
|
+
# {"distinct_types": selector} how many different creature types they have (CR 205.3m)
|
|
17
|
+
# {"distinct_colors": selector} how many colors among them (CR 105.2, 202.2)
|
|
18
|
+
# {"distinct_land_types": selector} how many basic land types among them (domain, CR 305.6)
|
|
19
|
+
# {"distinct_planeswalker_types": selector} how many planeswalker types among them (CR 205.3j)
|
|
20
|
+
# {"counters": {"of": selector, "kind": name}} counters on them, objects or players (any kind without "kind", CR 122)
|
|
21
|
+
# {"life_gained_this_turn" | "life_lost_this_turn" | "dilemmas_this_turn" | "returned_this_turn"
|
|
22
|
+
# | "cards_drawn_this_turn" | "cards_discarded_this_turn" | "noncombat_damage_this_turn"
|
|
23
|
+
# | "noncombat_damage_last_turn"
|
|
24
|
+
# | "spells_cast_this_game" | "turns_taken": player selector} this turn's (or game's) history, see
|
|
25
|
+
# Engine::TurnHistory ("turns_taken": turns begun, this one included)
|
|
26
|
+
# {"spells_cast_this_turn" | "lands_played_this_turn": {"by": player selector, "matching": filter,
|
|
27
|
+
# "from" | "not_from": zone}} spells cast / lands played this turn
|
|
28
|
+
# {"died_this_turn" | "entered_this_turn" | "left_this_turn" | "sacrificed_this_turn" | "attackers_this_turn"
|
|
29
|
+
# | "exiled_this_turn": {"each": filter, "controller": you|opponent|any}} objects in this turn's history
|
|
30
|
+
# {"this_way": n} how much effect n of this list changed (life it took)
|
|
31
|
+
# {"mana_spent": {"of": ..., "color" | "colors": ...}} mana spent to cast a spell (CR 601.2h), see mana_spent.rb
|
|
32
|
+
# {"times": [a, b]} / {"plus": [a, b]} / {"divide": [a, b]} arithmetic on amounts; division rounds down
|
|
33
|
+
# (CR 107.1a). "-X" is {"times": ["x", -1]}.
|
|
34
|
+
# A source that has left the battlefield is used as it last existed (CR 608.2h).
|
|
35
|
+
class Amount
|
|
36
|
+
SELECTOR_KINDS = %w[count power toughness greatest_power hand_size life graveyard_size distinct_names distinct_types
|
|
37
|
+
distinct_colors distinct_land_types distinct_planeswalker_types
|
|
38
|
+
life_gained_this_turn life_lost_this_turn dilemmas_this_turn returned_this_turn
|
|
39
|
+
cards_drawn_this_turn cards_discarded_this_turn spells_cast_this_game speed
|
|
40
|
+
noncombat_damage_this_turn noncombat_damage_last_turn turns_taken greatest_toughness
|
|
41
|
+
mana_value].freeze
|
|
42
|
+
ARITHMETIC = %w[times plus divide].freeze
|
|
43
|
+
BASIC_LAND_TYPES = %i[plains island swamp mountain forest].freeze # CR 305.6
|
|
44
|
+
# Object history lists: kind -> turn_log key (Engine::TurnHistory).
|
|
45
|
+
DEPARTED = { "died_this_turn" => "died", "entered_this_turn" => "entered", "left_this_turn" => "left",
|
|
46
|
+
"sacrificed_this_turn" => "sacrificed", "attackers_this_turn" => "attackers",
|
|
47
|
+
"exiled_this_turn" => "exiled" }.freeze
|
|
48
|
+
KINDS = (SELECTOR_KINDS + ARITHMETIC + DEPARTED.keys + %w[counters spells_cast_this_turn lands_played_this_turn this_way
|
|
49
|
+
mana_spent]).freeze
|
|
50
|
+
# Player history counters: kind -> turn_log/game_log key.
|
|
51
|
+
HISTORY = { "life_gained_this_turn" => "life_gained", "life_lost_this_turn" => "life_lost",
|
|
52
|
+
"dilemmas_this_turn" => "dilemmas", "returned_this_turn" => "returned",
|
|
53
|
+
"cards_drawn_this_turn" => "drawn", "cards_discarded_this_turn" => "discarded",
|
|
54
|
+
"noncombat_damage_this_turn" => "noncombat_damage" }.freeze
|
|
55
|
+
# The same, read from the previous turn's log ("was dealt noncombat damage last turn").
|
|
56
|
+
LAST_TURN = { "noncombat_damage_last_turn" => "noncombat_damage" }.freeze
|
|
57
|
+
|
|
58
|
+
# `min`: the smallest fixed number allowed (computed amounts may be anything their parts give).
|
|
59
|
+
def self.build(data, errors, path, targets_count:, min: 0, results_count: 0)
|
|
60
|
+
return Fixed.new(data) if data.is_a?(Integer) && data >= min
|
|
61
|
+
return X::INSTANCE if data == "x"
|
|
62
|
+
return Triggering::INSTANCE if data == { "triggering" => "amount" } # "that many" (CR 603.2)
|
|
63
|
+
|
|
64
|
+
unless data.is_a?(Hash) && data.size == 1 && KINDS.include?(data.keys.first.to_s)
|
|
65
|
+
errors << "#{path}: must be an integer >= #{min}, \"x\" or one of {#{KINDS.join("|")}: ...}"
|
|
66
|
+
return Fixed.new(0)
|
|
67
|
+
end
|
|
68
|
+
kind, ref = data.first
|
|
69
|
+
kind = kind.to_s
|
|
70
|
+
context = { targets_count: targets_count, results_count: results_count }
|
|
71
|
+
return Arithmetic.build(kind, ref, errors, "#{path}.#{kind}", **context) if ARITHMETIC.include?(kind)
|
|
72
|
+
return Counters.build(ref, errors, "#{path}.#{kind}", **context) if kind == "counters"
|
|
73
|
+
return SpellsCast.build(ref, errors, "#{path}.#{kind}", kind: kind, **context) if SpellsCast::LOGS.key?(kind)
|
|
74
|
+
return Departed.build(kind, ref, errors, "#{path}.#{kind}") if DEPARTED.key?(kind)
|
|
75
|
+
return ThisWay.build(ref, errors, "#{path}.#{kind}", results_count) if kind == "this_way"
|
|
76
|
+
return ManaSpent.build(ref, errors, "#{path}.#{kind}", **context) if kind == "mana_spent" # mana_spent.rb
|
|
77
|
+
|
|
78
|
+
new(kind, Selector.build(ref, errors, "#{path}.#{kind}", **context))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether the amount can be computed during the layer pass (CR 613): it may count objects by
|
|
82
|
+
# their types, but must not read power or toughness (that would depend on the pass's result).
|
|
83
|
+
def self.layer_safe?(amount) = !amount.respond_to?(:reads_power?) || !amount.reads_power?
|
|
84
|
+
|
|
85
|
+
def initialize(kind, selector)
|
|
86
|
+
@kind = kind
|
|
87
|
+
@selector = selector
|
|
88
|
+
freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reads_power? = %w[power toughness greatest_power greatest_toughness].include?(@kind)
|
|
92
|
+
|
|
93
|
+
def value(engine, ctx)
|
|
94
|
+
refs = @selector.resolve(engine, ctx) # built without errors: a selector
|
|
95
|
+
case @kind
|
|
96
|
+
when "count" then refs.size
|
|
97
|
+
when "power", "toughness" then refs.empty? ? Amount.last_known(engine, ctx, @selector, @kind) : stats(engine, refs).sum
|
|
98
|
+
when "greatest_power", "greatest_toughness" then stats(engine, refs).max.to_i
|
|
99
|
+
when "mana_value" then refs.sum do
|
|
100
|
+
Amount.mana_value(engine, engine.state.objects[it]) || engine.resolving_last_known(it)&.[]("mana_value").to_i
|
|
101
|
+
end
|
|
102
|
+
when "distinct_names" then live(engine, refs).map { engine.chars(it).name }.uniq.size
|
|
103
|
+
when "distinct_types" then live(engine, refs).flat_map { Amount.creature_types(engine.chars(it)) }.uniq.size
|
|
104
|
+
when "distinct_colors" then live(engine, refs).flat_map { engine.chars(it).colors }.uniq.size # CR 105.2
|
|
105
|
+
when "distinct_land_types" then (live(engine, refs).flat_map { engine.chars(it).subtypes } & BASIC_LAND_TYPES).size
|
|
106
|
+
when "distinct_planeswalker_types" # CR 205.3j: a planeswalker's subtypes are planeswalker types
|
|
107
|
+
live(engine, refs).flat_map { (c = engine.chars(it)).type?(:planeswalker) ? c.subtypes : [] }.uniq.size
|
|
108
|
+
else players(engine, refs).sum { player_value(engine, it) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def to_s = "the #{@kind.tr("_", " ")} of #{@selector}"
|
|
113
|
+
|
|
114
|
+
# CR 202.3: the mana value of an object (0 without a mana cost); a spell on the stack counts its X (CR 202.3e).
|
|
115
|
+
# Nil for an object that's gone.
|
|
116
|
+
def self.mana_value(engine, obj)
|
|
117
|
+
return nil if obj.nil?
|
|
118
|
+
|
|
119
|
+
cost = engine.chars(obj).mana_cost or return 0
|
|
120
|
+
(obj.zone == :stack ? cost.with_x(obj.x.to_i) : cost).mana_value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# CR 205.3m: a creature's or kindred's subtypes are creature types, except those of its other card types.
|
|
124
|
+
def self.creature_types(chars)
|
|
125
|
+
chars.creature? || chars.type?(:kindred) ? chars.subtypes - Engine::Characteristics::NON_CREATURE_SUBTYPES : []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# CR 608.2h: "its power" of a source that has left the battlefield is its last known value.
|
|
129
|
+
def self.last_known(engine, ctx, selector, key)
|
|
130
|
+
return 0 unless selector.kind == :self
|
|
131
|
+
|
|
132
|
+
lki = source_lki(engine, ctx)
|
|
133
|
+
lki ? lki[key].to_i : 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# How a source that has left the battlefield last existed: remembered by the resolving ability, or,
|
|
137
|
+
# while its leaves-the-battlefield trigger is being checked (CR 603.4, 603.10a), the engine's.
|
|
138
|
+
def self.source_lki(engine, ctx)
|
|
139
|
+
engine.state.objects[ctx.object]&.memory&.[]("source_lki") || engine.last_known(ctx.source)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def live(engine, refs) = refs.select { engine.state.objects.key?(it) }
|
|
145
|
+
|
|
146
|
+
def stats(engine, refs)
|
|
147
|
+
live(engine, refs).map { engine.public_send(@kind.end_with?("toughness") ? :toughness : :power, engine.object(it)) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def players(engine, refs) = refs.filter_map { Filters.player_index(engine, it) }
|
|
151
|
+
|
|
152
|
+
def player_value(engine, index)
|
|
153
|
+
case @kind
|
|
154
|
+
when "hand_size" then engine.player(index).hand.size
|
|
155
|
+
when "life" then engine.player(index).life
|
|
156
|
+
when "speed" then engine.speed(index) # CR 702.179f: 0 without speed
|
|
157
|
+
when "graveyard_size" then engine.player(index).graveyard.size
|
|
158
|
+
when "spells_cast_this_game" then engine.game_log(index).fetch("spells", 0)
|
|
159
|
+
when "turns_taken" then engine.game_log(index).fetch("turns", 0) # this turn included, if it's theirs
|
|
160
|
+
when *LAST_TURN.keys then engine.last_turn_log(index).fetch(LAST_TURN.fetch(@kind), 0)
|
|
161
|
+
else engine.turn_log(index).fetch(HISTORY.fetch(@kind), 0)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# X (CR 107.3a): the value announced for the resolving spell or ability. An enters-the-battlefield
|
|
166
|
+
# trigger carries the X its permanent was cast with (CR 107.3m). 0 when none was announced.
|
|
167
|
+
class X
|
|
168
|
+
INSTANCE = new.freeze
|
|
169
|
+
|
|
170
|
+
def value(engine, ctx) = engine.state.objects[ctx.object]&.x.to_i
|
|
171
|
+
def to_s = "X"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# "That many": the number in the event that triggered the resolving ability (damage dealt, life
|
|
175
|
+
# gained, counters put), see Triggers.event_facts. 0 outside a triggered ability.
|
|
176
|
+
class Triggering
|
|
177
|
+
INSTANCE = new.freeze
|
|
178
|
+
|
|
179
|
+
def value(engine, ctx) = Selector.memory(engine, ctx).fetch("triggering", {})["amount"].to_i
|
|
180
|
+
def to_s = "that many"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A fixed number.
|
|
184
|
+
class Fixed
|
|
185
|
+
attr_reader :n
|
|
186
|
+
|
|
187
|
+
def initialize(number)
|
|
188
|
+
@n = number
|
|
189
|
+
freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def value(_engine, _ctx) = @n
|
|
193
|
+
def to_s = @n.to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# {"times" | "plus" | "divide": [a, b]}: a × b, a + b, or a ÷ b rounded down (CR 107.1a).
|
|
197
|
+
class Arithmetic
|
|
198
|
+
def self.build(kind, parts, errors, path, **context)
|
|
199
|
+
unless parts.is_a?(Array) && parts.size == 2
|
|
200
|
+
errors << "#{path}: must be a list of two amounts"
|
|
201
|
+
return Fixed.new(0)
|
|
202
|
+
end
|
|
203
|
+
left, right = parts.each_with_index.map { |p, i| Amount.build(p, errors, "#{path}[#{i}]", min: -1000, **context) }
|
|
204
|
+
errors << "#{path}[1]: must be a positive integer" if kind == "divide" && !(parts[1].is_a?(Integer) && parts[1].positive?)
|
|
205
|
+
new(kind, left, right)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def initialize(kind, left, right)
|
|
209
|
+
@kind = kind
|
|
210
|
+
@left = left
|
|
211
|
+
@right = right
|
|
212
|
+
freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def reads_power? = [@left, @right].any? { !Amount.layer_safe?(it) }
|
|
216
|
+
|
|
217
|
+
def value(engine, ctx)
|
|
218
|
+
a = @left.value(engine, ctx)
|
|
219
|
+
b = @right.value(engine, ctx)
|
|
220
|
+
case @kind
|
|
221
|
+
when "times" then a * b
|
|
222
|
+
when "plus" then a + b
|
|
223
|
+
else a.div(b) # b was checked positive
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def to_s = "#{@left} #{{ "times" => "×", "plus" => "+", "divide" => "÷" }[@kind]} #{@right}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# {"this_way": n}: how much effect n of the same list changed, e.g. the life a lose_life effect took
|
|
231
|
+
# (CR 608.2c).
|
|
232
|
+
class ThisWay
|
|
233
|
+
def self.build(index, errors, path, results_count)
|
|
234
|
+
unless index.is_a?(Integer) && index >= 0 && index < results_count
|
|
235
|
+
errors << "#{path}: must refer to one of the #{results_count} earlier effects"
|
|
236
|
+
end
|
|
237
|
+
new(index.to_i)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def initialize(index)
|
|
241
|
+
@index = index
|
|
242
|
+
freeze
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def value(_engine, ctx) = ctx.amounts&.[](@index).to_i
|
|
246
|
+
def to_s = "the amount from effect ##{@index + 1}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# {"counters": {"of": selector, "kind": name}}: counters of one kind (or of every kind) on the
|
|
250
|
+
# objects or players (CR 122). A source that has left the battlefield counts as it last existed (CR 608.2h).
|
|
251
|
+
class Counters
|
|
252
|
+
def self.build(data, errors, path, **context)
|
|
253
|
+
data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
254
|
+
Mechanics.check_keys(data, %w[of kind], errors, path)
|
|
255
|
+
errors << "#{path}.of: required" unless data.key?("of")
|
|
256
|
+
kind = data["kind"] && Mechanics::Counters.parse(data["kind"], errors, "#{path}.kind")
|
|
257
|
+
new(Selector.build(data.fetch("of", "self"), errors, "#{path}.of", **context), kind)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def initialize(selector, kind)
|
|
261
|
+
@selector = selector
|
|
262
|
+
@kind = kind
|
|
263
|
+
freeze
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def value(engine, ctx)
|
|
267
|
+
all = @selector.resolve(engine, ctx)
|
|
268
|
+
refs = all.select { engine.state.objects.key?(it) }
|
|
269
|
+
counters = refs.map { engine.object(it).counters } +
|
|
270
|
+
all.filter_map { Filters.player_index(engine, it) }.map { engine.player_counters(it) } # CR 122.1
|
|
271
|
+
counters = [Amount.source_lki(engine, ctx)&.[]("counters") || {}] if refs.empty? && @selector.kind == :self
|
|
272
|
+
counters.sum { @kind ? it.fetch(@kind, 0) : it.values.sum }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def to_s = "the number of #{"#{@kind} " if @kind}counters on #{@selector}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# {"spells_cast_this_turn": {"by": "you", "matching": {"spell": {"not": ["creature"]}}}}: spells the
|
|
279
|
+
# players cast this turn whose card types match (CR 601.2i; a cancelled cast never counts).
|
|
280
|
+
# {"lands_played_this_turn": {"by": "you"}}: lands they played this turn (CR 305.1). `from` / `not_from`:
|
|
281
|
+
# only those cast or played from (not from) that zone (CR 601.2a).
|
|
282
|
+
class SpellsCast
|
|
283
|
+
LOGS = { "spells_cast_this_turn" => "spells", "lands_played_this_turn" => "lands_played" }.freeze
|
|
284
|
+
|
|
285
|
+
def self.build(data, errors, path, kind: "spells_cast_this_turn", **context)
|
|
286
|
+
data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
287
|
+
Mechanics.check_keys(data, %w[by matching from not_from], errors, path)
|
|
288
|
+
casters = Selector.build(data.fetch("by", "you"), errors, "#{path}.by", **context)
|
|
289
|
+
filter = data["matching"] && Mechanics.build_entry(:filter, data["matching"], errors, "#{path}.matching")
|
|
290
|
+
new(casters, filter, CastFrom.build(data, errors, path), LOGS.fetch(kind))
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def initialize(casters, filter, zones = CastFrom.new(nil, nil), log = "spells")
|
|
294
|
+
@by = casters
|
|
295
|
+
@filter = filter
|
|
296
|
+
@zones = zones
|
|
297
|
+
@log = log
|
|
298
|
+
freeze
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def value(engine, ctx)
|
|
302
|
+
players = @by.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }
|
|
303
|
+
players.sum do |index|
|
|
304
|
+
entries(engine.turn_log(index)).count do |card, zone|
|
|
305
|
+
@zones.match?(zone) && (@filter.nil? || Departed.facts_match?(engine, @filter, card, nil))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def to_s
|
|
311
|
+
what = @log == "spells" ? "#{@filter || "spell"}s cast" : "lands played"
|
|
312
|
+
"the number of #{what}#{@zones} this turn by #{@by}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
# [card, zone] per spell or land; a spell cast before zones were recorded counts as cast from the hand.
|
|
318
|
+
def entries(log)
|
|
319
|
+
return Array(log["lands_played"]) unless @log == "spells"
|
|
320
|
+
|
|
321
|
+
zones = Array(log["spells_from"])
|
|
322
|
+
Array(log["spells"]).each_with_index.map { |card, i| [card, zones[i] || "hand"] }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# {"died_this_turn" | "entered_this_turn" | ...: {"each": filter, "controller": ...}}: permanents that
|
|
327
|
+
# died (CR 700.4), entered, left the battlefield (CR 400.7) or were sacrificed (CR 701.21a) this turn,
|
|
328
|
+
# creatures that attacked (CR 508.1) and cards put into exile, matched by their card and the card types
|
|
329
|
+
# (and subtypes, except for died and exiled ones) they had then. `controller`: whose they were (the
|
|
330
|
+
# attacking player; for a card exiled from another zone, its owner). Without `each`, every one counts.
|
|
331
|
+
class Departed
|
|
332
|
+
def self.build(kind, data, errors, path)
|
|
333
|
+
errors << "#{path}: must be an object {\"each\": filter, \"controller\": ...}" unless data.is_a?(Hash)
|
|
334
|
+
data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
335
|
+
Mechanics.check_keys(data, %w[each controller], errors, path)
|
|
336
|
+
filter = data.key?("each") ? Mechanics.build_entry(:filter, data["each"], errors, "#{path}.each") : nil
|
|
337
|
+
controller = data.fetch("controller", "any")
|
|
338
|
+
errors << "#{path}.controller: must be one of you, opponent, any" unless %w[you opponent any].include?(controller)
|
|
339
|
+
new(DEPARTED.fetch(kind), filter, controller)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Whether a card (with the card types it had, when given) matches a filter's characteristics.
|
|
343
|
+
def self.facts_match?(engine, filter, card, types, subtypes = nil)
|
|
344
|
+
defn = engine.catalog.fetch(card)
|
|
345
|
+
chars = defn.printed_characteristics
|
|
346
|
+
chars = chars.with(types: types.map(&:to_sym)) if types
|
|
347
|
+
chars = chars.with(subtypes: subtypes.map(&:to_sym)) if subtypes
|
|
348
|
+
filter.matches_facts?(Filters::Facts.new(chars: chars, counters: {}, token: defn.token?))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def initialize(key, filter, controller)
|
|
352
|
+
@key = key
|
|
353
|
+
@filter = filter
|
|
354
|
+
@controller = controller
|
|
355
|
+
freeze
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def value(engine, ctx)
|
|
359
|
+
engine.state.players.each_index.sum do |index|
|
|
360
|
+
next 0 unless @controller == "any" || (@controller == "you") == (index == ctx.controller)
|
|
361
|
+
|
|
362
|
+
Array(engine.turn_log(index)[@key]).count do |card, types, subtypes|
|
|
363
|
+
@filter.nil? || Departed.facts_match?(engine, @filter, card, types, subtypes)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
WORDS = { "died" => "that died", "entered" => "that entered", "left" => "that left the battlefield",
|
|
369
|
+
"sacrificed" => "sacrificed", "attackers" => "that attacked", "exiled" => "put into exile" }.freeze
|
|
370
|
+
|
|
371
|
+
def to_s = "the number of #{Triggers.filter_words(@filter) || "object"}s #{WORDS.fetch(@key)} this turn"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A yes/no test about the game, checked when an effect resolves (CR 608.2c) or, as an
|
|
376
|
+
# intervening "if" (CR 603.4), both when a trigger condition is met and on resolution:
|
|
377
|
+
# {"amount": amount, "at_least": n, "at_most": n} any Amount compared with bounds (each a number or an amount),
|
|
378
|
+
# e.g. total power
|
|
379
|
+
# of creatures you control N or more: {"amount": {"power": {"each":
|
|
380
|
+
# "creature", "controller": "you"}}, "at_least": 8}; "if you do" after a
|
|
381
|
+
# mandatory effect: {"amount": {"count": {"result": 0}}, "at_least": 1}
|
|
382
|
+
# {"controls": selector, "at_least": 3} / {"life": "you", ...} / {"hand_size": "you", ...} (short forms)
|
|
383
|
+
# {"your_turn": true}
|
|
384
|
+
# {"max_speed": true} you have max speed: your speed is 4 (CR 702.179e)
|
|
385
|
+
# {"was_cast": true} "if you cast it": the source permanent was cast (CR 400.7d)
|
|
386
|
+
# {"cast_with": "warp"} / {"cast_from": "graveyard"} how this spell (or the spell this permanent was) was
|
|
387
|
+
# cast; {"cast_with_this_turn": "warp"}: a spell was cast with it this turn
|
|
388
|
+
# {"targets": filter} one of the chosen targets matches (e.g. "if it targets a tapped creature")
|
|
389
|
+
# {"attacked": true} a creature is attacking you (CR 508.1b)
|
|
390
|
+
# {"source": filter} the source is on the battlefield and matches ("if this isn't a creature":
|
|
391
|
+
# {"not": {"source": "creature"}})
|
|
392
|
+
# {"attacked_this_turn": true} the source attacked this turn
|
|
393
|
+
# {"you_attacked_this_turn": true} you attacked with one or more creatures this turn (raid, CR 508.1)
|
|
394
|
+
# {"nth_resolution": n} this is the nth time this turn this ability resolves (CR 608.2)
|
|
395
|
+
# {"share_card_type": selector | [selectors]} the referenced objects share a card type ("exiled_with": linked cards)
|
|
396
|
+
# {"chosen": word} that word was chosen for this object (choose_option, CR 607.2f)
|
|
397
|
+
# {"meld_with": name} you both own and control the source and a creature named so (CR 713.1a)
|
|
398
|
+
# {"not": condition}, {"all": [conditions]}, {"any": [conditions]}
|
|
399
|
+
class Condition
|
|
400
|
+
FLAGS = %w[your_turn was_cast attacked attacked_this_turn max_speed
|
|
401
|
+
you_attacked_this_turn].freeze
|
|
402
|
+
# Kinds named by one key and built by their class (ChosenIs: abilities/chosen_values.rb; Designation:
|
|
403
|
+
# keywords/storied.rb).
|
|
404
|
+
KEYED = { "targets" => :Targets, "triggering" => :TriggeringIs, "source" => :Source,
|
|
405
|
+
"nth_resolution" => :NthResolution, "chosen" => :ChosenIs, "designation" => :Designation,
|
|
406
|
+
"first_combat_phase" => :FirstCombatPhase, "meld_with" => :MeldWith }.freeze # MeldWith: effects/meld.rb
|
|
407
|
+
|
|
408
|
+
def self.build(data, errors, path, targets_count: 0, results_count: 0)
|
|
409
|
+
unless data.is_a?(Hash)
|
|
410
|
+
errors << "#{path}: must be a condition object"
|
|
411
|
+
return nil
|
|
412
|
+
end
|
|
413
|
+
data = data.transform_keys(&:to_s)
|
|
414
|
+
context = { targets_count: targets_count, results_count: results_count }
|
|
415
|
+
if data.key?("not") then Not.new(build(data["not"], errors, "#{path}.not", **context))
|
|
416
|
+
elsif data.key?("all") || data.key?("any")
|
|
417
|
+
key = data.key?("all") ? "all" : "any"
|
|
418
|
+
parts = Array(data[key]).each_with_index.map { |c, i| build(c, errors, "#{path}.#{key}[#{i}]", **context) }
|
|
419
|
+
Many.new(key, parts.compact)
|
|
420
|
+
elsif data.key?("kicked") then WasKicked.new(data["kicked"] != false)
|
|
421
|
+
elsif data.key?("sacrificed") then Sacrificed.build(data, errors, path)
|
|
422
|
+
elsif (flag = FLAGS.find { data.key?(it) }) then Flag.new(flag, data[flag] != false)
|
|
423
|
+
elsif data.key?("began_extra_turn") then BeganExtraTurn.new(data["began_extra_turn"] != false) # CR 500.7
|
|
424
|
+
elsif data.key?("share_card_type") then ShareCardType.build(data, errors, path, targets_count) # linked.rb
|
|
425
|
+
elsif (key = KEYED.keys.find { data.key?(it) }) then const_get(KEYED[key]).build(data, errors, path)
|
|
426
|
+
else build_more(data, errors, path, context)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# How a spell was cast (CastHow, keywords/alternative_costs.rb), or a comparison.
|
|
431
|
+
def self.build_more(data, errors, path, context)
|
|
432
|
+
key = CastHow::KEYS.find { data.key?(it) }
|
|
433
|
+
key ? CastHow.build(key, data, errors, path) : Compare.build(data, errors, path, **context)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# An Amount compared with bounds: {"amount": amount, "at_least": n, "at_most": n}, or a short form
|
|
437
|
+
# {"controls"|"life"|"hand_size": selector, ...}.
|
|
438
|
+
class Compare
|
|
439
|
+
SUBJECTS = { "controls" => "count", "life" => "life", "hand_size" => "hand_size" }.freeze
|
|
440
|
+
|
|
441
|
+
def self.build(data, errors, path, targets_count:, results_count: 0)
|
|
442
|
+
subject = (data.keys & (SUBJECTS.keys + ["amount"])).first
|
|
443
|
+
unless subject
|
|
444
|
+
errors << "#{path}: unknown condition (use amount, controls, life, hand_size, your_turn, was_cast, targets, " \
|
|
445
|
+
"attacked, source, attacked_this_turn, you_attacked_this_turn, nth_resolution, kicked, " \
|
|
446
|
+
"sacrificed, cast_with, cast_from, cast_with_this_turn, chosen, max_speed, designation, not, all, any)"
|
|
447
|
+
return nil
|
|
448
|
+
end
|
|
449
|
+
Mechanics.check_keys(data, [subject, "at_least", "at_most"], errors, path)
|
|
450
|
+
raw = subject == "amount" ? data[subject] : { SUBJECTS[subject] => data[subject] }
|
|
451
|
+
amount = Amount.build(raw, errors, "#{path}.#{subject}", targets_count: targets_count, results_count: results_count)
|
|
452
|
+
bounds = %w[at_least at_most].to_h { [it, data[it]] }.compact
|
|
453
|
+
errors << "#{path}: needs at_least and/or at_most" if bounds.empty?
|
|
454
|
+
bounds = bounds.to_h do |k, v| # a bound may itself be an amount ("less than its mana value")
|
|
455
|
+
[k, Amount.build(v, errors, "#{path}.#{k}", targets_count: targets_count, results_count: results_count, min: -1000)]
|
|
456
|
+
end
|
|
457
|
+
new(amount, bounds["at_least"], bounds["at_most"])
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
attr_reader :amount
|
|
461
|
+
|
|
462
|
+
def initialize(amount, at_least, at_most)
|
|
463
|
+
@amount = amount
|
|
464
|
+
@at_least = at_least
|
|
465
|
+
@at_most = at_most
|
|
466
|
+
freeze
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def true?(engine, ctx)
|
|
470
|
+
n = @amount.value(engine, ctx)
|
|
471
|
+
(@at_least.nil? || n >= @at_least.value(engine, ctx)) && (@at_most.nil? || n <= @at_most.value(engine, ctx))
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def to_s = "#{@amount} is #{[@at_least && "at least #{@at_least}", @at_most && "at most #{@at_most}"].compact.join(" and ")}"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# A yes/no fact about the turn or the source.
|
|
478
|
+
class Flag
|
|
479
|
+
def initialize(flag, wanted)
|
|
480
|
+
@flag = flag
|
|
481
|
+
@wanted = wanted
|
|
482
|
+
freeze
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def true?(engine, ctx) = holds?(engine, ctx) == @wanted
|
|
486
|
+
|
|
487
|
+
WORDS = { "your_turn" => ["it's your turn", "it's not your turn"], "was_cast" => ["it was cast", "it wasn't cast"],
|
|
488
|
+
"attacked" => ["a creature is attacking you", "no creature is attacking you"],
|
|
489
|
+
"attacked_this_turn" => ["it attacked this turn", "it didn't attack this turn"],
|
|
490
|
+
"max_speed" => ["you have max speed", "you don't have max speed"],
|
|
491
|
+
"you_attacked_this_turn" => ["you attacked this turn", "you didn't attack this turn"] }.freeze
|
|
492
|
+
|
|
493
|
+
def to_s = WORDS.fetch(@flag)[@wanted ? 0 : 1]
|
|
494
|
+
|
|
495
|
+
private
|
|
496
|
+
|
|
497
|
+
def holds?(engine, ctx)
|
|
498
|
+
case @flag
|
|
499
|
+
when "your_turn" then engine.state.active == ctx.controller
|
|
500
|
+
when "attacked" # a creature is attacking the controller, not a planeswalker they control (CR 508.1b)
|
|
501
|
+
you = Filters.player_ref(engine, ctx.controller)
|
|
502
|
+
engine.attackers.values.any? { it["target"] == you }
|
|
503
|
+
when "attacked_this_turn" then engine.attacked_this_turn?(ctx.source)
|
|
504
|
+
when "max_speed" then engine.max_speed?(ctx.controller) # CR 702.179e
|
|
505
|
+
when "you_attacked_this_turn" then !engine.turn_log(ctx.controller).fetch("attackers", []).empty?
|
|
506
|
+
else was_cast?(engine, ctx)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# "If you cast it": the source permanent entered as a resolving spell (CR 400.7d). Once the
|
|
511
|
+
# source has left, its triggered ability remembers it (last known information, CR 603.4 / 608.2h).
|
|
512
|
+
def was_cast?(engine, ctx)
|
|
513
|
+
source = engine.state.objects[ctx.source]
|
|
514
|
+
cast = source ? source.memory["cast"] : engine.state.objects[ctx.object]&.memory&.[]("source_cast")
|
|
515
|
+
cast == true
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# One of the chosen targets matches a filter. Before targets are chosen (`ctx.targets` nil, as when
|
|
520
|
+
# checking whether a cost reduction could apply) it counts as met.
|
|
521
|
+
class Targets
|
|
522
|
+
def self.build(data, errors, path)
|
|
523
|
+
Mechanics.check_keys(data, %w[targets], errors, path)
|
|
524
|
+
new(Mechanics.build_entry(:filter, data["targets"], errors, "#{path}.targets"))
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def initialize(filter)
|
|
528
|
+
@filter = filter
|
|
529
|
+
freeze
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def true?(engine, ctx)
|
|
533
|
+
return true if ctx.targets.nil?
|
|
534
|
+
|
|
535
|
+
ctx.targets.flatten.compact.any? { @filter&.match?(engine, it, ctx.controller) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def to_s = "it targets a #{@filter.respond_to?(:primitive_name) ? @filter.primitive_name : @filter}"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The source is on the battlefield and matches a filter, by its current characteristics.
|
|
542
|
+
class Source
|
|
543
|
+
def self.build(data, errors, path)
|
|
544
|
+
Mechanics.check_keys(data, %w[source], errors, path)
|
|
545
|
+
new(Mechanics.build_entry(:filter, data["source"], errors, "#{path}.source"))
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def initialize(filter)
|
|
549
|
+
@filter = filter
|
|
550
|
+
freeze
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def true?(engine, ctx)
|
|
554
|
+
!@filter.nil? && engine.state.battlefield.include?(ctx.source) && @filter.match?(engine, ctx.source, ctx.controller)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def to_s = "it's a #{@filter}"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# The nth resolution of an ability this turn: the resolving ability (ctx.object)
|
|
561
|
+
# has finished resolving n - 1 times this turn (Engine::TurnHistory counts completed resolutions).
|
|
562
|
+
class NthResolution
|
|
563
|
+
def self.build(data, errors, path)
|
|
564
|
+
Mechanics.check_keys(data, %w[nth_resolution], errors, path)
|
|
565
|
+
n = data["nth_resolution"]
|
|
566
|
+
errors << "#{path}.nth_resolution: must be a positive integer" unless n.is_a?(Integer) && n.positive?
|
|
567
|
+
new(n)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def initialize(nth)
|
|
571
|
+
@nth = nth
|
|
572
|
+
freeze
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def true?(engine, ctx)
|
|
576
|
+
obj = engine.state.objects[ctx.object]
|
|
577
|
+
!obj.nil? && obj.ability? && engine.times_resolved(obj.source, obj.ability) + 1 == @nth
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def to_s = "this is resolution number #{@nth} of this ability this turn"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# "If this spell was kicked" (CR 702.33d): the resolving spell (or the spell a permanent was) paid
|
|
584
|
+
# its kicker cost. Recorded on the stack object as it became cast, and carried to the permanent it
|
|
585
|
+
# becomes (CR 400.7d) and to that permanent's triggered abilities. While a trigger is being checked
|
|
586
|
+
# (no ability object yet, CR 603.4) the source permanent answers.
|
|
587
|
+
class WasKicked
|
|
588
|
+
def initialize(wanted)
|
|
589
|
+
@wanted = wanted
|
|
590
|
+
freeze
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def true?(engine, ctx)
|
|
594
|
+
holder = engine.state.objects[ctx.object || ctx.source]
|
|
595
|
+
(holder&.memory&.[]("kicked") == true) == @wanted
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def to_s = @wanted ? "it was kicked" : "it wasn't kicked"
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# "If you sacrificed a Klingon this way" (CR 608.2h): a permanent sacrificed to pay the resolving
|
|
602
|
+
# spell's or ability's cost had these subtypes / card types as it last existed.
|
|
603
|
+
# {"sacrificed": {"subtypes": ["klingon"]}} {"sacrificed": {"types": ["artifact"]}}
|
|
604
|
+
class Sacrificed
|
|
605
|
+
def self.build(data, errors, path)
|
|
606
|
+
Mechanics.check_keys(data, %w[sacrificed], errors, path)
|
|
607
|
+
spec = data["sacrificed"]
|
|
608
|
+
ok = spec.is_a?(Hash) && !spec.empty? && (spec.keys - %w[types subtypes]).empty? &&
|
|
609
|
+
spec.values.all? { it.is_a?(Array) && !it.empty? && it.all?(String) }
|
|
610
|
+
errors << "#{path}.sacrificed: must be {\"types\": [...]} and/or {\"subtypes\": [...]}" unless ok
|
|
611
|
+
new(ok ? spec : {})
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def initialize(spec)
|
|
615
|
+
@spec = spec
|
|
616
|
+
freeze
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def true?(engine, ctx)
|
|
620
|
+
paid = engine.state.objects[ctx.object]&.paid || []
|
|
621
|
+
paid.any? { |entry| entry["subtypes"] && @spec.all? { |key, wanted| entry[key].intersect?(wanted) } }
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def to_s = "a sacrificed permanent was #{@spec.values.flatten.join(" or ")}"
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# {"triggering": filter}: "if it's a Saga spell" — the card the trigger event was about matches a
|
|
628
|
+
# filter on its card's characteristics (types, subtypes, ...), wherever it is now.
|
|
629
|
+
class TriggeringIs
|
|
630
|
+
def self.build(data, errors, path)
|
|
631
|
+
Mechanics.check_keys(data, %w[triggering], errors, path)
|
|
632
|
+
new(Mechanics.build_entry(:filter, data["triggering"], errors, "#{path}.triggering"))
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def initialize(filter)
|
|
636
|
+
@filter = filter
|
|
637
|
+
freeze
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def true?(engine, ctx)
|
|
641
|
+
card = Selector.memory(engine, ctx).fetch("triggering", {})["card"]
|
|
642
|
+
return false unless card && @filter
|
|
643
|
+
|
|
644
|
+
defn = engine.catalog.fetch(card)
|
|
645
|
+
@filter.matches_facts?(Filters::Facts.new(chars: defn.printed_characteristics, counters: {}, token: defn.token?))
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def to_s = "it's a #{@filter.respond_to?(:primitive_name) ? @filter.primitive_name.tr("_", " ") : @filter}"
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# Negation.
|
|
652
|
+
class Not
|
|
653
|
+
def initialize(inner)
|
|
654
|
+
@inner = inner
|
|
655
|
+
freeze
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def true?(engine, ctx) = !@inner&.true?(engine, ctx)
|
|
659
|
+
def to_s = "not (#{@inner})"
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# all / any.
|
|
663
|
+
class Many
|
|
664
|
+
def initialize(mode, parts)
|
|
665
|
+
@mode = mode
|
|
666
|
+
@parts = parts
|
|
667
|
+
freeze
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def true?(engine, ctx) = @mode == "all" ? @parts.all? { it.true?(engine, ctx) } : @parts.any? { it.true?(engine, ctx) }
|
|
671
|
+
def to_s = @parts.join(@mode == "all" ? " and " : " or ")
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|