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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "You may [effects]. If you do, [then]. If you don't, [otherwise]." (CR 118.12): the controller
|
|
7
|
+
# answers yes or no while it resolves (an effect_choice). Yes is offered only when every effect
|
|
8
|
+
# in `effects` can be done fully, as for any cost (CR 118.3). `then` follows a yes and
|
|
9
|
+
# `otherwise` a no, whatever the effects actually did.
|
|
10
|
+
class May < ChoiceEffect
|
|
11
|
+
include NestedList
|
|
12
|
+
|
|
13
|
+
LISTS = %w[effects then otherwise].freeze
|
|
14
|
+
|
|
15
|
+
primitive :effect, :may,
|
|
16
|
+
description: "An optional effect. The controller chooses yes or no as it resolves; yes is offered only " \
|
|
17
|
+
"if every effect in `effects` can be done fully. On yes, `effects` then `then` (\"if you " \
|
|
18
|
+
"do\") happen; on no, `otherwise` (\"if you don't\"). Nested effects may name the results " \
|
|
19
|
+
"of earlier effects of the enclosing list.",
|
|
20
|
+
rules: %w[118.12 118.3 608.2d 608.2e],
|
|
21
|
+
params: { "effects" => "list of effects (the optional part)", "then" => "list of effects",
|
|
22
|
+
"otherwise" => "list of effects",
|
|
23
|
+
"chooser" => "who decides (default: the controller): a selector naming a player, or a " \
|
|
24
|
+
"permanent whose controller decides (e.g. {\"target\": 0}), or " \
|
|
25
|
+
"\"active_player\"" },
|
|
26
|
+
example: { "may" => { "effects" => [{ "discard" => { "amount" => 1 } }],
|
|
27
|
+
"then" => [{ "draw" => { "amount" => 1 } }] } }
|
|
28
|
+
|
|
29
|
+
def self.keys = LISTS + %w[if chooser]
|
|
30
|
+
def self.default_to = "you"
|
|
31
|
+
|
|
32
|
+
def setup(params, errors, path)
|
|
33
|
+
@lists = parse_lists(params, errors, path, LISTS)
|
|
34
|
+
errors << "#{path}.effects: needs at least one effect" if @lists["effects"].empty?
|
|
35
|
+
raw = Mechanics.fetch_param(params, :chooser)
|
|
36
|
+
@chooser = if raw.nil? || raw == "active_player" then raw
|
|
37
|
+
else Selector.build(raw, errors, "#{path}.chooser", targets_count: @targets_count, results_count: @results_count)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def effects = @lists["effects"]
|
|
42
|
+
def nested_effects = @lists.values.flatten
|
|
43
|
+
|
|
44
|
+
# CR 608.2e: an effect may have a player other than its controller make a choice. The chooser is
|
|
45
|
+
# the named player, or the controller of the named permanent; nobody legal: the controller.
|
|
46
|
+
def chooser_index(engine, ctx)
|
|
47
|
+
return ctx.controller if @chooser.nil?
|
|
48
|
+
return engine.state.active if @chooser == "active_player"
|
|
49
|
+
|
|
50
|
+
ref = @chooser.resolve(engine, ctx).first
|
|
51
|
+
Filters.player_index(engine, ref) || engine.state.objects[ref]&.controller || ctx.controller
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
text = "#{@chooser ? "#{@chooser} may have" : "you may"}: #{effects.join("; ")}"
|
|
56
|
+
text += " — if you do: #{@lists["then"].join("; ")}" unless @lists["then"].empty?
|
|
57
|
+
text += " — if you don't: #{@lists["otherwise"].join("; ")}" unless @lists["otherwise"].empty?
|
|
58
|
+
text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run(engine, ctx, locals)
|
|
62
|
+
unless locals["chose"]
|
|
63
|
+
return :done unless condition.nil? || condition.true?(engine, ctx)
|
|
64
|
+
|
|
65
|
+
answer = answers(locals).first or return ask_yes_no(engine, ctx, locals)
|
|
66
|
+
locals["chose"] = answer["type"]
|
|
67
|
+
locals["next"] = 0
|
|
68
|
+
end
|
|
69
|
+
run_list(engine, ctx, locals, locals["chose"] == "yes" ? effects + @lists["then"] : @lists["otherwise"])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def ask_yes_no(engine, ctx, locals)
|
|
75
|
+
choices = [{ "type" => "no" }]
|
|
76
|
+
choices.unshift({ "type" => "yes" }) if effects.all? { it.possible?(engine, ctx) }
|
|
77
|
+
ask(engine, locals, chooser_index(engine, ctx), "You may: #{effects.join("; ")}", choices)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "Exile them, then meld them into [back face]" (CR 701.42a, 713.1a): the source, and a creature named
|
|
7
|
+
# `with` that its controller both owns and controls, are exiled; if the two are a meld pair (CR 701.42b)
|
|
8
|
+
# they come back as one permanent with the combined back face up (Engine::Faces#meld!), else they stay
|
|
9
|
+
# exiled (CR 701.42c). The melded permanent is its result.
|
|
10
|
+
class Meld < Effect
|
|
11
|
+
primitive :effect, :meld,
|
|
12
|
+
description: "Exile this permanent and a creature named `with` that you both own and control, then " \
|
|
13
|
+
"meld them: a meld pair (cards with layout \"meld\" sharing a back face) enters the " \
|
|
14
|
+
"battlefield as one permanent with that back face up. When it leaves the battlefield, " \
|
|
15
|
+
"both cards go to the new zone.",
|
|
16
|
+
rules: %w[701.42a 701.42b 701.42c 713.1a 713.6 713.8],
|
|
17
|
+
params: { "with" => "the name of the other card of the pair" },
|
|
18
|
+
example: { "meld" => { "with" => "Fang, Fearless l'Cie" } }
|
|
19
|
+
|
|
20
|
+
def self.keys = %w[with if]
|
|
21
|
+
def self.default_to = "self"
|
|
22
|
+
|
|
23
|
+
def setup(params, errors, path)
|
|
24
|
+
@with = Mechanics.fetch_param(params, :with)
|
|
25
|
+
errors << "#{path}.with: must be a card name" unless @with.is_a?(String) && !@with.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def apply(engine, ctx)
|
|
29
|
+
pair = [Condition::MeldWith.own(engine, ctx.controller, ctx.source),
|
|
30
|
+
Condition::MeldWith.partner(engine, ctx.controller, @with)]
|
|
31
|
+
exiled = pair.compact.filter_map { engine.move!(it, :exile, reason: "meld") }
|
|
32
|
+
engine.meld!(*exiled, ctx.controller) if exiled.size == 2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s = "exile this and #{@with}, then meld them"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Condition
|
|
40
|
+
# "If you both own and control [this] and a creature named [name]" (the meld cards' condition, CR 713.1a):
|
|
41
|
+
# `{"meld_with": name}`.
|
|
42
|
+
class MeldWith
|
|
43
|
+
def self.build(data, errors, path)
|
|
44
|
+
Mechanics.check_keys(data, %w[meld_with], errors, path)
|
|
45
|
+
name = data["meld_with"]
|
|
46
|
+
errors << "#{path}.meld_with: must be a card name" unless name.is_a?(String) && !name.empty?
|
|
47
|
+
new(name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The source, if it's a permanent `index` owns and controls.
|
|
51
|
+
def self.own(engine, index, id)
|
|
52
|
+
obj = engine.state.objects[id]
|
|
53
|
+
id if obj&.zone == :battlefield && obj.owner == index && obj.controller == index
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A creature named `name` that `index` owns and controls.
|
|
57
|
+
def self.partner(engine, index, name)
|
|
58
|
+
engine.state.battlefield.find do |id|
|
|
59
|
+
obj = engine.object(id)
|
|
60
|
+
obj.owner == index && obj.controller == index && (c = engine.chars(obj)).creature? && c.name == name
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(name)
|
|
65
|
+
@name = name
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def true?(engine, ctx)
|
|
70
|
+
!MeldWith.own(engine, ctx.controller, ctx.source).nil? && !MeldWith.partner(engine, ctx.controller, @name).nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_s = "you both own and control this and a creature named #{@name}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Mill N cards (CR 701.17).
|
|
7
|
+
class Mill < Effect
|
|
8
|
+
primitive :effect, :mill, description: "Each referenced player puts the top `amount` cards of their library " \
|
|
9
|
+
"into their graveyard (default: you).",
|
|
10
|
+
rules: %w[701.17a], params: { "amount" => "integer", "to" => "selector" },
|
|
11
|
+
example: { "mill" => { "amount" => 2, "to" => "each_opponent" } }
|
|
12
|
+
def self.default_to = "you"
|
|
13
|
+
|
|
14
|
+
def apply(engine, ctx)
|
|
15
|
+
players(engine, ctx).each do |index|
|
|
16
|
+
engine.player(index).library.first(amount_for(engine, ctx)).each { engine.move!(it, :graveyard, reason: "mill") }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Paying a cost while a spell or ability resolves (CR 118.12): any costs (mana, life, discarding,
|
|
7
|
+
# sacrificing, poison counters, a choice between costs). The payer is asked whether to pay — one choice per
|
|
8
|
+
# way the costs could be paid now (CR 118.3), so a `one_of` is chosen with it — then makes the cost's choices
|
|
9
|
+
# (which card to discard) one `effect_choice` at a time, then pays the mana in a "pay" frame (mana abilities
|
|
10
|
+
# allowed, CR 605.3a); declining or cancelling that payment counts as not paying, and nothing is paid (a card
|
|
11
|
+
# revealed to behold stays revealed, a permanent tapped for a cost stays tapped). The rest is paid once the
|
|
12
|
+
# mana is.
|
|
13
|
+
module PaysCosts
|
|
14
|
+
# The costs given as `cost` (a list), or as the shorthands `mana`, `life` and `waterbend`.
|
|
15
|
+
def parse_payment(params, errors, path)
|
|
16
|
+
keys = %w[mana life waterbend cost] & self.class.keys
|
|
17
|
+
given = keys.reject { Mechanics.fetch_param(params, it).nil? }
|
|
18
|
+
errors << "#{path}: needs exactly one of #{keys.join(", ")}" unless given.size == 1
|
|
19
|
+
raw = given.first && Mechanics.fetch_param(params, given.first)
|
|
20
|
+
case given.first
|
|
21
|
+
when "cost"
|
|
22
|
+
Abilities::StackAbility.parse_costs(params, errors, path).tap do
|
|
23
|
+
errors << "#{path}.cost: must be a non-empty list" if it.empty?
|
|
24
|
+
end
|
|
25
|
+
when "life"
|
|
26
|
+
return [Costs::PayLife.new(raw)].freeze if raw.is_a?(Integer) && raw >= 1
|
|
27
|
+
|
|
28
|
+
(errors << "#{path}.life: must be an integer >= 1") && [].freeze
|
|
29
|
+
when nil then [].freeze
|
|
30
|
+
else [Mechanics.build_entry(:cost, { given.first => raw }, errors, path)].compact.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Each way of paying `costs` open to the payer now: its other parts payable for `payer_obj` (an object
|
|
35
|
+
# the payer controls, CR 118.3) and its mana affordable.
|
|
36
|
+
def open_variants(engine, payer_obj, costs)
|
|
37
|
+
engine.cost_variants(costs).each_index.select do |i|
|
|
38
|
+
variant = engine.cost_variants(costs)[i]
|
|
39
|
+
variant.reject(&:mana?).all? { it.payable?(engine, payer_obj) } &&
|
|
40
|
+
engine.affordable?(payer_obj.controller, engine.costs_mana(variant), nil, engine.waterbend_purpose(variant))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `{type: "pay"}` (with `option` and `cost` when the costs can be paid more than one way) for each open way.
|
|
45
|
+
def pay_choices(engine, payer_obj, costs)
|
|
46
|
+
ways = engine.cost_variants(costs)
|
|
47
|
+
open_variants(engine, payer_obj, costs).map do |i|
|
|
48
|
+
ways.size == 1 ? { "type" => "pay" } : { "type" => "pay", "option" => i, "cost" => ways[i].join(", ") }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# "{2}", "3 life", "discard a card or {2}".
|
|
53
|
+
def costs_text(costs) = costs.map { it.to_s.gsub(/(\A| or )[Pp]ay /, "\\1").sub(/\A\w/, &:downcase) }.join(", ")
|
|
54
|
+
|
|
55
|
+
# After a "pay" answer: makes the choices, pays the mana, then the rest. :wait while asking or paying
|
|
56
|
+
# mana; otherwise "paid" or "unpaid".
|
|
57
|
+
def pay_costs(engine, ctx, locals, payer_obj, costs)
|
|
58
|
+
paying = locals["paying"] ||= { "variant" => answers(locals).last["option"].to_i, "entries" => {},
|
|
59
|
+
"seen" => answers(locals).size }
|
|
60
|
+
variant = engine.cost_variants(costs)[paying["variant"]]
|
|
61
|
+
if answers(locals).size > paying["seen"]
|
|
62
|
+
paying["seen"] = answers(locals).size
|
|
63
|
+
answer = answers(locals).last
|
|
64
|
+
return "unpaid" if paying.delete("mana") && answer["type"] != "paid"
|
|
65
|
+
|
|
66
|
+
take_choice(engine, paying, variant, answer) if paying["cost"]
|
|
67
|
+
end
|
|
68
|
+
choose_next(engine, locals, paying, variant, payer_obj) || pay_variant(engine, ctx, paying, variant, payer_obj)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def take_choice(engine, paying, variant, answer)
|
|
74
|
+
i = paying.delete("cost")
|
|
75
|
+
(paying["entries"][i.to_s] ||= []) << variant[i].choose!(engine, { object: answer["object"] })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Asks for the next choice a cost needs (CR 601.2h, as for casting); nil when none is left.
|
|
79
|
+
def choose_next(engine, locals, paying, variant, payer_obj)
|
|
80
|
+
variant.each_with_index do |cost, i|
|
|
81
|
+
next unless cost.respond_to?(:choices_needed)
|
|
82
|
+
|
|
83
|
+
chosen = paying["entries"].fetch(i.to_s, [])
|
|
84
|
+
options = cost.choice_options(engine, payer_obj, chosen)
|
|
85
|
+
next if chosen.size >= cost.choices_needed || options.empty?
|
|
86
|
+
|
|
87
|
+
paying["cost"] = i
|
|
88
|
+
return ask(engine, locals, payer_obj.controller, cost.to_s, options.map { { "type" => "cost", "object" => it[:object] } })
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def pay_variant(engine, ctx, paying, variant, payer_obj)
|
|
94
|
+
mana = engine.costs_mana(variant)
|
|
95
|
+
if mana.mana_value.positive? && !paying["mana_paid"]
|
|
96
|
+
paying["mana"] = paying["mana_paid"] = true
|
|
97
|
+
bend = engine.waterbend_generic(variant)
|
|
98
|
+
engine.begin_payment!(payer_obj.controller, mana, ctx.object, waterbend: bend.positive? ? bend : nil)
|
|
99
|
+
return :wait
|
|
100
|
+
end
|
|
101
|
+
variant.each_with_index do |cost, i|
|
|
102
|
+
next if cost.mana?
|
|
103
|
+
|
|
104
|
+
if cost.respond_to?(:choices_needed)
|
|
105
|
+
cost.pay_chosen!(engine, payer_obj,
|
|
106
|
+
paying["entries"].fetch(i.to_s, []))
|
|
107
|
+
else
|
|
108
|
+
cost.pay!(engine, payer_obj)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
"paid"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# "[Player] may pay {1}. If they do, [then]. If they don't, [otherwise]." (CR 118.12): the payer answers
|
|
116
|
+
# pay or decline (pay only when the cost can be paid, CR 118.3), then pays (PaysCosts).
|
|
117
|
+
class Pay < ChoiceEffect
|
|
118
|
+
include NestedList
|
|
119
|
+
include PaysCosts
|
|
120
|
+
|
|
121
|
+
LISTS = %w[then otherwise].freeze
|
|
122
|
+
|
|
123
|
+
primitive :effect, :pay,
|
|
124
|
+
description: "You may pay `cost` (a list of costs: mana, life, discarding, sacrificing, a `one_of` " \
|
|
125
|
+
"choice, ...) as it resolves; `mana`, `life` and `waterbend` are shorthands for one cost. " \
|
|
126
|
+
"If you do, `then` happens; if you don't, `otherwise`. Paying mana works like paying a " \
|
|
127
|
+
"spell's cost (mana abilities may be activated; a `waterbend` cost may also be paid by " \
|
|
128
|
+
"tapping artifacts and creatures, CR 701.67a); a cost that can't be paid can't be " \
|
|
129
|
+
"chosen (CR 118.3). For another player paying, see `counter_unless_pays`.",
|
|
130
|
+
rules: %w[118.12 118.12a 118.3 119.4 605.3a 701.67a 701.67c],
|
|
131
|
+
params: { "cost" => "list of costs, e.g. [{\"one_of\": [{\"sacrifice_permanent\": {\"filter\": " \
|
|
132
|
+
"\"artifact\"}}, {\"discard\": {}}]}]",
|
|
133
|
+
"mana" => "mana cost string, e.g. \"{1}\"", "life" => "integer",
|
|
134
|
+
"waterbend" => "mana cost string: \"unless you waterbend {2}\"",
|
|
135
|
+
"then" => "list of effects",
|
|
136
|
+
"otherwise" => "list of effects" },
|
|
137
|
+
example: { "pay" => { "mana" => "{1}", "then" => [{ "draw" => { "amount" => 1 } }] } }
|
|
138
|
+
|
|
139
|
+
attr_reader :costs
|
|
140
|
+
|
|
141
|
+
def self.keys = %w[cost mana life waterbend then otherwise if]
|
|
142
|
+
def self.default_to = "you"
|
|
143
|
+
|
|
144
|
+
def setup(params, errors, path)
|
|
145
|
+
@costs = parse_payment(params, errors, path)
|
|
146
|
+
@lists = parse_lists(params, errors, path, LISTS)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def nested_effects = @lists.values.flatten
|
|
150
|
+
def what = costs_text(costs)
|
|
151
|
+
|
|
152
|
+
def to_s
|
|
153
|
+
text = "you may pay #{what}"
|
|
154
|
+
text += " — if paid: #{@lists["then"].join("; ")}" unless @lists["then"].empty?
|
|
155
|
+
text += " — if not: #{@lists["otherwise"].join("; ")}" unless @lists["otherwise"].empty?
|
|
156
|
+
text
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run(engine, ctx, locals)
|
|
160
|
+
unless locals["outcome"]
|
|
161
|
+
return :done unless condition.nil? || condition.true?(engine, ctx)
|
|
162
|
+
|
|
163
|
+
status = decide(engine, ctx, locals)
|
|
164
|
+
return status if status == :wait
|
|
165
|
+
|
|
166
|
+
locals["next"] = 0
|
|
167
|
+
end
|
|
168
|
+
run_list(engine, ctx, locals, @lists[locals["outcome"] == "paid" ? "then" : "otherwise"])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Asks pay or decline, then pays; returns :wait while a decision or the payment is pending.
|
|
174
|
+
def decide(engine, ctx, locals)
|
|
175
|
+
payer = engine.object(ctx.object)
|
|
176
|
+
unless locals["paying"] || answers(locals).first
|
|
177
|
+
choices = pay_choices(engine, payer, costs) + [{ "type" => "decline" }]
|
|
178
|
+
return ask(engine, locals, payer.controller, "Pay #{what}?", choices)
|
|
179
|
+
end
|
|
180
|
+
return locals["outcome"] = "unpaid" if answers(locals).first["type"] == "decline"
|
|
181
|
+
|
|
182
|
+
result = pay_costs(engine, ctx, locals, payer, costs)
|
|
183
|
+
result == :wait ? :wait : (locals["outcome"] = result)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# "Counter target spell unless its controller pays {3}." (CR 701.6a, 118.12a): the spell's (or
|
|
188
|
+
# ability's) controller may pay (PaysCosts); if they don't, it's countered.
|
|
189
|
+
class CounterUnlessPays < ChoiceEffect
|
|
190
|
+
include PaysCosts
|
|
191
|
+
|
|
192
|
+
primitive :effect, :counter_unless_pays,
|
|
193
|
+
description: "Counter each referenced spell or ability unless its controller pays `mana` (or " \
|
|
194
|
+
"`waterbend`s that cost, CR 701.67a, or pays the list of costs `cost`) as this " \
|
|
195
|
+
"resolves (they choose, and pay like any cost; mana abilities allowed).",
|
|
196
|
+
rules: %w[701.6a 118.12a 605.3a 701.67a],
|
|
197
|
+
params: { "mana" => "mana cost string", "waterbend" => "mana cost string, instead of mana",
|
|
198
|
+
"cost" => "list of costs, instead of mana", "to" => "selector (usually {\"target\": 0})" },
|
|
199
|
+
example: { "counter_unless_pays" => { "mana" => "{3}", "to" => { "target" => 0 } } }
|
|
200
|
+
|
|
201
|
+
def self.keys = %w[mana waterbend cost to if]
|
|
202
|
+
|
|
203
|
+
attr_reader :costs
|
|
204
|
+
|
|
205
|
+
def setup(params, errors, path) = @costs = parse_payment(params, errors, path)
|
|
206
|
+
|
|
207
|
+
def what = costs_text(costs)
|
|
208
|
+
def to_s = "counter #{to} unless its controller pays #{what}"
|
|
209
|
+
|
|
210
|
+
def run(engine, ctx, locals)
|
|
211
|
+
return :done unless locals["target"] || condition.nil? || condition.true?(engine, ctx)
|
|
212
|
+
|
|
213
|
+
target = (locals["target"] ||= on_stack(engine, ctx).first) or return :done
|
|
214
|
+
|
|
215
|
+
payer = engine.object(target)
|
|
216
|
+
unless locals["paying"] || answers(locals).first
|
|
217
|
+
choices = pay_choices(engine, payer, costs) + [{ "type" => "decline" }]
|
|
218
|
+
return ask(engine, locals, payer.controller, "Pay #{what} or #{payer.card} is countered?", choices)
|
|
219
|
+
end
|
|
220
|
+
paid = answers(locals).first["type"] == "pay" && pay_costs(engine, ctx, locals, payer, costs)
|
|
221
|
+
return :wait if paid == :wait
|
|
222
|
+
|
|
223
|
+
engine.counter!(target) unless paid == "paid"
|
|
224
|
+
:done
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def on_stack(engine, ctx) = refs(engine, ctx).select { engine.state.stack.include?(it) }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../abilities/turn_rules"
|
|
4
|
+
require_relative "../keywords/evergreen"
|
|
5
|
+
|
|
6
|
+
module Enjine
|
|
7
|
+
module Mechanics
|
|
8
|
+
module Effects
|
|
9
|
+
# "All artifacts and creatures phase out" (CR 702.26b): the permanents
|
|
10
|
+
# are treated as though they don't exist until they phase in during their controller's next untap step
|
|
11
|
+
# (CR 502.1). Auras and Equipment attached to them phase out with them (CR 702.26g). See
|
|
12
|
+
# Engine::TurnStructure.
|
|
13
|
+
class PhaseOut < Effect
|
|
14
|
+
primitive :effect, :phase_out,
|
|
15
|
+
description: "Each referenced permanent phases out, with the Auras and Equipment attached to it " \
|
|
16
|
+
"(indirectly). It phases in during the untap step of the player who controlled it as " \
|
|
17
|
+
"it phased out. Phasing isn't a zone change: no enters or leaves triggers.",
|
|
18
|
+
rules: %w[702.26a 702.26b 702.26d 702.26g 702.26h 502.1 506.4],
|
|
19
|
+
params: { "to" => "selector" },
|
|
20
|
+
example: { "phase_out" => { "to" => { "each" => { "permanent" => { "any_of" => %w[artifact creature] } } } } }
|
|
21
|
+
|
|
22
|
+
def self.keys = %w[to if]
|
|
23
|
+
|
|
24
|
+
def apply(engine, ctx) = engine.phase_out!(objects(engine, ctx))
|
|
25
|
+
|
|
26
|
+
def to_s = "phase out → #{to}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Keywords
|
|
31
|
+
Evergreen.define(:phasing, "During each of its controller's untap steps, before they untap, it phases out if " \
|
|
32
|
+
"it's phased in (and phases in if it phased out under their control).",
|
|
33
|
+
%w[702.26a 702.26p 502.1])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Abilities
|
|
37
|
+
# "Permanents can't phase in" (CR 702.26a, 502.1): while this permanent is on the battlefield, nothing
|
|
38
|
+
# phases in during untap steps.
|
|
39
|
+
class CantPhaseIn < Abilities::TurnRule
|
|
40
|
+
primitive :ability, :cant_phase_in,
|
|
41
|
+
description: "While this permanent is on the battlefield, permanents can't phase in.",
|
|
42
|
+
rules: %w[702.26a 502.1 604.1], example: "cant_phase_in"
|
|
43
|
+
|
|
44
|
+
INSTANCE = new.freeze
|
|
45
|
+
|
|
46
|
+
def kind = :cant_phase_in
|
|
47
|
+
def to_s = "Permanents can't phase in"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Proliferate (CR 701.34a): choose any number of permanents and/or players that have a counter, then
|
|
7
|
+
# give each one another counter of each kind it already has. "Proliferate X times" repeats it.
|
|
8
|
+
class Proliferate < ChoiceEffect
|
|
9
|
+
primitive :effect, :proliferate,
|
|
10
|
+
description: "You choose any number of permanents and/or players that have counters (one at a time, " \
|
|
11
|
+
"then done); each gets one more counter of each kind already on it. `amount` (default " \
|
|
12
|
+
"1): how many times to proliferate.",
|
|
13
|
+
rules: %w[701.34a 122.1 122.6],
|
|
14
|
+
params: { "amount" => "amount (default 1)" },
|
|
15
|
+
example: { "proliferate" => {} }
|
|
16
|
+
|
|
17
|
+
def self.keys = %w[amount if]
|
|
18
|
+
def self.default_to = "you"
|
|
19
|
+
|
|
20
|
+
def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
|
|
21
|
+
params = params.transform_keys(&:to_s)
|
|
22
|
+
params["amount"] = 1 unless params.key?("amount")
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s = "proliferate#{" #{amount} times" if amount != 1}"
|
|
27
|
+
|
|
28
|
+
# Each round asks until "done" (or nothing is left to choose), then gives the counters.
|
|
29
|
+
def run(engine, ctx, locals)
|
|
30
|
+
unless locals["times"]
|
|
31
|
+
return :done unless condition.nil? || condition.true?(engine, ctx)
|
|
32
|
+
|
|
33
|
+
locals["times"] = amount_for(engine, ctx)
|
|
34
|
+
end
|
|
35
|
+
while locals["times"].positive?
|
|
36
|
+
chosen = answers(locals).reject { it["type"] == "done" }.map { it["object"] }
|
|
37
|
+
options = answers(locals).any? { it["type"] == "done" } ? [] : candidates(engine, chosen)
|
|
38
|
+
return ask(engine, locals, ctx.controller, "Proliferate: choose a permanent or player", options + DONE) unless options.empty?
|
|
39
|
+
|
|
40
|
+
chosen.each { give!(engine, ctx, it) }
|
|
41
|
+
locals["times"] -= 1
|
|
42
|
+
locals.delete("answers")
|
|
43
|
+
end
|
|
44
|
+
:done
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
DONE = [{ "type" => "done" }].freeze
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Permanents with a counter (battlefield order), then players with one (seat order).
|
|
52
|
+
def candidates(engine, chosen)
|
|
53
|
+
permanents = engine.state.battlefield.select { engine.object(it).counters.any? { |_, n| n.positive? } }
|
|
54
|
+
players = engine.state.players.each_index.select { engine.player_counters(it).any? { |_, n| n.positive? } }
|
|
55
|
+
.map { Filters.player_ref(engine, it) }
|
|
56
|
+
(permanents + players - chosen).map { { "type" => "choose", "object" => it } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def give!(engine, ctx, ref)
|
|
60
|
+
if (index = Filters.player_index(engine, ref))
|
|
61
|
+
return engine.player_counters(index).each { |kind, n| engine.add_player_counters!(index, kind, 1) if n.positive? }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
obj = engine.state.objects[ref]
|
|
65
|
+
return unless obj&.zone == :battlefield
|
|
66
|
+
|
|
67
|
+
obj.counters.select { |_, n| n.positive? }.each_key { engine.add_counters!(obj, it, 1, by_player: ctx.controller) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# +N/+N until end of turn (CR 611.2, 613.4c). Affected set is locked in on resolution (611.2c).
|
|
7
|
+
# "Until your next turn" lasts until the controller's next turn begins (CR 611.2a, 500.1; see
|
|
8
|
+
# Engine::Rules#end_effects_until_turn_of!).
|
|
9
|
+
class Pump < Effect
|
|
10
|
+
DURATIONS = %w[end_of_turn until_your_next_turn].freeze
|
|
11
|
+
|
|
12
|
+
primitive :effect, :pump,
|
|
13
|
+
description: "Each referenced creature gets +power/+toughness until end of turn (`duration: " \
|
|
14
|
+
"\"until_your_next_turn\"`: until its controller's next turn begins). The set of " \
|
|
15
|
+
"affected creatures is locked in when the effect starts.",
|
|
16
|
+
rules: %w[611.2a 611.2c 613.4c 514.2 500.1],
|
|
17
|
+
params: { "power" => "amount (may be negative, e.g. {\"times\": [\"x\", -1]} for -X)",
|
|
18
|
+
"toughness" => "amount (may be negative)", "to" => "selector",
|
|
19
|
+
"duration" => DURATIONS.join(" | ") },
|
|
20
|
+
example: { "pump" => { "power" => 3, "toughness" => 3, "to" => { "target" => 0 } } }
|
|
21
|
+
|
|
22
|
+
attr_reader :duration
|
|
23
|
+
|
|
24
|
+
def self.keys = %w[power toughness to duration if]
|
|
25
|
+
|
|
26
|
+
# A signed amount: an integer, or a computed Amount ("+X/+X, where X is ...", CR 608.2h).
|
|
27
|
+
def self.signed(params, key, errors, path, **context)
|
|
28
|
+
raw = Mechanics.fetch_param(params, key)
|
|
29
|
+
raw.nil? ? Amount::Fixed.new(0) : Amount.build(raw, errors, "#{path}.#{key}", min: -1_000_000, **context)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.signed_text(amount) = amount.is_a?(Amount::Fixed) ? format("%+d", amount.n) : "+(#{amount})"
|
|
33
|
+
|
|
34
|
+
def setup(params, errors, path)
|
|
35
|
+
context = { targets_count: @targets_count, results_count: @results_count }
|
|
36
|
+
@power = Pump.signed(params, :power, errors, path, **context)
|
|
37
|
+
@toughness = Pump.signed(params, :toughness, errors, path, **context)
|
|
38
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
39
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The fixed bonus (for display); computed bonuses are Amounts.
|
|
43
|
+
def power = @power.is_a?(Amount::Fixed) ? @power.n : @power
|
|
44
|
+
def toughness = @toughness.is_a?(Amount::Fixed) ? @toughness.n : @toughness
|
|
45
|
+
|
|
46
|
+
def to_s
|
|
47
|
+
"#{Pump.signed_text(@power)}/#{Pump.signed_text(@toughness)} until #{duration.delete_prefix("until_").tr("_", " ")} → #{to}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The amounts are computed once, as the effect starts (CR 608.2h, 611.2c). A noncreature it affects (an
|
|
51
|
+
# uncrewed Vehicle) gets the bonus once it's a creature this turn (CR 611.2c, 208.3).
|
|
52
|
+
def apply(engine, ctx)
|
|
53
|
+
engine.add_continuous_effect!("pump", ctx, objects(engine, ctx), "power" => @power.value(engine, ctx),
|
|
54
|
+
"toughness" => @toughness.value(engine, ctx),
|
|
55
|
+
"duration" => duration)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|