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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../abilities/play_permissions"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Keywords
|
|
8
|
+
# Suspend (CR 702.62a-b): a card that has suspend and is exiled with time counters is "suspended". At the
|
|
9
|
+
# beginning of its owner's upkeep they remove a time counter from it; when the last is removed, if it's
|
|
10
|
+
# exiled, they may play it without paying its mana cost, and a creature spell cast this way has haste
|
|
11
|
+
# while they control it. Both triggered abilities are the engine's (#watch!), put on the stack as this
|
|
12
|
+
# keyword's ability "keyword:suspend", so they work for a card that gained suspend ("exile it with four
|
|
13
|
+
# time counters; if it doesn't have suspend, it gains suspend", Effects::Suspend) as for a printed one.
|
|
14
|
+
class Suspend < Primitive
|
|
15
|
+
primitive :keyword, :suspend,
|
|
16
|
+
description: "This card has suspend: while it's exiled with time counters, its owner removes one at " \
|
|
17
|
+
"the beginning of each of their upkeeps; when the last is removed they may play it without " \
|
|
18
|
+
"paying its mana cost (a creature spell gains haste). \"Suspend N—cost\" is the ability " \
|
|
19
|
+
"`suspend`.",
|
|
20
|
+
rules: %w[702.62a 702.62b], example: { "keywords" => ["suspend"] }
|
|
21
|
+
|
|
22
|
+
REF = "keyword:suspend"
|
|
23
|
+
|
|
24
|
+
# Never triggers by itself (zone :rules): the engine queues it (#watch!).
|
|
25
|
+
class Rules
|
|
26
|
+
def watches?(_event) = false
|
|
27
|
+
def leaves_battlefield? = false
|
|
28
|
+
def to_s = "Suspend"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.intrinsic_abilities
|
|
32
|
+
@intrinsic_abilities ||= [
|
|
33
|
+
Abilities::TriggeredAbility.new(trigger: Rules.new.freeze, targets: [].freeze, zone: :rules,
|
|
34
|
+
effects: [Effects::SuspendStep.new].freeze)
|
|
35
|
+
].freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The card has suspend: printed, from "Suspend N—cost", or gained in exile (memory "suspend").
|
|
39
|
+
def self.has?(engine, obj)
|
|
40
|
+
return true if obj.memory["suspend"]
|
|
41
|
+
|
|
42
|
+
chars = engine.chars(obj)
|
|
43
|
+
chars.keyword?(:suspend) || chars.abilities.any? { it.ability.is_a?(Abilities::Suspend) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# CR 702.62b.
|
|
47
|
+
def self.suspended?(engine, obj) = obj.zone == :exile && obj.counters["time"].to_i.positive? && has?(engine, obj)
|
|
48
|
+
|
|
49
|
+
# Queues the upkeep trigger of each suspended card of the active player, and the "last counter removed"
|
|
50
|
+
# trigger of an exiled card with suspend.
|
|
51
|
+
def self.watch!(engine, event)
|
|
52
|
+
case event
|
|
53
|
+
when Events::StepBegan
|
|
54
|
+
return unless event.step == "upkeep"
|
|
55
|
+
|
|
56
|
+
engine.state.exile.each do |id|
|
|
57
|
+
obj = engine.object(id)
|
|
58
|
+
queue!(engine, obj, event, "upkeep") if obj.owner == engine.state.active && suspended?(engine, obj)
|
|
59
|
+
end
|
|
60
|
+
when Events::CountersChanged
|
|
61
|
+
return unless event.counter == "time" && event.to.zero? && event.from.positive?
|
|
62
|
+
|
|
63
|
+
obj = engine.state.objects[event.object]
|
|
64
|
+
queue!(engine, obj, event, "last") if obj&.zone == :exile && has?(engine, obj)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# CR 108.4a: the ability of a card in exile is controlled by its owner.
|
|
69
|
+
def self.queue!(engine, obj, event, step)
|
|
70
|
+
engine.queue_pending_trigger!({ "id" => obj.id, "card" => obj.card, "controller" => obj.owner, "ability" => REF },
|
|
71
|
+
event, { "suspend" => step })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
module Abilities
|
|
77
|
+
# "Suspend N—[cost]" (CR 702.62a, 116.2f): if you could begin to cast this card from your hand, you may pay
|
|
78
|
+
# `cost` and exile it with N time counters (a special action, `{type: "special_action"}`). The card has
|
|
79
|
+
# suspend (Keywords::Suspend).
|
|
80
|
+
class Suspend < Primitive
|
|
81
|
+
primitive :ability, :suspend,
|
|
82
|
+
description: "Suspend `amount`—`cost`: whenever you could begin to cast this card from your hand, you " \
|
|
83
|
+
"may pay `cost` and exile it with `amount` time counters (special action " \
|
|
84
|
+
"`{type: \"special_action\"}`); then it works as the keyword `suspend`.",
|
|
85
|
+
rules: %w[702.62a 702.62c 116.2f],
|
|
86
|
+
params: { "amount" => "integer >= 1 (time counters)", "cost" => "mana cost string" },
|
|
87
|
+
example: { "suspend" => { "amount" => 3, "cost" => "{2}{W}" } }
|
|
88
|
+
|
|
89
|
+
attr_reader :amount, :special_cost
|
|
90
|
+
|
|
91
|
+
def self.build(params, errors, path, **)
|
|
92
|
+
Mechanics.check_keys(params, %w[amount cost], errors, path)
|
|
93
|
+
amount = Mechanics.integer_param(params, :amount, errors, path, min: 1)
|
|
94
|
+
cost = Costs::ManaPayment.build(Mechanics.fetch_param(params, :cost).to_s, errors, "#{path}.cost").mana_cost
|
|
95
|
+
new(amount, cost)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def initialize(amount, cost)
|
|
99
|
+
super()
|
|
100
|
+
@amount = amount
|
|
101
|
+
@special_cost = cost
|
|
102
|
+
freeze
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def kind = :suspend
|
|
106
|
+
def mana_ability? = false
|
|
107
|
+
def allowed_types = nil
|
|
108
|
+
def try_effects = []
|
|
109
|
+
def to_s = "Suspend #{amount}—#{special_cost}"
|
|
110
|
+
|
|
111
|
+
# CR 702.62a, 702.62c: when you could begin to cast it from your hand (timing; nothing forbids it).
|
|
112
|
+
def special_allowed?(engine, obj, index)
|
|
113
|
+
chars = engine.chars(obj)
|
|
114
|
+
obj.zone == :hand && (chars.type?(:instant) || chars.keyword?(:flash) || engine.sorcery_timing?(index)) &&
|
|
115
|
+
!engine.cast_forbidden?(index, obj)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def special!(engine, obj, _index) = Effects::Suspend.exile!(engine, obj.id, amount)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
module Effects
|
|
123
|
+
# "Exile [it] with N time counters on it. If it doesn't have suspend, it gains suspend." (CR 702.62a-b)
|
|
124
|
+
class Suspend < Effect
|
|
125
|
+
primitive :effect, :suspend,
|
|
126
|
+
description: "Exile each referenced card or spell with `amount` time counters on it; if it doesn't " \
|
|
127
|
+
"have suspend, it gains suspend (keyword `suspend`) while it stays exiled.",
|
|
128
|
+
rules: %w[702.62a 702.62b 406.1],
|
|
129
|
+
params: { "amount" => "amount (time counters)", "to" => "selector (a spell, a card or a permanent)" },
|
|
130
|
+
example: { "suspend" => { "to" => { "triggering" => "object" }, "amount" => 4 } }
|
|
131
|
+
|
|
132
|
+
def self.keys = %w[amount to if]
|
|
133
|
+
|
|
134
|
+
def self.exile!(engine, id, amount)
|
|
135
|
+
fresh = engine.move!(id, :exile, reason: "suspend")
|
|
136
|
+
return unless fresh&.zone == :exile
|
|
137
|
+
|
|
138
|
+
fresh.memory["suspend"] = true
|
|
139
|
+
engine.add_counters!(fresh, "time", amount, by_player: fresh.owner)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply(engine, ctx)
|
|
143
|
+
n = amount_for(engine, ctx)
|
|
144
|
+
refs(engine, ctx).each do |id|
|
|
145
|
+
obj = engine.state.objects[id]
|
|
146
|
+
Suspend.exile!(engine, id, n) if obj && !obj.ability? && obj.zone != :exile
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def to_s = "exile with #{amount} time counters (suspend) → #{to}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The resolving suspend trigger (Keywords::Suspend): "upkeep" removes a time counter if the card is still
|
|
154
|
+
# suspended (CR 603.4); "last" lets its owner play it free if it's still exiled. A creature spell cast this
|
|
155
|
+
# way has haste while they control it (memory "haste_while_controlled_by").
|
|
156
|
+
class SuspendStep < CastFree
|
|
157
|
+
def self.keys = []
|
|
158
|
+
|
|
159
|
+
def initialize
|
|
160
|
+
errors = []
|
|
161
|
+
super(to: Selector.build("self", errors, "keyword:suspend", targets_count: 0), params: { "play" => true },
|
|
162
|
+
errors: errors, path: "keyword:suspend")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def run(engine, ctx, locals)
|
|
166
|
+
card = engine.state.objects[ctx.source]
|
|
167
|
+
return :done unless card&.zone == :exile || locals["asked"] || locals["casting"]
|
|
168
|
+
|
|
169
|
+
if Selector.memory(engine, ctx)["suspend"] == "upkeep"
|
|
170
|
+
engine.remove_counters!(card, "time", 1) if Keywords::Suspend.suspended?(engine, card)
|
|
171
|
+
return :done
|
|
172
|
+
end
|
|
173
|
+
super
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_s = "remove a time counter; when the last is removed, you may play it without paying its mana cost"
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def settle_cast!(engine, locals)
|
|
181
|
+
id = locals["casting"]
|
|
182
|
+
super
|
|
183
|
+
return if id.nil? || engine.state.objects.key?(id) || engine.state.stack.empty?
|
|
184
|
+
|
|
185
|
+
spell = engine.object(engine.state.stack.first)
|
|
186
|
+
spell.memory["haste_while_controlled_by"] = spell.controller if engine.chars(spell).creature?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "crew"
|
|
4
|
+
require_relative "kicker"
|
|
5
|
+
|
|
6
|
+
module Enjine
|
|
7
|
+
module Mechanics
|
|
8
|
+
module Costs
|
|
9
|
+
# The cost of teamwork N (CR 702.194a): "tap any number of creatures you control with total power N or
|
|
10
|
+
# more", chosen like crew's (one `choose_cost` decision per creature, `done` once the total is enough).
|
|
11
|
+
class Teamwork < Crew
|
|
12
|
+
def to_s = "Tap any number of untapped creatures you control with total power #{power} or greater"
|
|
13
|
+
|
|
14
|
+
# "Whenever this creature becomes tapped to pay a teamwork cost" (trigger becomes_tapped `for`).
|
|
15
|
+
def pay_chosen!(engine, object, entries)
|
|
16
|
+
entries.each { engine.emit Events::TappedForCost.new(object: it["object"], cost: "teamwork", spell: object.id) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Abilities
|
|
22
|
+
# Teamwork N (CR 702.194a): "As an additional cost to cast this spell, you may tap any number of creatures
|
|
23
|
+
# you control with total power N or more." It is announced like kicker (the `choose_kicker` decision) and
|
|
24
|
+
# paid like additional costs (CR 601.2b, 601.2f-h). "If this spell was cast using teamwork" is the condition
|
|
25
|
+
# `{"cast_with": "teamwork"}` (CR 702.194b); targets only for teamwork are `kicked_targets` (CR 702.194c).
|
|
26
|
+
class Teamwork < Kicker
|
|
27
|
+
primitive :ability, :teamwork,
|
|
28
|
+
description: "Teamwork N: as you cast this spell you may also tap any number of untapped creatures " \
|
|
29
|
+
"you control with total power N or more (the `choose_kicker` decision, then one " \
|
|
30
|
+
"`choose_cost` per creature). `{\"cast_with\": \"teamwork\"}` holds if you did; " \
|
|
31
|
+
"`kicked_targets` are its targets then.",
|
|
32
|
+
rules: %w[702.194a 702.194b 702.194c 601.2b 601.2f 601.2h],
|
|
33
|
+
params: { "power" => "integer >= 1 (the N of teamwork N)" },
|
|
34
|
+
example: { "teamwork" => { "power" => 4 } }
|
|
35
|
+
|
|
36
|
+
def self.build(params, errors, path, **)
|
|
37
|
+
Mechanics.check_keys(params, %w[power], errors, path)
|
|
38
|
+
new([Costs::Teamwork.new(Mechanics.integer_param(params, :power, errors, path, min: 1).to_i)].freeze)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_s = "Teamwork #{costs.first.power}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../effects/pay"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Abilities
|
|
8
|
+
# Ward (CR 702.21a): "Whenever this permanent becomes the target of a spell or ability an opponent
|
|
9
|
+
# controls, counter that spell or ability unless that player pays [cost]." A triggered ability: it
|
|
10
|
+
# triggers as the target is chosen (CR 601.2c), goes on the stack once the spell is cast, and on
|
|
11
|
+
# resolution the targeting spell's controller may pay. Engine#choose_target! tells the trigger which
|
|
12
|
+
# spell or ability targeted it (its memory "targeted_by").
|
|
13
|
+
class Ward < TriggeredAbility
|
|
14
|
+
primitive :ability, :ward,
|
|
15
|
+
description: "Ward: whenever this permanent becomes the target of a spell or ability an opponent " \
|
|
16
|
+
"controls, counter that spell or ability unless its controller pays `cost`: a mana cost, " \
|
|
17
|
+
"or a list of costs (\"Ward—Pay 2 life\", \"Ward—Discard a card or pay {2}\" with " \
|
|
18
|
+
"`one_of`). With `waterbend`, they waterbend that cost: \"Ward—Waterbend {4}\".",
|
|
19
|
+
rules: %w[702.21a 601.2c 603.2 701.6a 701.67a],
|
|
20
|
+
params: { "cost" => "mana cost string, e.g. \"{2}\", or a list of costs, e.g. [{\"pay_life\": 2}]",
|
|
21
|
+
"waterbend" => "mana cost string, instead of cost" },
|
|
22
|
+
example: { "ward" => { "cost" => "{2}" } }
|
|
23
|
+
|
|
24
|
+
# The trigger: this permanent becomes the target of an opponent's spell or ability.
|
|
25
|
+
class BecomesTarget
|
|
26
|
+
def event_class = Events::TargetChosen
|
|
27
|
+
def watches?(event) = event.is_a?(Events::TargetChosen)
|
|
28
|
+
def leaves_battlefield? = false
|
|
29
|
+
def to_s = "When this becomes the target of a spell or ability an opponent controls"
|
|
30
|
+
|
|
31
|
+
def match?(engine, event, source)
|
|
32
|
+
return false unless event.target == source["id"]
|
|
33
|
+
|
|
34
|
+
engine.names.index(event.player) != source["controller"]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Counters the spell or ability that targeted the permanent unless its controller pays.
|
|
39
|
+
class Counter < Effects::CounterUnlessPays
|
|
40
|
+
def refs(engine, ctx) = [engine.object(ctx.object).memory["targeted_by"]]
|
|
41
|
+
def to_s = "counter that spell or ability unless its controller pays #{what}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
TRIGGER = BecomesTarget.new.freeze
|
|
45
|
+
|
|
46
|
+
# `cost`: a mana cost string ("Ward {2}") or a list of costs ("Ward—Pay 2 life", CR 702.21a).
|
|
47
|
+
def self.build(params, errors, path, **)
|
|
48
|
+
Mechanics.check_keys(params, %w[cost waterbend], errors, path)
|
|
49
|
+
raw = Mechanics.fetch_param(params, :cost)
|
|
50
|
+
bend = Mechanics.fetch_param(params, :waterbend)
|
|
51
|
+
errors << "#{path}.cost: required" if raw.nil? && bend.nil?
|
|
52
|
+
errors << "#{path}: needs exactly one of cost, waterbend" if params.key?("cost") && params.key?("waterbend")
|
|
53
|
+
effect = if raw.is_a?(Array) then Counter.new(to: nil, params: { "cost" => raw }, errors: errors, path: path)
|
|
54
|
+
elsif bend then Counter.new(to: nil, params: { "waterbend" => bend }, errors: errors, path: path)
|
|
55
|
+
else Counter.new(to: nil, params: { "mana" => raw || "{0}" }, errors: errors, path: "#{path}.cost")
|
|
56
|
+
end
|
|
57
|
+
new(targets: [].freeze, effects: [effect].freeze, trigger: TRIGGER)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_s
|
|
61
|
+
costs = effects.first.costs
|
|
62
|
+
costs.size == 1 && costs.first.instance_of?(Costs::ManaPayment) ? "Ward #{costs.first}" : "Ward—#{costs.join(", ")}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# Linked abilities (CR 607.2a, 406.6): cards an ability of a source exiled are kept in the source's
|
|
6
|
+
# memory, so another ability of the same object can name "the cards exiled with it". A source that
|
|
7
|
+
# changes zones is a new object (CR 400.7) with no memory, so the link ends with it. Also the last
|
|
8
|
+
# known controller of objects a resolving effect moved (CR 608.2h), for {"controller_of": selector}.
|
|
9
|
+
module Linked
|
|
10
|
+
EXILED = "exiled_with"
|
|
11
|
+
UNTIL_LEAVES = "until_leaves"
|
|
12
|
+
LKI = "lki"
|
|
13
|
+
|
|
14
|
+
# Cards still in exile that an ability of `source` exiled (CR 607.2a). Once the source has left the
|
|
15
|
+
# battlefield, its triggered ability remembers them ("when this leaves, return the exiled card").
|
|
16
|
+
def self.exiled_with(engine, source, ctx = nil)
|
|
17
|
+
obj = engine.state.objects[source]
|
|
18
|
+
ids = obj ? obj.memory[EXILED] : ctx && engine.state.objects[ctx.object]&.memory&.dig("source_lki", EXILED)
|
|
19
|
+
Array(ids).select { engine.state.exile.include?(it) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Called by a resolving effect before it moves an object: who controlled it (CR 608.2h), kept on the
|
|
23
|
+
# resolving spell or ability. Only a permanent has a controller other than its owner.
|
|
24
|
+
def self.remember!(engine, ctx, id)
|
|
25
|
+
obj = engine.object(id)
|
|
26
|
+
(engine.object(ctx.object).memory[LKI] ||= {})[id] = obj.controller if obj.zone == :battlefield
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An amount whose {"count": selector} may name earlier effects' results ("up to that many").
|
|
30
|
+
def self.amount(raw, errors, path, targets_count, results_count)
|
|
31
|
+
return Amount.build(raw, errors, path, targets_count: targets_count) unless raw.is_a?(Hash) && raw.keys.map(&:to_s) == ["count"]
|
|
32
|
+
|
|
33
|
+
Amount.new("count", Selector.build(raw.values.first, errors, "#{path}.count", targets_count: targets_count,
|
|
34
|
+
results_count: results_count))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Condition
|
|
39
|
+
# Whether the referenced objects (e.g. the cards exiled with a source, CR 607.2a) have at least one
|
|
40
|
+
# card type in common. No objects share nothing.
|
|
41
|
+
class ShareCardType
|
|
42
|
+
def self.build(data, errors, path, targets_count)
|
|
43
|
+
Mechanics.check_keys(data, %w[share_card_type], errors, path)
|
|
44
|
+
raw = data["share_card_type"]
|
|
45
|
+
list = raw.is_a?(Array) ? raw : [raw]
|
|
46
|
+
new(list.each_with_index.map do |sel, i|
|
|
47
|
+
Selector.build(sel, errors, "#{path}.share_card_type#{"[#{i}]" if raw.is_a?(Array)}", targets_count: targets_count)
|
|
48
|
+
end)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(selectors)
|
|
52
|
+
@selectors = selectors
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A list of selectors is "they share a card type": each must still name an object (CR 701.12a).
|
|
57
|
+
def true?(engine, ctx)
|
|
58
|
+
groups = @selectors.map { |sel| sel&.resolve(engine, ctx).to_a.filter_map { engine.state.objects[it] } }
|
|
59
|
+
objects = groups.flatten
|
|
60
|
+
groups.none?(&:empty?) && !objects.map { engine.chars(it).types }.reduce(:&).empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_s = "#{@selectors.join(" and ")} share a card type"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
class Amount
|
|
6
|
+
# {"mana_spent": {"of": ..., "color": "W" | "colors": true}}: the mana spent to cast a spell (CR 601.2h),
|
|
7
|
+
# recorded by type as it was paid (an "any type" payment counts as the mana's real type, CR 609.4b):
|
|
8
|
+
# no option the total amount of mana spent ("X is the amount of mana spent to cast it")
|
|
9
|
+
# "color": "W" how much of one type was spent ("if {W}{W} was spent": at_least 2; "C" for colorless)
|
|
10
|
+
# "colors": true how many colors were spent (converge, CR 207.2c; colorless mana is no color, CR 105.1)
|
|
11
|
+
# "from": spec how much came from sources matching a card spec ("if mana from a Treasure was spent":
|
|
12
|
+
# {"subtypes": ["treasure"]}), as they were when they made it (Engine::ManaSources)
|
|
13
|
+
# `of` is whose: "self" (default) is this spell, or the permanent this card became as a resolving spell
|
|
14
|
+
# (CR 400.7d; its triggered abilities remember it once it has left); {"triggering": "object"} is the
|
|
15
|
+
# spell a "whenever you cast" trigger is about, as it was cast; {"target": n} is a target spell. Nothing
|
|
16
|
+
# was spent on an object that wasn't cast, and a copy of a spell had no mana spent on it (CR 707.10).
|
|
17
|
+
class ManaSpent
|
|
18
|
+
OF = "\"self\", {\"triggering\": \"object\"} or {\"target\": n}"
|
|
19
|
+
|
|
20
|
+
def self.build(data, errors, path, targets_count:, results_count: 0)
|
|
21
|
+
errors << "#{path}: must be an object" unless data.is_a?(Hash)
|
|
22
|
+
data = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
23
|
+
Mechanics.check_keys(data, %w[of color colors from], errors, path)
|
|
24
|
+
of = Selector.build(data.fetch("of", "self"), errors, "#{path}.of", targets_count: targets_count,
|
|
25
|
+
results_count: results_count)
|
|
26
|
+
errors << "#{path}.of: must be #{OF}" if of && !(%i[self target].include?(of.kind) || of.value == "object")
|
|
27
|
+
color = data["color"]
|
|
28
|
+
errors << "#{path}.color: must be one of #{Mana::TYPES.join(", ")}" unless color.nil? || Mana::TYPES.map(&:to_s).include?(color)
|
|
29
|
+
errors << "#{path}: color and colors can't be used together" if color && data["colors"]
|
|
30
|
+
errors << "#{path}.colors: must be true" unless [nil, true].include?(data["colors"])
|
|
31
|
+
errors << "#{path}: from can't be used with color or colors" if data.key?("from") && (color || data["colors"])
|
|
32
|
+
from = data.key?("from") ? CardSpec.build(data["from"], errors, "#{path}.from") : nil
|
|
33
|
+
new(of, color, data["colors"] == true, from)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(whose, color, colors, from = nil)
|
|
37
|
+
@of = whose
|
|
38
|
+
@color = color
|
|
39
|
+
@colors = colors
|
|
40
|
+
@from = from
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def value(engine, ctx)
|
|
45
|
+
return from_count(engine, ctx) if @from
|
|
46
|
+
|
|
47
|
+
spent = ManaSpent.spent(engine, ctx, @of) || []
|
|
48
|
+
return (spent.uniq & Keywords::Sunburst::COLORS).size if @colors
|
|
49
|
+
return spent.count(@color) if @color
|
|
50
|
+
|
|
51
|
+
spent.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
what = if @colors then "the number of colors of mana"
|
|
56
|
+
elsif @color then "the amount of {#{@color}}"
|
|
57
|
+
elsif @from then "the amount of mana from a #{@from.to_s.delete_suffix(" card")} source"
|
|
58
|
+
else "the amount of mana"
|
|
59
|
+
end
|
|
60
|
+
"#{what} spent to cast #{@of&.kind == :self ? "it" : @of}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# How much of the mana spent came from sources matching `from` (their characteristics then, CR 106.6).
|
|
64
|
+
def from_count(engine, ctx)
|
|
65
|
+
sources = ManaSpent.spent(engine, ctx, @of, key: "mana_from") || []
|
|
66
|
+
sources.count { @from.chars_match?(engine, Engine::ManaSources.source_chars(it)) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The mana types spent on the object `whose` (the `of` selector) names, or nil when none was recorded.
|
|
70
|
+
# `key` "mana_from": instead, the remembered sources of the mana that came from artifacts.
|
|
71
|
+
def self.spent(engine, ctx, whose, key: "mana_spent")
|
|
72
|
+
case whose&.kind
|
|
73
|
+
when :target # a spell the resolving one countered: as it last existed (Engine::TurnHistory#remember_spell!)
|
|
74
|
+
ref = ctx.targets&.[](whose.value)
|
|
75
|
+
obj = engine.state.objects[ref]
|
|
76
|
+
obj ? recorded(obj, key) : engine.resolving_last_known(ref)&.[](key)
|
|
77
|
+
when :triggering then Selector.memory(engine, ctx).dig("triggering", key)
|
|
78
|
+
else own(engine, ctx, key)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# What `obj` recorded as it was cast: its mana spent (an attribute) or the mana's sources (memory).
|
|
83
|
+
def self.recorded(obj, key) = key == "mana_spent" ? obj.mana_spent : obj.memory[key]
|
|
84
|
+
|
|
85
|
+
# "This spell" while it resolves (or as it enters), else the source permanent, else what the resolving
|
|
86
|
+
# triggered ability remembers of a source that has left (CR 603.4, 608.2h).
|
|
87
|
+
def self.own(engine, ctx, key = "mana_spent")
|
|
88
|
+
holder = engine.state.objects[ctx.object]
|
|
89
|
+
return recorded(holder, key) if holder && !holder.ability?
|
|
90
|
+
|
|
91
|
+
source = engine.state.objects[ctx.source]
|
|
92
|
+
return recorded(source, key) if source && !source.ability? && %i[battlefield stack].include?(source.zone)
|
|
93
|
+
|
|
94
|
+
holder&.memory&.[]("source_#{key}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|