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,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Put permanents or cards on top or bottom of their owners' libraries (CR 401.4); with
|
|
7
|
+
# `position: "owner_choice"` each owner chooses top or bottom.
|
|
8
|
+
class PutInLibrary < ChoiceEffect
|
|
9
|
+
POSITIONS = %w[top bottom owner_choice].freeze
|
|
10
|
+
|
|
11
|
+
primitive :effect, :put_in_library,
|
|
12
|
+
description: "Put each referenced permanent, or card in a graveyard or exile, into its owner's " \
|
|
13
|
+
"library: on the `top` or `bottom`, or (`owner_choice`) where its owner chooses.",
|
|
14
|
+
rules: %w[401.4 400.7 608.2b],
|
|
15
|
+
params: { "to" => "selector", "position" => POSITIONS.join(" | ") },
|
|
16
|
+
example: { "put_in_library" => { "to" => { "target" => 0 }, "position" => "bottom" } }
|
|
17
|
+
|
|
18
|
+
FROM = %i[battlefield graveyard exile].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :position
|
|
21
|
+
|
|
22
|
+
def self.keys = %w[to position if]
|
|
23
|
+
|
|
24
|
+
def setup(params, errors, path)
|
|
25
|
+
@position = Mechanics.fetch_param(params, :position).to_s
|
|
26
|
+
errors << "#{path}.position: must be one of #{POSITIONS.join(", ")}" unless POSITIONS.include?(@position)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s = "put into its owner's library (#{position.tr("_", " ")}) → #{to}"
|
|
30
|
+
|
|
31
|
+
def run(engine, ctx, locals)
|
|
32
|
+
return :done unless locals["asked"] || locals.key?("refs") || condition.nil? || condition.true?(engine, ctx)
|
|
33
|
+
|
|
34
|
+
refs = (locals["refs"] ||= movable(engine, ctx))
|
|
35
|
+
done = (locals["done"] ||= 0)
|
|
36
|
+
while (id = refs[done])
|
|
37
|
+
where = position
|
|
38
|
+
if where == "owner_choice"
|
|
39
|
+
answer = answers(locals)[done] or return ask_owner(engine, locals, id)
|
|
40
|
+
where = answer["type"]
|
|
41
|
+
end
|
|
42
|
+
Linked.remember!(engine, ctx, id)
|
|
43
|
+
engine.move!(id, :library, reason: "put", position: where.to_sym)
|
|
44
|
+
locals["done"] = done += 1
|
|
45
|
+
end
|
|
46
|
+
:done
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def movable(engine, ctx)
|
|
52
|
+
refs(engine, ctx).select { FROM.include?(engine.state.objects[it]&.zone) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ask_owner(engine, locals, id)
|
|
56
|
+
obj = engine.object(id)
|
|
57
|
+
ask(engine, locals, obj.owner, "Put #{engine.chars(obj).name} on the top or the bottom of your library?",
|
|
58
|
+
[{ "type" => "top", "object" => id }, { "type" => "bottom", "object" => id }])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Shuffling cards into their owners' libraries (CR 701.24c): each owner shuffles their library even
|
|
63
|
+
# if none of the cards is still where it was.
|
|
64
|
+
class ShuffleIntoLibrary < Effect
|
|
65
|
+
primitive :effect, :shuffle_into_library,
|
|
66
|
+
description: "Put each referenced card (in a graveyard or exile) or permanent into its owner's " \
|
|
67
|
+
"library; each of those owners shuffles their library.",
|
|
68
|
+
rules: %w[701.24a 701.24c],
|
|
69
|
+
params: { "to" => "selector" },
|
|
70
|
+
example: { "shuffle_into_library" => { "to" => { "target" => 0 } } }
|
|
71
|
+
|
|
72
|
+
def self.keys = %w[to if]
|
|
73
|
+
|
|
74
|
+
def apply(engine, ctx)
|
|
75
|
+
owners = []
|
|
76
|
+
refs(engine, ctx).each do |id|
|
|
77
|
+
obj = engine.state.objects[id]
|
|
78
|
+
next unless obj && PutInLibrary::FROM.include?(obj.zone) # abilities are on the stack, players aren't objects
|
|
79
|
+
|
|
80
|
+
owners << obj.owner
|
|
81
|
+
engine.move!(id, :library, reason: "shuffle")
|
|
82
|
+
end
|
|
83
|
+
owners.uniq.sort.each { engine.shuffle_library!(it) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Shuffle a library (CR 701.24a).
|
|
88
|
+
class ShuffleLibrary < Effect
|
|
89
|
+
primitive :effect, :shuffle_library, description: "Each referenced player shuffles their library (default: you).",
|
|
90
|
+
rules: %w[701.24a], params: { "to" => "selector" },
|
|
91
|
+
example: { "shuffle_library" => {} }
|
|
92
|
+
def self.keys = %w[to if]
|
|
93
|
+
def self.default_to = "you"
|
|
94
|
+
def apply(engine, ctx) = players(engine, ctx).each { engine.shuffle_library!(it) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sacrifice"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Effects
|
|
8
|
+
# "Return a land you control to its owner's hand": like sacrifice_choice, each referenced player
|
|
9
|
+
# chooses permanents they control matching `filter` (a choice, not a target), in turn order starting
|
|
10
|
+
# with the active player (CR 101.4); then all of them are put into their owners' hands at once.
|
|
11
|
+
class ReturnChoice < SacrificeChoice
|
|
12
|
+
primitive :effect, :return_choice,
|
|
13
|
+
description: "Each referenced player (default: you) returns `count` (default 1; \"any\": any number) " \
|
|
14
|
+
"permanents they control matching `filter` to their owners' hands, chosen by them in " \
|
|
15
|
+
"turn order starting with the active player (not targeted); all move at once.",
|
|
16
|
+
rules: %w[101.4 400.7 608.2c],
|
|
17
|
+
params: { "filter" => "a filter, e.g. \"land\"", "count" => "integer or \"any\"",
|
|
18
|
+
"to" => "selector of players" },
|
|
19
|
+
example: { "return_choice" => { "filter" => "land" } }
|
|
20
|
+
|
|
21
|
+
def finish!(engine, id) = engine.move!(id, :hand, reason: "return")
|
|
22
|
+
def choosable?(_engine, obj, index) = obj.zone == :battlefield && obj.controller == index
|
|
23
|
+
def verb = "return"
|
|
24
|
+
|
|
25
|
+
def to_s = "#{to} returns #{count == "any" ? "any number of" : count} #{words} of their choice to its owner's hand"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Puts cards from exile or a graveyard onto the battlefield ("return that card to the
|
|
7
|
+
# battlefield under its owner's control"). Usually aimed at {"result": n}: a flicker is
|
|
8
|
+
# `exile` followed by `return_to_battlefield` of the exile's result.
|
|
9
|
+
class ReturnToBattlefield < Effect
|
|
10
|
+
UNDER = %w[owner you].freeze
|
|
11
|
+
ZONES = %i[exile graveyard].freeze
|
|
12
|
+
|
|
13
|
+
primitive :effect, :return_to_battlefield,
|
|
14
|
+
description: "Put each referenced card that is in exile or a graveyard onto the battlefield under its " \
|
|
15
|
+
"owner's control (`under: \"you\"`: under the controller's), tapped if `tapped: true`. " \
|
|
16
|
+
"A card that is no longer in the zone it was expected in, or isn't a permanent card, " \
|
|
17
|
+
"stays where it is. `transformed: true`: it enters with its back face up; a card that " \
|
|
18
|
+
"isn't double-faced, or whose back face isn't a permanent, stays where it is.",
|
|
19
|
+
rules: %w[400.7 400.4a 110.2 111.8 603.7c 614.1c 122.6 712.11a 712.11b],
|
|
20
|
+
params: { "to" => "selector (usually {\"result\": n})", "tapped" => "boolean", "transformed" => "boolean",
|
|
21
|
+
"under" => UNDER.join(" | "),
|
|
22
|
+
"counters" => "{counter name: n}: it enters with these counters (\"with a +1/+1 counter on it\")" },
|
|
23
|
+
example: { "return_to_battlefield" => { "to" => { "result" => 0 } } }
|
|
24
|
+
|
|
25
|
+
attr_reader :tapped, :under, :counters, :transformed
|
|
26
|
+
|
|
27
|
+
def self.keys = %w[to tapped under counters transformed if]
|
|
28
|
+
|
|
29
|
+
def setup(params, errors, path)
|
|
30
|
+
@tapped = Mechanics.fetch_param(params, :tapped) == true
|
|
31
|
+
@transformed = Mechanics.fetch_param(params, :transformed) == true
|
|
32
|
+
@under = Mechanics.fetch_param(params, :under) || "owner"
|
|
33
|
+
errors << "#{path}.under: must be one of #{UNDER.join(", ")}" unless UNDER.include?(@under)
|
|
34
|
+
@counters = parse_counters(Mechanics.fetch_param(params, :counters) || {}, errors, "#{path}.counters")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_counters(raw, errors, path)
|
|
38
|
+
return (errors << "#{path}: must be an object of counter name => count") && {} unless raw.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
raw.to_h do |name, n|
|
|
41
|
+
kind = Counters.parse(name.to_s, errors, "#{path}.#{name}")
|
|
42
|
+
errors << "#{path}.#{name}: must be a positive integer" unless n.is_a?(Integer) && n.positive?
|
|
43
|
+
[kind, n]
|
|
44
|
+
end.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
extra = counters.map { |kind, n| " with #{n} #{kind} counter(s)" }.join
|
|
49
|
+
status = "#{" tapped" if tapped}#{" transformed" if transformed}"
|
|
50
|
+
"return to the battlefield#{status}#{" under your control" if under == "you"}#{extra} → #{to}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# CR 614.12a: each card's "as this enters" choice is made first (asked in turn), then they all
|
|
54
|
+
# enter at the same time. The cards are fixed as the effect starts.
|
|
55
|
+
def run(engine, ctx, locals)
|
|
56
|
+
return :done unless locals.key?("cards") || condition.nil? || condition.true?(engine, ctx)
|
|
57
|
+
|
|
58
|
+
entries = (locals["cards"] ||= cards(engine, ctx).map { [it, under == "you" ? ctx.controller : engine.object(it).owner] })
|
|
59
|
+
entries = entries.select { engine.state.objects.key?(it[0]) }
|
|
60
|
+
return :wait if engine.ask_entering_choices!(locals, entries) == :wait
|
|
61
|
+
|
|
62
|
+
enters = { "tapped" => tapped || nil, "counters" => counters.empty? ? nil : counters.dup,
|
|
63
|
+
"face" => transformed ? 1 : nil }.compact
|
|
64
|
+
engine.enter_battlefield!(locals, entries.map { [*it, enters.empty? ? nil : enters] }, reason: "return")
|
|
65
|
+
:done
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# CR 400.4a: an instant or sorcery card can't enter the battlefield. CR 111.8: a token that
|
|
69
|
+
# has left the battlefield can't come back (it ceases to exist at the next SBA check).
|
|
70
|
+
def cards(engine, ctx)
|
|
71
|
+
refs(engine, ctx).select do |id|
|
|
72
|
+
obj = engine.state.objects[id]
|
|
73
|
+
obj && ZONES.include?(obj.zone) && !engine.token?(obj) &&
|
|
74
|
+
!(engine.chars(obj).types - %i[instant sorcery]).empty? && (!transformed || engine.can_enter_face?(obj, 1))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Return to its owner's hand (CR 400.6).
|
|
7
|
+
class ReturnToHand < ZoneEffect
|
|
8
|
+
primitive :effect, :return_to_hand,
|
|
9
|
+
description: "Return each referenced permanent, spell (\"return target spell to its owner's hand\"), or " \
|
|
10
|
+
"card in a graveyard or exile (\"target creature " \
|
|
11
|
+
"card from your graveyard\"), to its owner's hand.",
|
|
12
|
+
rules: %w[400.6 404.1 608.2b], params: { "to" => "selector" },
|
|
13
|
+
example: { "return_to_hand" => { "to" => { "target" => 0 } } }
|
|
14
|
+
def self.zone = :hand
|
|
15
|
+
def self.reason = "bounce"
|
|
16
|
+
def self.from_zones = %i[battlefield graveyard exile stack]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Rolling dice (CR 706): you roll `amount` dice with `sides` sides each (CR 706.1a), each result from 1 to
|
|
7
|
+
# `sides` at random (the game's random number generator). The total of the results is the effect's amount
|
|
8
|
+
# for later effects ({"this_way": n}), so a results table is a list of effects with `if` comparing it
|
|
9
|
+
# (CR 706.3a).
|
|
10
|
+
class RollDice < Effect
|
|
11
|
+
primitive :effect, :roll_dice,
|
|
12
|
+
description: "You roll `amount` dice (default 1) with `sides` sides (default 6). The total of the " \
|
|
13
|
+
"results is this effect's amount: {\"this_way\": n} in later effects and conditions " \
|
|
14
|
+
"(a results table is effects with an `if` on it).",
|
|
15
|
+
rules: %w[706.1 706.1a 706.2 706.3a 608.2c],
|
|
16
|
+
params: { "sides" => "integer >= 2 (default 6)", "amount" => "amount (default 1)" },
|
|
17
|
+
example: { "roll_dice" => { "sides" => 20 } }
|
|
18
|
+
|
|
19
|
+
def self.keys = %w[sides amount if]
|
|
20
|
+
def self.default_to = "you"
|
|
21
|
+
|
|
22
|
+
def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
|
|
23
|
+
params = params.transform_keys(&:to_s)
|
|
24
|
+
params["amount"] = 1 unless params.key?("amount")
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def setup(params, errors, path)
|
|
29
|
+
@sides = Mechanics.fetch_param(params, :sides) || 6
|
|
30
|
+
errors << "#{path}.sides: must be an integer >= 2" unless @sides.is_a?(Integer) && @sides >= 2
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s = "roll #{amount == 1 ? "a" : amount} d#{@sides}"
|
|
34
|
+
|
|
35
|
+
def apply(engine, ctx)
|
|
36
|
+
amount_for(engine, ctx).times do
|
|
37
|
+
result = engine.state.rng.rand(@sides) + 1 # CR 706.2: the natural result
|
|
38
|
+
engine.emit Events::RolledDie.new(player: engine.name(ctx.controller), sides: @sides, result: result)
|
|
39
|
+
engine.record_amount!(result)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Sacrifice permanents (CR 701.21): "sacrifice it at the beginning of the next end step".
|
|
7
|
+
class Sacrifice < Effect
|
|
8
|
+
primitive :effect, :sacrifice,
|
|
9
|
+
description: "The controller sacrifices each referenced permanent they control (it goes to its " \
|
|
10
|
+
"owner's graveyard; it isn't destroyed, so indestructible doesn't stop it).",
|
|
11
|
+
rules: %w[701.21a],
|
|
12
|
+
params: { "to" => "selector" },
|
|
13
|
+
example: { "sacrifice" => { "to" => { "result" => 0 } } }
|
|
14
|
+
|
|
15
|
+
def self.keys = %w[to if]
|
|
16
|
+
|
|
17
|
+
def apply(engine, ctx) = mine(engine, ctx).each { engine.move!(it, :graveyard, reason: "sacrifice") }
|
|
18
|
+
|
|
19
|
+
# CR 701.21a: a player can't sacrifice a permanent they don't control.
|
|
20
|
+
def mine(engine, ctx) = objects(engine, ctx).select { engine.can_sacrifice?(engine.object(it), ctx.controller) }
|
|
21
|
+
def possible?(engine, ctx) = !mine(engine, ctx).empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Edicts and sacrifices of the player's choice: each referenced player chooses a number of permanents
|
|
25
|
+
# they control matching `filter`, in APNAP order (CR 101.4); then all of them are sacrificed at the
|
|
26
|
+
# same time (CR 701.21a).
|
|
27
|
+
class SacrificeChoice < ChoiceEffect
|
|
28
|
+
primitive :effect, :sacrifice_choice,
|
|
29
|
+
description: "Each referenced player (default: you) sacrifices `count` (default 1; \"any\": any number) " \
|
|
30
|
+
"permanents they control matching `filter`, chosen by them in turn order starting with " \
|
|
31
|
+
"the active player; all are sacrificed at once. The `filter`'s `controller` and `other` " \
|
|
32
|
+
"restrictions are relative to the effect's controller and source.",
|
|
33
|
+
rules: %w[701.21a 101.4 118.3],
|
|
34
|
+
params: { "filter" => "a filter, e.g. {\"creature\": {\"token\": false}}", "count" => "integer or \"any\"",
|
|
35
|
+
"to" => "selector of players" },
|
|
36
|
+
example: { "sacrifice_choice" => { "filter" => "creature", "to" => "each_opponent" } }
|
|
37
|
+
|
|
38
|
+
attr_reader :filter, :count
|
|
39
|
+
|
|
40
|
+
def self.keys = %w[filter count to if]
|
|
41
|
+
def self.default_to = "you"
|
|
42
|
+
|
|
43
|
+
def setup(params, errors, path)
|
|
44
|
+
raw = Mechanics.fetch_param(params, :filter)
|
|
45
|
+
errors << "#{path}.filter: required" if raw.nil?
|
|
46
|
+
@filter = raw && Mechanics.build_entry(:filter, raw, errors, "#{path}.filter")
|
|
47
|
+
@count = Mechanics.fetch_param(params, :count) || 1
|
|
48
|
+
return if @count == "any" || (@count.is_a?(Integer) && @count.positive?)
|
|
49
|
+
|
|
50
|
+
errors << "#{path}.count: must be a positive integer or \"any\""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_s = "#{to} sacrifices #{count == "any" ? "any number of" : count} #{words} of their choice"
|
|
54
|
+
def words = filter.is_a?(Class) ? filter.primitive_name.tr("_", " ") : filter.to_s
|
|
55
|
+
|
|
56
|
+
# CR 118.3: as the optional part of a "may", there must be enough permanents to sacrifice.
|
|
57
|
+
def possible?(engine, ctx)
|
|
58
|
+
players(engine, ctx).all? { candidates(engine, ctx, it, []).size >= (count == "any" ? 0 : count) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run(engine, ctx, locals)
|
|
62
|
+
return :done unless locals["asked"] || locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
|
|
63
|
+
|
|
64
|
+
queue = (locals["queue"] ||= apnap(engine, players(engine, ctx)))
|
|
65
|
+
picks = (locals["picks"] ||= [])
|
|
66
|
+
take_answers(locals, picks)
|
|
67
|
+
while (index = queue.first)
|
|
68
|
+
mine = picks.select { it[0] == index }.map { it[1] }
|
|
69
|
+
ask_for = remaining(engine, ctx, locals, index, mine)
|
|
70
|
+
return ask_for if ask_for == :wait
|
|
71
|
+
|
|
72
|
+
queue.shift
|
|
73
|
+
end
|
|
74
|
+
picks.each { finish!(engine, it[1]) }
|
|
75
|
+
:done
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Hooks for Effects::ReturnChoice: what happens to the chosen permanents, who may choose one.
|
|
79
|
+
def finish!(engine, id) = engine.move!(id, :graveyard, reason: "sacrifice")
|
|
80
|
+
def choosable?(engine, obj, index) = engine.can_sacrifice?(obj, index)
|
|
81
|
+
def verb = "sacrifice"
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def apnap(engine, indexes) = engine.turn_order_from_active.select { indexes.include?(it) }
|
|
86
|
+
|
|
87
|
+
# Permanents `index` controls that match the filter, not yet chosen.
|
|
88
|
+
def candidates(engine, ctx, index, chosen)
|
|
89
|
+
filter.candidates_for(engine, ctx.controller, ctx.source)
|
|
90
|
+
.select { engine.state.objects.key?(it) && choosable?(engine, engine.object(it), index) } - chosen
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Asks `index` for their next permanent, or picks automatically when there is no real choice.
|
|
94
|
+
def remaining(engine, ctx, locals, index, mine)
|
|
95
|
+
left = candidates(engine, ctx, index, mine)
|
|
96
|
+
return :next if left.empty? || locals["stopped"] == index || (count != "any" && mine.size >= count)
|
|
97
|
+
|
|
98
|
+
if count != "any" && left.size <= count - mine.size
|
|
99
|
+
left.each { locals["picks"] << [index, it] }
|
|
100
|
+
return :next
|
|
101
|
+
end
|
|
102
|
+
choices = left.map { { "type" => verb, "object" => it } }
|
|
103
|
+
choices << { "type" => "done" } if count == "any"
|
|
104
|
+
locals["chooser"] = index
|
|
105
|
+
ask(engine, locals, index, "#{verb.capitalize} #{words}", choices)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def take_answers(locals, picks)
|
|
109
|
+
(locals["taken"] ||= 0).upto(answers(locals).size - 1) do |i|
|
|
110
|
+
answer = answers(locals)[i]
|
|
111
|
+
answer["type"] == "done" ? locals["stopped"] = locals["chooser"] : picks << [locals["chooser"], answer["object"]]
|
|
112
|
+
end
|
|
113
|
+
locals["taken"] = answers(locals).size
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Scry N / surveil N (CR 701.22, 701.25): look at the top N, put each on the top or away
|
|
7
|
+
# (bottom / graveyard), then order the ones kept on top.
|
|
8
|
+
class LookAtTop < ChoiceEffect
|
|
9
|
+
def self.default_to = "you"
|
|
10
|
+
def self.away = raise(NotImplementedError)
|
|
11
|
+
|
|
12
|
+
def run(engine, ctx, locals)
|
|
13
|
+
index = ctx.controller
|
|
14
|
+
locals["amount"] ||= amount_for(engine, ctx)
|
|
15
|
+
seen = (locals["seen"] ||= engine.player(index).library.first(locals["amount"]))
|
|
16
|
+
decided = answers(locals).reject { it["type"] == "order" }
|
|
17
|
+
if decided.size < seen.size
|
|
18
|
+
card = seen[decided.size]
|
|
19
|
+
return ask(engine, locals, index, "#{self.class.primitive_name}: keep on top or #{self.class.away}?",
|
|
20
|
+
[{ "type" => "top", "object" => card }, { "type" => self.class.away, "object" => card }])
|
|
21
|
+
end
|
|
22
|
+
tops = decided.select { it["type"] == "top" }.map { it["object"] }
|
|
23
|
+
order = answers(locals).select { it["type"] == "order" }.map { it["object"] }
|
|
24
|
+
if tops.size - order.size > 1
|
|
25
|
+
return ask(engine, locals, index, "Choose the next card to put on top (the first chosen ends up on top)",
|
|
26
|
+
(tops - order).map { { "type" => "order", "object" => it } })
|
|
27
|
+
end
|
|
28
|
+
place(engine, index, order + (tops - order), decided.reject { it["type"] == "top" }.map { it["object"] })
|
|
29
|
+
performed!(engine, index, locals["amount"])
|
|
30
|
+
:done
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# CR 701.22b, 701.25c-d: scry or surveil N > 0 is an event once it's complete, even if the library
|
|
34
|
+
# had fewer cards; "whenever you scry" triggers on it.
|
|
35
|
+
def performed!(engine, index, amount)
|
|
36
|
+
return unless amount.positive?
|
|
37
|
+
|
|
38
|
+
engine.emit Events::KeywordAction.new(player: engine.name(index), action: self.class.primitive_name.to_s,
|
|
39
|
+
amount: amount)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Scry N (CR 701.22).
|
|
44
|
+
class Scry < LookAtTop
|
|
45
|
+
primitive :effect, :scry, description: "Look at the top `amount` cards of your library; put any number on the " \
|
|
46
|
+
"bottom and the rest on top in any order.",
|
|
47
|
+
rules: %w[701.22a], params: { "amount" => "integer" }, example: { "scry" => { "amount" => 2 } }
|
|
48
|
+
def self.away = "bottom"
|
|
49
|
+
|
|
50
|
+
def place(engine, index, tops, bottoms)
|
|
51
|
+
library = engine.player(index).library
|
|
52
|
+
library.replace(tops + (library - tops - bottoms) + bottoms)
|
|
53
|
+
engine.emit Events::LibraryReordered.new(player: engine.name(index), top: tops.size, bottom: bottoms.size)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Surveil N (CR 701.25).
|
|
58
|
+
class Surveil < LookAtTop
|
|
59
|
+
primitive :effect, :surveil, description: "Look at the top `amount` cards of your library; put any number into " \
|
|
60
|
+
"your graveyard and the rest on top in any order.",
|
|
61
|
+
rules: %w[701.25a], params: { "amount" => "integer" },
|
|
62
|
+
example: { "surveil" => { "amount" => 2 } }
|
|
63
|
+
def self.away = "graveyard"
|
|
64
|
+
|
|
65
|
+
def place(engine, index, tops, gone)
|
|
66
|
+
library = engine.player(index).library
|
|
67
|
+
library.replace(tops + (library - tops))
|
|
68
|
+
gone.each { engine.move!(it, :graveyard, reason: "surveil") }
|
|
69
|
+
engine.emit Events::LibraryReordered.new(player: engine.name(index), top: tops.size, bottom: 0)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "library"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
module Mechanics
|
|
7
|
+
module Effects
|
|
8
|
+
# Search your library (CR 701.23), optionally reveal what was found (CR 701.20a), put it
|
|
9
|
+
# into your hand or onto the battlefield, then shuffle (CR 701.24).
|
|
10
|
+
#
|
|
11
|
+
# The searcher picks one card at a time. Copies of the same card in a library are
|
|
12
|
+
# interchangeable, so each distinct card is offered once. Only the searcher sees the
|
|
13
|
+
# choices; other players see the reveal (when the effect reveals), the zone change
|
|
14
|
+
# as their zone rules allow, and the shuffle.
|
|
15
|
+
class SearchLibrary < ChoiceEffect
|
|
16
|
+
include EntersBattlefield # effects/library.rb
|
|
17
|
+
|
|
18
|
+
primitive :effect, :search_library,
|
|
19
|
+
description: "Search your library for up to `count` cards matching `for`, then put them into your " \
|
|
20
|
+
"hand (or onto the battlefield, tapped if `tapped`, with `counters`) and shuffle. With a " \
|
|
21
|
+
"stated quality you may find fewer (or none); a search for just \"a card\" must find one " \
|
|
22
|
+
"if it can. `reveal` shows the found cards to every player. `searcher` names another " \
|
|
23
|
+
"player who searches their own library (e.g. {\"controller_of\": {\"target\": 0}}); " \
|
|
24
|
+
"`may: true` lets the searcher decline (then nothing is shuffled). `different_names: true`: " \
|
|
25
|
+
"the cards found must all have different names (\"basic land cards with different names\").",
|
|
26
|
+
rules: %w[701.23a 701.23b 701.23d 701.23e 701.20a 701.24a 701.24b 118.12 110.2a],
|
|
27
|
+
params: { "for" => "card spec: \"card\" (default), {\"types\"|\"subtypes\"|\"supertypes\": [...], " \
|
|
28
|
+
"\"name\": \"...\"} or {\"any_of\": [spec, ...]}",
|
|
29
|
+
"count" => "integer (default 1), \"any\" (any number), \"x\" or an amount (\"up to that many\")",
|
|
30
|
+
"put" => "\"hand\" (default) or \"battlefield\"",
|
|
31
|
+
"tapped" => "true: the cards enter tapped (with put: battlefield)",
|
|
32
|
+
"counters" => "{counter name: n}: the cards enter with these counters (with put: battlefield)",
|
|
33
|
+
"reveal" => "true: reveal the found cards",
|
|
34
|
+
"searcher" => "selector of the searching player (default \"you\")",
|
|
35
|
+
"may" => "true: the searcher may choose not to search",
|
|
36
|
+
"different_names" => "true: each card found has a different name" },
|
|
37
|
+
example: { "search_library" => { "for" => { "supertypes" => ["basic"], "types" => ["land"] },
|
|
38
|
+
"reveal" => true } }
|
|
39
|
+
|
|
40
|
+
ZONES = %w[hand battlefield].freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :spec, :count, :put, :tapped, :reveal
|
|
43
|
+
|
|
44
|
+
def self.keys = %w[for count put tapped counters reveal searcher may different_names if]
|
|
45
|
+
def self.default_to = "you"
|
|
46
|
+
|
|
47
|
+
def setup(params, errors, path)
|
|
48
|
+
@spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
|
|
49
|
+
@count = parse_count(Mechanics.fetch_param(params, :count) || 1, errors, path)
|
|
50
|
+
@put = (Mechanics.fetch_param(params, :put) || "hand").to_s
|
|
51
|
+
errors << "#{path}.put: must be one of #{ZONES.join(", ")}" unless ZONES.include?(@put)
|
|
52
|
+
@tapped = Mechanics.flag_param(params, :tapped, errors, path)
|
|
53
|
+
@reveal = Mechanics.flag_param(params, :reveal, errors, path)
|
|
54
|
+
@may = Mechanics.flag_param(params, :may, errors, path)
|
|
55
|
+
@different_names = Mechanics.flag_param(params, :different_names, errors, path)
|
|
56
|
+
errors << "#{path}.tapped: only cards put onto the battlefield can enter tapped" if @tapped && @put != "battlefield"
|
|
57
|
+
@counters = parse_counters(Mechanics.fetch_param(params, :counters), errors, path)
|
|
58
|
+
raw = Mechanics.fetch_param(params, :searcher) || "you"
|
|
59
|
+
@searcher = Selector.build(raw, errors, "#{path}.searcher", targets_count: @targets_count, results_count: @results_count)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_count(raw, errors, path)
|
|
63
|
+
return raw if raw == "any" || (raw.is_a?(Integer) && raw.positive?)
|
|
64
|
+
return Linked.amount(raw, errors, "#{path}.count", @targets_count, @results_count) if raw.is_a?(Hash) || raw == "x"
|
|
65
|
+
|
|
66
|
+
errors << "#{path}.count: must be a positive integer, \"any\" or an amount"
|
|
67
|
+
1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_counters(raw, errors, path)
|
|
71
|
+
return {} if raw.nil?
|
|
72
|
+
|
|
73
|
+
unless raw.is_a?(Hash) && raw.all? { |name, n| Counters.valid?(Counters.normalize(name.to_s)) && n.is_a?(Integer) && n.positive? }
|
|
74
|
+
errors << "#{path}.counters: must be {counter name: positive integer}"
|
|
75
|
+
return {}
|
|
76
|
+
end
|
|
77
|
+
errors << "#{path}.counters: only cards put onto the battlefield enter with counters" if @put != "battlefield"
|
|
78
|
+
raw.to_h { |name, n| [Counters.normalize(name.to_s), n] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_s
|
|
82
|
+
many = if count == "any" then "any number of"
|
|
83
|
+
elsif count.is_a?(Integer) then "up to #{count}"
|
|
84
|
+
else "up to #{count} (computed)"
|
|
85
|
+
end
|
|
86
|
+
"search your library for #{many} #{spec}#{", reveal" if reveal} → #{put}#{" tapped" if tapped}, shuffle"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run(engine, ctx, locals)
|
|
90
|
+
return :done unless locals["asked"] || condition.nil? || condition.true?(engine, ctx)
|
|
91
|
+
|
|
92
|
+
index = (locals["searcher"] ||= [searcher(engine, ctx)]).first
|
|
93
|
+
return :done if index.nil?
|
|
94
|
+
|
|
95
|
+
if @may && !locals["search"]
|
|
96
|
+
answer = answers(locals).first or return ask(engine, locals, index, "Search your library?",
|
|
97
|
+
[{ "type" => "search" }, { "type" => "decline" }])
|
|
98
|
+
return :done if answer["type"] == "decline"
|
|
99
|
+
|
|
100
|
+
locals["search"] = true
|
|
101
|
+
end
|
|
102
|
+
found = answers(locals).select { it["type"] == "find" }.map { it["object"] }
|
|
103
|
+
unless stop?(engine, ctx, locals, found)
|
|
104
|
+
choices = candidates(engine, index, found)
|
|
105
|
+
return ask(engine, locals, index, "Search your library (#{spec}): find a card", choices) unless choices.empty?
|
|
106
|
+
end
|
|
107
|
+
return :wait if entering_choices?(engine, index, locals, found)
|
|
108
|
+
|
|
109
|
+
finish!(engine, index, found, locals)
|
|
110
|
+
:done
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def searcher(engine, ctx) = @searcher.resolve(engine, ctx).filter_map { Filters.player_index(engine, it) }.first
|
|
116
|
+
|
|
117
|
+
def limit(engine, ctx, locals)
|
|
118
|
+
return count unless count.respond_to?(:value)
|
|
119
|
+
|
|
120
|
+
locals["limit"] ||= count.value(engine, ctx)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stop?(engine, ctx, locals, found)
|
|
124
|
+
wanted = limit(engine, ctx, locals)
|
|
125
|
+
answers(locals).any? { it["type"] == "done" } || (wanted != "any" && found.size >= wanted)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One option per distinct matching card (CR 701.23a); "done" stops looking unless the
|
|
129
|
+
# search is for just a quantity of cards (CR 701.23b, 701.23d).
|
|
130
|
+
def candidates(engine, index, found)
|
|
131
|
+
names = @different_names ? found.map { engine.chars(it).name } : []
|
|
132
|
+
ids = (engine.player(index).library - found).sort.select do
|
|
133
|
+
spec.match?(engine, engine.object(it)) && !names.include?(engine.chars(it).name)
|
|
134
|
+
end
|
|
135
|
+
choices = ids.uniq { engine.object(it).card }.map { { "type" => "find", "object" => it } }
|
|
136
|
+
return choices if choices.empty? || (spec.any? && count != "any")
|
|
137
|
+
|
|
138
|
+
choices << { "type" => "done" }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def finish!(engine, index, found, locals)
|
|
142
|
+
if reveal && put == "hand"
|
|
143
|
+
found.each do |id|
|
|
144
|
+
engine.emit Events::CardRevealed.new(player: engine.name(index), object: id, card: engine.object(id).card)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
enters = {}
|
|
148
|
+
enters["tapped"] = true if tapped
|
|
149
|
+
enters["counters"] = @counters.dup unless @counters.empty?
|
|
150
|
+
put_cards!(engine, locals, found, index, reason: "search", enters: enters.empty? ? nil : enters)
|
|
151
|
+
engine.shuffle_library!(index) # CR 701.24b: the found cards have left; the rest is shuffled
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Tap permanents (CR 701.26).
|
|
7
|
+
class TapEffect < Effect
|
|
8
|
+
primitive :effect, :tap, description: "Tap each referenced permanent.", rules: %w[701.26a],
|
|
9
|
+
params: { "to" => "selector" }, example: { "tap" => { "to" => { "target" => 0 } } }
|
|
10
|
+
def self.keys = %w[to if]
|
|
11
|
+
def apply(engine, ctx) = objects(engine, ctx).each { engine.tap!(engine.object(it)) unless engine.object(it).tapped }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Untap permanents (CR 701.26).
|
|
15
|
+
class UntapEffect < Effect
|
|
16
|
+
primitive :effect, :untap, description: "Untap each referenced permanent.", rules: %w[701.26b],
|
|
17
|
+
params: { "to" => "selector" }, example: { "untap" => { "to" => "self" } }
|
|
18
|
+
def self.keys = %w[to if]
|
|
19
|
+
def apply(engine, ctx) = objects(engine, ctx).each { engine.untap!(engine.object(it)) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|