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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# One-shot and continuous effects (CR 609-611). Each effect is built from data
|
|
7
|
+
# and applied at resolution with an EffectContext.
|
|
8
|
+
# Base effect: parses the common "to" selector, an "amount" (fixed or computed, see
|
|
9
|
+
# Mechanics::Amount) and an optional "if" condition checked on resolution (CR 608.2c).
|
|
10
|
+
class Effect < Primitive
|
|
11
|
+
attr_reader :to, :condition
|
|
12
|
+
|
|
13
|
+
def self.keys = %w[amount to if]
|
|
14
|
+
def self.default_to = nil
|
|
15
|
+
# Whether `amount` may be left out ("discard your hand").
|
|
16
|
+
def self.amount_optional?(_params) = false
|
|
17
|
+
|
|
18
|
+
# `results_count`: how many earlier effects of the enclosing list a {"result": n} selector may name.
|
|
19
|
+
def self.build(params, errors, path, targets_count: 0, results_count: 0, **)
|
|
20
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
21
|
+
raw_to = Mechanics.fetch_param(params, :to) || default_to
|
|
22
|
+
to = if raw_to.nil?
|
|
23
|
+
errors << "#{path}.to: required"
|
|
24
|
+
nil
|
|
25
|
+
else
|
|
26
|
+
Selector.build(raw_to, errors, "#{path}.to", targets_count: targets_count, results_count: results_count)
|
|
27
|
+
end
|
|
28
|
+
new(to: to, params: params, errors: errors, path: path, targets_count: targets_count, results_count: results_count)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(to:, params:, errors:, path:, targets_count: 0, results_count: 0)
|
|
32
|
+
super()
|
|
33
|
+
@to = to
|
|
34
|
+
@targets_count = targets_count
|
|
35
|
+
@results_count = results_count
|
|
36
|
+
if self.class.keys.include?("amount")
|
|
37
|
+
raw = Mechanics.fetch_param(params, :amount)
|
|
38
|
+
@amount = if raw.nil?
|
|
39
|
+
errors << "#{path}.amount: required" unless self.class.amount_optional?(params)
|
|
40
|
+
Amount::Fixed.new(0)
|
|
41
|
+
else
|
|
42
|
+
Amount.build(raw, errors, "#{path}.amount", targets_count: targets_count,
|
|
43
|
+
results_count: results_count)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
raw_if = Mechanics.fetch_param(params, :if)
|
|
47
|
+
@condition = raw_if && Condition.build(raw_if, errors, "#{path}.if", targets_count: targets_count,
|
|
48
|
+
results_count: results_count)
|
|
49
|
+
setup(params, errors, path)
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The fixed amount (for display), or the amount computed for this resolution.
|
|
54
|
+
def amount = @amount.is_a?(Amount::Fixed) ? @amount.n : @amount
|
|
55
|
+
def amount_for(engine, ctx) = @amount.value(engine, ctx)
|
|
56
|
+
|
|
57
|
+
def setup(_params, _errors, _path) = nil
|
|
58
|
+
|
|
59
|
+
# Effects nested inside this one (a "may" or delayed trigger's list); see CardDefinition#all_effects.
|
|
60
|
+
def nested_effects = []
|
|
61
|
+
|
|
62
|
+
# CR 118.3: whether the effect can be done fully now (an optional effect offered as a cost).
|
|
63
|
+
def possible?(_engine, _ctx) = true
|
|
64
|
+
|
|
65
|
+
# Runs the effect during resolution. Effects that need a player's choice override this,
|
|
66
|
+
# keep their progress in `locals` (plain JSON data) and return :wait after asking.
|
|
67
|
+
def run(engine, ctx, _locals)
|
|
68
|
+
apply(engine, ctx) if condition.nil? || condition.true?(engine, ctx)
|
|
69
|
+
:done
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def refs(engine, ctx) = to ? to.resolve(engine, ctx) : []
|
|
73
|
+
|
|
74
|
+
def to_s
|
|
75
|
+
words = self.class.primitive_name.tr("_", " ")
|
|
76
|
+
words += " #{amount}" if amount
|
|
77
|
+
"#{words} → #{to}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def objects(engine, ctx)
|
|
81
|
+
refs(engine, ctx).select { engine.state.objects.key?(it) && engine.state.battlefield.include?(it) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def players(engine, ctx) = refs(engine, ctx).filter_map { Filters.player_index(engine, it) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Moves each referenced permanent, or card in one of the class's other zones (a target card in a
|
|
88
|
+
# graveyard, a card chosen from a hand), to a zone. A card that has left the zone it was in
|
|
89
|
+
# (CR 400.7, 608.2b) isn't found any more, so it isn't moved.
|
|
90
|
+
class ZoneEffect < Effect
|
|
91
|
+
def self.keys = %w[to if]
|
|
92
|
+
def self.zone = raise(NotImplementedError)
|
|
93
|
+
def self.reason = name.split("::").last.downcase
|
|
94
|
+
def self.from_zones = %i[battlefield graveyard]
|
|
95
|
+
|
|
96
|
+
def apply(engine, ctx)
|
|
97
|
+
movable(engine, ctx).each { move(engine, ctx, it) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The referenced objects that are permanents or cards in the zones this effect moves cards from.
|
|
101
|
+
def movable(engine, ctx)
|
|
102
|
+
refs(engine, ctx).select do |id|
|
|
103
|
+
obj = engine.state.objects[id]
|
|
104
|
+
obj && !obj.ability? && self.class.from_zones.include?(obj.zone)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def move(engine, ctx, id)
|
|
109
|
+
Linked.remember!(engine, ctx, id)
|
|
110
|
+
engine.move!(id, self.class.zone, reason: self.class.reason)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Base for effects that ask the controller (or a referenced player) questions while resolving.
|
|
115
|
+
# Progress lives in `locals` (plain data in the resolve frame), so they survive export/import.
|
|
116
|
+
class ChoiceEffect < Effect
|
|
117
|
+
def ask(engine, locals, player, prompt, choices)
|
|
118
|
+
locals["asked"] = true
|
|
119
|
+
engine.ask!("kind" => "effect_choice", "player" => player, "prompt" => prompt, "choices" => choices)
|
|
120
|
+
:wait
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def answers(locals) = locals["answers"] ||= []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Runs a nested effect list one effect at a time (`may`, `pay`, `choose_cards`); a waiting effect keeps its locals
|
|
127
|
+
# under "child", where Engine#effect_answer! delivers answers.
|
|
128
|
+
module NestedList
|
|
129
|
+
def run_list(engine, ctx, locals, list)
|
|
130
|
+
while locals["next"] < list.size
|
|
131
|
+
return :wait if list[locals["next"]].run(engine, ctx, locals["child"] ||= {}) == :wait
|
|
132
|
+
|
|
133
|
+
locals.delete("child")
|
|
134
|
+
locals["next"] += 1
|
|
135
|
+
end
|
|
136
|
+
:done
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_lists(params, errors, path, keys)
|
|
140
|
+
keys.to_h do |key|
|
|
141
|
+
[key, Abilities::StackAbility.parse_effects(params, errors, path, @targets_count, key: key.to_sym,
|
|
142
|
+
results_count: @results_count)]
|
|
143
|
+
end.freeze
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "[It] becomes a Ferengi Scout with base power and toughness 3/4 and gains '...'" (CR 611.2a, 613):
|
|
7
|
+
# one resolved effect that, for its duration, adds card types, supertypes and subtypes "in addition to
|
|
8
|
+
# its other types" and sets creature types (layer 4, CR 205.1a-b), sets colors (layer 5), grants keywords
|
|
9
|
+
# and abilities (layer 6) and sets base power and toughness (layer 7b). "This land becomes a 3/3 red
|
|
10
|
+
# Elemental creature with haste until end of turn. It's still a land." is one `become`. The affected set
|
|
11
|
+
# is locked in as it resolves (CR 611.2c). `duration: "indefinite"` lasts as long as the permanent stays
|
|
12
|
+
# on the battlefield. Granted abilities are part of this card's data, referenced "<this effect's ref>:<j>"
|
|
13
|
+
# (CardDefinition#effect_granted); on the permanent they are "granted:<card>:<ref>:<j>".
|
|
14
|
+
class Become < Effect
|
|
15
|
+
DURATIONS = %w[end_of_turn indefinite while_source_on_battlefield while_you_control_source].freeze
|
|
16
|
+
GRANTABLE = %i[triggered mana activated play_from additional_land_plays hexproof_from protection_from].freeze
|
|
17
|
+
# Card types an effect can add (CR 205.1b); instants, sorceries and planeswalkers aren't permanents it makes.
|
|
18
|
+
ADDABLE_TYPES = %w[artifact creature enchantment land].freeze
|
|
19
|
+
CHANGES = %w[types supertypes subtypes creature_types colors loses_abilities keywords base_power_toughness
|
|
20
|
+
abilities].freeze
|
|
21
|
+
|
|
22
|
+
primitive :effect, :become,
|
|
23
|
+
description: "Each referenced permanent becomes what the parameters say, for `duration` " \
|
|
24
|
+
"(end_of_turn, default; indefinite: as long as it stays on the battlefield; " \
|
|
25
|
+
"while_source_on_battlefield: for as long as this effect's source remains on the " \
|
|
26
|
+
"battlefield; while_you_control_source: for as long as you control it): it has " \
|
|
27
|
+
"the card `types`, `supertypes` and `subtypes` in addition to its other types (layer 4, " \
|
|
28
|
+
"\"becomes an artifact creature\"), its creature types are exactly `creature_types` " \
|
|
29
|
+
"(layer 4), its colors are `colors` (layer 5), with `loses_abilities: true` it loses all " \
|
|
30
|
+
"the abilities it had (layer 6; abilities granted later, keyword counters put on it " \
|
|
31
|
+
"later and this effect's own `keywords` and `abilities` stay, CR 613.7), it has the " \
|
|
32
|
+
"`keywords` and `abilities` " \
|
|
33
|
+
"(layer 6: triggered, activated, mana, play_from or additional_land_plays abilities) and " \
|
|
34
|
+
"its base " \
|
|
35
|
+
"power and toughness are `base_power_toughness` (layer 7b). A permanent that becomes a " \
|
|
36
|
+
"creature has its printed power and toughness (a Vehicle's), or 0/0.",
|
|
37
|
+
rules: %w[611.2a 611.2b 611.2c 613.1d 613.1e 613.1f 613.4b 613.7b 205.1a 205.1b 301.7b 514.2],
|
|
38
|
+
params: { "types" => "card types it also has: #{ADDABLE_TYPES.join(", ")}",
|
|
39
|
+
"supertypes" => "supertypes it also has (\"legendary\")",
|
|
40
|
+
"subtypes" => "subtypes it also has (\"in addition to its other types\")",
|
|
41
|
+
"creature_types" => "list of creature types (replaces the others)",
|
|
42
|
+
"colors" => "list of W, U, B, R, G (replaces its colors)",
|
|
43
|
+
"loses_abilities" => "true: it loses all abilities (\"loses all abilities\")",
|
|
44
|
+
"keywords" => "list of keywords it gains",
|
|
45
|
+
"base_power_toughness" => "{\"power\": n, \"toughness\": n}",
|
|
46
|
+
"abilities" => "list of abilities it gains", "duration" => DURATIONS.join(" | "),
|
|
47
|
+
"to" => "selector" },
|
|
48
|
+
example: { "become" => { "types" => ["creature"], "creature_types" => ["elemental"], "colors" => ["R"],
|
|
49
|
+
"base_power_toughness" => { "power" => 3, "toughness" => 3 },
|
|
50
|
+
"keywords" => ["haste"], "to" => "self" } }
|
|
51
|
+
|
|
52
|
+
attr_reader :ref, :creature_types, :base_power_toughness, :granted_abilities, :duration, :card_types,
|
|
53
|
+
:supertypes, :subtypes, :colors, :keywords
|
|
54
|
+
|
|
55
|
+
def self.keys = CHANGES + %w[duration to if]
|
|
56
|
+
|
|
57
|
+
# A list of registered names (card types, supertypes, keywords) as symbols; [] when absent.
|
|
58
|
+
def self.names_param(params, key, category, errors, path, allowed: nil)
|
|
59
|
+
raw = Mechanics.fetch_param(params, key)
|
|
60
|
+
return [].freeze if raw.nil?
|
|
61
|
+
|
|
62
|
+
names = raw.is_a?(Array) ? raw.map { it.to_s.downcase.tr(" -", "__") } : []
|
|
63
|
+
ok = !names.empty? && names.all? { Enjine.registry.fetch(category, it) && (allowed.nil? || allowed.include?(it)) }
|
|
64
|
+
unless ok
|
|
65
|
+
choices = allowed || Enjine.registry.names(category)
|
|
66
|
+
errors << "#{path}.#{key}: must be a non-empty list of #{category}s (#{choices.join(", ")})"
|
|
67
|
+
return [].freeze
|
|
68
|
+
end
|
|
69
|
+
names.uniq.map(&:to_sym).freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def setup(params, errors, path)
|
|
73
|
+
@ref = "become:#{path}"
|
|
74
|
+
@card_types = Become.names_param(params, :types, :type, errors, path, allowed: ADDABLE_TYPES)
|
|
75
|
+
@supertypes = Become.names_param(params, :supertypes, :supertype, errors, path)
|
|
76
|
+
@subtypes = parse_types(Mechanics.fetch_param(params, :subtypes), errors, "#{path}.subtypes", "subtypes") || []
|
|
77
|
+
@creature_types = parse_types(Mechanics.fetch_param(params, :creature_types), errors, "#{path}.creature_types",
|
|
78
|
+
"creature types")
|
|
79
|
+
@colors = Become.parse_colors(Mechanics.fetch_param(params, :colors), errors, path)
|
|
80
|
+
@loses_abilities = Mechanics.fetch_param(params, :loses_abilities)
|
|
81
|
+
errors << "#{path}.loses_abilities: must be true (or absent)" unless [nil, true].include?(@loses_abilities)
|
|
82
|
+
@keywords = Become.names_param(params, :keywords, :keyword, errors, path)
|
|
83
|
+
@base_power_toughness = Become.parse_power_toughness(Mechanics.fetch_param(params, :base_power_toughness), errors, path)
|
|
84
|
+
@granted_abilities = parse_abilities(params, errors, path)
|
|
85
|
+
@duration = Mechanics.fetch_param(params, :duration) || "end_of_turn"
|
|
86
|
+
errors << "#{path}.duration: must be one of #{DURATIONS.join(", ")}" unless DURATIONS.include?(@duration)
|
|
87
|
+
return unless CHANGES.all? { Mechanics.fetch_param(params, it).nil? }
|
|
88
|
+
|
|
89
|
+
errors << "#{path}: needs at least one of #{CHANGES.join(", ")}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def loses_abilities? = @loses_abilities == true
|
|
93
|
+
|
|
94
|
+
# The state.effects duration and the details it needs. "For as long as [source] remains on the
|
|
95
|
+
# battlefield" does nothing if the source has already left as this resolves (CR 611.2b).
|
|
96
|
+
def duration_details(engine, ctx)
|
|
97
|
+
case @duration
|
|
98
|
+
when "indefinite" then { "duration" => "permanent" }
|
|
99
|
+
when "end_of_turn" then { "duration" => "end_of_turn" }
|
|
100
|
+
else
|
|
101
|
+
holds = engine.source_duration_holds?(@duration, ctx.source, ctx.controller)
|
|
102
|
+
holds ? { "duration" => @duration, "source" => ctx.source } : nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def apply(engine, ctx)
|
|
107
|
+
affected = objects(engine, ctx)
|
|
108
|
+
lasting = duration_details(engine, ctx) or return
|
|
109
|
+
add = ->(kind, details) { engine.add_continuous_effect!(kind, ctx, affected, details.merge(lasting)) }
|
|
110
|
+
# Removal first: this effect's own keywords and abilities come after it (CR 613.7b).
|
|
111
|
+
add.call("lose_abilities", {}) if loses_abilities?
|
|
112
|
+
unless card_types.empty? && supertypes.empty? && subtypes.empty?
|
|
113
|
+
add.call("add_types", "types" => card_types.map(&:to_s), "supertypes" => supertypes.map(&:to_s), "subtypes" => subtypes)
|
|
114
|
+
end
|
|
115
|
+
creatures = affected.select { engine.chars(it).creature? } # after the types above are added
|
|
116
|
+
engine.add_continuous_effect!("creature_types", ctx, creatures, { "types" => creature_types }.merge(lasting)) if creature_types
|
|
117
|
+
add.call("colors", "colors" => colors) if colors
|
|
118
|
+
add.call("grant", "keywords" => keywords.map(&:to_s)) unless keywords.empty?
|
|
119
|
+
engine.add_continuous_effect!("base_power_toughness", ctx, creatures, base_power_toughness.merge(lasting)) if base_power_toughness
|
|
120
|
+
return if granted_abilities.empty?
|
|
121
|
+
|
|
122
|
+
add.call("abilities", "card" => engine.object(ctx.object).card, "ref" => ref)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def nested_effects = granted_abilities.flat_map(&:try_effects)
|
|
126
|
+
|
|
127
|
+
def to_s
|
|
128
|
+
bits = []
|
|
129
|
+
bits << "loses all abilities" if loses_abilities?
|
|
130
|
+
bits << [*supertypes, *colors, *subtypes, *card_types].join(" ") unless (card_types + supertypes + subtypes).empty?
|
|
131
|
+
bits << creature_types.join(" ") if creature_types
|
|
132
|
+
bits << "base #{base_power_toughness.values.join("/")}" if base_power_toughness
|
|
133
|
+
bits << keywords.join(", ") unless keywords.empty?
|
|
134
|
+
bits << "\"#{granted_abilities.join("; ")}\"" unless granted_abilities.empty?
|
|
135
|
+
"becomes #{bits.join(", ")}#{" until end of turn" if duration == "end_of_turn"} → #{to}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.parse_colors(raw, errors, path)
|
|
139
|
+
return nil if raw.nil?
|
|
140
|
+
|
|
141
|
+
ok = raw.is_a?(Array) && raw.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
|
|
142
|
+
errors << "#{path}.colors: must be a list of W, U, B, R, G ([] for colorless)" unless ok
|
|
143
|
+
ok ? raw.map { it.to_s.upcase }.uniq.freeze : nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.parse_power_toughness(raw, errors, path)
|
|
147
|
+
return nil if raw.nil?
|
|
148
|
+
|
|
149
|
+
unless raw.is_a?(Hash)
|
|
150
|
+
errors << "#{path}.base_power_toughness: must be {\"power\": n, \"toughness\": n}"
|
|
151
|
+
return nil
|
|
152
|
+
end
|
|
153
|
+
sub = "#{path}.base_power_toughness"
|
|
154
|
+
Mechanics.check_keys(raw, %w[power toughness], errors, sub)
|
|
155
|
+
{ "power" => Mechanics.integer_param(raw, :power, errors, sub, min: 0),
|
|
156
|
+
"toughness" => Mechanics.integer_param(raw, :toughness, errors, sub, min: 0) }.freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def parse_types(raw, errors, path, noun)
|
|
162
|
+
return nil if raw.nil?
|
|
163
|
+
|
|
164
|
+
ok = raw.is_a?(Array) && !raw.empty? && raw.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
|
|
165
|
+
errors << "#{path}: must be a non-empty list of lowercase #{noun}" unless ok
|
|
166
|
+
ok ? raw.map(&:to_s).freeze : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def parse_abilities(params, errors, path)
|
|
170
|
+
Mechanics.list_param(params, :abilities, errors, path).each_with_index.filter_map do |entry, j|
|
|
171
|
+
ability = Mechanics.build_entry(:ability, entry, errors, "#{path}.abilities[#{j}]")
|
|
172
|
+
next ability if ability.nil? || (GRANTABLE.include?(ability.kind) && !(ability.kind == :play_from && ability.self?))
|
|
173
|
+
|
|
174
|
+
errors << "#{path}.abilities[#{j}]: only triggered, activated, mana, play_from (for other cards), " \
|
|
175
|
+
"additional_land_plays, hexproof_from and protection_from abilities can be gained"
|
|
176
|
+
nil
|
|
177
|
+
end.freeze
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# "Behold a Dragon" as a resolving instruction (CR 701.4a), usually optional: "you may behold a Dragon.
|
|
7
|
+
# If you do, create a Treasure token" is {"may": {"effects": [{"behold": ...}], "then": [...]}}. You
|
|
8
|
+
# reveal a matching card from your hand or choose a matching permanent you control.
|
|
9
|
+
class Behold < ChoiceEffect
|
|
10
|
+
primitive :effect, :behold,
|
|
11
|
+
description: "You behold an object matching the card spec `card`: reveal such a card from your " \
|
|
12
|
+
"hand or choose such a permanent you control. Under `may`, \"yes\" is offered only " \
|
|
13
|
+
"when there is one.",
|
|
14
|
+
rules: %w[701.4a 701.4b 118.3],
|
|
15
|
+
params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"dragon\"]}" },
|
|
16
|
+
example: { "may" => { "effects" => [{ "behold" => { "card" => { "subtypes" => ["dragon"] } } }],
|
|
17
|
+
"then" => [{ "create_token" => { "token" => "Treasure" } }] } }
|
|
18
|
+
|
|
19
|
+
def self.keys = %w[card if]
|
|
20
|
+
def self.default_to = "you"
|
|
21
|
+
|
|
22
|
+
def setup(params, errors, path)
|
|
23
|
+
raw = Mechanics.fetch_param(params, :card)
|
|
24
|
+
errors << "#{path}.card: required" if raw.nil?
|
|
25
|
+
@card = CardSpec.build(raw, errors, "#{path}.card")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def possible?(engine, ctx) = !Beholding.candidates(engine, ctx.controller, @card).empty?
|
|
29
|
+
|
|
30
|
+
def run(engine, ctx, locals)
|
|
31
|
+
return :done unless condition.nil? || condition.true?(engine, ctx)
|
|
32
|
+
|
|
33
|
+
options = Beholding.candidates(engine, ctx.controller, @card)
|
|
34
|
+
chosen = answers(locals).first
|
|
35
|
+
return :done if options.empty? && chosen.nil?
|
|
36
|
+
|
|
37
|
+
choices = options.map { { "type" => "behold", "object" => it } }
|
|
38
|
+
return ask(engine, locals, ctx.controller, "Behold a #{@card}", choices) unless chosen
|
|
39
|
+
|
|
40
|
+
Beholding.behold!(engine, ctx.controller, chosen["object"]) if engine.state.objects.key?(chosen["object"])
|
|
41
|
+
:done
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s = "behold a #{@card.to_s.delete_suffix(" card")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Blight N" as a cost (CR 701.68a, 601.2h, 602.2b): put N -1/-1 counters on a creature you control,
|
|
7
|
+
# chosen while casting or activating; the counters are put on it as the spell or ability becomes cast.
|
|
8
|
+
# Without a creature it can't be paid (CR 701.68b, 118.3). The paid entry names the blighted creature
|
|
9
|
+
# (CR 701.68c).
|
|
10
|
+
class Blight < Choosing
|
|
11
|
+
primitive :cost, :blight,
|
|
12
|
+
description: "Blight `amount`: put that many -1/-1 counters on a creature you control, chosen " \
|
|
13
|
+
"while casting or activating and put on it as it becomes cast.",
|
|
14
|
+
rules: %w[701.68a 701.68b 601.2h 602.2b 118.3],
|
|
15
|
+
params: { "amount" => "integer >= 1" },
|
|
16
|
+
example: { "blight" => { "amount" => 1 } }
|
|
17
|
+
|
|
18
|
+
attr_reader :amount
|
|
19
|
+
|
|
20
|
+
def self.keys = %w[amount]
|
|
21
|
+
def self.default_filter = "creature"
|
|
22
|
+
|
|
23
|
+
def initialize(filter, count, other, params, errors, path)
|
|
24
|
+
@amount = Mechanics.integer_param(params, :amount, errors, path, min: 1)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pay_chosen!(engine, object, entries)
|
|
29
|
+
entries.each do |entry|
|
|
30
|
+
perm = engine.state.objects[entry["object"]]
|
|
31
|
+
engine.add_counters!(perm, "-1/-1", amount, by_player: object.controller) if perm&.zone == :battlefield
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s = "Blight #{amount}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module Effects
|
|
40
|
+
# Blight N (CR 701.68a): each referenced player (default: you) puts N -1/-1 counters on a creature they
|
|
41
|
+
# control, of their choice. A player without creatures can't (CR 701.68b), so in a `may` yes is offered
|
|
42
|
+
# only when everyone can. The blighted creatures are the effect's results ("the blighted creature",
|
|
43
|
+
# CR 701.68c).
|
|
44
|
+
class Blight < ChoiceEffect
|
|
45
|
+
primitive :effect, :blight,
|
|
46
|
+
description: "Each referenced player (default: you) blights `amount`: puts that many -1/-1 " \
|
|
47
|
+
"counters on a creature they control, chosen as it resolves (one `effect_choice` " \
|
|
48
|
+
"each, in turn order). The blighted creatures are its results.",
|
|
49
|
+
rules: %w[701.68a 701.68b 701.68c],
|
|
50
|
+
params: { "amount" => "amount", "to" => "player selector (default you)" },
|
|
51
|
+
example: { "blight" => { "amount" => 2 } }
|
|
52
|
+
|
|
53
|
+
def self.default_to = "you"
|
|
54
|
+
|
|
55
|
+
def possible?(engine, ctx) = players(engine, ctx).all? { !creatures(engine, it).empty? }
|
|
56
|
+
|
|
57
|
+
def run(engine, ctx, locals)
|
|
58
|
+
return :done unless locals.key?("queue") || condition.nil? || condition.true?(engine, ctx)
|
|
59
|
+
|
|
60
|
+
queue = (locals["queue"] ||= players(engine, ctx))
|
|
61
|
+
locals["n"] ||= amount_for(engine, ctx)
|
|
62
|
+
chosen = answers(locals)
|
|
63
|
+
while chosen.size < queue.size
|
|
64
|
+
index = queue[chosen.size]
|
|
65
|
+
options = creatures(engine, index).map { { "type" => "blight", "object" => it } }
|
|
66
|
+
return ask(engine, locals, index, "Blight #{locals["n"]}: choose a creature you control", options) unless options.empty?
|
|
67
|
+
|
|
68
|
+
chosen << { "type" => "none" } # CR 701.68b: nothing to blight
|
|
69
|
+
end
|
|
70
|
+
apply_all(engine, locals, queue, chosen)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_s = "blight #{amount} → #{to}"
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def creatures(engine, index)
|
|
78
|
+
engine.state.battlefield.select { engine.object(it).controller == index && engine.chars(it).creature? }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The counters are put on once every player has chosen.
|
|
82
|
+
def apply_all(engine, locals, queue, chosen)
|
|
83
|
+
chosen.each_with_index do |answer, i|
|
|
84
|
+
next unless answer["type"] == "blight" && locals["n"].positive?
|
|
85
|
+
|
|
86
|
+
engine.add_counters!(engine.object(answer["object"]), "-1/-1", locals["n"], by_player: queue[i])
|
|
87
|
+
engine.record_result!(answer["object"])
|
|
88
|
+
end
|
|
89
|
+
:done
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Effects
|
|
6
|
+
# Choose cards from among a set (CR 700.2-style choices made on resolution): from what an earlier
|
|
7
|
+
# effect moved ({"result": n}), from a player's hand or graveyard, or from permanents. The chosen
|
|
8
|
+
# cards go to `put`; `then` follows if a card was chosen ("if you do"), `otherwise` if none was
|
|
9
|
+
# ("if you can't" / "if you don't").
|
|
10
|
+
class ChooseCards < ChoiceEffect
|
|
11
|
+
include NestedList
|
|
12
|
+
|
|
13
|
+
PUT = %w[hand graveyard exile battlefield library_top library_bottom].freeze
|
|
14
|
+
ZONES = %w[hand graveyard].freeze
|
|
15
|
+
REVEAL = %w[chosen all].freeze
|
|
16
|
+
LISTS = %w[then otherwise].freeze
|
|
17
|
+
|
|
18
|
+
primitive :effect, :choose_cards,
|
|
19
|
+
description: "You choose `count` (default 1; \"any\": any number) cards matching `for` from `from`: a " \
|
|
20
|
+
"selector (e.g. {\"result\": 0}, the cards an earlier effect milled) or a player's zone, " \
|
|
21
|
+
"{\"hand\": selector} / {\"graveyard\": selector}. `optional`: you may choose fewer. Then " \
|
|
22
|
+
"they are put into `put` (`tapped` onto the battlefield, under your control; exiled cards " \
|
|
23
|
+
"`link`ed to the source). `look`: you look at the whole zone first; `reveal`: \"chosen\" " \
|
|
24
|
+
"reveals what you chose, \"all\" the whole set. `then` runs if a card was chosen, " \
|
|
25
|
+
"`otherwise` if none was (\"if you can't\"). `random: true`: the cards are picked at random " \
|
|
26
|
+
"(the game's random number generator) instead of chosen.",
|
|
27
|
+
rules: %w[701.20a 701.20e 400.7j 608.2c 607.2a 118.12 701.9b],
|
|
28
|
+
params: { "from" => "selector or {\"hand\"|\"graveyard\": player selector}", "for" => "card spec",
|
|
29
|
+
"count" => "integer or \"any\"", "optional" => "boolean", "put" => PUT.join(" | "),
|
|
30
|
+
"tapped" => "boolean", "link" => "boolean", "look" => "boolean", "random" => "boolean",
|
|
31
|
+
"reveal" => REVEAL.join(" | "), "then" => "list of effects", "otherwise" => "list of effects" },
|
|
32
|
+
example: { "choose_cards" => { "from" => { "result" => 0 }, "for" => { "permanent" => true },
|
|
33
|
+
"optional" => true, "put" => "hand" } }
|
|
34
|
+
|
|
35
|
+
attr_reader :spec, :count, :put
|
|
36
|
+
|
|
37
|
+
def self.keys = %w[from for count optional put tapped link look random reveal then otherwise if]
|
|
38
|
+
def self.default_to = "you"
|
|
39
|
+
|
|
40
|
+
def setup(params, errors, path)
|
|
41
|
+
@from_zone, @from = parse_from(Mechanics.fetch_param(params, :from), errors, path)
|
|
42
|
+
@spec = CardSpec.build(Mechanics.fetch_param(params, :for), errors, "#{path}.for")
|
|
43
|
+
@count = Mechanics.fetch_param(params, :count) || 1
|
|
44
|
+
unless @count == "any" || (@count.is_a?(Integer) && @count.positive?)
|
|
45
|
+
errors << "#{path}.count: must be a positive integer or \"any\""
|
|
46
|
+
end
|
|
47
|
+
@optional = Mechanics.flag_param(params, "optional", errors, path)
|
|
48
|
+
@put = Mechanics.fetch_param(params, :put).to_s
|
|
49
|
+
errors << "#{path}.put: must be one of #{PUT.join(", ")}" unless PUT.include?(@put)
|
|
50
|
+
@flags = %w[tapped link look random].to_h { [it, Mechanics.flag_param(params, it, errors, path)] }
|
|
51
|
+
errors << "#{path}.count: must be a number to pick at random" if @flags["random"] && @count == "any"
|
|
52
|
+
@reveal = Mechanics.fetch_param(params, :reveal)
|
|
53
|
+
errors << "#{path}.reveal: must be one of #{REVEAL.join(", ")}" unless @reveal.nil? || REVEAL.include?(@reveal)
|
|
54
|
+
@lists = parse_lists(params, errors, path, LISTS)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_from(raw, errors, path)
|
|
58
|
+
zone = raw.is_a?(Hash) && raw.size == 1 && ZONES.include?(raw.keys.first.to_s) ? raw.keys.first.to_s : nil
|
|
59
|
+
inner = zone ? raw.values.first : raw
|
|
60
|
+
return [zone, nil] if inner.nil? && (errors << "#{path}.from: required")
|
|
61
|
+
|
|
62
|
+
[zone, Selector.build(inner, errors, "#{path}.from#{".#{zone}" if zone}", targets_count: @targets_count,
|
|
63
|
+
results_count: @results_count)]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nested_effects = @lists.values.flatten
|
|
67
|
+
|
|
68
|
+
def to_s
|
|
69
|
+
many = count == "any" ? "any number of" : "#{"up to " if @optional}#{count}"
|
|
70
|
+
"choose #{many} #{spec}#{" at random" if @flags["random"]} from #{"the #{@from_zone} of " if @from_zone}#{@from} " \
|
|
71
|
+
"→ #{put.tr("_", " ")}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# CR 118.3: as the optional part of a "may", the full number of cards must be available.
|
|
75
|
+
def possible?(engine, ctx) = pool(engine, ctx).size >= (count == "any" ? 1 : count)
|
|
76
|
+
|
|
77
|
+
def run(engine, ctx, locals)
|
|
78
|
+
return :done unless locals["asked"] || locals.key?("chose") || condition.nil? || condition.true?(engine, ctx)
|
|
79
|
+
|
|
80
|
+
unless locals.key?("chose")
|
|
81
|
+
show!(engine, ctx, locals)
|
|
82
|
+
chosen = picked(engine, ctx, locals)
|
|
83
|
+
choices = @flags["random"] || stop?(locals, chosen) ? [] : choices(engine, ctx, chosen)
|
|
84
|
+
return ask(engine, locals, ctx.controller, "Choose #{spec}", choices) unless choices.empty?
|
|
85
|
+
|
|
86
|
+
return :wait if put == "battlefield" && engine.ask_entering_choices!(locals, chosen.map { [it, ctx.controller] }) == :wait
|
|
87
|
+
|
|
88
|
+
locals["chose"] = !perform!(engine, ctx, chosen, locals).empty?
|
|
89
|
+
locals["next"] = 0
|
|
90
|
+
end
|
|
91
|
+
run_list(engine, ctx, locals, @lists[locals["chose"] ? "then" : "otherwise"])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# The cards chosen so far, or picked at random once (as in CR 701.9b, through the game's RNG).
|
|
97
|
+
def picked(engine, ctx, locals)
|
|
98
|
+
return answers(locals).select { it["type"] == "choose" }.map { it["object"] } unless @flags["random"]
|
|
99
|
+
|
|
100
|
+
locals["picked"] ||= engine.state.rng.shuffle!(pool(engine, ctx)).first(count)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The cards to choose from, in a stable order.
|
|
104
|
+
def pool(engine, ctx)
|
|
105
|
+
refs = set(engine, ctx)
|
|
106
|
+
refs.select { (obj = engine.state.objects[it]) && spec.match?(engine, obj, ctx) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def set(engine, ctx)
|
|
110
|
+
refs = @from.resolve(engine, ctx)
|
|
111
|
+
return refs.select { engine.state.objects.key?(it) } unless @from_zone
|
|
112
|
+
|
|
113
|
+
refs.filter_map { Filters.player_index(engine, it) }.flat_map { engine.player(it).public_send(@from_zone) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Looking at or revealing the whole set happens once, before the choice (CR 701.20a, 701.20e).
|
|
117
|
+
def show!(engine, ctx, locals)
|
|
118
|
+
return if locals["shown"]
|
|
119
|
+
|
|
120
|
+
locals["shown"] = true
|
|
121
|
+
ids = set(engine, ctx)
|
|
122
|
+
if @reveal == "all"
|
|
123
|
+
ids.group_by { engine.object(it).owner }.each_value { engine.reveal!(engine.object(it.first).owner, it) }
|
|
124
|
+
elsif @flags["look"]
|
|
125
|
+
ids.group_by { engine.object(it).owner }.each { |owner, list| engine.look_at!(ctx.controller, owner, list) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stop?(locals, chosen)
|
|
130
|
+
answers(locals).any? { it["type"] == "done" } || (count != "any" && chosen.size >= count)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def choices(engine, ctx, chosen)
|
|
134
|
+
ids = pool(engine, ctx) - chosen
|
|
135
|
+
options = ids.map { { "type" => "choose", "object" => it } }
|
|
136
|
+
return options if options.empty?
|
|
137
|
+
|
|
138
|
+
options << { "type" => "done" } if @optional || count == "any"
|
|
139
|
+
options
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def perform!(engine, ctx, chosen, locals)
|
|
143
|
+
engine.reveal!(ctx.controller, chosen) if @reveal == "chosen"
|
|
144
|
+
return enter!(engine, ctx, chosen, locals) if put == "battlefield"
|
|
145
|
+
|
|
146
|
+
chosen.filter_map { move(engine, ctx, it) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# CR 614.12a: their "as this enters" choices were made first; they enter at the same time.
|
|
150
|
+
def enter!(engine, ctx, chosen, locals)
|
|
151
|
+
enters = @flags["tapped"] ? { "tapped" => true } : nil
|
|
152
|
+
engine.enter_battlefield!(locals, chosen.map { [it, ctx.controller, enters] }, reason: "put")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def move(engine, ctx, id)
|
|
156
|
+
case put
|
|
157
|
+
when "library_top", "library_bottom"
|
|
158
|
+
engine.move!(id, :library, reason: "put", position: put == "library_top" ? :top : :bottom)
|
|
159
|
+
else
|
|
160
|
+
fresh = engine.move!(id, put.to_sym, reason: put == "graveyard" ? "put" : put)
|
|
161
|
+
engine.link_exiled!(ctx.source, fresh.id) if fresh && @flags["link"]
|
|
162
|
+
fresh
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|