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,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Remove N counters from among [permanents] you control" (CR 122.8 / 118.3): each choice is one
|
|
7
|
+
# counter on one permanent; they are removed as the ability becomes cast.
|
|
8
|
+
class RemoveCounters < Choosing
|
|
9
|
+
primitive :cost, :remove_counters,
|
|
10
|
+
description: "Remove `count` counters (of kind `counter`, or any kind) from among permanents you " \
|
|
11
|
+
"control matching `filter` (default creature), one counter per choice. Removed as " \
|
|
12
|
+
"it becomes cast.",
|
|
13
|
+
rules: %w[602.2b 601.2h 118.3 122.1],
|
|
14
|
+
params: { "count" => "integer", "filter" => "filter (default creature)", "counter" => "counter name (optional)" },
|
|
15
|
+
example: { "remove_counters" => { "count" => 3 } }
|
|
16
|
+
|
|
17
|
+
def self.keys = %w[filter count counter]
|
|
18
|
+
def self.default_filter = "creature"
|
|
19
|
+
|
|
20
|
+
def initialize(filter, count, other, params, errors, path)
|
|
21
|
+
@counter = Mechanics.fetch_param(params, :counter)
|
|
22
|
+
errors << "#{path}.counter: #{Counters.error(@counter)}" if @counter && Counters.error(@counter)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Counters still available: [object, kind] once per counter, minus those already chosen.
|
|
27
|
+
def units(engine, object, chosen)
|
|
28
|
+
taken = chosen.map { [it["object"], it["counter"]] }
|
|
29
|
+
matching(engine, object).flat_map do |id|
|
|
30
|
+
engine.object(id).counters.flat_map do |kind, n|
|
|
31
|
+
next [] unless @counter.nil? || kind == @counter
|
|
32
|
+
|
|
33
|
+
left = n - taken.count([id, kind])
|
|
34
|
+
left.positive? ? [[id, kind]] * left : []
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def payable?(engine, object) = units(engine, object, []).size >= count
|
|
40
|
+
def choice_options(engine, object, chosen) = units(engine, object, chosen).uniq.map { { object: it[0], counter: it[1] } }
|
|
41
|
+
def choose!(_engine, option) = { "object" => option[:object], "counter" => option[:counter] }
|
|
42
|
+
|
|
43
|
+
def pay_chosen!(engine, _object, entries)
|
|
44
|
+
entries.each { engine.remove_counters!(engine.object(it["object"]), it["counter"], 1) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s = "Remove #{count} #{"#{@counter} " if @counter}counters from among #{what}s you control"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Sacrifice this permanent" as a cost (CR 701.21, 602.2b).
|
|
7
|
+
class SacrificeSelf < Primitive
|
|
8
|
+
primitive :cost, :sacrifice,
|
|
9
|
+
description: "Sacrifice the permanent the ability belongs to (it goes to its owner's graveyard).",
|
|
10
|
+
rules: %w[701.21a 602.2b 118.3],
|
|
11
|
+
example: "sacrifice"
|
|
12
|
+
|
|
13
|
+
INSTANCE = new.freeze
|
|
14
|
+
|
|
15
|
+
def self.build(params, errors, path, **)
|
|
16
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
17
|
+
INSTANCE
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mana? = false
|
|
21
|
+
def uses_source? = true
|
|
22
|
+
# CR 701.21a, 614.17b: only a permanent on the battlefield its controller may sacrifice.
|
|
23
|
+
def payable?(engine, object) = engine.can_sacrifice?(object, object.controller)
|
|
24
|
+
def pay!(engine, object) = engine.move!(object.id, :graveyard, reason: "sacrifice")
|
|
25
|
+
def to_s = "Sacrifice this"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# "Sacrifice a creature" / "Sacrifice another creature or artifact" as a cost (CR 701.21a, 601.2h).
|
|
29
|
+
# The permanent is chosen while casting and sacrificed as the spell or ability becomes cast; what
|
|
30
|
+
# it was (types, subtypes, power) is kept for "if you sacrificed a Klingon this way" (CR 608.2h).
|
|
31
|
+
class SacrificePermanent < Choosing
|
|
32
|
+
primitive :cost, :sacrifice_permanent,
|
|
33
|
+
description: "Sacrifice `count` permanents you control matching `filter` (`other: true`: not the " \
|
|
34
|
+
"source). Chosen while casting or activating, sacrificed as it becomes cast; the " \
|
|
35
|
+
"sacrificed permanent's last known types and subtypes are remembered (condition " \
|
|
36
|
+
"`sacrificed`).",
|
|
37
|
+
rules: %w[701.21a 601.2h 602.2b 118.3 608.2h],
|
|
38
|
+
params: { "filter" => "filter (default permanent)", "count" => "integer (default 1)",
|
|
39
|
+
"other" => "true: not the source itself" },
|
|
40
|
+
example: { "sacrifice_permanent" => { "filter" => "creature", "other" => true } }
|
|
41
|
+
|
|
42
|
+
def pay_chosen!(engine, _object, entries)
|
|
43
|
+
entries.each do |entry| # still there: chosen permanents can't be sacrificed for mana (Engine::Payment)
|
|
44
|
+
perm = engine.object(entry["object"])
|
|
45
|
+
facts = engine.chars(perm)
|
|
46
|
+
entry.merge!("types" => facts.types.map(&:to_s), "subtypes" => facts.subtypes.map(&:to_s),
|
|
47
|
+
"power" => engine.power(perm))
|
|
48
|
+
engine.move!(perm.id, :graveyard, reason: "sacrifice")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# CR 701.21a, 614.17b: only permanents their controller may sacrifice (Engine::Rules).
|
|
53
|
+
def candidates(engine, object) = matching(engine, object).select { engine.can_sacrifice?(engine.object(it), object.controller) }
|
|
54
|
+
|
|
55
|
+
def to_s = "Sacrifice #{amount_words} #{what}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Tap N untapped [permanents] you control" (CR 601.2h, 602.2b): Collation Trimatrix taps three
|
|
7
|
+
# artifact creatures. Summoning sickness doesn't matter (CR 302.6 is about {T} abilities only).
|
|
8
|
+
class TapPermanents < Choosing
|
|
9
|
+
primitive :cost, :tap_permanents,
|
|
10
|
+
description: "Tap `count` untapped permanents you control matching `filter` (default: creature), " \
|
|
11
|
+
"chosen while casting or activating; `other: true` excludes the source. They are " \
|
|
12
|
+
"tapped as they are chosen; cancelling untaps them.",
|
|
13
|
+
rules: %w[601.2h 602.2b 302.6 118.3],
|
|
14
|
+
params: { "count" => "integer (default 1)", "filter" => "filter (default creature)",
|
|
15
|
+
"other" => "true: not the source itself" },
|
|
16
|
+
example: { "tap_permanents" => { "count" => 3, "filter" => { "creature" => { "types" => ["artifact"] } } } }
|
|
17
|
+
|
|
18
|
+
def self.default_filter = "creature"
|
|
19
|
+
|
|
20
|
+
def candidates(engine, object) = matching(engine, object).reject { engine.object(it).tapped }
|
|
21
|
+
|
|
22
|
+
def choose!(engine, option)
|
|
23
|
+
perm = engine.object(option[:object])
|
|
24
|
+
engine.tap!(perm)
|
|
25
|
+
{ "object" => perm.id, "power" => engine.power(perm), "tapped" => true }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s = "Tap #{count == 1 ? "an" : count} untapped #{what}#{"s" if count > 1} you control"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# {T}: tap this permanent (CR 107.5, 602.5a).
|
|
32
|
+
class Tap < Primitive
|
|
33
|
+
primitive :cost, :tap,
|
|
34
|
+
description: "{T} — tap the permanent the ability belongs to. Requires it to be untapped.",
|
|
35
|
+
rules: %w[107.5 602.5a],
|
|
36
|
+
example: "tap"
|
|
37
|
+
|
|
38
|
+
INSTANCE = new.freeze
|
|
39
|
+
|
|
40
|
+
def self.build(params, errors, path, **)
|
|
41
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
42
|
+
INSTANCE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def payable?(engine, object) = !object.tapped && !engine.summoning_sick_for_tap?(object)
|
|
46
|
+
|
|
47
|
+
def pay!(engine, object)
|
|
48
|
+
engine.tap!(object)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mana? = false
|
|
52
|
+
def to_s = "{T}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mana"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Costs
|
|
8
|
+
# Waterbend [cost] (CR 701.67a): "Pay [cost]. For each generic mana in that cost, you may tap an untapped
|
|
9
|
+
# artifact or creature you control rather than pay that mana." A mana cost, so it adds to the total cost
|
|
10
|
+
# like any other (an activated ability's cost, an additional cost, kicker); while paying, the `pay_cost`
|
|
11
|
+
# option `{type: "waterbend", object: id}` taps one of those permanents for {1}, at most as many times as
|
|
12
|
+
# the waterbend cost has generic mana (CR 701.67b). Paying it is waterbending (CR 701.67c).
|
|
13
|
+
class Waterbend < ManaPayment
|
|
14
|
+
primitive :cost, :waterbend,
|
|
15
|
+
description: "Waterbend [cost]: pay this mana cost; for each generic mana in it you may instead tap " \
|
|
16
|
+
"an untapped artifact or creature you control (pay_cost option `{type: \"waterbend\", " \
|
|
17
|
+
"object: id}`). Only the waterbend cost's own generic mana can be paid this way.",
|
|
18
|
+
rules: %w[701.67a 701.67b 701.67c],
|
|
19
|
+
params: { "(value)" => "mana cost string, e.g. \"{3}\" or \"{X}\"" },
|
|
20
|
+
example: { "waterbend" => "{3}" }
|
|
21
|
+
|
|
22
|
+
# The generic mana tapping may pay for, with X announced.
|
|
23
|
+
def generic(x_value) = mana_cost.with_x(x_value).generic
|
|
24
|
+
|
|
25
|
+
def to_s = "Waterbend #{mana_cost}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Counters
|
|
6
|
+
# Counters (CR 122). Any counter name is valid card data: a counter with no rules of its own
|
|
7
|
+
# (oil, charge, lore, ...) is just a marker that abilities can count (CR 122.1). The kinds
|
|
8
|
+
# registered here carry rules of their own:
|
|
9
|
+
# - power/toughness counters "+X/+Y" / "-X/-Y" (CR 122.1a, layer 7c, 613.4c)
|
|
10
|
+
# - keyword counters "flying", "hexproof", ... (CR 122.1b, layer 6, 613.1f)
|
|
11
|
+
# - stun counters stop untapping (CR 122.1d), shield counters protect (CR 122.1c)
|
|
12
|
+
# Replacement and prevention effects of counters run through Engine#propose (CR 614, 615).
|
|
13
|
+
NAME = %r{\A(?:[+-]\d+/[+-]\d+|[a-z][a-z0-9_]*)\z}
|
|
14
|
+
|
|
15
|
+
POWER_TOUGHNESS = %r{\A([+-]\d+)/([+-]\d+)\z}
|
|
16
|
+
|
|
17
|
+
# CR 122.1b: the keywords a keyword counter can be (those the engine implements).
|
|
18
|
+
KEYWORDS = %i[flying first_strike double_strike deathtouch decayed haste hexproof indestructible lifelink menace
|
|
19
|
+
reach trample vigilance].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# "first strike" and "First-Strike" are written first_strike.
|
|
24
|
+
def normalize(name)
|
|
25
|
+
return name unless name.is_a?(String) || name.is_a?(Symbol)
|
|
26
|
+
|
|
27
|
+
POWER_TOUGHNESS.match?(name.to_s) ? name.to_s : name.to_s.downcase.tr(" -", "__")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid?(name) = name.is_a?(String) && NAME.match?(name)
|
|
31
|
+
|
|
32
|
+
# A counter name from card data, normalized; an invalid one is reported at `path`.
|
|
33
|
+
def parse(raw, errors, path)
|
|
34
|
+
name = normalize(raw)
|
|
35
|
+
message = error(name)
|
|
36
|
+
errors << "#{path}: #{message}" if message
|
|
37
|
+
name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Error message for an invalid counter name, or nil.
|
|
41
|
+
def error(name)
|
|
42
|
+
return nil if valid?(name)
|
|
43
|
+
|
|
44
|
+
"must be a counter name: +X/+Y, -X/-Y or a lowercase word (e.g. +1/+1, stun, shield, oil)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [power delta, toughness delta] from all power/toughness counters (CR 122.1a, 613.4c).
|
|
48
|
+
def power_toughness(counters)
|
|
49
|
+
counters.each_with_object([0, 0]) do |(name, n), delta|
|
|
50
|
+
m = POWER_TOUGHNESS.match(name) or next
|
|
51
|
+
delta[0] += Integer(m[1]) * n
|
|
52
|
+
delta[1] += Integer(m[2]) * n
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Keywords granted by keyword counters (CR 122.1b).
|
|
57
|
+
def keywords(counters)
|
|
58
|
+
counters.filter_map do |name, n|
|
|
59
|
+
name.to_sym if n.positive? && KEYWORDS.include?(name.to_sym) && Enjine.registry.fetch(:keyword, name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def any?(counters) = counters.any? { |_, n| n.positive? }
|
|
64
|
+
|
|
65
|
+
# Whether `name` is a keyword counter (CR 122.1b).
|
|
66
|
+
def keyword?(name) = KEYWORDS.include?(name.to_s.to_sym)
|
|
67
|
+
|
|
68
|
+
# Replacement / prevention effects created by the counters on the object a proposal affects.
|
|
69
|
+
def replace!(engine, proposal)
|
|
70
|
+
id = proposal.data[proposal.kind == "damage" ? "target" : "object"]
|
|
71
|
+
obj = id && engine.state.objects[id]
|
|
72
|
+
return unless obj && obj.zone == :battlefield
|
|
73
|
+
|
|
74
|
+
if proposal.kind == "damage" && engine.damage_batch&.include?(obj.id) # its shield already applied (CR 615.12a)
|
|
75
|
+
proposal.data["amount"] = 0 unless proposal.data["unpreventable"]
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
obj.counters.each do |name, n|
|
|
80
|
+
kind = Enjine.registry.fetch(:counter, name)
|
|
81
|
+
next unless n.positive? && kind.respond_to?(:replace!) && !proposal.applied.include?("counter:#{name}")
|
|
82
|
+
|
|
83
|
+
proposal.applied << "counter:#{name}" # CR 614.5: each replacement applies once
|
|
84
|
+
kind.replace!(engine, obj, proposal)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A counter kind with rules (documentation and behavior).
|
|
89
|
+
class Kind < Primitive
|
|
90
|
+
def self.build(_params, _errors, _path, **) = self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# +1/+1 counters (CR 122.1a). Annihilate with -1/-1 counters as a state-based action (CR 704.5q).
|
|
94
|
+
class PlusOne < Kind
|
|
95
|
+
primitive :counter, :"+1/+1",
|
|
96
|
+
description: "Adds 1 to power and toughness (layer 7c). Any +X/+Y or -X/-Y counter works the same " \
|
|
97
|
+
"way. If a permanent has both +1/+1 and -1/-1 counters, N of each are removed as a " \
|
|
98
|
+
"state-based action (N = the smaller count).",
|
|
99
|
+
rules: %w[122.1a 122.3 613.4c 704.5q],
|
|
100
|
+
example: { "add_counters" => { "counter" => "+1/+1", "amount" => 1, "to" => "self" } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# -1/-1 counters (CR 122.1a).
|
|
104
|
+
class MinusOne < Kind
|
|
105
|
+
primitive :counter, :"-1/-1", description: "Subtracts 1 from power and toughness (layer 7c).",
|
|
106
|
+
rules: %w[122.1a 122.3 613.4c 704.5q],
|
|
107
|
+
example: { "add_counters" => { "counter" => "-1/-1", "amount" => 1, "to" => { "target" => 0 } } }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Loyalty counters (CR 122.1e, 306.5b).
|
|
111
|
+
class Loyalty < Kind
|
|
112
|
+
primitive :counter, :loyalty, description: "A planeswalker's loyalty; at 0 it is put into its owner's graveyard.",
|
|
113
|
+
rules: %w[122.1e 306.5b 704.5i],
|
|
114
|
+
example: { "add_counters" => { "counter" => "loyalty", "amount" => 1, "to" => "self" } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Keyword counters (CR 122.1b). Registered once under the name "keyword" for documentation;
|
|
118
|
+
# the counter itself is named after its keyword ("flying", "hexproof", "first_strike", ...).
|
|
119
|
+
class KeywordCounter < Kind
|
|
120
|
+
primitive :counter, :keyword,
|
|
121
|
+
description: "A counter named after a keyword (#{KEYWORDS.join(", ")}) gives the permanent that " \
|
|
122
|
+
"keyword (layer 6). Write the keyword itself as the counter name.",
|
|
123
|
+
rules: %w[122.1b 613.1f],
|
|
124
|
+
example: { "add_counters" => { "counter" => "hexproof", "amount" => 1, "to" => "self" } }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Stun counters (CR 122.1d): "If a permanent with a stun counter on it would become untapped,
|
|
128
|
+
# instead remove a stun counter from it."
|
|
129
|
+
class Stun < Kind
|
|
130
|
+
primitive :counter, :stun,
|
|
131
|
+
description: "If the permanent would become untapped (untap step or an effect), instead one stun " \
|
|
132
|
+
"counter is removed from it.",
|
|
133
|
+
rules: %w[122.1d 502.3 701.26b],
|
|
134
|
+
example: { "add_counters" => { "counter" => "stun", "amount" => 1, "to" => { "target" => 0 } } }
|
|
135
|
+
|
|
136
|
+
def self.replace!(engine, obj, proposal)
|
|
137
|
+
return unless proposal.kind == "untap"
|
|
138
|
+
|
|
139
|
+
proposal.data["cancelled"] = true
|
|
140
|
+
engine.remove_counters!(obj, "stun", 1)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Finality counters (CR 122.1h): "If this permanent would be put into a graveyard from the
|
|
145
|
+
# battlefield, exile it instead."
|
|
146
|
+
class Finality < Kind
|
|
147
|
+
primitive :counter, :finality,
|
|
148
|
+
description: "If the permanent would be put into a graveyard from the battlefield, it is exiled instead.",
|
|
149
|
+
rules: %w[122.1h 614.1a],
|
|
150
|
+
example: { "enters_with_counters" => { "counters" => { "finality" => 1 } } }
|
|
151
|
+
|
|
152
|
+
def self.replace!(_engine, _obj, proposal)
|
|
153
|
+
proposal.data["to"] = :exile if proposal.kind == "zone_change" && proposal.data["to"] == :graveyard
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Shield counters (CR 122.1c): one replacement effect ("if it would be destroyed as the result of
|
|
158
|
+
# an effect, instead remove a shield counter") and one prevention effect ("if damage would be dealt
|
|
159
|
+
# to it, prevent that damage and remove a shield counter").
|
|
160
|
+
class Shield < Kind
|
|
161
|
+
primitive :counter, :shield,
|
|
162
|
+
description: "If the permanent would be destroyed by an effect, instead remove a shield counter. If " \
|
|
163
|
+
"damage would be dealt to it, prevent it and remove a shield counter (simultaneous " \
|
|
164
|
+
"damage, such as combat damage, removes only one).",
|
|
165
|
+
rules: %w[122.1c 614.1a 615.1a 701.8b],
|
|
166
|
+
example: { "add_counters" => { "counter" => "shield", "amount" => 1, "to" => { "target" => 0 } } }
|
|
167
|
+
|
|
168
|
+
def self.replace!(engine, obj, proposal)
|
|
169
|
+
case proposal.kind
|
|
170
|
+
when "zone_change"
|
|
171
|
+
return unless proposal.data["reason"] == "destroy" # CR 701.8b: lethal-damage SBAs aren't "an effect"
|
|
172
|
+
|
|
173
|
+
proposal.data["cancelled"] = true
|
|
174
|
+
engine.remove_counters!(obj, "shield", 1)
|
|
175
|
+
when "damage"
|
|
176
|
+
prevent_damage!(engine, obj, proposal)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# CR 615.1a. Damage dealt at the same time by several sources is one event: one counter. Damage that
|
|
181
|
+
# can't be prevented is dealt, and the counter is still removed (CR 615.12).
|
|
182
|
+
def self.prevent_damage!(engine, obj, proposal)
|
|
183
|
+
proposal.data["amount"] = 0 unless proposal.data["unpreventable"]
|
|
184
|
+
engine.damage_batch&.push(obj.id)
|
|
185
|
+
engine.remove_counters!(obj, "shield", 1)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "Until end of turn, they become Warriors in addition to their other types." (CR 613.1d, 205.1b)
|
|
7
|
+
class AddSubtypes < Effect
|
|
8
|
+
primitive :effect, :add_subtypes,
|
|
9
|
+
description: "Each referenced permanent gets the `subtypes` in addition to its other types until " \
|
|
10
|
+
"end of turn (layer 4).",
|
|
11
|
+
rules: %w[613.1d 205.1b 611.2a 611.2c],
|
|
12
|
+
params: { "subtypes" => "list of lowercase subtypes", "to" => "selector" },
|
|
13
|
+
example: { "add_subtypes" => { "subtypes" => ["warrior"], "to" => { "target" => 0 } } }
|
|
14
|
+
|
|
15
|
+
attr_reader :subtypes
|
|
16
|
+
|
|
17
|
+
def self.keys = %w[subtypes to if]
|
|
18
|
+
|
|
19
|
+
def setup(params, errors, path)
|
|
20
|
+
@subtypes = Mechanics.list_param(params, :subtypes, errors, path).map(&:to_s)
|
|
21
|
+
ok = !@subtypes.empty? && @subtypes.all? { CardDefinition::SUBTYPE_FORMAT.match?(it) }
|
|
22
|
+
errors << "#{path}.subtypes: must be a non-empty list of lowercase subtypes" unless ok
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply(engine, ctx) = engine.add_continuous_effect!("add_subtypes", ctx, objects(engine, ctx), "subtypes" => subtypes)
|
|
26
|
+
def to_s = "becomes #{subtypes.join(" ")} in addition until end of turn → #{to}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Airbend (CR 701.65a): "exile those objects. For each card exiled this way, for as long as it remains
|
|
7
|
+
# exiled, its owner may cast it by paying {2} rather than paying its mana cost." The permanents and spells
|
|
8
|
+
# are the referenced ones (usually targets); each exiled card gets a permission for its owner whose
|
|
9
|
+
# alternative cost is {2} (CR 118.9, Engine::Permissions). A token or a copy of a spell ceases to exist
|
|
10
|
+
# (CR 111.7, 707.10a), so it can't be cast. Exiling one or more objects this way is airbending (CR 701.65b).
|
|
11
|
+
class Airbend < ZoneEffect
|
|
12
|
+
COST = "{2}"
|
|
13
|
+
|
|
14
|
+
primitive :effect, :airbend,
|
|
15
|
+
description: "Airbend: exile each referenced permanent or spell; for as long as each card stays " \
|
|
16
|
+
"exiled, its owner may cast it by paying {2} rather than its mana cost (cast option " \
|
|
17
|
+
"`via: \"effect:<n>\"`). The exiled cards are the effect's results.",
|
|
18
|
+
rules: %w[701.65a 701.65b 118.9 601.2f],
|
|
19
|
+
params: { "to" => "selector of permanents and/or spells" },
|
|
20
|
+
example: { "airbend" => { "to" => { "target" => 0 } } }
|
|
21
|
+
|
|
22
|
+
def self.zone = :exile
|
|
23
|
+
def self.reason = "airbend"
|
|
24
|
+
def self.from_zones = %i[battlefield stack]
|
|
25
|
+
|
|
26
|
+
def to_s = "airbend → #{to}"
|
|
27
|
+
|
|
28
|
+
def apply(engine, ctx)
|
|
29
|
+
moved = movable(engine, ctx).filter_map { move(engine, ctx, it) }
|
|
30
|
+
return if moved.empty?
|
|
31
|
+
|
|
32
|
+
moved.select { it.zone == :exile }.group_by(&:owner).each do |owner, cards|
|
|
33
|
+
engine.grant_play_permission!(owner, cards.map(&:id), duration: "while_exiled", play: "cast",
|
|
34
|
+
alt: { "cost" => COST })
|
|
35
|
+
end
|
|
36
|
+
engine.emit Events::KeywordAction.new(player: engine.name(ctx.controller), action: "airbend", amount: moved.size)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "If you don't control a [kind of permanent], create one as a token. Choose one you control. Put N
|
|
7
|
+
# [counters] on it.": the shared shape of amass (CR 701.47a) and empower Jace (CR 701.71a). The choice
|
|
8
|
+
# is an `effect_choice` only when there is more than one candidate. The chosen permanent is the
|
|
9
|
+
# effect's result ("the amassed Army", CR 701.47c); a token just created already is.
|
|
10
|
+
class ChooseOrCreate < ChoiceEffect
|
|
11
|
+
def self.keys = %w[amount if]
|
|
12
|
+
def self.default_to = "you"
|
|
13
|
+
|
|
14
|
+
def run(engine, ctx, locals)
|
|
15
|
+
return :done unless locals.key?("n") || condition.nil? || condition.true?(engine, ctx)
|
|
16
|
+
|
|
17
|
+
unless locals.key?("n")
|
|
18
|
+
locals["n"] = amount_for(engine, ctx)
|
|
19
|
+
locals["created"] = candidates(engine, ctx.controller).empty?
|
|
20
|
+
engine.create_token!(token, ctx.controller) if locals["created"]
|
|
21
|
+
end
|
|
22
|
+
options = candidates(engine, ctx.controller)
|
|
23
|
+
answer = answers(locals).first
|
|
24
|
+
if answer.nil? && options.size > 1
|
|
25
|
+
return ask(engine, locals, ctx.controller, "#{self}: choose one",
|
|
26
|
+
options.map { { "type" => "choose", "object" => it } })
|
|
27
|
+
end
|
|
28
|
+
chosen = answer ? answer["object"] : options.first
|
|
29
|
+
finish(engine, ctx, chosen, locals) if chosen
|
|
30
|
+
:done
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def created_tokens = [token]
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def candidates(engine, index)
|
|
38
|
+
engine.state.battlefield.select { engine.object(it).controller == index && candidate?(engine, engine.object(it)) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def finish(engine, ctx, chosen, locals)
|
|
42
|
+
perm = engine.object(chosen)
|
|
43
|
+
engine.record_result!(chosen) unless locals["created"]
|
|
44
|
+
engine.add_counters!(perm, counter, locals["n"], by_player: ctx.controller)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Amass [subtype] N (CR 701.47a): if you don't control an Army creature, create a 0/0 black [subtype]
|
|
49
|
+
# Army creature token; choose an Army creature you control; put N +1/+1 counters on it; if it isn't a
|
|
50
|
+
# [subtype], it becomes one in addition to its other types (layer 4, for as long as it stays).
|
|
51
|
+
class Amass < ChooseOrCreate
|
|
52
|
+
primitive :effect, :amass,
|
|
53
|
+
description: "Amass `subtype` `amount`: if you control no Army creature, create a 0/0 black " \
|
|
54
|
+
"[subtype] Army creature token; then put `amount` +1/+1 counters on an Army creature " \
|
|
55
|
+
"you control (chosen if there are several), which becomes a [subtype] too.",
|
|
56
|
+
rules: %w[701.47a 701.47b 701.47c 613.1d],
|
|
57
|
+
params: { "subtype" => "lowercase creature type, e.g. \"orc\"", "amount" => "amount" },
|
|
58
|
+
example: { "amass" => { "subtype" => "orc", "amount" => 2 } }
|
|
59
|
+
|
|
60
|
+
attr_reader :subtype, :token
|
|
61
|
+
|
|
62
|
+
def self.keys = %w[subtype amount if]
|
|
63
|
+
|
|
64
|
+
def setup(params, errors, path)
|
|
65
|
+
@subtype = Mechanics.fetch_param(params, :subtype).to_s
|
|
66
|
+
unless CardDefinition::SUBTYPE_FORMAT.match?(@subtype) && @subtype != "army"
|
|
67
|
+
errors << "#{path}.subtype: must be a lowercase creature type other than army"
|
|
68
|
+
@subtype = "zombie"
|
|
69
|
+
end
|
|
70
|
+
@token = CardDefinition.token({ "name" => "#{@subtype.split("_").map(&:capitalize).join(" ")} Army",
|
|
71
|
+
"types" => ["creature"], "subtypes" => [@subtype, "army"], "colors" => ["B"],
|
|
72
|
+
"power" => 0, "toughness" => 0 })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def counter = "+1/+1"
|
|
76
|
+
def to_s = "amass #{subtype.tr("_", " ")} #{amount}"
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def candidate?(engine, perm)
|
|
81
|
+
chars = engine.chars(perm)
|
|
82
|
+
chars.creature? && chars.subtypes.include?(:army)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def finish(engine, ctx, chosen, locals)
|
|
86
|
+
super
|
|
87
|
+
return if engine.chars(chosen).subtypes.include?(subtype.to_sym)
|
|
88
|
+
|
|
89
|
+
engine.add_continuous_effect!("add_subtypes", ctx, [chosen], "subtypes" => [subtype], "duration" => "permanent")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Empower Jace N (CR 701.71a): if you don't control a Jace planeswalker token, create a blue Jace
|
|
94
|
+
# planeswalker token with 0 loyalty, "[-1]: Surveil 1" and "[-3]: Draw a card"; choose a Jace
|
|
95
|
+
# planeswalker token you control; put N loyalty counters on it. State-based actions aren't checked
|
|
96
|
+
# while the effect resolves, so the new token gets its counters first (CR 704.3).
|
|
97
|
+
class EmpowerJace < ChooseOrCreate
|
|
98
|
+
primitive :effect, :empower_jace,
|
|
99
|
+
description: "Empower Jace `amount`: if you control no Jace planeswalker token, create a blue Jace " \
|
|
100
|
+
"planeswalker token (0 loyalty; -1: surveil 1; -3: draw a card); then put `amount` " \
|
|
101
|
+
"loyalty counters on a Jace planeswalker token you control.",
|
|
102
|
+
rules: %w[701.71a 704.3],
|
|
103
|
+
params: { "amount" => "amount" },
|
|
104
|
+
example: { "empower_jace" => { "amount" => 2 } }
|
|
105
|
+
|
|
106
|
+
TOKEN = { "name" => "Jace", "types" => ["planeswalker"], "subtypes" => ["jace"], "colors" => ["U"], "loyalty" => 0,
|
|
107
|
+
"abilities" => [{ "loyalty" => { "cost" => -1, "effects" => [{ "surveil" => { "amount" => 1 } }] } },
|
|
108
|
+
{ "loyalty" => { "cost" => -3, "effects" => [{ "draw" => { "amount" => 1 } }] } }] }.freeze
|
|
109
|
+
|
|
110
|
+
def token = EmpowerJace.token
|
|
111
|
+
def self.token = @token ||= CardDefinition.token(TOKEN)
|
|
112
|
+
def counter = "loyalty"
|
|
113
|
+
def to_s = "empower Jace #{amount}"
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def candidate?(engine, perm)
|
|
118
|
+
chars = engine.chars(perm)
|
|
119
|
+
engine.token?(perm) && chars.planeswalker? && chars.subtypes.include?(:jace)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Assimilate (CR 701.72). A permanent: gain control of it and put a
|
|
7
|
+
# +1/+1 counter on it (701.72a). A permanent card in another zone: put it onto the battlefield under
|
|
8
|
+
# your control with a +1/+1 counter (701.72b). Either way it's a Borg artifact creature from then on:
|
|
9
|
+
# it keeps its other card types and subtypes except its creature types (CR 205.1b).
|
|
10
|
+
#
|
|
11
|
+
# `choose: true` assimilates one of the referenced cards, chosen by the controller as it resolves
|
|
12
|
+
# ("assimilate a creature card exiled with it", 701.72c, 607.2a); `for` keeps only cards matching a
|
|
13
|
+
# card spec. What it put onto the battlefield is its result, so a later effect can say "if you can't".
|
|
14
|
+
class Assimilate < ChoiceEffect
|
|
15
|
+
primitive :effect, :assimilate,
|
|
16
|
+
description: "Assimilate each referenced permanent or permanent card: you gain control of it (or " \
|
|
17
|
+
"put it onto the battlefield under your control) with a +1/+1 counter, and it " \
|
|
18
|
+
"becomes a Borg artifact creature while it stays on the battlefield. `for`: only cards " \
|
|
19
|
+
"matching a card spec; `choose: true`: you choose one of them.",
|
|
20
|
+
rules: %w[701.72a 701.72b 701.72c 607.2a 205.1b 613.1b 613.1d 614.12a],
|
|
21
|
+
params: { "to" => "selector (permanents, or a target card in a graveyard, or \"exiled_with\")",
|
|
22
|
+
"for" => "card spec (default: any)", "choose" => "boolean" },
|
|
23
|
+
example: { "assimilate" => { "to" => { "target" => 0 } } }
|
|
24
|
+
|
|
25
|
+
def self.keys = %w[to for choose if]
|
|
26
|
+
|
|
27
|
+
def setup(params, errors, path)
|
|
28
|
+
raw = Mechanics.fetch_param(params, :for)
|
|
29
|
+
@spec = raw && CardSpec.build(raw, errors, "#{path}.for")
|
|
30
|
+
@choose = Mechanics.flag_param(params, "choose", errors, path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s = "assimilate#{" one" if @choose}#{" #{@spec}" if @spec} → #{to}"
|
|
34
|
+
|
|
35
|
+
def run(engine, ctx, locals)
|
|
36
|
+
return :done unless locals.key?("refs") || condition.nil? || condition.true?(engine, ctx)
|
|
37
|
+
|
|
38
|
+
refs = (locals["refs"] ||= candidates(engine, ctx))
|
|
39
|
+
unless locals.key?("chosen")
|
|
40
|
+
return ask_which(engine, ctx, locals, refs) if @choose && refs.size > 1 && answers(locals).empty?
|
|
41
|
+
|
|
42
|
+
picked = answers(locals).map { it["object"] }
|
|
43
|
+
picked = refs.first(1) if picked.empty?
|
|
44
|
+
locals["chosen"] = @choose ? picked : refs
|
|
45
|
+
end
|
|
46
|
+
chosen = locals["chosen"].select { engine.state.objects.key?(it) }
|
|
47
|
+
entering = chosen.reject { engine.object(it).zone == :battlefield }.map { [it, ctx.controller] }
|
|
48
|
+
return :wait if engine.ask_entering_choices!(locals, entering) == :wait
|
|
49
|
+
|
|
50
|
+
chosen.each do |id|
|
|
51
|
+
engine.assimilate!(id, ctx.controller, ctx, enters: engine.with_entering_answer(locals, id, ctx.controller, nil))
|
|
52
|
+
end
|
|
53
|
+
:done
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def candidates(engine, ctx)
|
|
59
|
+
refs(engine, ctx).select do |id|
|
|
60
|
+
obj = engine.state.objects[id]
|
|
61
|
+
obj && !obj.ability? && (@spec.nil? || @spec.match?(engine, obj, ctx))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ask_which(engine, ctx, locals, refs)
|
|
66
|
+
ask(engine, locals, ctx.controller, "Choose a card to assimilate",
|
|
67
|
+
refs.map { { "type" => "assimilate", "object" => it } })
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|