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,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "card_faces"
|
|
4
|
+
|
|
5
|
+
module Enjine
|
|
6
|
+
# Immutable, validated blueprint of a card, built from a data block.
|
|
7
|
+
#
|
|
8
|
+
# CardDefinition.from_h({"name" => "Forest", "types" => ["land"], ...})
|
|
9
|
+
#
|
|
10
|
+
# Every type, supertype, subtype with rules, ability and cost is looked up in
|
|
11
|
+
# Enjine.registry. Anything unknown is reported (with its path) in a single
|
|
12
|
+
# InvalidCardDefinition error.
|
|
13
|
+
class CardDefinition
|
|
14
|
+
KEYS = %w[name mana_cost colors types supertypes subtypes keywords power toughness loyalty abilities rules_text deck_limit].freeze
|
|
15
|
+
# Optional catalogue data beside the rules data, never read by the rules: rarity, and the set code and
|
|
16
|
+
# collector number printed on the card ({"set" => "trk", "number" => "12"}).
|
|
17
|
+
PRINT_KEYS = %w[rarity printing].freeze
|
|
18
|
+
SUBTYPE_FORMAT = /\A[a-z][a-z0-9_]*\z/
|
|
19
|
+
include Faces
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :name, :mana_cost, :types, :supertypes, :subtypes, :keywords, :power, :toughness, :loyalty,
|
|
22
|
+
:abilities, :rules_text, :type_modules, :source, :colors, :power_amount, :toughness_amount, :rarity, :printing
|
|
23
|
+
|
|
24
|
+
# Canonical id derived from a card name: "Jace, the Mind Sculptor" -> "jace_the_mind_sculptor".
|
|
25
|
+
# A multi-face card is named by its front face: "Alfa // Bravo" -> "alfa" (see CardDefinition::Faces).
|
|
26
|
+
def self.slug(name)
|
|
27
|
+
name.to_s.split(" // ").first.to_s.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "").downcase.delete("'’")
|
|
28
|
+
.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A token definition (CR 111.1). Its id is derived from its data so two different tokens
|
|
32
|
+
# with the same name ("Soldier" 1/1 and "Soldier" 2/2) never collide in a catalog.
|
|
33
|
+
def self.token(data)
|
|
34
|
+
card = data.transform_keys(&:to_s)
|
|
35
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(card))[0, 8]
|
|
36
|
+
errors = []
|
|
37
|
+
new(card, errors, token_id: "#{slug(card["name"])}_token_#{digest}").tap do
|
|
38
|
+
raise InvalidCardDefinition.new(errors, name: card["name"]) unless errors.empty?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.from_h(hash)
|
|
43
|
+
return hash if hash.is_a?(CardDefinition)
|
|
44
|
+
|
|
45
|
+
errors = []
|
|
46
|
+
raise InvalidCardDefinition.new(["card definition must be an object, got #{hash.class}"]) unless hash.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
card = hash.transform_keys(&:to_s)
|
|
49
|
+
new(card, errors).tap do
|
|
50
|
+
raise InvalidCardDefinition.new(errors, name: card["name"]) unless errors.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `face`: [front definition, index] when building a face other than the front (CardDefinition::Faces).
|
|
55
|
+
def initialize(card, errors, token_id: nil, face: nil)
|
|
56
|
+
card = read_print(card, errors) if face.nil?
|
|
57
|
+
if face.nil? && card.key?("faces")
|
|
58
|
+
read_faces(card, errors, token_id)
|
|
59
|
+
else
|
|
60
|
+
read_card(card, errors, token_id, face)
|
|
61
|
+
@source = normalize(card)
|
|
62
|
+
end
|
|
63
|
+
@rules_data = @source
|
|
64
|
+
@source = deep_freeze(@source.merge("rarity" => @rarity, "printing" => @printing).compact) if @rarity || @printing
|
|
65
|
+
@printed_characteristics = Characteristics.printed(self) if errors.empty?
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_card(card, errors, token_id, face = nil)
|
|
70
|
+
(card.keys - KEYS).each { |k| errors << "#{k}: unknown field (allowed: #{(KEYS + PRINT_KEYS).join(", ")})" }
|
|
71
|
+
@name = read_name(card, errors)
|
|
72
|
+
@id = face ? "#{face[0].id}#{FACE_SEPARATOR}#{face[1]}" : token_id || self.class.slug(@name)
|
|
73
|
+
@front, @face_index = face
|
|
74
|
+
@token = !token_id.nil?
|
|
75
|
+
@types, @type_modules = read_names(card, "types", :type, errors, required: true)
|
|
76
|
+
@supertypes, = read_names(card, "supertypes", :supertype, errors)
|
|
77
|
+
@subtypes = read_subtypes(card, errors)
|
|
78
|
+
@mana_cost = card.key?("mana_cost") ? Mana.parse_cost(card["mana_cost"], errors) : nil
|
|
79
|
+
@keywords, keyword_modules = read_names(card, "keywords", :keyword, errors)
|
|
80
|
+
@colors = read_colors(card, errors)
|
|
81
|
+
@deck_limit = read_deck_limit(card, errors)
|
|
82
|
+
@power, @power_amount = read_pt(card, "power", errors)
|
|
83
|
+
@toughness, @toughness_amount = read_pt(card, "toughness", errors)
|
|
84
|
+
@loyalty = read_stat(card, "loyalty", errors)
|
|
85
|
+
@rules_text = card["rules_text"]
|
|
86
|
+
errors << "rules_text: must be a string" unless @rules_text.nil? || @rules_text.is_a?(String)
|
|
87
|
+
@abilities = read_abilities(card, errors)
|
|
88
|
+
@type_modules.each { |mod| mod.validate(card, errors) }
|
|
89
|
+
keyword_modules.each { |mod| mod.validate(card, errors) if mod.respond_to?(:validate) }
|
|
90
|
+
precompute_predicates
|
|
91
|
+
validate_type_specific(errors)
|
|
92
|
+
end
|
|
93
|
+
private :read_card
|
|
94
|
+
|
|
95
|
+
def type?(type) = @types.include?(type.to_sym)
|
|
96
|
+
def supertype?(type) = @supertypes.include?(type.to_sym)
|
|
97
|
+
def keyword?(keyword) = @keywords.include?(keyword.to_sym)
|
|
98
|
+
|
|
99
|
+
# Predicates below are computed once at construction: they sit on the engine's hot path.
|
|
100
|
+
attr_reader :spell_ability, :ability_instances
|
|
101
|
+
|
|
102
|
+
# Characteristics as printed: the starting point of the layer system (CR 613).
|
|
103
|
+
attr_reader :printed_characteristics
|
|
104
|
+
|
|
105
|
+
# Any ability of the card by reference: printed or intrinsic, one a station symbol grants only
|
|
106
|
+
# while it applies (CR 722.2a), or a delayed triggered ability one of its effects creates
|
|
107
|
+
# ("delayed:<path>", see Effects::DelayedTrigger).
|
|
108
|
+
def ability(ref)
|
|
109
|
+
(@ability_instances.find { it.ref == ref } || @nested_instances.find { it.ref == ref })&.ability ||
|
|
110
|
+
@delayed_abilities[ref] || @effect_granted.values.flatten.find { it.ref == ref }&.ability ||
|
|
111
|
+
(ref.to_s.start_with?("keyword:") && CardDefinition.keyword_instances(ref.delete_prefix("keyword:")).first&.ability) ||
|
|
112
|
+
CardDefinition.keyword_delayed(ref)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A delayed triggered ability created by a keyword's ability (decayed's "sacrifice it at end of combat"):
|
|
116
|
+
# it's part of the keyword's data, referenced "delayed:keyword:<name>...".
|
|
117
|
+
def self.keyword_delayed(ref)
|
|
118
|
+
keyword = ref.to_s[/\Adelayed:keyword:([a-z_]+)/, 1] or return nil
|
|
119
|
+
every = ->(effects) { effects.flat_map { [it, *every.call(it.nested_effects)] } }
|
|
120
|
+
every.call(keyword_instances(keyword).flat_map { it.ability.try_effects })
|
|
121
|
+
.find { it.respond_to?(:delayed_ability) && it.ref == ref }&.delayed_ability
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The abilities a keyword stands for ("Prowess" is a triggered ability, CR 702.108a), as instances
|
|
125
|
+
# referenced "keyword:<name>"; the same for a printed keyword and one an effect grants (CR 613.1f).
|
|
126
|
+
def self.keyword_instances(keyword)
|
|
127
|
+
rule = Enjine.registry.fetch(:keyword, keyword.to_s)
|
|
128
|
+
abilities = rule.respond_to?(:intrinsic_abilities) ? rule.intrinsic_abilities : []
|
|
129
|
+
abilities.map { AbilityInstance.new(ref: "keyword:#{keyword}", ability: it) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The abilities a subtype grants ("Forest" has "{T}: Add {G}.", CR 305.6), referenced "intrinsic:<subtype>";
|
|
133
|
+
# the same for a printed subtype and one an effect adds.
|
|
134
|
+
def self.intrinsic_instances(subtype)
|
|
135
|
+
rule = Enjine.registry.fetch(:subtype, subtype.to_s)
|
|
136
|
+
abilities = rule.respond_to?(:intrinsic_abilities) ? rule.intrinsic_abilities : []
|
|
137
|
+
abilities.each_with_index.map { |a, i| AbilityInstance.new(ref: "intrinsic:#{subtype}#{":#{i}" if i.positive?}", ability: a) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Abilities an effect of this card grants for a while ("<effect ref>:<j>", see Effects::Become).
|
|
141
|
+
def effect_granted(ref) = @effect_granted.fetch(ref, EMPTY)
|
|
142
|
+
|
|
143
|
+
# Abilities the ability at `ref` grants while it applies (a station symbol's, CR 722.2).
|
|
144
|
+
def nested_instances(ref) = @nested_by_ref.fetch(ref, EMPTY)
|
|
145
|
+
EMPTY = [].freeze
|
|
146
|
+
|
|
147
|
+
def permanent? = @permanent
|
|
148
|
+
|
|
149
|
+
# CR 604.3: power and/or toughness defined by a characteristic-defining ability ("*" printed).
|
|
150
|
+
def defined_pt? = !(@power_amount.nil? && @toughness_amount.nil?)
|
|
151
|
+
def token? = @token
|
|
152
|
+
|
|
153
|
+
# Token definitions this card's effects can create; catalogs register them with the card.
|
|
154
|
+
def token_definitions
|
|
155
|
+
(@all_effects.filter_map { it.definition if it.is_a?(Mechanics::Effects::CreateToken) } +
|
|
156
|
+
@all_effects.flat_map { it.respond_to?(:created_tokens) ? it.created_tokens : [] } + # recruit, amass, ...
|
|
157
|
+
@abilities.filter_map { it.token_definition if it.respond_to?(:token_definition) } +
|
|
158
|
+
other_faces.flat_map(&:token_definitions)).compact.uniq(&:id)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Every effect of every ability, including effects nested in other effects ("may", delayed triggers).
|
|
162
|
+
attr_reader :all_effects
|
|
163
|
+
|
|
164
|
+
def triggered? = @triggered
|
|
165
|
+
def land? = @land
|
|
166
|
+
def creature? = @creature
|
|
167
|
+
def planeswalker? = @planeswalker
|
|
168
|
+
def deck_limit_exempt? = @deck_limit_exempt
|
|
169
|
+
|
|
170
|
+
# Most copies a constructed deck may hold (CR 100.2a): nil for any number (basic lands, or a card that
|
|
171
|
+
# says "a deck can have any number of cards named ~").
|
|
172
|
+
def deck_limit = @deck_limit_exempt ? nil : (@deck_limit || Deck::MAX_COPIES)
|
|
173
|
+
|
|
174
|
+
# Whether this card can be cast at instant speed (CR 304.1, 702.8a).
|
|
175
|
+
def instant_speed? = @instant_speed
|
|
176
|
+
|
|
177
|
+
# Lands are played, not cast; a card without a mana cost can't be cast normally (CR 202.1b).
|
|
178
|
+
def castable? = @castable
|
|
179
|
+
|
|
180
|
+
def mana_abilities = @ability_instances.map(&:ability).select(&:mana_ability?)
|
|
181
|
+
|
|
182
|
+
def type_line
|
|
183
|
+
left = (@supertypes + @types).map { |t| t.to_s.capitalize }.join(" ")
|
|
184
|
+
right = @subtypes.map { |t| t.to_s.split("_").map(&:capitalize).join(" ") }.join(" ")
|
|
185
|
+
right.empty? ? left : "#{left} — #{right}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Canonical data form; CardDefinition.from_h(defn.to_h) == defn.
|
|
189
|
+
def to_h = @source
|
|
190
|
+
|
|
191
|
+
# The canonical data form without the PRINT_KEYS: what the rules see.
|
|
192
|
+
attr_reader :rules_data
|
|
193
|
+
|
|
194
|
+
# The same card: equal rules data, whatever its rarity or printing (a reprint is the same card).
|
|
195
|
+
def ==(other) = other.is_a?(CardDefinition) && other.rules_data == rules_data
|
|
196
|
+
alias eql? ==
|
|
197
|
+
|
|
198
|
+
def hash = @rules_data.hash
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def precompute_predicates
|
|
203
|
+
@land = @types.include?(:land)
|
|
204
|
+
@creature = @types.include?(:creature)
|
|
205
|
+
@planeswalker = @types.include?(:planeswalker)
|
|
206
|
+
@permanent = @type_modules.any?(&:permanent?)
|
|
207
|
+
@instant_speed = @type_modules.any?(&:instant_speed?) || @keywords.include?(:flash)
|
|
208
|
+
@castable = !@land && !@mana_cost.nil?
|
|
209
|
+
@deck_limit_exempt = @deck_limit == "any" || @supertypes.any? { Enjine.registry.fetch(:supertype, it).deck_limit_exempt? }
|
|
210
|
+
@spell_ability = @abilities.find { it.kind == :spell } || aura_spell_ability
|
|
211
|
+
every = @abilities + @nested_instances.map(&:ability)
|
|
212
|
+
@triggered = every.any? { it.kind == :triggered }
|
|
213
|
+
@all_effects = nested(every.flat_map do |ability|
|
|
214
|
+
ability.respond_to?(:modes) && ability.modal? ? ability.modes.flat_map(&:effects) : ability.try_effects
|
|
215
|
+
end).freeze
|
|
216
|
+
@delayed_abilities = @all_effects.select { it.respond_to?(:delayed_ability) }.to_h { [it.ref, it.delayed_ability] }
|
|
217
|
+
.merge(granted_by_effects).freeze
|
|
218
|
+
@effect_granted = @all_effects.select { it.respond_to?(:granted_abilities) }.to_h do |effect|
|
|
219
|
+
[effect.ref, effect.granted_abilities.each_with_index.map do |a, j|
|
|
220
|
+
AbilityInstance.new(ref: "#{effect.ref}:#{j}", ability: a)
|
|
221
|
+
end.freeze]
|
|
222
|
+
end.freeze
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def nested(effects) = effects.flat_map { [it, *nested(it.nested_effects)] }
|
|
226
|
+
|
|
227
|
+
# Abilities effects grant ("that card gains ...", Effects::GrantAbility), referenced by the effect's path.
|
|
228
|
+
def granted_by_effects
|
|
229
|
+
@all_effects.select { it.respond_to?(:granted_ability) && it.granted_ability }.to_h { [it.ref, it.granted_ability] }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_type_specific(errors)
|
|
233
|
+
if !creature? && !@subtypes.include?(:vehicle) && (@power || @toughness) # CR 301.7a: a Vehicle has them printed
|
|
234
|
+
errors << "power/toughness: only creatures and Vehicles have power and toughness"
|
|
235
|
+
end
|
|
236
|
+
errors << "loyalty: only planeswalkers have loyalty" if !planeswalker? && @loyalty
|
|
237
|
+
@abilities.each_with_index do |ability, i|
|
|
238
|
+
allowed = ability.allowed_types
|
|
239
|
+
next if allowed.nil? || allowed.any? { type?(it) }
|
|
240
|
+
|
|
241
|
+
errors << "abilities[#{i}]: #{ability.class.primitive_name} abilities need a #{allowed.join("/")} card"
|
|
242
|
+
end
|
|
243
|
+
validate_definition_hooks(errors)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Rules that depend on the whole card, e.g. "an Aura has an enchant ability" (CR 303.4a).
|
|
247
|
+
def validate_definition_hooks(errors)
|
|
248
|
+
@subtypes.each do |sub|
|
|
249
|
+
rule = Enjine.registry.fetch(:subtype, sub)
|
|
250
|
+
rule.validate_definition(self, errors) if rule.respond_to?(:validate_definition)
|
|
251
|
+
end
|
|
252
|
+
@abilities.each_with_index do |ability, i|
|
|
253
|
+
ability.validate_definition(self, errors, "abilities[#{i}]") if ability.respond_to?(:validate_definition)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# CR 115.1b / 303.4a: an Aura spell targets what its enchant ability allows.
|
|
258
|
+
def aura_spell_ability
|
|
259
|
+
enchant = @subtypes.include?(:aura) && @abilities.find { it.kind == :enchant }
|
|
260
|
+
Mechanics::Abilities::AuraSpell.for(enchant) if enchant
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def read_name(card, errors)
|
|
264
|
+
name = card["name"]
|
|
265
|
+
return name if name.is_a?(String) && !self.class.slug(name).empty?
|
|
266
|
+
|
|
267
|
+
errors << "name: required, must be a non-empty string"
|
|
268
|
+
"?"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def read_names(card, key, category, errors, required: false)
|
|
272
|
+
list = card.fetch(key, [])
|
|
273
|
+
if !list.is_a?(Array) || (required && list.empty?)
|
|
274
|
+
errors << "#{key}: must be a #{"non-empty " if required}list"
|
|
275
|
+
return [[].freeze, [].freeze]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
modules = list.each_with_index.filter_map do |raw, i|
|
|
279
|
+
Enjine.registry.fetch(category, raw.to_s.downcase.tr(" -", "__")).tap do |mod|
|
|
280
|
+
unless mod
|
|
281
|
+
errors << "#{key}[#{i}]: unknown #{category} #{raw.inspect} " \
|
|
282
|
+
"(supported: #{Enjine.registry.names(category).join(", ")})"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
[modules.map { |m| m.primitive_name.to_sym }.uniq.freeze, modules.uniq.freeze]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def read_subtypes(card, errors)
|
|
290
|
+
list = card.fetch("subtypes", [])
|
|
291
|
+
unless list.is_a?(Array)
|
|
292
|
+
errors << "subtypes: must be a list"
|
|
293
|
+
return [].freeze
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
list.each_with_index.filter_map do |raw, i|
|
|
297
|
+
sub = raw.to_s.downcase.tr(" -", "__")
|
|
298
|
+
unless SUBTYPE_FORMAT.match?(sub)
|
|
299
|
+
errors << "subtypes[#{i}]: invalid subtype #{raw.inspect}"
|
|
300
|
+
next
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
rule = Enjine.registry.fetch(:subtype, sub)
|
|
304
|
+
if rule && !rule.applies_to.intersect?(@types)
|
|
305
|
+
errors << "subtypes[#{i}]: #{sub} is only valid for #{rule.applies_to.join("/")} cards (CR 205.3d)"
|
|
306
|
+
end
|
|
307
|
+
sub.to_sym
|
|
308
|
+
end.uniq.freeze
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# CR 202.2: a card's colors come from its mana cost, unless it has a color indicator (CR 204) or is a
|
|
312
|
+
# token whose creating effect gives its colors (CR 111.4): `"colors": ["G"]` (`[]` for colorless).
|
|
313
|
+
def read_colors(card, errors)
|
|
314
|
+
return (@mana_cost.is_a?(Mana::Cost) ? Mana::COLORS.select { @mana_cost.colored.key?(it) } : []).freeze unless card.key?("colors")
|
|
315
|
+
|
|
316
|
+
list = card["colors"]
|
|
317
|
+
ok = list.is_a?(Array) && list.all? { Mana::COLORS.include?(it.to_s.upcase.to_sym) }
|
|
318
|
+
errors << "colors: must be a list of W, U, B, R, G" unless ok
|
|
319
|
+
ok ? Mana::COLORS.select { |c| list.map { it.to_s.upcase }.include?(c.to_s) }.freeze : [].freeze
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# CR 100.2a: "a deck can have any number of cards named ~" is `"deck_limit": "any"`; "up to seven" is 7.
|
|
323
|
+
# Reads the PRINT_KEYS of a whole card; returns the card without them.
|
|
324
|
+
def read_print(card, errors)
|
|
325
|
+
@rarity = card["rarity"]
|
|
326
|
+
errors << "rarity: must be a string" unless @rarity.nil? || @rarity.is_a?(String)
|
|
327
|
+
printing = card["printing"]
|
|
328
|
+
printing = printing.transform_keys(&:to_s) if printing.is_a?(Hash)
|
|
329
|
+
if printing.is_a?(Hash) && printing.keys.sort == %w[number set] && printing.values.all? { it.is_a?(String) || it.is_a?(Integer) }
|
|
330
|
+
@printing = printing.transform_values(&:to_s).freeze
|
|
331
|
+
elsif printing
|
|
332
|
+
errors << "printing: must be {\"set\": code, \"number\": collector number}"
|
|
333
|
+
end
|
|
334
|
+
card.except(*PRINT_KEYS)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def read_deck_limit(card, errors)
|
|
338
|
+
value = card["deck_limit"]
|
|
339
|
+
return value if value.nil? || value == "any" || (value.is_a?(Integer) && value >= 1)
|
|
340
|
+
|
|
341
|
+
errors << "deck_limit: must be \"any\" or an integer >= 1"
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Power/toughness: an integer, or an amount for a characteristic-defining ability ("*", CR 604.3,
|
|
346
|
+
# 208.2a), e.g. {"count": {"each": {"creature": {"types": ["artifact"]}}, "controller": "you"}}.
|
|
347
|
+
# Returns [printed value, amount]; the defined value is computed in layer 7a (CR 613.4a).
|
|
348
|
+
def read_pt(card, key, errors)
|
|
349
|
+
value = card[key]
|
|
350
|
+
return [read_stat(card, key, errors), nil] unless value.is_a?(Hash)
|
|
351
|
+
|
|
352
|
+
amount = Mechanics::Amount.build(value, errors, key, targets_count: 0)
|
|
353
|
+
unless Mechanics::Amount.layer_safe?(amount)
|
|
354
|
+
errors << "#{key}: a characteristic-defining amount can't read power or toughness (CR 613.8)"
|
|
355
|
+
end
|
|
356
|
+
[0, amount]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def read_stat(card, key, errors)
|
|
360
|
+
return nil unless card.key?(key)
|
|
361
|
+
|
|
362
|
+
value = card[key]
|
|
363
|
+
errors << "#{key}: must be an integer" unless value.is_a?(Integer)
|
|
364
|
+
value
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def read_abilities(card, errors)
|
|
368
|
+
list = card.fetch("abilities", [])
|
|
369
|
+
unless list.is_a?(Array)
|
|
370
|
+
errors << "abilities: must be a list"
|
|
371
|
+
list = []
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
explicit = list.each_with_index.filter_map do |entry, i|
|
|
375
|
+
ability = Mechanics.build_entry(:ability, entry, errors, "abilities[#{i}]")
|
|
376
|
+
AbilityInstance.new(ref: "printed:#{i}", ability: ability) if ability
|
|
377
|
+
end
|
|
378
|
+
intrinsic = @subtypes.flat_map { CardDefinition.intrinsic_instances(it) }
|
|
379
|
+
@ability_instances = (explicit + linked_instances(explicit) + intrinsic + keyword_abilities).freeze
|
|
380
|
+
read_nested_abilities(explicit)
|
|
381
|
+
@ability_instances.map(&:ability).freeze
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Abilities that come with another one ("When this permanent enters, if its evoke cost was paid, ...",
|
|
385
|
+
# Abilities::Evoke), referenced as "<ref>:linked:<j>".
|
|
386
|
+
def linked_instances(explicit)
|
|
387
|
+
explicit.flat_map do |inst|
|
|
388
|
+
linked = inst.ability.respond_to?(:linked_abilities) ? inst.ability.linked_abilities : EMPTY
|
|
389
|
+
linked.each_with_index.map { |a, j| AbilityInstance.new(ref: "#{inst.ref}:linked:#{j}", ability: a) }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Keyword abilities that stand for an ability ("Station" is an activated ability, CR 702.184a).
|
|
394
|
+
def keyword_abilities = @keywords.flat_map { CardDefinition.keyword_instances(it) }
|
|
395
|
+
|
|
396
|
+
# Abilities nested in another ability (a station symbol's, CR 722.2), referenced as
|
|
397
|
+
# "<parent ref>:<j>". They're part of the object's characteristics only while the parent applies.
|
|
398
|
+
def read_nested_abilities(explicit)
|
|
399
|
+
@nested_by_ref = explicit.to_h do |inst|
|
|
400
|
+
nested = inst.ability.respond_to?(:nested_abilities) ? inst.ability.nested_abilities : EMPTY
|
|
401
|
+
[inst.ref, nested.each_with_index.map { |a, j| AbilityInstance.new(ref: "#{inst.ref}:#{j}", ability: a) }.freeze]
|
|
402
|
+
end.freeze
|
|
403
|
+
@nested_instances = @nested_by_ref.values.flatten.freeze
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def normalize(card)
|
|
407
|
+
out = { "name" => @name }
|
|
408
|
+
out["mana_cost"] = @mana_cost.to_s if @mana_cost
|
|
409
|
+
out["colors"] = @colors.map(&:to_s) if card.key?("colors")
|
|
410
|
+
out["types"] = @types.map(&:to_s)
|
|
411
|
+
out["supertypes"] = @supertypes.map(&:to_s) unless @supertypes.empty?
|
|
412
|
+
out["subtypes"] = @subtypes.map(&:to_s) unless @subtypes.empty?
|
|
413
|
+
out["keywords"] = @keywords.map(&:to_s) unless @keywords.empty?
|
|
414
|
+
%w[power toughness].each { |key| out[key] = normalized_stat(card, key) unless instance_variable_get("@#{key}").nil? }
|
|
415
|
+
out["loyalty"] = @loyalty unless @loyalty.nil?
|
|
416
|
+
out["abilities"] = Snapshot.deep_stringify(card["abilities"]) if card["abilities"].is_a?(Array) && !card["abilities"].empty?
|
|
417
|
+
out["rules_text"] = @rules_text if @rules_text
|
|
418
|
+
out["deck_limit"] = @deck_limit if @deck_limit
|
|
419
|
+
deep_freeze(out)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# An integer, or the amount of a characteristic-defining ability as given.
|
|
423
|
+
def normalized_stat(card, key) = card[key].is_a?(Hash) ? Snapshot.deep_stringify(card[key]) : instance_variable_get("@#{key}")
|
|
424
|
+
|
|
425
|
+
def deep_freeze(value)
|
|
426
|
+
case value
|
|
427
|
+
when Hash then value.each_value { deep_freeze(it) }
|
|
428
|
+
when Array then value.each { deep_freeze(it) }
|
|
429
|
+
end
|
|
430
|
+
value.freeze
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
class CardDefinition
|
|
5
|
+
# Cards with more than one set of characteristics, written as
|
|
6
|
+
#
|
|
7
|
+
# {"name": "Alfa // Bravo", "layout": "transform", "faces": [{...front...}, {...back...}]}
|
|
8
|
+
#
|
|
9
|
+
# where each face is a complete card block (name, mana_cost, types, abilities, ...). `name` is optional
|
|
10
|
+
# and, when given, must be "<front name> // <back name>". The layouts:
|
|
11
|
+
#
|
|
12
|
+
# transform a nonmodal double-faced card (CR 712.2): cast and played front face up, it can transform
|
|
13
|
+
# (CR 701.27) or enter transformed (CR 712.11a)
|
|
14
|
+
# modal a modal double-faced card (CR 712.3): its owner chooses the face to cast or play (CR 712.8b,
|
|
15
|
+
# 712.9); a modal card may also transform (CR 712.3)
|
|
16
|
+
# adventure an adventurer card (CR 716): faces[1] is the Adventure, an instant or sorcery
|
|
17
|
+
# omen an omen card (CR 721): faces[1] is the Omen, an instant or sorcery
|
|
18
|
+
# prepare a preparation card (CR 723): faces[1] is the prepare spell, an instant or sorcery
|
|
19
|
+
# meld a meld card (CR 713): faces[1] is the combined back face of its meld pair, the same on both
|
|
20
|
+
# cards; it's up only on the melded permanent (CR 713.1b, 713.5c; effect `meld`)
|
|
21
|
+
#
|
|
22
|
+
# The definition itself has the front face's (faces[0]'s) characteristics, which are the card's in
|
|
23
|
+
# every zone where no other face is up (CR 712.6a, 716.4, 721.4, 723.4). Every other face is a
|
|
24
|
+
# CardDefinition with the id "<card id>/<index>"; a game object whose `card` names a face has that
|
|
25
|
+
# face up (see Engine::Faces).
|
|
26
|
+
module Faces
|
|
27
|
+
LAYOUTS = %w[transform modal adventure omen prepare meld].freeze
|
|
28
|
+
DOUBLE_FACED = %w[transform modal].freeze
|
|
29
|
+
# Layouts whose faces[1] is a spell the card can be (CR 716.2, 721.2, 723.2).
|
|
30
|
+
SPELL_FACE = { "adventure" => "adventure", "omen" => "omen", "prepare" => nil }.freeze
|
|
31
|
+
FACED_KEYS = %w[name layout faces].freeze
|
|
32
|
+
FACE_SEPARATOR = "/"
|
|
33
|
+
|
|
34
|
+
# A face's error list: it holds the face's own messages (so validation reading them back sees the
|
|
35
|
+
# same paths as for a single-faced card) and adds each to the card's errors under the face's path
|
|
36
|
+
# ("faces[1].types: ...").
|
|
37
|
+
class PathErrors < Array
|
|
38
|
+
def initialize(errors, path)
|
|
39
|
+
super()
|
|
40
|
+
@errors = errors
|
|
41
|
+
@path = path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def <<(message)
|
|
45
|
+
@errors << "#{@path}.#{message}"
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The layout of the card this face belongs to (nil for a single-faced card).
|
|
51
|
+
def layout = @front ? @front.layout : @layout
|
|
52
|
+
|
|
53
|
+
# Every face, front first ([self] for a single-faced card).
|
|
54
|
+
def faces = @front ? @front.faces : (@faces || [self])
|
|
55
|
+
def face(index) = faces.fetch(index)
|
|
56
|
+
def other_faces = @front || @faces.nil? ? [] : @faces.drop(1)
|
|
57
|
+
|
|
58
|
+
# 0 for the front face (and a single-faced card).
|
|
59
|
+
def face_index = @face_index || 0
|
|
60
|
+
|
|
61
|
+
# The card's own definition: the front face.
|
|
62
|
+
def card = @front || self
|
|
63
|
+
|
|
64
|
+
# A double-faced card (CR 712.1), whose faces can transform (CR 712.7).
|
|
65
|
+
def double_faced? = DOUBLE_FACED.include?(layout)
|
|
66
|
+
|
|
67
|
+
# The face to turn to when this face transforms (CR 701.27a).
|
|
68
|
+
def other_face = faces[1 - face_index]
|
|
69
|
+
|
|
70
|
+
# "Alfa // Bravo" for a card with faces.
|
|
71
|
+
def full_name = faces.map(&:name).join(" // ")
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def read_faces(card, errors, token_id)
|
|
76
|
+
(card.keys - FACED_KEYS).each do |key|
|
|
77
|
+
errors << "#{key}: a card with faces keeps its characteristics in each face (allowed beside faces: " \
|
|
78
|
+
"#{(FACED_KEYS + PRINT_KEYS).join(", ")})"
|
|
79
|
+
end
|
|
80
|
+
@layout = card["layout"]
|
|
81
|
+
errors << "layout: required with faces, one of #{LAYOUTS.join(", ")}" unless LAYOUTS.include?(@layout)
|
|
82
|
+
list = card["faces"]
|
|
83
|
+
unless list.is_a?(Array) && list.size == 2 && list.all?(Hash)
|
|
84
|
+
errors << "faces: must be a list of two faces, each a card object"
|
|
85
|
+
list = Array(list).grep(Hash).first(2)
|
|
86
|
+
list << {} while list.size < 2
|
|
87
|
+
end
|
|
88
|
+
front, back = list.map { it.transform_keys(&:to_s) }
|
|
89
|
+
read_card(front, prefixed(errors, "faces[0]"), token_id)
|
|
90
|
+
@faces = [self, CardDefinition.new(back, prefixed(errors, "faces[1]"), face: [self, 1])].freeze
|
|
91
|
+
validate_faces(card, errors)
|
|
92
|
+
@source = deep_freeze({ "name" => full_name, "layout" => @layout,
|
|
93
|
+
"faces" => [normalize(front), @faces[1].to_h] })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def prefixed(errors, path) = PathErrors.new(errors, path)
|
|
97
|
+
|
|
98
|
+
def validate_faces(card, errors)
|
|
99
|
+
name = card["name"]
|
|
100
|
+
errors << "name: must be #{full_name.inspect} (the faces' names joined by \" // \")" unless name.nil? || name == full_name
|
|
101
|
+
errors << "faces[1].types: a melded permanent is a permanent (CR 713.1a)" if @layout == "meld" && !@faces[1].permanent?
|
|
102
|
+
return unless SPELL_FACE.key?(@layout)
|
|
103
|
+
|
|
104
|
+
spell = @faces[1]
|
|
105
|
+
errors << "faces[0].types: the main face of a #{@layout} card is a permanent card" unless permanent?
|
|
106
|
+
unless spell.type?(:instant) || spell.type?(:sorcery)
|
|
107
|
+
errors << "faces[1].types: the #{@layout} face is an instant or sorcery (CR 716.2, 721.2, 723.2)"
|
|
108
|
+
end
|
|
109
|
+
subtype = SPELL_FACE[@layout]
|
|
110
|
+
return if subtype.nil? || spell.subtypes.include?(subtype.to_sym)
|
|
111
|
+
|
|
112
|
+
errors << "faces[1].subtypes: an #{@layout} face has the spell type #{subtype} (CR 205.3k)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# A set of card definitions keyed by id. Built per game from the players' decks;
|
|
5
|
+
# there is no global card registry.
|
|
6
|
+
class Catalog
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def self.from_definitions(list)
|
|
10
|
+
new.tap { |c| list.each { c.add(it) } }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@definitions = {}
|
|
15
|
+
@faces = {} # a card's other faces, by face id ("alfa/1", see CardDefinition::Faces)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Adds a definition (CardDefinition or data Hash). Raises CatalogConflict when a
|
|
19
|
+
# different definition with the same id is already present.
|
|
20
|
+
def add(definition)
|
|
21
|
+
defn = CardDefinition.from_h(definition)
|
|
22
|
+
existing = @definitions[defn.id]
|
|
23
|
+
raise CatalogConflict, "two different definitions for #{defn.name.inspect} (#{defn.id})" if existing && existing != defn
|
|
24
|
+
|
|
25
|
+
@definitions[defn.id] ||= defn
|
|
26
|
+
defn.other_faces.each { @faces[it.id] = it }
|
|
27
|
+
defn.token_definitions.each { add(it) unless @definitions.key?(it.id) }
|
|
28
|
+
defn
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def merge(other)
|
|
32
|
+
other.each { add(it) }
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Accepts an id ("lightning_bolt") or a display name ("Lightning Bolt"). Ids hit an exact
|
|
37
|
+
# lookup; only names pay for slugging (Unicode normalization), so the engine's hot path stays cheap.
|
|
38
|
+
def fetch(ref)
|
|
39
|
+
@definitions[ref] || @faces[ref] ||
|
|
40
|
+
@definitions.fetch(CardDefinition.slug(ref)) { raise KeyError, "unknown card #{ref.inspect}" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def [](ref) = @definitions[ref] || @faces[ref] || @definitions[CardDefinition.slug(ref)]
|
|
44
|
+
def include?(ref) = @definitions.key?(ref) || @faces.key?(ref) || @definitions.key?(CardDefinition.slug(ref))
|
|
45
|
+
def ids = @definitions.keys
|
|
46
|
+
def size = @definitions.size
|
|
47
|
+
def each(&) = @definitions.each_value(&)
|
|
48
|
+
def to_a = @definitions.values.map(&:to_h)
|
|
49
|
+
|
|
50
|
+
def freeze
|
|
51
|
+
@definitions.freeze
|
|
52
|
+
@faces.freeze
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Enjine
|
|
4
|
+
# An ability as an object currently has it, with a stable reference:
|
|
5
|
+
# "printed:<i>" the i-th ability in the card data
|
|
6
|
+
# "intrinsic:<subtype>" granted by a subtype rule (e.g. a basic land type, CR 305.6)
|
|
7
|
+
# Later: "keyword:<name>", "granted:<effect>:<i>". References are what options and
|
|
8
|
+
# stack objects carry, so they stay valid when abilities are granted or removed.
|
|
9
|
+
AbilityInstance = Data.define(:ref, :ability)
|
|
10
|
+
|
|
11
|
+
# An object's characteristics after continuous effects (CR 109.3, 613).
|
|
12
|
+
# Rules code asks these questions via Engine#chars, never the printed CardDefinition.
|
|
13
|
+
Characteristics = Data.define(:name, :mana_cost, :colors, :types, :supertypes, :subtypes, :keywords, :abilities,
|
|
14
|
+
:power, :toughness, :loyalty) do
|
|
15
|
+
def self.printed(defn)
|
|
16
|
+
new(name: defn.name, mana_cost: defn.mana_cost, colors: defn.colors, types: defn.types, supertypes: defn.supertypes,
|
|
17
|
+
subtypes: defn.subtypes, keywords: defn.keywords, abilities: defn.ability_instances,
|
|
18
|
+
power: defn.power, toughness: defn.toughness, loyalty: defn.loyalty)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def type?(type) = types.include?(type)
|
|
22
|
+
def legendary? = supertypes.include?(:legendary)
|
|
23
|
+
def creature? = types.include?(:creature)
|
|
24
|
+
def planeswalker? = types.include?(:planeswalker)
|
|
25
|
+
def land? = types.include?(:land)
|
|
26
|
+
def keyword?(keyword) = keywords.include?(keyword)
|
|
27
|
+
|
|
28
|
+
def ability(ref) = abilities.find { it.ref == ref }&.ability
|
|
29
|
+
def mana_abilities = abilities.select { it.ability.mana_ability? }
|
|
30
|
+
def activated_abilities = abilities.select { %i[activated loyalty].include?(it.ability.kind) }
|
|
31
|
+
def triggered_abilities = abilities.select { it.ability.kind == :triggered }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A card exiled face down has no characteristics (CR 406.3a).
|
|
35
|
+
Characteristics::FACE_DOWN = Characteristics.new(
|
|
36
|
+
name: "", mana_cost: nil, colors: [].freeze, types: [].freeze, supertypes: [].freeze, subtypes: [].freeze,
|
|
37
|
+
keywords: [].freeze, abilities: [].freeze, power: nil, toughness: nil, loyalty: nil
|
|
38
|
+
)
|
|
39
|
+
end
|