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,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Cards an effect puts onto the battlefield under one player's control: each one's "as this enters"
|
|
7
|
+
# choice is made first, one card at a time, then they all enter at once (CR 614.12, 614.12a). The
|
|
8
|
+
# including effect has a `put` zone; other zones move one card at a time as before.
|
|
9
|
+
module EntersBattlefield
|
|
10
|
+
# Whether a choice is now pending (the effect returns :wait and asks again on resume).
|
|
11
|
+
def entering_choices?(engine, controller, locals, ids)
|
|
12
|
+
@put.to_s == "battlefield" && engine.ask_entering_choices!(locals, ids.map { [it, controller] }) == :wait
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Moves the cards to `@put`; onto the battlefield, under `controller`'s control and all at once.
|
|
16
|
+
def put_cards!(engine, locals, ids, controller, reason:, enters: nil)
|
|
17
|
+
return engine.enter_battlefield!(locals, ids.map { [it, controller, enters] }, reason: reason) if @put.to_s == "battlefield"
|
|
18
|
+
|
|
19
|
+
ids.each { engine.move!(it, @put.to_sym, reason: reason, enters: enters) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Where the cards a library effect didn't take go: the bottom of the library in a random order or in
|
|
24
|
+
# an order the player chooses (CR 401.4), or the graveyard.
|
|
25
|
+
module RestOfCards
|
|
26
|
+
REST = %w[bottom_random bottom_any graveyard].freeze
|
|
27
|
+
|
|
28
|
+
def parse_rest(params, errors, path)
|
|
29
|
+
rest = (Mechanics.fetch_param(params, :rest) || "bottom_random").to_s
|
|
30
|
+
errors << "#{path}.rest: must be one of #{REST.join(", ")}" unless REST.include?(rest)
|
|
31
|
+
rest
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Asks for the bottom order one card at a time ("the first chosen goes on the bottom first").
|
|
35
|
+
# Returns :wait while asking, else places the cards.
|
|
36
|
+
def place_rest(engine, locals, index, rest_ids)
|
|
37
|
+
if @rest == "graveyard"
|
|
38
|
+
engine.unrecorded { rest_ids.each { engine.move!(it, :graveyard, reason: "put") } }
|
|
39
|
+
return :done
|
|
40
|
+
end
|
|
41
|
+
if @rest == "bottom_any"
|
|
42
|
+
order = answers(locals).select { it["type"] == "bottom" }.map { it["object"] }
|
|
43
|
+
left = rest_ids - order
|
|
44
|
+
if left.size > 1
|
|
45
|
+
return ask(engine, locals, index, "Choose the next card to put on the bottom of your library",
|
|
46
|
+
left.map { { "type" => "bottom", "object" => it } })
|
|
47
|
+
end
|
|
48
|
+
rest_ids = order + left
|
|
49
|
+
end
|
|
50
|
+
engine.put_on_bottom!(index, rest_ids, random: @rest == "bottom_random")
|
|
51
|
+
:done
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Look at (or reveal) the top N cards of your library, take the ones matching a card spec (all, a
|
|
56
|
+
# number, or up to a number), then put the rest away (CR 701.20a, 701.20e, 401.4).
|
|
57
|
+
class Dig < ChoiceEffect
|
|
58
|
+
include RestOfCards
|
|
59
|
+
include EntersBattlefield
|
|
60
|
+
|
|
61
|
+
PUT = %w[hand battlefield graveyard exile].freeze
|
|
62
|
+
REVEAL = %w[none chosen all].freeze
|
|
63
|
+
|
|
64
|
+
primitive :effect, :dig,
|
|
65
|
+
description: "Look at the top `amount` cards of your library (`reveal: \"all\"`: reveal them). Take " \
|
|
66
|
+
"`count` (default 1; \"all\": every matching card, no choice; \"any\": any number) " \
|
|
67
|
+
"cards matching `for` and put them into `put` (`tapped` onto the battlefield); " \
|
|
68
|
+
"`optional`: you may take fewer. `reveal: \"chosen\"` reveals the cards taken. The rest go " \
|
|
69
|
+
"to `rest`: \"bottom_random\" (default), \"bottom_any\" (in an order you choose) or \"graveyard\". " \
|
|
70
|
+
"`put: \"exile\"` takes `face_down: true` (only you may look at them) and `link: true` " \
|
|
71
|
+
"(exiled with the source, CR 607.2a: hideaway).",
|
|
72
|
+
rules: %w[701.20a 701.20e 401.4 400.7j 406.3 607.2a],
|
|
73
|
+
params: { "amount" => "amount (how many cards)", "for" => "card spec (default any card)",
|
|
74
|
+
"count" => "integer, \"all\" or \"any\"", "optional" => "boolean", "put" => PUT.join(" | "),
|
|
75
|
+
"tapped" => "boolean", "reveal" => REVEAL.join(" | "), "rest" => RestOfCards::REST.join(" | "),
|
|
76
|
+
"face_down" => "boolean (put exile)", "link" => "boolean (put exile)" },
|
|
77
|
+
example: { "dig" => { "amount" => 6, "for" => { "types" => ["artifact"] }, "optional" => true,
|
|
78
|
+
"reveal" => "chosen", "put" => "hand" } }
|
|
79
|
+
|
|
80
|
+
attr_reader :spec, :count, :put
|
|
81
|
+
|
|
82
|
+
def self.keys = %w[amount for count optional put tapped reveal rest face_down link if]
|
|
83
|
+
def self.default_to = "you"
|
|
84
|
+
|
|
85
|
+
def setup(params, errors, path)
|
|
86
|
+
@spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
|
|
87
|
+
@count = Mechanics.fetch_param(params, :count) || 1
|
|
88
|
+
unless %w[all any].include?(@count) || (@count.is_a?(Integer) && @count.positive?)
|
|
89
|
+
errors << "#{path}.count: must be a positive integer, \"all\" or \"any\""
|
|
90
|
+
end
|
|
91
|
+
@optional = Mechanics.flag_param(params, "optional", errors, path)
|
|
92
|
+
@put = (Mechanics.fetch_param(params, :put) || "hand").to_s
|
|
93
|
+
errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
|
|
94
|
+
@tapped = Mechanics.flag_param(params, "tapped", errors, path)
|
|
95
|
+
@reveal = (Mechanics.fetch_param(params, :reveal) || "none").to_s
|
|
96
|
+
errors << "#{path}.reveal: must be one of #{REVEAL.join(", ")}" unless REVEAL.include?(@reveal)
|
|
97
|
+
@rest = parse_rest(params, errors, path)
|
|
98
|
+
@face_down, @link = %i[face_down link].map { Mechanics.flag_param(params, it, errors, path) }
|
|
99
|
+
errors << "#{path}: face_down and link need put: \"exile\"" if (@face_down || @link) && @put != "exile"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_s
|
|
103
|
+
many = { "all" => "all", "any" => "any number of" }[count] || "#{"up to " if @optional}#{count}"
|
|
104
|
+
"#{@reveal == "all" ? "reveal" : "look at"} the top #{amount} cards, #{many} #{spec} → #{put}, rest → " \
|
|
105
|
+
"#{@rest.tr("_", " ")}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run(engine, ctx, locals)
|
|
109
|
+
return :done unless locals.key?("seen") || condition.nil? || condition.true?(engine, ctx)
|
|
110
|
+
|
|
111
|
+
index = ctx.controller
|
|
112
|
+
seen = (locals["seen"] ||= first_look(engine, ctx, index))
|
|
113
|
+
unless locals["taken"]
|
|
114
|
+
taken = answers(locals).select { it["type"] == "take" }.map { it["object"] }
|
|
115
|
+
choices = count == "all" ? [] : choices(engine, ctx, seen, taken, locals)
|
|
116
|
+
return ask(engine, locals, index, "Choose #{spec} to take", choices) unless choices.empty?
|
|
117
|
+
|
|
118
|
+
taken = seen.select { spec.match?(engine, engine.object(it), ctx) } if count == "all"
|
|
119
|
+
return :wait if entering_choices?(engine, ctx.controller, locals, taken)
|
|
120
|
+
|
|
121
|
+
take!(engine, ctx, index, taken, locals)
|
|
122
|
+
locals["taken"] = taken
|
|
123
|
+
end
|
|
124
|
+
place_rest(engine, locals, index, seen - locals["taken"])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def first_look(engine, ctx, index)
|
|
130
|
+
seen = engine.player(index).library.first(amount_for(engine, ctx))
|
|
131
|
+
@reveal == "all" ? engine.reveal!(index, seen) : engine.look_at!(index, index, seen)
|
|
132
|
+
seen
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def choices(engine, ctx, seen, taken, locals)
|
|
136
|
+
return [] if answers(locals).any? { it["type"] == "done" } || (count != "any" && taken.size >= count)
|
|
137
|
+
|
|
138
|
+
ids = (seen - taken).select { spec.match?(engine, engine.object(it), ctx) }
|
|
139
|
+
options = ids.uniq { engine.object(it).card }.map { { "type" => "take", "object" => it } }
|
|
140
|
+
options << { "type" => "done" } if (@optional || count == "any") && !options.empty?
|
|
141
|
+
options
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def take!(engine, ctx, index, taken, locals)
|
|
145
|
+
engine.reveal!(index, taken) if @reveal == "chosen"
|
|
146
|
+
return exile!(engine, ctx, index, taken) if @put == "exile"
|
|
147
|
+
|
|
148
|
+
put_cards!(engine, locals, taken, ctx.controller, reason: "put", enters: @tapped ? { "tapped" => true } : nil)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# CR 406.3, 607.2a: face down (only you may look), linked to the source.
|
|
152
|
+
def exile!(engine, ctx, index, taken)
|
|
153
|
+
taken.each do |id|
|
|
154
|
+
fresh = engine.move!(id, :exile, reason: "exile", face_down: @face_down ? [index] : nil)
|
|
155
|
+
engine.link_exiled!(ctx.source, fresh.id) if fresh && @link
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Reveal cards from the top of your library one at a time until N cards matching a spec are revealed;
|
|
161
|
+
# those go to a zone, the other revealed cards are put away (CR 701.20a, 401.4).
|
|
162
|
+
class RevealUntil < ChoiceEffect
|
|
163
|
+
include RestOfCards
|
|
164
|
+
include EntersBattlefield
|
|
165
|
+
|
|
166
|
+
PUT = %w[hand battlefield].freeze
|
|
167
|
+
|
|
168
|
+
primitive :effect, :reveal_until,
|
|
169
|
+
description: "Reveal cards from the top of your library until `count` (an amount, default 1) cards " \
|
|
170
|
+
"matching `for` are revealed (or the library runs out). Put those into `put` " \
|
|
171
|
+
"(`tapped` onto the battlefield, under your control) and the rest to `rest` (default " \
|
|
172
|
+
"the bottom in a random order).",
|
|
173
|
+
rules: %w[701.20a 401.4 400.7j],
|
|
174
|
+
params: { "for" => "card spec", "count" => "amount", "put" => PUT.join(" | "), "tapped" => "boolean",
|
|
175
|
+
"rest" => RestOfCards::REST.join(" | ") },
|
|
176
|
+
example: { "reveal_until" => { "for" => { "types" => ["land"] }, "count" => 2, "put" => "battlefield",
|
|
177
|
+
"tapped" => true } }
|
|
178
|
+
|
|
179
|
+
def self.keys = %w[for count put tapped rest if]
|
|
180
|
+
def self.default_to = "you"
|
|
181
|
+
|
|
182
|
+
def setup(params, errors, path)
|
|
183
|
+
@spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
|
|
184
|
+
@count = Amount.build(Mechanics.fetch_param(params, :count) || 1, errors, "#{path}.count", targets_count: @targets_count)
|
|
185
|
+
@put = (Mechanics.fetch_param(params, :put) || "hand").to_s
|
|
186
|
+
errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
|
|
187
|
+
@tapped = Mechanics.flag_param(params, "tapped", errors, path)
|
|
188
|
+
@rest = parse_rest(params, errors, path)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def to_s = "reveal until #{@count} #{@spec} → #{@put}#{" tapped" if @tapped}, rest → #{@rest.tr("_", " ")}"
|
|
192
|
+
|
|
193
|
+
def run(engine, ctx, locals)
|
|
194
|
+
return :done unless locals.key?("found") || condition.nil? || condition.true?(engine, ctx)
|
|
195
|
+
|
|
196
|
+
index = ctx.controller
|
|
197
|
+
unless locals.key?("found")
|
|
198
|
+
revealed, found = reveal(engine, ctx, index)
|
|
199
|
+
locals["found"] = found
|
|
200
|
+
locals["rest"] = revealed - found
|
|
201
|
+
end
|
|
202
|
+
unless locals["moved"]
|
|
203
|
+
return :wait if entering_choices?(engine, index, locals, locals["found"])
|
|
204
|
+
|
|
205
|
+
put_cards!(engine, locals, locals["found"], index, reason: "put", enters: @tapped ? { "tapped" => true } : nil)
|
|
206
|
+
locals["moved"] = true
|
|
207
|
+
end
|
|
208
|
+
place_rest(engine, locals, index, locals["rest"])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def reveal(engine, ctx, index)
|
|
214
|
+
wanted = @count.value(engine, ctx)
|
|
215
|
+
revealed = []
|
|
216
|
+
found = []
|
|
217
|
+
engine.player(index).library.each do |id|
|
|
218
|
+
break if found.size >= wanted
|
|
219
|
+
|
|
220
|
+
revealed << id
|
|
221
|
+
found << id if @spec.match?(engine, engine.object(id), ctx)
|
|
222
|
+
end
|
|
223
|
+
engine.reveal!(index, revealed)
|
|
224
|
+
[revealed, found]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Exile the top N cards of a library (CR 406.1). What was exiled is the effect's result ({"result": n}),
|
|
229
|
+
# for later effects of the list (returning the permanent cards among them, permissions to play them);
|
|
230
|
+
# `link: true` exiles them with the source (CR 607.2a).
|
|
231
|
+
class ExileTop < Effect
|
|
232
|
+
primitive :effect, :exile_top,
|
|
233
|
+
description: "Each referenced player (default: you) exiles the top `amount` cards of their library " \
|
|
234
|
+
"(face up). Later effects name them with {\"result\": n}; `link: true` exiles them with " \
|
|
235
|
+
"the source. `face_down: true` exiles them face down: only the `looker` players " \
|
|
236
|
+
"(default: you) may look at them.",
|
|
237
|
+
rules: %w[406.1 400.7j 607.2a 406.3 406.3a],
|
|
238
|
+
params: { "amount" => "amount", "to" => "selector of players", "link" => "boolean",
|
|
239
|
+
"face_down" => "boolean", "looker" => "selector of players who may look (default \"you\")" },
|
|
240
|
+
example: { "exile_top" => { "amount" => 4 } }
|
|
241
|
+
|
|
242
|
+
def self.keys = %w[amount to link face_down looker if]
|
|
243
|
+
def self.default_to = "you"
|
|
244
|
+
|
|
245
|
+
def setup(params, errors, path)
|
|
246
|
+
@link = Mechanics.flag_param(params, "link", errors, path)
|
|
247
|
+
@face_down = Mechanics.flag_param(params, :face_down, errors, path)
|
|
248
|
+
raw = Mechanics.fetch_param(params, :looker)
|
|
249
|
+
errors << "#{path}.looker: needs face_down: true" if raw && !@face_down
|
|
250
|
+
@looker = Selector.build(raw || "you", errors, "#{path}.looker", targets_count: @targets_count,
|
|
251
|
+
results_count: @results_count)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def to_s = @face_down ? super.sub(" → ", " face down → ") : super
|
|
255
|
+
|
|
256
|
+
def apply(engine, ctx)
|
|
257
|
+
n = amount_for(engine, ctx)
|
|
258
|
+
lookers = @face_down ? @looker.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) } : nil
|
|
259
|
+
players(engine, ctx).each do |index|
|
|
260
|
+
engine.player(index).library.first(n).each do |id|
|
|
261
|
+
fresh = engine.move!(id, :exile, reason: "exile", face_down: lookers)
|
|
262
|
+
engine.link_exiled!(ctx.source, fresh.id) if fresh && @link
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Explore (CR 701.44a): reveal the top card of your library; a land card goes to your hand,
|
|
269
|
+
# otherwise the exploring permanent gets a +1/+1 counter and you may put the card into your graveyard.
|
|
270
|
+
class Explore < ChoiceEffect
|
|
271
|
+
primitive :effect, :explore,
|
|
272
|
+
description: "Each referenced permanent explores: its controller reveals the top card of their " \
|
|
273
|
+
"library. A land card goes to their hand; otherwise the permanent gets a +1/+1 counter " \
|
|
274
|
+
"(if it's still on the battlefield) and they may put the card into their graveyard.",
|
|
275
|
+
rules: %w[701.44a 701.44b 701.44c],
|
|
276
|
+
params: { "to" => "selector of permanents" },
|
|
277
|
+
example: { "explore" => { "to" => "self" } }
|
|
278
|
+
|
|
279
|
+
def self.keys = %w[to if]
|
|
280
|
+
|
|
281
|
+
def run(engine, ctx, locals)
|
|
282
|
+
return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
|
|
283
|
+
|
|
284
|
+
queue = (locals["queue"] ||= refs(engine, ctx).select { engine.state.objects.key?(it) })
|
|
285
|
+
while (id = queue.first)
|
|
286
|
+
return :wait if explore(engine, ctx, locals, id) == :wait
|
|
287
|
+
|
|
288
|
+
queue.shift
|
|
289
|
+
locals.delete("card")
|
|
290
|
+
locals.delete("answers")
|
|
291
|
+
end
|
|
292
|
+
:done
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
# CR 701.44c: a permanent that has left uses its last known controller (here: the effect's controller).
|
|
298
|
+
def explore(engine, ctx, locals, id)
|
|
299
|
+
obj = engine.object(id)
|
|
300
|
+
index = obj.zone == :battlefield ? obj.controller : ctx.controller
|
|
301
|
+
unless locals.key?("card")
|
|
302
|
+
locals["card"] = engine.player(index).library.first
|
|
303
|
+
return land_or_counter(engine, locals, id, index)
|
|
304
|
+
end
|
|
305
|
+
answer = answers(locals).first
|
|
306
|
+
card = locals["card"]
|
|
307
|
+
engine.move!(card, :graveyard, reason: "explore") if answer["type"] == "graveyard"
|
|
308
|
+
:done
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def land_or_counter(engine, locals, id, index)
|
|
312
|
+
card = locals["card"]
|
|
313
|
+
engine.reveal!(index, [card]) if card
|
|
314
|
+
if card && engine.chars(card).land?
|
|
315
|
+
engine.move!(card, :hand, reason: "explore")
|
|
316
|
+
return :done
|
|
317
|
+
end
|
|
318
|
+
engine.add_counters!(engine.object(id), "+1/+1", 1, by_player: index) if engine.state.battlefield.include?(id)
|
|
319
|
+
return :done unless card
|
|
320
|
+
|
|
321
|
+
ask(engine, locals, index, "Explore: keep the revealed card on top or put it into your graveyard?",
|
|
322
|
+
[{ "type" => "top", "object" => card }, { "type" => "graveyard", "object" => card }])
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Gain life (CR 119.3).
|
|
7
|
+
class GainLife < Effect
|
|
8
|
+
primitive :effect, :gain_life, description: "Each referenced player gains `amount` life (default: you).",
|
|
9
|
+
rules: %w[119.3], params: { "amount" => "integer", "to" => "selector" },
|
|
10
|
+
example: { "gain_life" => { "amount" => 3 } }
|
|
11
|
+
def self.default_to = "you"
|
|
12
|
+
def apply(engine, ctx) = players(engine, ctx).each { LoseLife.change!(engine, it, amount_for(engine, ctx)) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Lose life (CR 119.3).
|
|
16
|
+
class LoseLife < Effect
|
|
17
|
+
primitive :effect, :lose_life, description: "Each referenced player loses `amount` life. The life lost is " \
|
|
18
|
+
"recorded for {\"this_way\": n}.",
|
|
19
|
+
rules: %w[119.3], params: { "amount" => "integer", "to" => "selector" },
|
|
20
|
+
example: { "lose_life" => { "amount" => 2, "to" => "each_opponent" } }
|
|
21
|
+
def apply(engine, ctx) = players(engine, ctx).each { LoseLife.change!(engine, it, -amount_for(engine, ctx)) }
|
|
22
|
+
|
|
23
|
+
# Changes a life total and records how much it changed (life lost "this way", CR 608.2c).
|
|
24
|
+
def self.change!(engine, index, delta)
|
|
25
|
+
before = engine.player(index).life
|
|
26
|
+
engine.change_life!(index, delta)
|
|
27
|
+
engine.record_amount!((engine.player(index).life - before).abs)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "Your life total becomes 10" (CR 119.5): the player gains or loses the difference.
|
|
32
|
+
class SetLife < Effect
|
|
33
|
+
primitive :effect, :set_life,
|
|
34
|
+
description: "Each referenced player's life total becomes `amount` (default: you): they gain or " \
|
|
35
|
+
"lose the difference (so \"can't gain life\" and life-gain triggers apply).",
|
|
36
|
+
rules: %w[119.5 119.7], params: { "amount" => "amount", "to" => "selector" },
|
|
37
|
+
example: { "set_life" => { "amount" => 10 } }
|
|
38
|
+
def self.default_to = "you"
|
|
39
|
+
|
|
40
|
+
def apply(engine, ctx)
|
|
41
|
+
players(engine, ctx).each { LoseLife.change!(engine, it, amount_for(engine, ctx) - engine.player(it).life) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "Exchange life totals with target opponent" (CR 701.12a, 701.12c): the player `to` (default you) and
|
|
46
|
+
# the player `with` each gain or lose life to end at the other's previous total; if either can't (CR
|
|
47
|
+
# 119.7), nothing happens. The life the first player lost this way is recorded for {"this_way": n}.
|
|
48
|
+
class ExchangeLifeTotals < Effect
|
|
49
|
+
primitive :effect, :exchange_life_totals,
|
|
50
|
+
description: "The player `to` references (default you) and the one `with` references exchange " \
|
|
51
|
+
"life totals: each gains or loses the difference. If either can't gain life and " \
|
|
52
|
+
"would, no exchange happens. The life the first player lost this way is recorded for " \
|
|
53
|
+
"{\"this_way\": n}.",
|
|
54
|
+
rules: %w[701.12a 701.12c 119.7],
|
|
55
|
+
params: { "to" => "selector (a player, default you)", "with" => "selector (a player)" },
|
|
56
|
+
example: { "exchange_life_totals" => { "with" => { "target" => 0 } } }
|
|
57
|
+
|
|
58
|
+
attr_reader :with
|
|
59
|
+
|
|
60
|
+
def self.keys = %w[to with if]
|
|
61
|
+
def self.default_to = "you"
|
|
62
|
+
|
|
63
|
+
def setup(params, errors, path)
|
|
64
|
+
@with = Exchange.with(params, errors, path, @targets_count, @results_count)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply(engine, ctx)
|
|
68
|
+
one, other = [to, with].map { |sel| sel.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.first }
|
|
69
|
+
return engine.record_amount!(0) if one.nil? || other.nil? || one == other
|
|
70
|
+
|
|
71
|
+
lives = [one, other].to_h { [it, engine.player(it).life] }
|
|
72
|
+
gains = { one => lives[other] - lives[one], other => lives[one] - lives[other] }
|
|
73
|
+
return engine.record_amount!(0) if gains.any? { |index, delta| delta.positive? && engine.player_rule?(index, "cant_gain_life") }
|
|
74
|
+
|
|
75
|
+
gains.each { |index, delta| engine.change_life!(index, delta) }
|
|
76
|
+
engine.record_amount!([lives[one] - engine.player(one).life, 0].max)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_s = "exchange life totals: #{to} and #{with}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Looking at a player's hand (CR 701.20e): the controller sees those cards; others don't.
|
|
7
|
+
class LookAtHand < Effect
|
|
8
|
+
primitive :effect, :look_at_hand,
|
|
9
|
+
description: "You look at each referenced player's hand (other players don't see the cards).",
|
|
10
|
+
rules: %w[701.20e], params: { "to" => "selector of players" },
|
|
11
|
+
example: { "look_at_hand" => { "to" => { "target" => 0 } } }
|
|
12
|
+
def self.keys = %w[to if]
|
|
13
|
+
def apply(engine, ctx) = players(engine, ctx).each { engine.look_at!(ctx.controller, it, engine.player(it).hand) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "Draw N cards, then discard N cards; then something for the nonland cards discarded": the shared
|
|
7
|
+
# shape of connive (CR 701.50) and recruit (CR 701.70). The player draws, then chooses the discards one
|
|
8
|
+
# `effect_choice` at a time (fewer if their hand runs out); the reward counts the nonland cards among
|
|
9
|
+
# them by their card types as they were discarded.
|
|
10
|
+
class Loot < ChoiceEffect
|
|
11
|
+
def run(engine, ctx, locals)
|
|
12
|
+
return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
|
|
13
|
+
|
|
14
|
+
queue = (locals["queue"] ||= loot_queue(engine, ctx))
|
|
15
|
+
while (entry = queue.first)
|
|
16
|
+
return :wait if loot(engine, locals, entry) == :wait
|
|
17
|
+
|
|
18
|
+
reward(engine, ctx, entry, locals["nonland"])
|
|
19
|
+
queue.shift
|
|
20
|
+
%w[drawn nonland applied answers asked].each { locals.delete(it) }
|
|
21
|
+
end
|
|
22
|
+
:done
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def loot(engine, locals, entry)
|
|
28
|
+
index = entry["player"]
|
|
29
|
+
unless locals["drawn"]
|
|
30
|
+
entry["n"].times { engine.draw!(index) }
|
|
31
|
+
locals.merge!("drawn" => true, "nonland" => 0, "applied" => 0)
|
|
32
|
+
end
|
|
33
|
+
chosen = answers(locals)
|
|
34
|
+
chosen.drop(locals["applied"]).each do |answer|
|
|
35
|
+
locals["nonland"] += 1 unless engine.chars(answer["object"]).land?
|
|
36
|
+
engine.move!(answer["object"], :graveyard, reason: "discard")
|
|
37
|
+
end
|
|
38
|
+
locals["applied"] = chosen.size
|
|
39
|
+
hand = engine.player(index).hand
|
|
40
|
+
return :done if chosen.size >= entry["n"] || hand.empty?
|
|
41
|
+
|
|
42
|
+
ask(engine, locals, index, "Discard a card (#{entry["n"] - chosen.size} to go)",
|
|
43
|
+
hand.map { { "type" => "discard", "object" => it } })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Connive / connive N (CR 701.50a, 701.50d): the permanent's controller draws N cards, then discards N
|
|
48
|
+
# cards, then puts a +1/+1 counter on it for each nonland card discarded. A permanent that has left the
|
|
49
|
+
# battlefield connives as it last existed: its last controller (here, the effect's controller) loots
|
|
50
|
+
# and no counters are put (CR 701.50b). Connive 0 does nothing (CR 701.50e).
|
|
51
|
+
class Connive < Loot
|
|
52
|
+
primitive :effect, :connive,
|
|
53
|
+
description: "Each referenced permanent (default: this one) connives `amount` (default 1): its " \
|
|
54
|
+
"controller draws that many cards, then discards that many, then it gets a +1/+1 " \
|
|
55
|
+
"counter for each nonland card discarded.",
|
|
56
|
+
rules: %w[701.50a 701.50b 701.50d 701.50e],
|
|
57
|
+
params: { "amount" => "amount (default 1)", "to" => "selector of permanents (default self)" },
|
|
58
|
+
example: { "connive" => { "to" => "self" } }
|
|
59
|
+
|
|
60
|
+
def self.default_to = "self"
|
|
61
|
+
|
|
62
|
+
def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
|
|
63
|
+
params = params.transform_keys(&:to_s)
|
|
64
|
+
params["amount"] = 1 unless params.key?("amount")
|
|
65
|
+
super
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_s = "connive #{amount} → #{to}"
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def loot_queue(engine, ctx)
|
|
73
|
+
n = amount_for(engine, ctx)
|
|
74
|
+
return [] unless n.positive?
|
|
75
|
+
|
|
76
|
+
refs(engine, ctx).filter_map do |id|
|
|
77
|
+
obj = engine.state.objects[id] or next
|
|
78
|
+
{ "player" => obj.zone == :battlefield ? obj.controller : ctx.controller, "n" => n, "object" => id }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reward(engine, _ctx, entry, nonland)
|
|
83
|
+
perm = engine.state.objects[entry["object"]]
|
|
84
|
+
engine.add_counters!(perm, "+1/+1", nonland, by_player: entry["player"]) if perm&.zone == :battlefield
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Recruit (CR 701.70a): "Draw a card, then discard a card. If you discarded a nonland card this way,
|
|
89
|
+
# create a 1/1 white Human Soldier creature token."
|
|
90
|
+
class Recruit < Loot
|
|
91
|
+
primitive :effect, :recruit,
|
|
92
|
+
description: "Each referenced player (default: you) recruits: draws a card, then discards a card; " \
|
|
93
|
+
"if a nonland card was discarded, they create a 1/1 white Human Soldier creature token.",
|
|
94
|
+
rules: %w[701.70a],
|
|
95
|
+
params: { "to" => "player selector (default you)" },
|
|
96
|
+
example: { "recruit" => {} }
|
|
97
|
+
|
|
98
|
+
TOKEN = { "name" => "Human Soldier", "types" => ["creature"], "subtypes" => %w[human soldier], "colors" => ["W"],
|
|
99
|
+
"power" => 1, "toughness" => 1 }.freeze
|
|
100
|
+
|
|
101
|
+
def self.keys = %w[to if]
|
|
102
|
+
def self.default_to = "you"
|
|
103
|
+
def self.token = @token ||= CardDefinition.token(TOKEN)
|
|
104
|
+
|
|
105
|
+
def created_tokens = [Recruit.token]
|
|
106
|
+
def to_s = "recruit → #{to}"
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def loot_queue(engine, ctx) = players(engine, ctx).map { { "player" => it, "n" => 1 } }
|
|
111
|
+
|
|
112
|
+
def reward(engine, _ctx, entry, nonland)
|
|
113
|
+
engine.create_token!(Recruit.token, entry["player"]) if nonland.positive?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Adding mana as the effect of a resolving spell or ability (CR 106.4): "Add {R}{R}{R}." or
|
|
7
|
+
# "Add two mana of any one color." The color is chosen as the effect resolves (CR 608.2d).
|
|
8
|
+
# Activated abilities without targets that add mana are mana abilities instead (CR 605.1a).
|
|
9
|
+
class AddMana < ChoiceEffect
|
|
10
|
+
COLORS = Mana::COLORS.map(&:to_s).freeze
|
|
11
|
+
CHOICES = %w[any_one any chosen].freeze
|
|
12
|
+
|
|
13
|
+
primitive :effect, :add_mana,
|
|
14
|
+
description: "You add mana to your mana pool: the fixed mana in `produce`, or `amount` mana of a " \
|
|
15
|
+
"color you choose as it resolves (`color: \"any_one\"`: all of one color; " \
|
|
16
|
+
"`color: \"any\"`: each mana's color chosen separately, \"in any combination of " \
|
|
17
|
+
"colors\"; `\"chosen\"`: of the color chosen for its source or by it, CR 607.2d). " \
|
|
18
|
+
"`colors` limits the choice (\"add {R} or {G}\"). `spend_only` restricts " \
|
|
19
|
+
"how the mana can be spent (CR 106.6). `until: \"end_of_combat\"`: you don't lose this " \
|
|
20
|
+
"mana as steps end until end of combat (CR 702.189a).",
|
|
21
|
+
rules: %w[106.1a 106.4 608.2d 605.1a 106.6 500.5 607.2d],
|
|
22
|
+
params: { "produce" => "mana symbols, e.g. \"{R}{R}\"", "amount" => "amount (default 1; with produce: how many times)",
|
|
23
|
+
"color" => CHOICES.join(" | "), "colors" => "list of colors to choose from (default all five)",
|
|
24
|
+
"spend_only" => "{\"spells\": card spec, \"abilities\": card spec}",
|
|
25
|
+
"until" => "\"end_of_combat\" (optional)" },
|
|
26
|
+
example: { "add_mana" => { "amount" => 2, "color" => "any_one" } }
|
|
27
|
+
|
|
28
|
+
attr_reader :produce, :color
|
|
29
|
+
|
|
30
|
+
def self.keys = %w[produce amount color colors spend_only until if]
|
|
31
|
+
def self.default_to = "you"
|
|
32
|
+
|
|
33
|
+
def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
|
|
34
|
+
params = params.transform_keys(&:to_s)
|
|
35
|
+
params["amount"] = 1 unless params.key?("amount")
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def setup(params, errors, path)
|
|
40
|
+
raw = Mechanics.fetch_param(params, :produce)
|
|
41
|
+
@color = Mechanics.fetch_param(params, :color)
|
|
42
|
+
@produce = raw && Mana.parse_produce(raw, errors, "#{path}.produce")
|
|
43
|
+
errors << "#{path}: needs exactly one of produce, color" unless raw.nil? ^ @color.nil?
|
|
44
|
+
errors << "#{path}.color: must be one of #{CHOICES.join(", ")}" if @color && !CHOICES.include?(@color)
|
|
45
|
+
raw_spend = Mechanics.fetch_param(params, :spend_only)
|
|
46
|
+
@spend_only = raw_spend && SpendOnly.build(raw_spend, errors, "#{path}.spend_only")
|
|
47
|
+
@until = Mechanics.fetch_param(params, :until)
|
|
48
|
+
errors << "#{path}.until: must be \"end_of_combat\"" unless [nil, "end_of_combat"].include?(@until)
|
|
49
|
+
raw_colors = Mechanics.fetch_param(params, :colors)
|
|
50
|
+
@colors = raw_colors || COLORS
|
|
51
|
+
return if raw_colors.nil?
|
|
52
|
+
return if %w[any_one any].include?(@color) && @colors.is_a?(Array) && @colors.size >= 2 &&
|
|
53
|
+
(@colors - COLORS).empty? && @colors.uniq == @colors
|
|
54
|
+
|
|
55
|
+
errors << "#{path}.colors: must list two or more colors (W U B R G), with color"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(engine, ctx, locals)
|
|
59
|
+
return :done unless condition.nil? || condition.true?(engine, ctx)
|
|
60
|
+
|
|
61
|
+
index = ctx.controller
|
|
62
|
+
return add(engine, index, @produce * amount_for(engine, ctx), ctx) unless @color # the fixed mana, `amount` times
|
|
63
|
+
return add(engine, index, Array(chosen_color(engine, ctx)) * amount_for(engine, ctx), ctx) if @color == "chosen"
|
|
64
|
+
|
|
65
|
+
n = (locals["n"] ||= amount_for(engine, ctx))
|
|
66
|
+
picks = answers(locals)
|
|
67
|
+
if picks.size < (@color == "any_one" ? [n, 1].min : n)
|
|
68
|
+
return ask(engine, locals, index, "Choose the color of #{@color == "any_one" ? "the" : "the next"} mana",
|
|
69
|
+
@colors.map { { "type" => "color", "color" => it } })
|
|
70
|
+
end
|
|
71
|
+
colors = picks.map { it["color"].to_sym }
|
|
72
|
+
add(engine, index, @color == "any_one" ? colors * n : colors, ctx)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# CR 607.2d: the color chosen for the source, or by this spell or ability as it resolved ("choose a color").
|
|
76
|
+
def chosen_color(engine, ctx) = Filters.chosen_memory(engine, ctx.source, ctx)["chosen_color"]&.to_sym
|
|
77
|
+
|
|
78
|
+
def to_s
|
|
79
|
+
return "add #{produce.map { "{#{it}}" }.join}" if produce
|
|
80
|
+
return "add #{amount} mana of the chosen color" if @color == "chosen"
|
|
81
|
+
|
|
82
|
+
return "add #{amount} mana: #{@colors.map { "{#{it}}" }.join(" or ")}" unless @colors == COLORS
|
|
83
|
+
|
|
84
|
+
"add #{amount} mana of #{@color == "any_one" ? "any one color" : "any combination of colors"}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def add(engine, index, produce, ctx)
|
|
90
|
+
engine.add_mana!(index, produce, ctx.source, restriction: @spend_only&.data, keep_until: @until) unless produce.nil?
|
|
91
|
+
:done
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|