cw_card_utils 0.1.6 → 0.1.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8d8eb329c364d67fedf00a49698b9f145f06b6e08f7effae3eb92fb5c22d244
4
- data.tar.gz: 8cc571695d9d0b3b07c2da11abffe0a047d08591c8b490fbaffdf88a21226fe5
3
+ metadata.gz: e301d659cb6e96974ef4b5aa075e3951d8b875b645a3518ec8ae7c77211a0d42
4
+ data.tar.gz: 638f1bb63aabb8db4d648df1070c156c34c6d9b8c5dc7770b0cde08fa4513f9d
5
5
  SHA512:
6
- metadata.gz: 38000b90cc9c72b84efdf4c8eb892d20748fe4050af8874c99847a9e4b572b4fcbe573cfba49a964fcda60b8d177bbf9195f3f9b89c9f92c4f08d716d18c41ca
7
- data.tar.gz: 9f62737b9b7fde768f4f990b0367b94b6e86d8bcab4a4925e1c91a8c6abfaae71a2ac56bd8ecfe5ffb74ec956010ad3c6d8658e102c032eeb3514e8488e0e79f
6
+ metadata.gz: afd81df332b4f152ab7acd249aeff97d482b2fbc8300eeb6b6f42edec278575b2e33333ad09140d1651f80e2cfd4de7ad3dd18d7fc8c04111d60bb8711c22536
7
+ data.tar.gz: db60344938a90eebb336e84e944dd6528699e227b2231cac4bca997dbaeddc3f0ad705511a72726c5a87ee7e4557939bf2334b94338729acee5b1360ff6e837f
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CwCardUtils
4
-
5
4
  class DeckComparator
6
5
  attr_reader :deck_a, :deck_b, :analysis
7
6
 
7
+ WEIGHTS = {
8
+ archetype: 0.30,
9
+ curve: 0.25,
10
+ synergy: 0.25,
11
+ interaction: 0.20,
12
+ }.freeze
13
+
8
14
  def initialize(deck_a, deck_b)
9
15
  @deck_a = deck_a
10
16
  @deck_b = deck_b
@@ -12,31 +18,49 @@ module CwCardUtils
12
18
  end
13
19
 
14
20
  def compare
15
- a_info = analyze_deck(deck_a)
16
- b_info = analyze_deck(deck_b)
21
+ results = {
22
+ on_play: matchup_scenario(on_play: true),
23
+ on_draw: matchup_scenario(on_play: false),
24
+ }
25
+ @analysis = results
26
+ end
27
+
28
+ private
29
+
30
+ def matchup_scenario(on_play:)
31
+ a_info = analyze_deck(deck_a, on_play: on_play)
32
+ b_info = analyze_deck(deck_b, on_play: !on_play)
33
+
34
+ win_rate_a = weighted_score(a_info, b_info)
35
+ win_rate_b = (1.0 - win_rate_a).round(3)
17
36
 
18
- matchup = {
37
+ {
19
38
  archetype_a: a_info[:archetype],
20
39
  archetype_b: b_info[:archetype],
21
- likely_winner: predict_matchup(a_info, b_info),
40
+ win_rate_a: win_rate_a,
41
+ win_rate_b: win_rate_b,
42
+ favored: if win_rate_a > win_rate_b
43
+ "Deck A"
44
+ else
45
+ ((win_rate_b > win_rate_a) ? "Deck B" : "Even")
46
+ end,
22
47
  interaction_overlap: compare_interaction_density(a_info, b_info),
23
- notes: generate_notes(a_info, b_info),
48
+ synergy_a: a_info[:synergy_hit_rate],
49
+ synergy_b: b_info[:synergy_hit_rate],
50
+ notes: generate_notes(a_info, b_info, on_play: on_play),
24
51
  }
25
-
26
- @analysis = matchup
27
52
  end
28
53
 
29
- private
30
-
31
- def analyze_deck(deck)
32
- detector = ArchetypeDetector.new(deck)
54
+ def analyze_deck(deck, on_play:)
55
+ detector = CwCardUtils::DecklistParser::ArchetypeDetector.new(deck)
33
56
  archetype = detector.detect
34
-
35
57
  curve = deck.normalized_curve
36
58
 
37
59
  early = ((curve[0] || 0) + (curve[1] || 0)).round(2)
38
- mid = ((curve[2] || 0) + (curve[3] || 0)).round(2)
39
- late = ((curve[4] || 0) + (curve[5] || 0) + (curve[6] || 0) + (curve[7] || 0)).round(2)
60
+ mid = ((curve[2] || 0) + (curve[3] || 0)).round(2)
61
+ late = ((curve[4] || 0) + (curve[5] || 0) + (curve[6] || 0) + (curve[7] || 0)).round(2)
62
+
63
+ synergy_hit_rate = calc_synergy_hit_rate(deck, on_play: on_play)
40
64
 
41
65
  {
42
66
  archetype: archetype,
@@ -46,16 +70,86 @@ module CwCardUtils
46
70
  late_curve: late,
47
71
  tag_ratios: detector.tag_ratios,
48
72
  tag_counts: detector.tag_counts,
73
+ synergy_hit_rate: synergy_hit_rate,
49
74
  tribe: deck.tribe,
50
75
  format: deck.format,
51
76
  }
52
77
  end
53
78
 
79
+ def calc_synergy_hit_rate(deck, on_play:)
80
+ synergy_pairs = extract_synergy_pairs(deck)
81
+ return 0 if synergy_pairs.empty?
82
+
83
+ synergy_checker = CwCardUtils::SynergyProbability.new(deck, deck_size: deck.main.map(&:count).sum)
84
+
85
+ probs = synergy_pairs.map do |pair|
86
+ synergy_checker.prob_combo(pair, draws_by_turn(5, on_play: on_play))
87
+ end
88
+ (probs.sum / probs.size).round(2)
89
+ end
90
+
91
+ def extract_synergy_pairs(deck)
92
+ synergy_cards = deck.main.select do |card|
93
+ (card.tags & [:synergistic_finisher, :tribal_synergy, :scaling_threat]).any?
94
+ end
95
+ return [] if synergy_cards.size < 2
96
+ synergy_cards.map(&:name).combination(2).to_a
97
+ end
98
+
99
+ def draws_by_turn(turn, on_play:)
100
+ on_play ? (7 + (turn - 1)) : (7 + turn)
101
+ end
102
+
103
+ def weighted_score(a, b)
104
+ # Archetype advantage
105
+ archetype_score = case predict_matchup(a, b)
106
+ when "Deck A (Aggro)", "Deck A (Faster curve)", "Deck A (Synergy)" then 1.0
107
+ when "Deck B (Aggro)", "Deck B (Faster curve)", "Deck B (Synergy)" then 0.0
108
+ else 0.5
109
+ end
110
+
111
+ # Curve advantage
112
+ curve_score = if a[:early_curve] > b[:early_curve] + 0.15
113
+ 1.0
114
+ elsif b[:early_curve] > a[:early_curve] + 0.15
115
+ 0.0
116
+ else
117
+ 0.5
118
+ end
119
+
120
+ # Synergy advantage
121
+ synergy_score = if a[:synergy_hit_rate] > b[:synergy_hit_rate] + 0.1
122
+ 1.0
123
+ elsif b[:synergy_hit_rate] > a[:synergy_hit_rate] + 0.1
124
+ 0.0
125
+ else
126
+ 0.5
127
+ end
128
+
129
+ # Interaction advantage
130
+ interaction_score = if a[:tag_ratios][:interaction].to_f > b[:tag_ratios][:interaction].to_f + 0.05
131
+ 1.0
132
+ elsif b[:tag_ratios][:interaction].to_f > a[:tag_ratios][:interaction].to_f + 0.05
133
+ 0.0
134
+ else
135
+ 0.5
136
+ end
137
+
138
+ (
139
+ archetype_score * WEIGHTS[:archetype] +
140
+ curve_score * WEIGHTS[:curve] +
141
+ synergy_score * WEIGHTS[:synergy] +
142
+ interaction_score * WEIGHTS[:interaction]
143
+ ).round(3)
144
+ end
145
+
54
146
  def predict_matchup(a, b)
55
147
  return "Deck A (Aggro)" if is_aggro?(a) && is_control?(b)
56
148
  return "Deck B (Aggro)" if is_aggro?(b) && is_control?(a)
57
149
  return "Deck A (Faster curve)" if a[:early_curve] > b[:early_curve] + 0.15
58
150
  return "Deck B (Faster curve)" if b[:early_curve] > a[:early_curve] + 0.15
151
+ return "Deck A (Synergy)" if a[:synergy_hit_rate] > b[:synergy_hit_rate] + 0.1
152
+ return "Deck B (Synergy)" if b[:synergy_hit_rate] > a[:synergy_hit_rate] + 0.1
59
153
  "Even Matchup"
60
154
  end
61
155
 
@@ -80,8 +174,9 @@ module CwCardUtils
80
174
  end
81
175
  end
82
176
 
83
- def generate_notes(a, b)
177
+ def generate_notes(a, b, on_play:)
84
178
  notes = []
179
+ notes << (on_play ? "Deck A is on the play" : "Deck B is on the play")
85
180
 
86
181
  if a[:tribe] && b[:tribe]
87
182
  notes << "Both decks are tribal: #{a[:tribe].capitalize} vs #{b[:tribe].capitalize}"
@@ -95,14 +190,10 @@ module CwCardUtils
95
190
  notes << "Color overlap may result in symmetrical strategies"
96
191
  end
97
192
 
98
- notes << "Deck A has a stronger early game" if a[:early_curve] > b[:early_curve] + 0.2
99
- notes << "Deck B has a stronger early game" if b[:early_curve] > a[:early_curve] + 0.2
100
-
101
- notes << "Deck A has more combo elements" if a[:tag_ratios][:combo_piece].to_f > 0.1
102
- notes << "Deck B has more combo elements" if b[:tag_ratios][:combo_piece].to_f > 0.1
193
+ notes << "Deck A synergy hit rate: #{(a[:synergy_hit_rate] * 100).round}%"
194
+ notes << "Deck B synergy hit rate: #{(b[:synergy_hit_rate] * 100).round}%"
103
195
 
104
196
  notes
105
197
  end
106
198
  end
107
-
108
199
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+ class ArchetypeDetector
6
+ attr_reader :deck, :format, :tribe, :colors, :tag_counts, :tag_ratios
7
+
8
+ def initialize(deck)
9
+ @deck = deck
10
+ @format = deck.format.to_s.downcase # :standard, :edh, etc.
11
+ @tribe = deck.tribe&.capitalize # e.g., "Cat"
12
+ @colors = resolve_color_identity(deck)
13
+ @tag_counts = Hash.new(0)
14
+ @tag_ratios = {}
15
+ count_tags
16
+ calculate_ratios
17
+ end
18
+
19
+ def detect
20
+ archetype = detect_archetype
21
+ color_label = resolve_color_label(colors)
22
+ tribe_label = tribe ? " #{tribe} Tribal" : ""
23
+ archetype_label = " #{archetype.to_s.capitalize}"
24
+
25
+
26
+ [color_label, tribe_label, archetype_label].join.strip.squeeze(" ")
27
+ end
28
+
29
+ private
30
+
31
+ # === TAG COUNTING ===
32
+
33
+ def count_tags
34
+ total_tags = 0
35
+
36
+ deck.each do |card|
37
+ next unless card.respond_to?(:tags)
38
+
39
+ card.tags.each do |tag|
40
+ @tag_counts[tag] += card.count
41
+ total_tags += card.count
42
+ end
43
+ end
44
+
45
+ @tag_counts[:_total] = total_tags
46
+ end
47
+
48
+ def calculate_ratios
49
+ total = @tag_counts[:_total].to_f
50
+ return if total.zero?
51
+
52
+ @tag_counts.each do |tag, count|
53
+ next if tag == :_total
54
+ @tag_ratios[tag] = count / total
55
+ end
56
+ end
57
+
58
+ # === CMC UTILITY ===
59
+
60
+ def average_cmc
61
+ @average_cmc ||= begin
62
+ cmcs = deck.main.map { |card| card.respond_to?(:cmc) ? card.cmc.to_f : nil }.compact
63
+ return 0 if cmcs.empty? # rubocop:disable Lint/NoReturnInBeginEndBlocks
64
+ cmcs.sum / cmcs.size
65
+ end
66
+ end
67
+
68
+ # === FORMAT-AWARE DETECTION ===
69
+ def detect_archetype
70
+ if format == "edh"
71
+ return :combo if tag_ratios[:combo_piece].to_f > 0.10 && tag_ratios[:draw].to_f > 0.08
72
+
73
+ control_indicators = tag_counts[:interaction].to_i +
74
+ tag_counts[:draw].to_i +
75
+ tag_counts[:tax].to_i +
76
+ tag_counts[:lock_piece].to_i
77
+
78
+ return :control if control_indicators >= 14 && tag_counts[:threat].to_i <= 6
79
+
80
+ return :ramp if tag_counts[:ramp] >= 10 && average_cmc >= 4.0
81
+ return :midrange if tag_counts[:threat] >= 15 && tag_counts[:interaction] >= 6
82
+
83
+ return :aggro if tag_ratios[:threat].to_f >= 0.2 ||
84
+ tag_counts[:synergistic_finisher].to_i >= 3 ||
85
+ (tag_counts[:scaling_threat].to_i >= 6 && average_cmc <= 3.5) ||
86
+ (tag_counts[:synergy].to_i >= 6 && average_cmc <= 3.0)
87
+ else
88
+ return :combo if tag_ratios[:combo_piece].to_f >= 0.12
89
+ return :control if tag_ratios[:interaction].to_f >= 0.25 &&
90
+ tag_ratios[:threat].to_f < 0.2 &&
91
+ (tag_ratios[:draw].to_f + tag_ratios[:tax].to_f + tag_ratios[:lock_piece].to_f) >= 0.15
92
+
93
+ return :aggro if tag_ratios[:threat].to_f >= 0.2 ||
94
+ tag_counts[:synergistic_finisher].to_i >= 3 ||
95
+ (tag_counts[:scaling_threat].to_i >= 6 && average_cmc <= 3.5) ||
96
+ (tag_counts[:synergy].to_i >= 6 && average_cmc <= 3.0)
97
+
98
+ return :ramp if tag_ratios[:ramp].to_f >= 0.12 && tag_counts[:finisher] >= 3 && average_cmc >= 4.0
99
+ return :tempo if tag_ratios[:interaction].to_f >= 0.2 && tag_ratios[:threat].to_f >= 0.2 && average_cmc <= 3.5
100
+ return :midrange if tag_ratios[:threat].to_f >= 0.2 && tag_ratios[:interaction].to_f >= 0.1
101
+ end
102
+
103
+ :unknown
104
+ end
105
+
106
+ # === COLOR IDENTITY ===
107
+
108
+ def resolve_color_identity(deck)
109
+ deck.main.flat_map { |c| c.respond_to?(:color_identity) ? c.color_identity : [] }.uniq.sort
110
+ end
111
+
112
+ def resolve_color_label(identity)
113
+ ColorIdentityResolver.resolve(identity)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+ # A Card is a single card in a deck.
6
+ class Card
7
+ def initialize(name, count, cmc_data_source = CwCardUtils::ScryfallCmcData.instance)
8
+ @name = name
9
+ @count = count
10
+ @tags = []
11
+ @cmc_data_source = cmc_data_source
12
+ end
13
+
14
+ attr_reader :name, :count, :cmc_data_source
15
+ attr_accessor :tags
16
+
17
+ def cmc
18
+ @cmc ||= @cmc_data_source&.cmc_for_card(@name)
19
+ end
20
+
21
+ def type
22
+ @type ||= @cmc_data_source&.type_for_card(@name) || "Land"
23
+ end
24
+
25
+ def keywords
26
+ @keywords ||= @cmc_data_source&.keywords_for_card(@name) || []
27
+ end
28
+
29
+ def oracle_text
30
+ @oracle_text ||= @cmc_data_source&.oracle_text_for_card(@name)
31
+ end
32
+
33
+ def power
34
+ @power ||= @cmc_data_source&.power_for_card(@name)
35
+ end
36
+
37
+ def toughness
38
+ @toughness ||= @cmc_data_source&.toughness_for_card(@name)
39
+ end
40
+
41
+ def color_identity
42
+ @color_identity ||= @cmc_data_source&.color_identity_for_card(@name) || []
43
+ end
44
+
45
+ def inspect
46
+ "<Card: #{@name} (#{@count}) #{cmc}>"
47
+ end
48
+
49
+ def to_h
50
+ { name: @name, count: @count, cmc: cmc, type: type, keywords: keywords, power: power, toughness: toughness, oracle_text: oracle_text }
51
+ end
52
+
53
+ def to_json(*_args)
54
+ to_h.to_json
55
+ end
56
+ end
57
+
58
+
59
+ end
60
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+
6
+ class CardTagger
7
+ def initialize(card, deck)
8
+ @card = card
9
+ @text = card.oracle_text.to_s.downcase
10
+ @type_line = card.type.to_s.downcase
11
+ @cmc = card.cmc || 0
12
+ @power = begin
13
+ card.power.to_i
14
+ rescue StandardError
15
+ 0
16
+ end
17
+ @toughness = begin
18
+ card.toughness.to_i
19
+ rescue StandardError
20
+ 0
21
+ end
22
+ @keywords = card.keywords.map(&:downcase)
23
+ @deck = deck
24
+ @tribe = deck.tribe&.to_s&.downcase
25
+ end
26
+
27
+ def tags
28
+ t = []
29
+
30
+ # === Threat ===
31
+ t << :threat if creature? && @power >= 3
32
+
33
+ # === Finisher ===
34
+ t << :finisher if likely_finisher?
35
+
36
+ # === Ramp ===
37
+ t << :ramp if @text.match?(/add .* mana|search.*basic|put.*land|mana pool/)
38
+
39
+ # === Draw ===
40
+ t << :draw if @text.match?(/draw.*card|investigate|learn/)
41
+
42
+ # === Tutor ===
43
+ t << :tutor if @text.match?(/search.*library/)
44
+
45
+ # === Interaction ===
46
+ t << :interaction if @text.match?(/destroy|exile|counter|fight|return.*to.*hand|sacrifice.*creature|discard/i)
47
+
48
+ # === Lock Pieces ===
49
+ t << :lock_piece if @text.match?(/players can't|creatures can't|skip.*step|can't untap|prevent all combat damage|each player.*sacrifice/i)
50
+
51
+ # === Tax Effects ===
52
+ t << :tax if @text.match?(/whenever an opponent|opponents? .* pay|opponents? .* can't|noncreature spells .* cost|can't cast more than/i)
53
+
54
+ # === Combo ===
55
+ t << :combo_piece if @text.match?(/infinite|copy.*spell|storm|you win the game/i)
56
+
57
+ # === ETB Effects ===
58
+ if @text.match?(/when.*enters.*the battlefield/)
59
+ t << :etb
60
+ t << :etb_buff if @text.match?(/creatures.*get.*\+/)
61
+ t << :etb_token if @text.match?(/create.*token/)
62
+ t << :etb_removal if @text.match?(/destroy|exile|tap|fight/)
63
+ end
64
+
65
+ # === Evasion / Scaling Threats ===
66
+ if @keywords.any? { |kw| %w[flying trample menace unblockable].include?(kw) } || @text.match?(/flying|trample|menace|unblockable/)
67
+ t << :evasive
68
+ end
69
+
70
+ if @text.match?(/gets \+x\/\+x|for each other|creatures you control|other .*#{@tribe}s? you control/i)
71
+ t << :scaling_threat
72
+ t << :synergistic_finisher if @cmc <= 4 && t.include?(:evasive)
73
+ end
74
+
75
+ # === Go-Wide Synergy ===
76
+ if @text.match?(/whenever.*creature.*enters|token.*you control/i)
77
+ t << :synergy
78
+ end
79
+
80
+ # === Choose-a-type tribal enablers ===
81
+ if @text.include?("choose a creature type")
82
+ t << :tribal_synergy
83
+ t << :tribal_creature
84
+ if @text.match?(/creatures.*chosen type.*get.*\+.*\+|whenever.*creature.*of the chosen type/i)
85
+ t << :threat unless t.include?(:threat)
86
+ end
87
+ end
88
+
89
+ if creature? && @power <= 2 && @tribe &&
90
+ (@text.include?("whenever") || @text.include?("enters") || @text.include?("lifelink") || @text.include?("vigilance") || @text.include?("+1/+1"))
91
+ t << :threat
92
+ end
93
+
94
+ # === Static tribal buffs ===
95
+ if @tribe
96
+ t << :tribal_creature if creature_subtypes.include?(@tribe)
97
+ t << :tribal_synergy if @text.include?("#{@tribe}s") || @text.include?("other #{@tribe}")
98
+
99
+ if @text.match?(/other .*#{@tribe}s? you control.*\+\d\/\+\d|creature you control of the chosen type.*\+\d\/\+\d/i)
100
+ t << :tribal_synergy
101
+ t << :threat unless t.include?(:threat)
102
+ end
103
+
104
+ # Threat based on synergy
105
+ if @tribe && @text.match?(/creatures.*you control.*#{@tribe}|gets \+1\/\+1 for each.*#{@tribe}/)
106
+ t << :scaling_threat
107
+ t << :threat unless t.include?(:threat)
108
+ end
109
+ end
110
+ if t.include?(:scaling_threat) && !t.include?(:threat) # rubocop:disable Style/InverseMethods
111
+ t << :threat
112
+ end
113
+
114
+ t.uniq.sort
115
+ end
116
+
117
+ private
118
+
119
+ def creature?
120
+ @type_line.include?("creature")
121
+ end
122
+
123
+ def planeswalker?
124
+ @type_line.include?("planeswalker")
125
+ end
126
+
127
+ def creature_subtypes
128
+ return [] unless creature?
129
+ raw = @type_line.split(/[—-]/).last.to_s.strip
130
+ raw.split.map(&:downcase)
131
+ end
132
+
133
+ def likely_finisher?
134
+ return true if @cmc >= 6 && @text.match?(/flying|trample|indestructible|each opponent|you win|extra turn|double/i)
135
+ return true if planeswalker? && @text.match?(/creatures.*get.*flying|you get an emblem|each opponent/i)
136
+ false
137
+ end
138
+ end
139
+
140
+ end
141
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+ class ColorIdentityResolver
6
+ COLOR_NAMES = {
7
+ %w[W] => "White",
8
+ %w[U] => "Blue",
9
+ %w[B] => "Black",
10
+ %w[R] => "Red",
11
+ %w[G] => "Green",
12
+
13
+ %w[U W] => "Azorius",
14
+ %w[B U] => "Dimir",
15
+ %w[B R] => "Rakdos",
16
+ %w[G R] => "Gruul",
17
+ %w[G W] => "Selesnya",
18
+ %w[B W] => "Orzhov",
19
+ %w[R U] => "Izzet",
20
+ %w[B G] => "Golgari",
21
+ %w[R W] => "Boros",
22
+ %w[G U] => "Simic",
23
+
24
+ %w[B U W] => "Esper",
25
+ %w[B R U] => "Grixis",
26
+ %w[B G R] => "Jund",
27
+ %w[G R W] => "Naya",
28
+ %w[G U W] => "Bant",
29
+ %w[B R W] => "Mardu",
30
+ %w[G R U] => "Temur",
31
+ %w[B G W] => "Abzan",
32
+ %w[R U W] => "Jeskai",
33
+ %w[B G U] => "Sultai",
34
+
35
+ %w[B R U W] => "Glint",
36
+ %w[B G R U] => "Dune",
37
+ %w[B G R W] => "Ink",
38
+ %w[G R U W] => "Witch",
39
+ %w[B G U W] => "Yore",
40
+
41
+ %w[B G R U W] => "Five-Color",
42
+ [] => "Colorless",
43
+ }.freeze
44
+
45
+ def self.resolve(identity)
46
+ key = identity.sort
47
+ COLOR_NAMES[key] || key.join("/")
48
+ end
49
+ end
50
+ end
51
+ end