cw_card_utils 0.1.2 → 0.1.5
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 +4 -4
- data/lib/cw_card_utils/decklist_parser.rb +192 -48
- data/lib/cw_card_utils/scryfall_cmc_data.rb +17 -9
- data/lib/cw_card_utils/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e6190122b537e6cb162c5a2bc4005b960ce0182955e958817d4c92375b7cceb4
|
4
|
+
data.tar.gz: '08a0e11c0648db7ff053f6941d9bf0001ee6f88657136ada38b013343cd4cf16'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e1fe28a4668d7df9915b717a77ede85cc8d97494640f80b2838ebc42c527c181c365df59f8d65f434a6394e164b95d45c1884eeaba98e28f4fe44fbf8b16e37f
|
7
|
+
data.tar.gz: 62cfd74b58393017358a6d3242c521c38a2d988d1d4c9a7230e9e5e60ef2379f8e9836cc0721deef065339562f3394f0cbc9f23a274c4bb48e39a4d9570c9753
|
@@ -25,37 +25,34 @@ module CwCardUtils
|
|
25
25
|
def tags
|
26
26
|
t = []
|
27
27
|
|
28
|
-
# Threat
|
28
|
+
# === Threat ===
|
29
29
|
t << :threat if creature? && @power >= 3
|
30
30
|
|
31
|
-
#
|
31
|
+
# === Finisher ===
|
32
32
|
t << :finisher if likely_finisher?
|
33
33
|
|
34
|
-
# Ramp
|
34
|
+
# === Ramp ===
|
35
35
|
t << :ramp if @text.match?(/add .* mana|search.*basic|put.*land|mana pool/)
|
36
36
|
|
37
|
-
# Draw
|
37
|
+
# === Draw ===
|
38
38
|
t << :draw if @text.match?(/draw.*card|investigate|learn/)
|
39
39
|
|
40
|
-
#
|
40
|
+
# === Tutor ===
|
41
41
|
t << :tutor if @text.match?(/search.*library/)
|
42
42
|
|
43
|
-
# Interaction
|
44
|
-
if @text.match?(/destroy|exile|counter|fight|return.*to.*hand|sacrifice.*creature|discard/i)
|
45
|
-
t << :interaction
|
46
|
-
end
|
43
|
+
# === Interaction ===
|
44
|
+
t << :interaction if @text.match?(/destroy|exile|counter|fight|return.*to.*hand|sacrifice.*creature|discard/i)
|
47
45
|
|
48
|
-
# Lock
|
49
|
-
if @text.match?(/players can'
|
50
|
-
t << :lock_piece
|
51
|
-
end
|
46
|
+
# === Lock Pieces ===
|
47
|
+
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)
|
52
48
|
|
53
|
-
#
|
54
|
-
if @text.match?(/
|
55
|
-
t << :combo_piece
|
56
|
-
end
|
49
|
+
# === Tax Effects ===
|
50
|
+
t << :tax if @text.match?(/whenever an opponent|opponents? .* pay|opponents? .* can't|noncreature spells .* cost|can't cast more than/i)
|
57
51
|
|
58
|
-
#
|
52
|
+
# === Combo ===
|
53
|
+
t << :combo_piece if @text.match?(/infinite|copy.*spell|storm|you win the game/i)
|
54
|
+
|
55
|
+
# === ETB Effects ===
|
59
56
|
if @text.match?(/when.*enters.*the battlefield/)
|
60
57
|
t << :etb
|
61
58
|
t << :etb_buff if @text.match?(/creatures.*get.*\+/)
|
@@ -63,50 +60,57 @@ module CwCardUtils
|
|
63
60
|
t << :etb_removal if @text.match?(/destroy|exile|tap|fight/)
|
64
61
|
end
|
65
62
|
|
66
|
-
# Evasion /
|
63
|
+
# === Evasion / Scaling Threats ===
|
67
64
|
if @keywords.any? { |kw| %w[flying trample menace unblockable].include?(kw) } || @text.match?(/flying|trample|menace|unblockable/)
|
68
65
|
t << :evasive
|
69
66
|
end
|
70
67
|
|
71
|
-
|
72
|
-
if @text.match?(/gets \+x\/\+x|for each other|creatures you control/i)
|
68
|
+
if @text.match?(/gets \+x\/\+x|for each other|creatures you control|other .*#{@tribe}s? you control/i)
|
73
69
|
t << :scaling_threat
|
74
70
|
t << :synergistic_finisher if @cmc <= 4 && t.include?(:evasive)
|
75
71
|
end
|
76
72
|
|
77
|
-
# Go-
|
73
|
+
# === Go-Wide Synergy ===
|
78
74
|
if @text.match?(/whenever.*creature.*enters|token.*you control/i)
|
79
75
|
t << :synergy
|
80
76
|
end
|
81
77
|
|
78
|
+
# === Choose-a-type tribal enablers ===
|
82
79
|
if @text.include?("choose a creature type")
|
83
80
|
t << :tribal_synergy
|
84
|
-
t << :tribal_creature
|
85
|
-
|
81
|
+
t << :tribal_creature
|
86
82
|
if @text.match?(/creatures.*chosen type.*get.*\+.*\+|whenever.*creature.*of the chosen type/i)
|
87
83
|
t << :threat unless t.include?(:threat)
|
88
84
|
end
|
89
85
|
end
|
90
86
|
|
91
|
-
|
87
|
+
if creature? && @power <= 2 && @tribe &&
|
88
|
+
(@text.include?("whenever") || @text.include?("enters") || @text.include?("lifelink") || @text.include?("vigilance") || @text.include?("+1/+1"))
|
89
|
+
t << :threat
|
90
|
+
end
|
91
|
+
|
92
|
+
# === Static tribal buffs ===
|
92
93
|
if @tribe
|
93
|
-
if creature_subtypes.include?(@tribe)
|
94
|
-
|
95
|
-
end
|
94
|
+
t << :tribal_creature if creature_subtypes.include?(@tribe)
|
95
|
+
t << :tribal_synergy if @text.include?("#{@tribe}s") || @text.include?("other #{@tribe}")
|
96
96
|
|
97
|
-
|
98
|
-
if @text.include?("#{@tribe}s") || @text.include?("other #{@tribe}")
|
97
|
+
if @text.match?(/other .*#{@tribe}s? you control.*\+\d\/\+\d|creature you control of the chosen type.*\+\d\/\+\d/i)
|
99
98
|
t << :tribal_synergy
|
99
|
+
t << :threat unless t.include?(:threat)
|
100
100
|
end
|
101
101
|
|
102
|
-
#
|
103
|
-
if @text.match?(/
|
104
|
-
t << :
|
102
|
+
# Threat based on synergy
|
103
|
+
if @tribe && @text.match?(/creatures.*you control.*#{@tribe}|gets \+1\/\+1 for each.*#{@tribe}/)
|
104
|
+
t << :scaling_threat
|
105
105
|
t << :threat unless t.include?(:threat)
|
106
106
|
end
|
107
107
|
end
|
108
108
|
|
109
|
-
t.
|
109
|
+
if t.include?(:scaling_threat) && !t.include?(:threat) # rubocop:disable Style/InverseMethods
|
110
|
+
t << :threat
|
111
|
+
end
|
112
|
+
|
113
|
+
t.uniq.sort
|
110
114
|
end
|
111
115
|
|
112
116
|
private
|
@@ -121,7 +125,7 @@ module CwCardUtils
|
|
121
125
|
|
122
126
|
def creature_subtypes
|
123
127
|
return [] unless creature?
|
124
|
-
raw = @type_line.split(/[—-]/).last.to_s.strip
|
128
|
+
raw = @type_line.split(/[—-]/).last.to_s.strip
|
125
129
|
raw.split.map(&:downcase)
|
126
130
|
end
|
127
131
|
|
@@ -132,6 +136,118 @@ module CwCardUtils
|
|
132
136
|
end
|
133
137
|
end
|
134
138
|
|
139
|
+
class ArchetypeDetector
|
140
|
+
attr_reader :deck, :format, :tribe, :colors, :tag_counts, :tag_ratios
|
141
|
+
|
142
|
+
def initialize(deck)
|
143
|
+
@deck = deck
|
144
|
+
@format = deck.format.to_s.downcase # :standard, :edh, etc.
|
145
|
+
@tribe = deck.tribe&.capitalize # e.g., "Cat"
|
146
|
+
@colors = resolve_color_identity(deck)
|
147
|
+
@tag_counts = Hash.new(0)
|
148
|
+
@tag_ratios = {}
|
149
|
+
count_tags
|
150
|
+
calculate_ratios
|
151
|
+
end
|
152
|
+
|
153
|
+
def detect
|
154
|
+
archetype = detect_archetype
|
155
|
+
color_label = resolve_color_label(colors)
|
156
|
+
tribe_label = tribe ? " #{tribe} Tribal" : ""
|
157
|
+
archetype_label = " #{archetype.to_s.capitalize}"
|
158
|
+
|
159
|
+
|
160
|
+
[color_label, tribe_label, archetype_label].join.strip.squeeze(" ")
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
# === TAG COUNTING ===
|
166
|
+
|
167
|
+
def count_tags
|
168
|
+
total_tags = 0
|
169
|
+
|
170
|
+
deck.each do |card|
|
171
|
+
next unless card.respond_to?(:tags)
|
172
|
+
|
173
|
+
card.tags.each do |tag|
|
174
|
+
@tag_counts[tag] += card.count
|
175
|
+
total_tags += card.count
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
@tag_counts[:_total] = total_tags
|
180
|
+
end
|
181
|
+
|
182
|
+
def calculate_ratios
|
183
|
+
total = @tag_counts[:_total].to_f
|
184
|
+
return if total.zero?
|
185
|
+
|
186
|
+
@tag_counts.each do |tag, count|
|
187
|
+
next if tag == :_total
|
188
|
+
@tag_ratios[tag] = count / total
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# === CMC UTILITY ===
|
193
|
+
|
194
|
+
def average_cmc
|
195
|
+
@average_cmc ||= begin
|
196
|
+
cmcs = deck.main.map { |card| card.respond_to?(:cmc) ? card.cmc.to_f : nil }.compact
|
197
|
+
return 0 if cmcs.empty? # rubocop:disable Lint/NoReturnInBeginEndBlocks
|
198
|
+
cmcs.sum / cmcs.size
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# === FORMAT-AWARE DETECTION ===
|
203
|
+
def detect_archetype
|
204
|
+
if format == "edh"
|
205
|
+
return :combo if tag_ratios[:combo_piece].to_f > 0.10 && tag_ratios[:draw].to_f > 0.08
|
206
|
+
|
207
|
+
control_indicators = tag_counts[:interaction].to_i +
|
208
|
+
tag_counts[:draw].to_i +
|
209
|
+
tag_counts[:tax].to_i +
|
210
|
+
tag_counts[:lock_piece].to_i
|
211
|
+
|
212
|
+
return :control if control_indicators >= 14 && tag_counts[:threat].to_i <= 6
|
213
|
+
|
214
|
+
return :ramp if tag_counts[:ramp] >= 10 && average_cmc >= 4.0
|
215
|
+
return :midrange if tag_counts[:threat] >= 15 && tag_counts[:interaction] >= 6
|
216
|
+
|
217
|
+
return :aggro if tag_ratios[:threat].to_f >= 0.2 ||
|
218
|
+
tag_counts[:synergistic_finisher].to_i >= 3 ||
|
219
|
+
(tag_counts[:scaling_threat].to_i >= 6 && average_cmc <= 3.5) ||
|
220
|
+
(tag_counts[:synergy].to_i >= 6 && average_cmc <= 3.0)
|
221
|
+
else
|
222
|
+
return :combo if tag_ratios[:combo_piece].to_f >= 0.12
|
223
|
+
return :control if tag_ratios[:interaction].to_f >= 0.25 &&
|
224
|
+
tag_ratios[:threat].to_f < 0.2 &&
|
225
|
+
(tag_ratios[:draw].to_f + tag_ratios[:tax].to_f + tag_ratios[:lock_piece].to_f) >= 0.15
|
226
|
+
|
227
|
+
return :aggro if tag_ratios[:threat].to_f >= 0.2 ||
|
228
|
+
tag_counts[:synergistic_finisher].to_i >= 3 ||
|
229
|
+
(tag_counts[:scaling_threat].to_i >= 6 && average_cmc <= 3.5) ||
|
230
|
+
(tag_counts[:synergy].to_i >= 6 && average_cmc <= 3.0)
|
231
|
+
|
232
|
+
return :ramp if tag_ratios[:ramp].to_f >= 0.12 && tag_counts[:finisher] >= 3 && average_cmc >= 4.0
|
233
|
+
return :tempo if tag_ratios[:interaction].to_f >= 0.2 && tag_ratios[:threat].to_f >= 0.2 && average_cmc <= 3.5
|
234
|
+
return :midrange if tag_ratios[:threat].to_f >= 0.2 && tag_ratios[:interaction].to_f >= 0.1
|
235
|
+
end
|
236
|
+
|
237
|
+
:unknown
|
238
|
+
end
|
239
|
+
|
240
|
+
# === COLOR IDENTITY ===
|
241
|
+
|
242
|
+
def resolve_color_identity(deck)
|
243
|
+
deck.main.flat_map { |c| c.respond_to?(:color_identity) ? c.color_identity : [] }.uniq.sort
|
244
|
+
end
|
245
|
+
|
246
|
+
def resolve_color_label(identity)
|
247
|
+
ColorIdentityResolver.resolve(identity)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
135
251
|
class ColorIdentityResolver
|
136
252
|
COLOR_NAMES = {
|
137
253
|
%w[W] => "White",
|
@@ -182,6 +298,10 @@ module CwCardUtils
|
|
182
298
|
class DecklistParser
|
183
299
|
attr_reader :deck
|
184
300
|
|
301
|
+
IGNORED_TRIBAL_TYPES = %w[
|
302
|
+
human wizard soldier scout shaman warrior cleric rogue advisor knight druid
|
303
|
+
].freeze
|
304
|
+
|
185
305
|
def initialize(desklist, cmc_data_source = ScryfallCmcData.new)
|
186
306
|
@decklist = desklist.is_a?(IO) ? desklist.read : desklist
|
187
307
|
@deck = Deck.new(cmc_data_source)
|
@@ -288,8 +408,6 @@ module CwCardUtils
|
|
288
408
|
@x_to_cast = []
|
289
409
|
|
290
410
|
@cmc_data_source = cmc_data_source
|
291
|
-
|
292
|
-
@curve_calculator = CurveCalculator.new(self)
|
293
411
|
end
|
294
412
|
|
295
413
|
def to_h
|
@@ -327,24 +445,28 @@ module CwCardUtils
|
|
327
445
|
end
|
328
446
|
end
|
329
447
|
|
448
|
+
def archetype
|
449
|
+
@archetype ||= ArchetypeDetector.new(self).detect
|
450
|
+
end
|
451
|
+
|
330
452
|
def inspect
|
331
453
|
"<Deck: main: #{mainboard_size} sideboard: #{sideboard_size} lands: #{lands_count} x_to_cast: #{x_to_cast_count} cards: #{cards_count}>"
|
332
454
|
end
|
333
455
|
|
334
456
|
def collapsed_curve
|
335
|
-
@
|
457
|
+
@collapsed_curve ||= CurveCalculator.new(self).collapsed_curve
|
336
458
|
end
|
337
459
|
|
338
460
|
def curve
|
339
|
-
@
|
461
|
+
@curve ||=CurveCalculator.new(self).curve
|
340
462
|
end
|
341
463
|
|
342
464
|
def normalized_curve
|
343
|
-
@
|
465
|
+
@normalized_curve ||=CurveCalculator.new(self).normalized_curve
|
344
466
|
end
|
345
467
|
|
346
468
|
def collapsed_normalized_curve
|
347
|
-
@
|
469
|
+
@collapsed_normalized_curve ||=CurveCalculator.new(self).collapsed_normalized_curve
|
348
470
|
end
|
349
471
|
|
350
472
|
def empty?
|
@@ -387,6 +509,20 @@ module CwCardUtils
|
|
387
509
|
@cards_count = nil
|
388
510
|
end
|
389
511
|
|
512
|
+
def format
|
513
|
+
@format ||= detect_format_for_deck
|
514
|
+
end
|
515
|
+
|
516
|
+
def detect_format_for_deck
|
517
|
+
if mainboard_size >= 100
|
518
|
+
:commander
|
519
|
+
elsif mainboard_size >= 60
|
520
|
+
:standard
|
521
|
+
else
|
522
|
+
:modern
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
390
526
|
def mainboard_size
|
391
527
|
@mainboard_size ||= main.sum { |card| card.count }
|
392
528
|
end
|
@@ -428,21 +564,29 @@ module CwCardUtils
|
|
428
564
|
next unless card.type&.include?("Creature")
|
429
565
|
|
430
566
|
total_creatures += 1
|
431
|
-
subtypes = card.type.split(
|
567
|
+
subtypes = card.type.split(/[—-]/).last.to_s.strip.split
|
432
568
|
subtypes.each do |type|
|
433
|
-
subtype_counts[type] += 1
|
569
|
+
subtype_counts[type.downcase] += 1
|
434
570
|
end
|
435
571
|
end
|
436
572
|
|
437
|
-
return nil if total_creatures.
|
573
|
+
return nil if total_creatures < 6 || subtype_counts.empty?
|
438
574
|
|
439
575
|
most_common = subtype_counts.max_by { |_, count| count }
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
576
|
+
return nil if most_common.nil?
|
577
|
+
|
578
|
+
dominant_type = most_common[0]
|
579
|
+
count = most_common[1]
|
580
|
+
|
581
|
+
# Suppress only if it's a boring type *and* barely dominant
|
582
|
+
if IGNORED_TRIBAL_TYPES.include?(dominant_type) && count.to_f / total_creatures < 0.7
|
583
|
+
return nil
|
444
584
|
end
|
585
|
+
|
586
|
+
return dominant_type.to_sym if count.to_f / total_creatures >= 0.4
|
587
|
+
nil
|
445
588
|
end
|
589
|
+
|
446
590
|
end
|
447
591
|
end
|
448
592
|
end
|
@@ -5,9 +5,9 @@ require "json"
|
|
5
5
|
module CwCardUtils
|
6
6
|
# Represents a card with Scryfall data
|
7
7
|
class ScryfallCard
|
8
|
-
def initialize(name)
|
8
|
+
def initialize(name, data_source = ScryfallCmcData.new)
|
9
9
|
@name = name
|
10
|
-
@data =
|
10
|
+
@data = data_source.find_card(@name) || {}
|
11
11
|
end
|
12
12
|
|
13
13
|
def cmc
|
@@ -43,6 +43,7 @@ module CwCardUtils
|
|
43
43
|
class ScryfallCmcData
|
44
44
|
def initialize
|
45
45
|
@data = JSON.parse(File.read(File.expand_path("../../data/scryfall.cards.cmc.json", __dir__)))
|
46
|
+
@found_cards = {}
|
46
47
|
end
|
47
48
|
|
48
49
|
def find_card(name)
|
@@ -52,43 +53,50 @@ module CwCardUtils
|
|
52
53
|
end
|
53
54
|
|
54
55
|
def cmc_for_card(name)
|
55
|
-
@
|
56
|
+
@found_cards[name] ||= find_card(name)
|
57
|
+
@found_cards[name]["cmc"]
|
56
58
|
rescue StandardError
|
57
59
|
nil
|
58
60
|
end
|
59
61
|
|
60
62
|
def color_identity_for_card(name)
|
61
|
-
@
|
63
|
+
@found_cards[name] ||= find_card(name)
|
64
|
+
@found_cards[name]["color_identity"]
|
62
65
|
rescue StandardError
|
63
66
|
nil
|
64
67
|
end
|
65
68
|
|
66
69
|
def keywords_for_card(name)
|
67
|
-
@
|
70
|
+
@found_cards[name] ||= find_card(name)
|
71
|
+
@found_cards[name]["keywords"]
|
68
72
|
rescue StandardError
|
69
73
|
nil
|
70
74
|
end
|
71
75
|
|
72
76
|
def toughness_for_card(name)
|
73
|
-
@
|
77
|
+
@found_cards[name] ||= find_card(name)
|
78
|
+
@found_cards[name]["toughness"]
|
74
79
|
rescue StandardError
|
75
80
|
nil
|
76
81
|
end
|
77
82
|
|
78
83
|
def power_for_card(name)
|
79
|
-
@
|
84
|
+
@found_cards[name] ||= find_card(name)
|
85
|
+
@found_cards[name]["power"]
|
80
86
|
rescue StandardError
|
81
87
|
nil
|
82
88
|
end
|
83
89
|
|
84
90
|
def oracle_text_for_card(name)
|
85
|
-
@
|
91
|
+
@found_cards[name] ||= find_card(name)
|
92
|
+
@found_cards[name]["oracle_text"]
|
86
93
|
rescue StandardError
|
87
94
|
nil
|
88
95
|
end
|
89
96
|
|
90
97
|
def type_for_card(name)
|
91
|
-
|
98
|
+
@found_cards[name] ||= find_card(name)
|
99
|
+
type = @found_cards[name]["type_line"]
|
92
100
|
if type.include?("Land")
|
93
101
|
type = nil
|
94
102
|
end
|