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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# The exceptions of a copy effect (CR 707.9a-b), e.g. {"legendary": false} ("except it isn't
|
|
6
|
+
# legendary") or {"keywords": ["menace"]} ("except it has menace"). They become part of the copy's
|
|
7
|
+
# copiable values, so a copy of the copy has them too (CR 707.2). See Engine::Copies.
|
|
8
|
+
module CopyExcept
|
|
9
|
+
KEYS = %w[legendary keywords types subtypes supertypes name power toughness colors].freeze
|
|
10
|
+
LISTS = { "keywords" => :keyword, "types" => :type, "supertypes" => :supertype }.freeze
|
|
11
|
+
PARAM = "{\"legendary\": false, \"keywords\": [...], \"types\": [...], \"subtypes\": [...], " \
|
|
12
|
+
"\"supertypes\": [...], \"name\": ..., \"power\": n, \"toughness\": n, \"colors\": [...]}"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def build(raw, errors, path)
|
|
17
|
+
return {}.freeze if raw.nil?
|
|
18
|
+
return (errors << "#{path}: must be an object #{PARAM}") && {}.freeze unless raw.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
raw = raw.transform_keys(&:to_s)
|
|
21
|
+
Mechanics.check_keys(raw, KEYS, errors, path)
|
|
22
|
+
out = {}
|
|
23
|
+
errors << "#{path}.legendary: must be false (\"it isn't legendary\")" if raw.key?("legendary") && raw["legendary"] != false
|
|
24
|
+
out["legendary"] = false if raw.key?("legendary")
|
|
25
|
+
LISTS.each { |key, category| out[key] = names(raw[key], category, errors, "#{path}.#{key}") if raw.key?(key) }
|
|
26
|
+
out["subtypes"] = subtypes(raw["subtypes"], errors, "#{path}.subtypes") if raw.key?("subtypes")
|
|
27
|
+
values(raw, out, errors, path)
|
|
28
|
+
out["colors"] = colors(raw["colors"], errors, "#{path}.colors") if raw.key?("colors")
|
|
29
|
+
out.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# "name", "power", "toughness": values the copy has instead of the copied ones.
|
|
33
|
+
def values(raw, out, errors, path)
|
|
34
|
+
out["name"] = raw["name"] if raw.key?("name")
|
|
35
|
+
errors << "#{path}.name: must be a non-empty string" if raw.key?("name") && !(raw["name"].is_a?(String) && !raw["name"].empty?)
|
|
36
|
+
%w[power toughness].each do |key|
|
|
37
|
+
next unless raw.key?(key)
|
|
38
|
+
|
|
39
|
+
errors << "#{path}.#{key}: must be an integer" unless raw[key].is_a?(Integer)
|
|
40
|
+
out[key] = raw[key]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def names(list, category, errors, path)
|
|
45
|
+
ok = list.is_a?(Array) && !list.empty? && list.all? { Enjine.registry.fetch(category, it.to_s.downcase.tr(" -", "__")) }
|
|
46
|
+
errors << "#{path}: must be a non-empty list of #{category}s (#{Enjine.registry.names(category).join(", ")})" unless ok
|
|
47
|
+
ok ? list.map { it.to_s.downcase.tr(" -", "__") }.freeze : [].freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def subtypes(list, errors, path)
|
|
51
|
+
ok = list.is_a?(Array) && !list.empty? && list.all? { CardDefinition::SUBTYPE_FORMAT.match?(it.to_s) }
|
|
52
|
+
errors << "#{path}: must be a non-empty list of lowercase subtypes" unless ok
|
|
53
|
+
ok ? list.map(&:to_s).freeze : [].freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def colors(list, errors, path)
|
|
57
|
+
ok = list.is_a?(Array) && list.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
|
|
58
|
+
errors << "#{path}: must be a list of W, U, B, R, G ([] for colorless)" unless ok
|
|
59
|
+
ok ? list.map { it.to_s.upcase }.freeze : [].freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_s(except)
|
|
63
|
+
return "" if except.empty?
|
|
64
|
+
|
|
65
|
+
bits = []
|
|
66
|
+
bits << "not legendary" if except["legendary"] == false
|
|
67
|
+
%w[name power toughness].each { bits << "#{it} #{except[it]}" if except.key?(it) }
|
|
68
|
+
%w[keywords types subtypes supertypes colors].each { bits << "#{it} #{except[it].join("/")}" if except[it] }
|
|
69
|
+
" (except #{bits.join(", ")})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# Costs that need choices while casting or activating (CR 601.2h, 602.2b): which permanents to tap
|
|
7
|
+
# or sacrifice, which counters to remove. Each choice is a `choose_cost` decision recorded in the
|
|
8
|
+
# cast frame's "paid" list ({"cost": index, "object": id, ...}); the spell or ability keeps that list
|
|
9
|
+
# (GameObject#paid) so effects can ask about "the sacrificed creature". Tapping happens at once
|
|
10
|
+
# (cancelling untaps); sacrificing and removing counters happen as the spell or ability becomes cast.
|
|
11
|
+
class Choosing < Primitive
|
|
12
|
+
attr_reader :count, :filter
|
|
13
|
+
|
|
14
|
+
def self.build(params, errors, path, **)
|
|
15
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
16
|
+
raw = Mechanics.fetch_param(params, :filter) || default_filter
|
|
17
|
+
filter = Mechanics.build_entry(:filter, raw, errors, "#{path}.filter")
|
|
18
|
+
count = Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
|
|
19
|
+
new(filter, count, Mechanics.flag_param(params, :other, errors, path), params, errors, path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.keys = %w[filter count other]
|
|
23
|
+
def self.default_filter = "permanent"
|
|
24
|
+
|
|
25
|
+
def initialize(filter, count, other, _params, _errors, _path)
|
|
26
|
+
super()
|
|
27
|
+
@filter = filter
|
|
28
|
+
@count = count
|
|
29
|
+
@other = other
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mana? = false
|
|
34
|
+
def choices_needed = count
|
|
35
|
+
|
|
36
|
+
# Permanents the controller of `object` controls that match (not `object` itself for "another").
|
|
37
|
+
def matching(engine, object)
|
|
38
|
+
engine.state.battlefield.select do |id|
|
|
39
|
+
perm = engine.object(id)
|
|
40
|
+
perm.controller == object.controller && !(@other && id == object.id) &&
|
|
41
|
+
@filter.match_for?(engine, id, object.controller, object.id)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The options still open, given the choices already made for this cost (`chosen`: paid entries).
|
|
46
|
+
def choice_options(engine, object, chosen)
|
|
47
|
+
taken = chosen.map { it["object"] }
|
|
48
|
+
candidates(engine, object).reject { taken.include?(it) }.map { { object: it } }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def candidates(engine, object) = matching(engine, object)
|
|
52
|
+
def payable?(engine, object) = candidates(engine, object).size >= count
|
|
53
|
+
|
|
54
|
+
# Called when a choice is made: the paid entry to record (and any immediate payment).
|
|
55
|
+
def choose!(_engine, option) = { "object" => option[:object] }
|
|
56
|
+
|
|
57
|
+
# Called as the spell or ability becomes cast, with this cost's paid entries.
|
|
58
|
+
def pay_chosen!(_engine, _object, _entries) = nil
|
|
59
|
+
|
|
60
|
+
# Costs without choices of their own are paid by ActivatedAbility's finishing step.
|
|
61
|
+
def pay!(_engine, _object) = nil
|
|
62
|
+
|
|
63
|
+
def what = @filter.is_a?(Class) ? @filter.primitive_name.tr("_", " ") : @filter.to_s
|
|
64
|
+
|
|
65
|
+
def amount_words
|
|
66
|
+
if count == 1
|
|
67
|
+
@other ? "another" : "a"
|
|
68
|
+
else
|
|
69
|
+
count.to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `count` cards from one of your zones (`zone`) matching the card spec `card`, never the card the spell
|
|
75
|
+
# or ability belongs to: "discard a card", "exile two cards from your graveyard" (CR 601.2h, 602.2b).
|
|
76
|
+
class CardsCost < Choosing
|
|
77
|
+
def self.keys = %w[count card]
|
|
78
|
+
def self.zone = raise(NotImplementedError)
|
|
79
|
+
|
|
80
|
+
# `count` "X": as many as the X announced for the spell or ability (CR 601.2b, 107.3a).
|
|
81
|
+
def self.build(params, errors, path, **)
|
|
82
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
83
|
+
count = if Mechanics.fetch_param(params, :count) == "X" then "X"
|
|
84
|
+
else Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
|
|
85
|
+
end
|
|
86
|
+
raw = Mechanics.fetch_param(params, :card)
|
|
87
|
+
spec = raw && CardSpec.build(raw, errors, "#{path}.card")
|
|
88
|
+
new(spec, count, false, params, errors, path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def candidates(engine, object)
|
|
92
|
+
engine.player(object.controller).public_send(self.class.zone).select do |id|
|
|
93
|
+
id != object.id && (filter.nil? || filter.match?(engine, engine.object(id)))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# "Exile X cards": the engine asks for as many as X (Engine::CastChoices#cost_chosen?); X can be at
|
|
98
|
+
# most the number of matching cards (Engine::Costs#x_limit).
|
|
99
|
+
def x? = count == "X"
|
|
100
|
+
def choices_needed = x? ? 0 : super
|
|
101
|
+
def payable?(engine, object) = x? || super
|
|
102
|
+
def x_limit(engine, object) = candidates(engine, object).size
|
|
103
|
+
|
|
104
|
+
# As it becomes cast, the chosen cards still where they were chosen are paid (exiled by default).
|
|
105
|
+
def pay_chosen!(engine, _object, entries)
|
|
106
|
+
entries.map { it["object"] }.select { engine.state.objects[it]&.zone == self.class.zone }.each { pay_card!(engine, it) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def pay_card!(engine, id) = engine.move!(id, :exile, reason: "cost")
|
|
110
|
+
|
|
111
|
+
def cards_text = "#{count == 1 ? "a" : count} #{filter || "card"}#{"s" unless count == 1}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
# Behold (CR 701.4a): "behold a [quality]" means reveal a [quality] card from your hand or choose a
|
|
6
|
+
# [quality] permanent you control. Whether a [quality] was beheld looks at the object as it was beheld
|
|
7
|
+
# (CR 701.4b).
|
|
8
|
+
module Beholding
|
|
9
|
+
# The cards in `index`'s hand and the permanents they control matching the card spec.
|
|
10
|
+
def self.candidates(engine, index, spec)
|
|
11
|
+
perms = engine.state.battlefield.select { engine.object(it).controller == index }
|
|
12
|
+
(perms + engine.player(index).hand).select { spec.match?(engine, engine.object(it)) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Reveals a card from the hand (CR 701.4a); the entry remembers what was beheld (CR 701.4b).
|
|
16
|
+
def self.behold!(engine, index, id)
|
|
17
|
+
obj = engine.object(id)
|
|
18
|
+
engine.reveal!(index, [id]) if obj.zone == :hand
|
|
19
|
+
facts = engine.chars(obj)
|
|
20
|
+
{ "object" => id, "zone" => obj.zone.to_s, "types" => facts.types.map(&:to_s),
|
|
21
|
+
"subtypes" => facts.subtypes.map(&:to_s) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Costs
|
|
26
|
+
# "Behold a Goblin" / "Behold three Elementals" / "Behold a Kithkin and exile it" as a cost (CR 701.4a,
|
|
27
|
+
# 601.2h). Each choice is a card in your hand or a permanent you control matching `card`; with `exile`
|
|
28
|
+
# they are exiled as the spell or ability becomes cast, linked to it ("the exiled card", CR 607.2a). A
|
|
29
|
+
# spell's linked cards stay linked to the permanent it becomes.
|
|
30
|
+
class Behold < Choosing
|
|
31
|
+
primitive :cost, :behold,
|
|
32
|
+
description: "Behold `count` (default 1) objects matching the card spec `card`: each a card " \
|
|
33
|
+
"revealed from your hand or a permanent you control, chosen while casting or " \
|
|
34
|
+
"activating. `exile: true` also exiles them as it becomes cast; they are then " \
|
|
35
|
+
"\"exiled with\" it (selector `exiled_with`).",
|
|
36
|
+
rules: %w[701.4a 701.4b 601.2h 602.2b 118.3 607.2a],
|
|
37
|
+
params: { "card" => "card spec (required), e.g. {\"subtypes\": [\"goblin\"]}",
|
|
38
|
+
"count" => "integer (default 1)", "exile" => "true: exile what was beheld" },
|
|
39
|
+
example: { "behold" => { "card" => { "subtypes" => ["goblin"] }, "exile" => true } }
|
|
40
|
+
|
|
41
|
+
def self.keys = %w[card count exile]
|
|
42
|
+
|
|
43
|
+
def self.build(params, errors, path, **)
|
|
44
|
+
Mechanics.check_keys(params, keys, errors, path)
|
|
45
|
+
count = Mechanics.integer_param(params, :count, errors, path, default: 1, min: 1)
|
|
46
|
+
raw = Mechanics.fetch_param(params, :card)
|
|
47
|
+
errors << "#{path}.card: required" if raw.nil?
|
|
48
|
+
new(CardSpec.build(raw, errors, "#{path}.card"), count, false, params, errors, path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(filter, count, other, params, errors, path)
|
|
52
|
+
@exile = Mechanics.flag_param(params, :exile, errors, path)
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Not the card being cast (it is on the stack once casting begins).
|
|
57
|
+
def candidates(engine, object) = Beholding.candidates(engine, object.controller, filter) - [object.id]
|
|
58
|
+
def choose!(engine, option) = Beholding.behold!(engine, engine.object(option[:object]).controller, option[:object])
|
|
59
|
+
|
|
60
|
+
def pay_chosen!(engine, object, entries)
|
|
61
|
+
return unless @exile
|
|
62
|
+
|
|
63
|
+
entries.each do |entry|
|
|
64
|
+
card = engine.state.objects[entry["object"]]
|
|
65
|
+
next unless card&.zone.to_s == entry["zone"]
|
|
66
|
+
|
|
67
|
+
fresh = engine.move!(card.id, :exile, reason: "cost")
|
|
68
|
+
engine.link_exiled!(object.id, fresh.id) if fresh
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_s = "Behold #{count == 1 ? "a" : count} #{filter.to_s.delete_suffix(" card")}#{" and exile it" if @exile}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Discard this card" as a cost (CR 701.9a, 602.2b). It moves the card out of its owner's
|
|
7
|
+
# hand, so an ability with this cost functions only from the hand (CR 113.6m).
|
|
8
|
+
class DiscardSelf < Primitive
|
|
9
|
+
primitive :cost, :discard_self,
|
|
10
|
+
description: "Discard the card the ability belongs to (it goes from your hand to your graveyard). " \
|
|
11
|
+
"An ability with this cost is activated from your hand.",
|
|
12
|
+
rules: %w[701.9a 602.2b 113.6m 118.3],
|
|
13
|
+
example: "discard_self"
|
|
14
|
+
|
|
15
|
+
INSTANCE = new.freeze
|
|
16
|
+
|
|
17
|
+
def self.build(params, errors, path, **)
|
|
18
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
19
|
+
INSTANCE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mana? = false
|
|
23
|
+
def zone = :hand
|
|
24
|
+
def payable?(engine, object) = engine.player(object.owner).hand.include?(object.id)
|
|
25
|
+
def pay!(engine, object) = engine.move!(object.id, :graveyard, reason: "discard")
|
|
26
|
+
def to_s = "Discard this card"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "Discard a card" / "Discard two cards" / "Discard a creature card" / "Discard a card at random" as a
|
|
30
|
+
# cost (CR 701.9a-b, 601.2h): cards from your hand other than this one, chosen while casting or activating
|
|
31
|
+
# and discarded as the spell or ability becomes cast. Random discards are paid last (CR 601.2h).
|
|
32
|
+
class Discard < CardsCost
|
|
33
|
+
primitive :cost, :discard,
|
|
34
|
+
description: "Discard `count` cards (default 1) from your hand, never this card, matching the card " \
|
|
35
|
+
"spec `card` if given: chosen while casting or activating and discarded as the spell " \
|
|
36
|
+
"or ability becomes cast. `random: true` discards them at random instead.",
|
|
37
|
+
rules: %w[701.9a 701.9b 601.2h 602.2b 118.3],
|
|
38
|
+
params: { "count" => "integer (default 1), or \"X\": the X announced",
|
|
39
|
+
"card" => "card spec, e.g. {\"types\": [\"land\"]}",
|
|
40
|
+
"random" => "true: at random (CR 701.9b)" },
|
|
41
|
+
example: { "discard" => { "count" => 1 } }
|
|
42
|
+
|
|
43
|
+
def self.keys = %w[count card random]
|
|
44
|
+
def self.zone = :hand
|
|
45
|
+
|
|
46
|
+
def initialize(filter, count, other, params, errors, path)
|
|
47
|
+
@random = Mechanics.flag_param(params, :random, errors, path)
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def choices_needed = @random ? 0 : count
|
|
52
|
+
|
|
53
|
+
def pay_chosen!(engine, object, entries)
|
|
54
|
+
return super unless @random
|
|
55
|
+
|
|
56
|
+
left = candidates(engine, object)
|
|
57
|
+
[count, left.size].min.times { pay_card!(engine, left.delete_at(engine.state.rng.rand(left.size))) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pay_card!(engine, id) = engine.move!(id, :graveyard, reason: "discard")
|
|
61
|
+
def to_s = "Discard #{cards_text}#{" at random" if @random}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Exert [this permanent]" as a cost (CR 701.43a): it won't untap during your next untap step. It can be
|
|
7
|
+
# exerted tapped or untapped, and again before that untap step (one untap step is skipped, CR 701.43b);
|
|
8
|
+
# only a permanent can be (CR 701.43c).
|
|
9
|
+
class Exert < Primitive
|
|
10
|
+
primitive :cost, :exert,
|
|
11
|
+
description: "Exert this permanent: it doesn't untap during your next untap step.",
|
|
12
|
+
rules: %w[701.43a 701.43b 701.43c],
|
|
13
|
+
example: "exert"
|
|
14
|
+
|
|
15
|
+
INSTANCE = new.freeze
|
|
16
|
+
|
|
17
|
+
def self.build(params, errors, path, **)
|
|
18
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
19
|
+
INSTANCE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def payable?(_engine, object) = object.zone == :battlefield
|
|
23
|
+
def pay!(engine, object) = engine.exert!(object)
|
|
24
|
+
def mana? = false
|
|
25
|
+
def to_s = "Exert this"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Exile a card from your hand" / "Exile two cards from your hand" as a cost (CR 406.1, 601.2h,
|
|
7
|
+
# 602.2b). The cards are chosen while casting or activating and exiled as it becomes cast; never the
|
|
8
|
+
# card the ability belongs to.
|
|
9
|
+
class ExileFromHand < CardsCost
|
|
10
|
+
primitive :cost, :exile_from_hand,
|
|
11
|
+
description: "Exile `count` cards from your hand (default 1), chosen while casting or activating, " \
|
|
12
|
+
"exiled as the spell or ability becomes cast. `card` (a card spec) restricts which.",
|
|
13
|
+
rules: %w[406.1 601.2h 602.2b 118.3],
|
|
14
|
+
params: { "count" => "integer (default 1), or \"X\": the X announced",
|
|
15
|
+
"card" => "card spec, e.g. {\"types\": [\"creature\"]}" },
|
|
16
|
+
example: { "exile_from_hand" => { "count" => 1 } }
|
|
17
|
+
|
|
18
|
+
def self.zone = :hand
|
|
19
|
+
def to_s = "Exile #{cards_text} from your hand"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# "Exile a creature card from your graveyard" / "Exile two cards from your graveyard" as a cost (CR 406.1,
|
|
23
|
+
# 601.2h): cards other than the one the ability belongs to, chosen while casting or activating.
|
|
24
|
+
class ExileFromGraveyard < CardsCost
|
|
25
|
+
primitive :cost, :exile_from_graveyard,
|
|
26
|
+
description: "Exile `count` cards from your graveyard (default 1), never this card, chosen while " \
|
|
27
|
+
"casting or activating and exiled as the spell or ability becomes cast. `card` (a card " \
|
|
28
|
+
"spec) restricts which.",
|
|
29
|
+
rules: %w[406.1 601.2h 602.2b 118.3],
|
|
30
|
+
params: { "count" => "integer (default 1), or \"X\": the X announced",
|
|
31
|
+
"card" => "card spec, e.g. {\"types\": [\"creature\"]}" },
|
|
32
|
+
example: { "exile_from_graveyard" => { "count" => 2 } }
|
|
33
|
+
|
|
34
|
+
def self.zone = :graveyard
|
|
35
|
+
def to_s = "Exile #{cards_text} from your graveyard"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Exile this card from your graveyard" / "Put this card from your graveyard on the bottom of your
|
|
7
|
+
# library" as a cost: the ability functions only from the graveyard (CR 113.6m).
|
|
8
|
+
class FromGraveyard < Primitive
|
|
9
|
+
def self.build(params, errors, path, **)
|
|
10
|
+
Mechanics.check_keys(params, [], errors, path)
|
|
11
|
+
new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mana? = false
|
|
15
|
+
def zone = :graveyard
|
|
16
|
+
def uses_source? = true
|
|
17
|
+
def payable?(engine, object) = engine.player(object.owner).graveyard.include?(object.id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Exile this card from your graveyard (CR 406, 113.6m).
|
|
21
|
+
class ExileSelfFromGraveyard < FromGraveyard
|
|
22
|
+
primitive :cost, :exile_self_from_graveyard,
|
|
23
|
+
description: "Exile this card from your graveyard. An ability with this cost is activated from " \
|
|
24
|
+
"your graveyard.",
|
|
25
|
+
rules: %w[406.1 113.6m 602.2b 118.3],
|
|
26
|
+
example: "exile_self_from_graveyard"
|
|
27
|
+
|
|
28
|
+
def pay!(engine, object) = engine.move!(object.id, :exile, reason: "cost")
|
|
29
|
+
def to_s = "Exile this card from your graveyard"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Put this card from your graveyard on the bottom of your library (CR 401, 113.6m).
|
|
33
|
+
class BottomSelfFromGraveyard < FromGraveyard
|
|
34
|
+
primitive :cost, :bottom_self_from_graveyard,
|
|
35
|
+
description: "Put this card from your graveyard on the bottom of your library. An ability with " \
|
|
36
|
+
"this cost is activated from your graveyard.",
|
|
37
|
+
rules: %w[401.1 113.6m 602.2b 118.3],
|
|
38
|
+
example: "bottom_self_from_graveyard"
|
|
39
|
+
|
|
40
|
+
def pay!(engine, object) = engine.move!(object.id, :library, position: :bottom, reason: "cost")
|
|
41
|
+
def to_s = "Put this card from your graveyard on the bottom of your library"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Get five poison counters" as a cost (CR 118.1, 122.1): the payer gets that many counters of a kind
|
|
7
|
+
# (a player with ten or more poison counters loses the game, CR 704.5c). It can always be paid.
|
|
8
|
+
class GetCounters < Primitive
|
|
9
|
+
primitive :cost, :get_counters,
|
|
10
|
+
description: "You get `amount` counters of the kind `counter` (e.g. poison). It can always be paid.",
|
|
11
|
+
rules: %w[118.1 122.1 704.5c],
|
|
12
|
+
params: { "counter" => "player counter kind, e.g. \"poison\"", "amount" => "integer >= 1" },
|
|
13
|
+
example: { "get_counters" => { "counter" => "poison", "amount" => 5 } }
|
|
14
|
+
|
|
15
|
+
attr_reader :counter, :amount
|
|
16
|
+
|
|
17
|
+
def self.build(params, errors, path, **)
|
|
18
|
+
Mechanics.check_keys(params, %w[counter amount], errors, path)
|
|
19
|
+
counter = Mechanics.fetch_param(params, :counter)
|
|
20
|
+
errors << "#{path}.counter: must be a counter kind, e.g. \"poison\"" unless counter.is_a?(String) && !counter.empty?
|
|
21
|
+
new(counter.to_s, Mechanics.integer_param(params, :amount, errors, path, min: 1))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(counter, amount)
|
|
25
|
+
super()
|
|
26
|
+
@counter = counter
|
|
27
|
+
@amount = amount
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mana? = false
|
|
32
|
+
def spell_cost? = true
|
|
33
|
+
def payable?(_engine, _object) = true
|
|
34
|
+
def pay!(engine, object) = engine.add_player_counters!(object.controller, counter, amount)
|
|
35
|
+
def to_s = "Get #{amount} #{counter} counter#{"s" unless amount == 1}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# A mana cost for an activated ability, e.g. {"mana": "{1}{R}"} (CR 602.2b, 601.2f).
|
|
7
|
+
class ManaPayment < Primitive
|
|
8
|
+
primitive :cost, :mana,
|
|
9
|
+
description: "Pay mana as part of an activation. Paid during the activation by spending mana from " \
|
|
10
|
+
"your pool, tapping sources one at a time.",
|
|
11
|
+
rules: %w[601.2g 601.2h 602.2b],
|
|
12
|
+
params: { "(value)" => "mana cost string, e.g. \"{1}{R}\"" },
|
|
13
|
+
example: { "mana" => "{1}{R}" }
|
|
14
|
+
|
|
15
|
+
attr_reader :mana_cost
|
|
16
|
+
|
|
17
|
+
def self.scalar_params? = true
|
|
18
|
+
|
|
19
|
+
# Accepts {"mana": "{1}{R}"}; the params object here is the cost string itself.
|
|
20
|
+
def self.build(params, errors, path, **)
|
|
21
|
+
new(Mana.parse_cost(params, errors, path) || Mana::Cost.new(generic: 0, colored: {}))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(mana_cost)
|
|
25
|
+
super()
|
|
26
|
+
@mana_cost = mana_cost
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mana? = true
|
|
31
|
+
def payable?(_engine, _object) = true
|
|
32
|
+
def pay!(_engine, _object) = nil
|
|
33
|
+
def to_s = mana_cost.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# A choice between costs: "behold a Goblin or pay {2}", "sacrifice an artifact or discard a nonland card"
|
|
7
|
+
# (CR 118.3, 601.2b). Which one will be paid is announced while casting or activating, before X and the
|
|
8
|
+
# total cost (CR 601.2b, 601.2f, 602.2b): the `choose_cost_option` decision. From then on the chosen cost
|
|
9
|
+
# stands in its place. While resolving (`pay`, ward) the payer chooses as they pay (CR 118.12).
|
|
10
|
+
class OneOf < Primitive
|
|
11
|
+
primitive :cost, :one_of,
|
|
12
|
+
description: "Pay one of the listed costs (two or more), chosen as the spell or ability is cast or " \
|
|
13
|
+
"activated (the `choose_cost_option` decision) or, for `pay` and ward, as it is paid. " \
|
|
14
|
+
"A cost that can't be paid can't be chosen.",
|
|
15
|
+
rules: %w[118.3 601.2b 601.2f 602.2b 118.12],
|
|
16
|
+
params: { "(list)" => "two or more costs" },
|
|
17
|
+
example: { "one_of" => [{ "behold" => { "card" => { "subtypes" => ["goblin"] } } }, { "mana" => "{2}" }] }
|
|
18
|
+
|
|
19
|
+
attr_reader :options
|
|
20
|
+
|
|
21
|
+
def self.scalar_params? = true
|
|
22
|
+
|
|
23
|
+
def self.build(params, errors, path, **)
|
|
24
|
+
unless params.is_a?(Array) && params.size >= 2
|
|
25
|
+
errors << "#{path}: must be a list of at least two costs"
|
|
26
|
+
return new([])
|
|
27
|
+
end
|
|
28
|
+
options = params.each_with_index.filter_map { |c, i| Mechanics.build_entry(:cost, c, errors, "#{path}[#{i}]") }
|
|
29
|
+
errors << "#{path}: a one_of can't contain another one_of" if options.any?(OneOf)
|
|
30
|
+
new(options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(options)
|
|
34
|
+
super()
|
|
35
|
+
@options = options.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mana? = false
|
|
40
|
+
|
|
41
|
+
# Some option could be paid (a mana option is checked against the total cost by the engine).
|
|
42
|
+
def payable?(engine, object) = options.any? { it.mana? || it.payable?(engine, object) }
|
|
43
|
+
# Each option as text: "Behold a Goblin", "Pay {2}".
|
|
44
|
+
def option_texts = options.map { it.mana? ? "Pay #{it}" : it.to_s }
|
|
45
|
+
def to_s = option_texts.each_with_index.map { |text, i| i.zero? ? text : text.sub(/\A\w/, &:downcase) }.join(" or ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
module Mechanics
|
|
5
|
+
module Costs
|
|
6
|
+
# "Pay 2 life" / "Pay X life" as a cost (CR 119.4, 118.3b): {"pay_life": 2} or {"pay_life": "X"}. A
|
|
7
|
+
# player can pay life only if their life total is at least that much; 0 life can always be paid
|
|
8
|
+
# (CR 119.4b). X is announced with the spell or ability (CR 601.2b, 107.3a), at most the life total.
|
|
9
|
+
class PayLife < Primitive
|
|
10
|
+
primitive :cost, :pay_life,
|
|
11
|
+
description: "Pay that much life (you lose it). Only possible while your life total is at least " \
|
|
12
|
+
"that much. `\"X\"` pays the X announced for the spell or ability (at most your life " \
|
|
13
|
+
"total); it may be a spell's additional cost.",
|
|
14
|
+
rules: %w[119.4 119.4b 118.3b 601.2b 107.3a],
|
|
15
|
+
params: { "(value)" => "integer >= 1, or \"X\"" },
|
|
16
|
+
example: { "pay_life" => 2 }
|
|
17
|
+
|
|
18
|
+
attr_reader :amount
|
|
19
|
+
|
|
20
|
+
def self.scalar_params? = true
|
|
21
|
+
|
|
22
|
+
def self.build(params, errors, path, **)
|
|
23
|
+
errors << "#{path}: must be an integer >= 1 or \"X\"" unless params == "X" || (params.is_a?(Integer) && params >= 1)
|
|
24
|
+
new(params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(amount)
|
|
28
|
+
super()
|
|
29
|
+
@amount = amount
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mana? = false
|
|
34
|
+
def spell_cost? = true
|
|
35
|
+
def x? = @amount == "X"
|
|
36
|
+
def x_limit(engine, object) = [engine.player(object.controller).life, 0].max
|
|
37
|
+
|
|
38
|
+
# X is bounded by the life total as it's announced (Engine::Costs#x_limit).
|
|
39
|
+
def payable?(engine, object) = x? || engine.player(object.controller).life >= @amount.to_i
|
|
40
|
+
|
|
41
|
+
def pay!(engine, object)
|
|
42
|
+
life = x? ? engine.casting&.fetch("x", 0).to_i : @amount.to_i
|
|
43
|
+
engine.change_life!(object.controller, -life) if life.positive?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_s = "Pay #{@amount} life"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|