cw_card_utils 0.1.5 → 0.1.7
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/deck_comparator.rb +108 -0
- data/lib/cw_card_utils/decklist_parser/archetype_detector.rb +117 -0
- data/lib/cw_card_utils/decklist_parser/card.rb +60 -0
- data/lib/cw_card_utils/decklist_parser/card_tagger.rb +141 -0
- data/lib/cw_card_utils/decklist_parser/color_identity_resolver.rb +51 -0
- data/lib/cw_card_utils/decklist_parser/deck.rb +223 -0
- data/lib/cw_card_utils/decklist_parser/parser.rb +51 -0
- data/lib/cw_card_utils/decklist_parser.rb +6 -590
- data/lib/cw_card_utils/scryfall_cmc_data.rb +98 -51
- data/lib/cw_card_utils/version.rb +1 -1
- data/lib/cw_card_utils.rb +1 -0
- metadata +8 -1
@@ -1,592 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
@cmc = card.cmc || 0
|
10
|
-
@power = begin
|
11
|
-
card.power.to_i
|
12
|
-
rescue StandardError
|
13
|
-
0
|
14
|
-
end
|
15
|
-
@toughness = begin
|
16
|
-
card.toughness.to_i
|
17
|
-
rescue StandardError
|
18
|
-
0
|
19
|
-
end
|
20
|
-
@keywords = card.keywords.map(&:downcase)
|
21
|
-
@deck = deck
|
22
|
-
@tribe = deck.tribe&.to_s&.downcase
|
23
|
-
end
|
24
|
-
|
25
|
-
def tags
|
26
|
-
t = []
|
27
|
-
|
28
|
-
# === Threat ===
|
29
|
-
t << :threat if creature? && @power >= 3
|
30
|
-
|
31
|
-
# === Finisher ===
|
32
|
-
t << :finisher if likely_finisher?
|
33
|
-
|
34
|
-
# === Ramp ===
|
35
|
-
t << :ramp if @text.match?(/add .* mana|search.*basic|put.*land|mana pool/)
|
36
|
-
|
37
|
-
# === Draw ===
|
38
|
-
t << :draw if @text.match?(/draw.*card|investigate|learn/)
|
39
|
-
|
40
|
-
# === Tutor ===
|
41
|
-
t << :tutor if @text.match?(/search.*library/)
|
42
|
-
|
43
|
-
# === Interaction ===
|
44
|
-
t << :interaction if @text.match?(/destroy|exile|counter|fight|return.*to.*hand|sacrifice.*creature|discard/i)
|
45
|
-
|
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)
|
48
|
-
|
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)
|
51
|
-
|
52
|
-
# === Combo ===
|
53
|
-
t << :combo_piece if @text.match?(/infinite|copy.*spell|storm|you win the game/i)
|
54
|
-
|
55
|
-
# === ETB Effects ===
|
56
|
-
if @text.match?(/when.*enters.*the battlefield/)
|
57
|
-
t << :etb
|
58
|
-
t << :etb_buff if @text.match?(/creatures.*get.*\+/)
|
59
|
-
t << :etb_token if @text.match?(/create.*token/)
|
60
|
-
t << :etb_removal if @text.match?(/destroy|exile|tap|fight/)
|
61
|
-
end
|
62
|
-
|
63
|
-
# === Evasion / Scaling Threats ===
|
64
|
-
if @keywords.any? { |kw| %w[flying trample menace unblockable].include?(kw) } || @text.match?(/flying|trample|menace|unblockable/)
|
65
|
-
t << :evasive
|
66
|
-
end
|
67
|
-
|
68
|
-
if @text.match?(/gets \+x\/\+x|for each other|creatures you control|other .*#{@tribe}s? you control/i)
|
69
|
-
t << :scaling_threat
|
70
|
-
t << :synergistic_finisher if @cmc <= 4 && t.include?(:evasive)
|
71
|
-
end
|
72
|
-
|
73
|
-
# === Go-Wide Synergy ===
|
74
|
-
if @text.match?(/whenever.*creature.*enters|token.*you control/i)
|
75
|
-
t << :synergy
|
76
|
-
end
|
77
|
-
|
78
|
-
# === Choose-a-type tribal enablers ===
|
79
|
-
if @text.include?("choose a creature type")
|
80
|
-
t << :tribal_synergy
|
81
|
-
t << :tribal_creature
|
82
|
-
if @text.match?(/creatures.*chosen type.*get.*\+.*\+|whenever.*creature.*of the chosen type/i)
|
83
|
-
t << :threat unless t.include?(:threat)
|
84
|
-
end
|
85
|
-
end
|
86
|
-
|
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 ===
|
93
|
-
if @tribe
|
94
|
-
t << :tribal_creature if creature_subtypes.include?(@tribe)
|
95
|
-
t << :tribal_synergy if @text.include?("#{@tribe}s") || @text.include?("other #{@tribe}")
|
96
|
-
|
97
|
-
if @text.match?(/other .*#{@tribe}s? you control.*\+\d\/\+\d|creature you control of the chosen type.*\+\d\/\+\d/i)
|
98
|
-
t << :tribal_synergy
|
99
|
-
t << :threat unless t.include?(:threat)
|
100
|
-
end
|
101
|
-
|
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
|
-
t << :threat unless t.include?(:threat)
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
if t.include?(:scaling_threat) && !t.include?(:threat) # rubocop:disable Style/InverseMethods
|
110
|
-
t << :threat
|
111
|
-
end
|
112
|
-
|
113
|
-
t.uniq.sort
|
114
|
-
end
|
115
|
-
|
116
|
-
private
|
117
|
-
|
118
|
-
def creature?
|
119
|
-
@type_line.include?("creature")
|
120
|
-
end
|
121
|
-
|
122
|
-
def planeswalker?
|
123
|
-
@type_line.include?("planeswalker")
|
124
|
-
end
|
125
|
-
|
126
|
-
def creature_subtypes
|
127
|
-
return [] unless creature?
|
128
|
-
raw = @type_line.split(/[—-]/).last.to_s.strip
|
129
|
-
raw.split.map(&:downcase)
|
130
|
-
end
|
131
|
-
|
132
|
-
def likely_finisher?
|
133
|
-
return true if @cmc >= 6 && @text.match?(/flying|trample|indestructible|each opponent|you win|extra turn|double/i)
|
134
|
-
return true if planeswalker? && @text.match?(/creatures.*get.*flying|you get an emblem|each opponent/i)
|
135
|
-
false
|
136
|
-
end
|
137
|
-
end
|
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
|
-
|
251
|
-
class ColorIdentityResolver
|
252
|
-
COLOR_NAMES = {
|
253
|
-
%w[W] => "White",
|
254
|
-
%w[U] => "Blue",
|
255
|
-
%w[B] => "Black",
|
256
|
-
%w[R] => "Red",
|
257
|
-
%w[G] => "Green",
|
258
|
-
|
259
|
-
%w[U W] => "Azorius",
|
260
|
-
%w[B U] => "Dimir",
|
261
|
-
%w[B R] => "Rakdos",
|
262
|
-
%w[G R] => "Gruul",
|
263
|
-
%w[G W] => "Selesnya",
|
264
|
-
%w[B W] => "Orzhov",
|
265
|
-
%w[R U] => "Izzet",
|
266
|
-
%w[B G] => "Golgari",
|
267
|
-
%w[R W] => "Boros",
|
268
|
-
%w[G U] => "Simic",
|
269
|
-
|
270
|
-
%w[B U W] => "Esper",
|
271
|
-
%w[B R U] => "Grixis",
|
272
|
-
%w[B G R] => "Jund",
|
273
|
-
%w[G R W] => "Naya",
|
274
|
-
%w[G U W] => "Bant",
|
275
|
-
%w[B R W] => "Mardu",
|
276
|
-
%w[G R U] => "Temur",
|
277
|
-
%w[B G W] => "Abzan",
|
278
|
-
%w[R U W] => "Jeskai",
|
279
|
-
%w[B G U] => "Sultai",
|
280
|
-
|
281
|
-
%w[B R U W] => "Glint",
|
282
|
-
%w[B G R U] => "Dune",
|
283
|
-
%w[B G R W] => "Ink",
|
284
|
-
%w[G R U W] => "Witch",
|
285
|
-
%w[B G U W] => "Yore",
|
286
|
-
|
287
|
-
%w[B G R U W] => "Five-Color",
|
288
|
-
[] => "Colorless",
|
289
|
-
}.freeze
|
290
|
-
|
291
|
-
def self.resolve(identity)
|
292
|
-
key = identity.sort
|
293
|
-
COLOR_NAMES[key] || key.join("/")
|
294
|
-
end
|
295
|
-
end
|
296
|
-
|
297
|
-
# Parses a decklist and returns a Deck object.
|
298
|
-
class DecklistParser
|
299
|
-
attr_reader :deck
|
300
|
-
|
301
|
-
IGNORED_TRIBAL_TYPES = %w[
|
302
|
-
human wizard soldier scout shaman warrior cleric rogue advisor knight druid
|
303
|
-
].freeze
|
304
|
-
|
305
|
-
def initialize(desklist, cmc_data_source = ScryfallCmcData.new)
|
306
|
-
@decklist = desklist.is_a?(IO) ? desklist.read : desklist
|
307
|
-
@deck = Deck.new(cmc_data_source)
|
308
|
-
end
|
309
|
-
|
310
|
-
def inspect
|
311
|
-
"<DecklistParser: #{@decklist.length}>"
|
312
|
-
end
|
313
|
-
|
314
|
-
# Parses the decklist and returns a Deck object.
|
315
|
-
def parse
|
316
|
-
return @deck if @deck.any?
|
317
|
-
|
318
|
-
sideboard = false
|
319
|
-
|
320
|
-
@decklist.each_line do |line|
|
321
|
-
line = line.strip
|
322
|
-
next if line.empty?
|
323
|
-
|
324
|
-
sideboard = true if line.downcase.start_with?("sideboard") && (sideboard == false)
|
325
|
-
|
326
|
-
if line.match?(/^(Deck|Sideboard|About|Commander|Creatures|Lands|Spells|Artifacts|Enchantments|Planeswalkers|Mainboard|Maybeboard|Companion)/i)
|
327
|
-
next
|
328
|
-
end
|
329
|
-
next if line.start_with?("#") # skip comment-style lines
|
330
|
-
next if line.start_with?("//") # skip comment-style lines
|
331
|
-
next if line.start_with?("Name") # skip deck name
|
332
|
-
|
333
|
-
# Match patterns like: "4 Lightning Bolt", "2 Sol Ring (CMM) 452", "1 Atraxa, Praetors' Voice"
|
334
|
-
next unless match = line.match(/^(\d+)\s+(.+?)(?:\s+\(.*?\)\s+\d+)?$/) # rubocop:disable Lint/AssignmentInCondition
|
335
|
-
|
336
|
-
count = match[1].to_i
|
337
|
-
name = match[2].strip
|
338
|
-
target = sideboard ? :sideboard : :mainboard
|
339
|
-
@deck.add({ count: count, name: name }, target)
|
340
|
-
end
|
341
|
-
|
342
|
-
@deck
|
343
|
-
end
|
344
|
-
|
345
|
-
# A Deck is a collection of cards.
|
346
|
-
class Deck
|
347
|
-
# A Card is a single card in a deck.
|
348
|
-
class Card
|
349
|
-
def initialize(name, count, cmc_data_source)
|
350
|
-
@name = name
|
351
|
-
@count = count
|
352
|
-
@tags = []
|
353
|
-
if cmc_data_source.is_a?(ScryfallCmcData)
|
354
|
-
@scryfall_card = ScryfallCard.new(name)
|
355
|
-
else
|
356
|
-
@cmc_data_source = cmc_data_source
|
357
|
-
end
|
358
|
-
end
|
359
|
-
|
360
|
-
attr_reader :name, :count
|
361
|
-
attr_accessor :tags
|
362
|
-
|
363
|
-
def cmc
|
364
|
-
@cmc ||= @scryfall_card&.cmc || @cmc_data_source&.cmc_for_card(@name)
|
365
|
-
end
|
366
|
-
|
367
|
-
def type
|
368
|
-
@type ||= @scryfall_card&.type || @cmc_data_source&.type_for_card(@name) || "Land"
|
369
|
-
end
|
370
|
-
|
371
|
-
def keywords
|
372
|
-
@keywords ||= @scryfall_card&.keywords || @cmc_data_source&.keywords_for_card(@name) || []
|
373
|
-
end
|
374
|
-
|
375
|
-
def oracle_text
|
376
|
-
@oracle_text ||= @scryfall_card&.oracle_text || @cmc_data_source&.oracle_text_for_card(@name)
|
377
|
-
end
|
378
|
-
|
379
|
-
def power
|
380
|
-
@power ||= @scryfall_card&.power || @cmc_data_source&.power_for_card(@name)
|
381
|
-
end
|
382
|
-
|
383
|
-
def toughness
|
384
|
-
@toughness ||= @scryfall_card&.toughness || @cmc_data_source&.toughness_for_card(@name)
|
385
|
-
end
|
386
|
-
|
387
|
-
def color_identity
|
388
|
-
@color_identity ||= @scryfall_card&.color_identity || @cmc_data_source&.color_identity_for_card(@name) || []
|
389
|
-
end
|
390
|
-
|
391
|
-
def inspect
|
392
|
-
"<Card: #{@name} (#{@count}) #{cmc}>"
|
393
|
-
end
|
394
|
-
|
395
|
-
def to_h
|
396
|
-
{ name: @name, count: @count, cmc: cmc, type: type, keywords: keywords, power: power, toughness: toughness, oracle_text: oracle_text }
|
397
|
-
end
|
398
|
-
|
399
|
-
def to_json(*_args)
|
400
|
-
to_h.to_json
|
401
|
-
end
|
402
|
-
end
|
403
|
-
|
404
|
-
def initialize(cmc_data_source)
|
405
|
-
@main = []
|
406
|
-
@sideboard = []
|
407
|
-
@lands = []
|
408
|
-
@x_to_cast = []
|
409
|
-
|
410
|
-
@cmc_data_source = cmc_data_source
|
411
|
-
end
|
412
|
-
|
413
|
-
def to_h
|
414
|
-
{
|
415
|
-
mainboard: main.map(&:to_h),
|
416
|
-
sideboard: sideboard.map(&:to_h),
|
417
|
-
tribe: tribe,
|
418
|
-
}
|
419
|
-
end
|
420
|
-
|
421
|
-
def color_identity
|
422
|
-
@color_identity ||= main.map(&:color_identity).flatten.uniq
|
423
|
-
end
|
424
|
-
|
425
|
-
def color_identity_string
|
426
|
-
@color_identity_string ||= ColorIdentityResolver.resolve(color_identity)
|
427
|
-
end
|
428
|
-
|
429
|
-
def to_json(*_args)
|
430
|
-
to_h.to_json
|
431
|
-
end
|
432
|
-
|
433
|
-
def main
|
434
|
-
tag_cards(@main)
|
435
|
-
end
|
436
|
-
|
437
|
-
def sideboard
|
438
|
-
tag_cards(@sideboard)
|
439
|
-
end
|
440
|
-
|
441
|
-
def tag_cards(cards)
|
442
|
-
cards.map do |c|
|
443
|
-
c.tags = CardTagger.new(c, self).tags
|
444
|
-
c
|
445
|
-
end
|
446
|
-
end
|
447
|
-
|
448
|
-
def archetype
|
449
|
-
@archetype ||= ArchetypeDetector.new(self).detect
|
450
|
-
end
|
451
|
-
|
452
|
-
def inspect
|
453
|
-
"<Deck: main: #{mainboard_size} sideboard: #{sideboard_size} lands: #{lands_count} x_to_cast: #{x_to_cast_count} cards: #{cards_count}>"
|
454
|
-
end
|
455
|
-
|
456
|
-
def collapsed_curve
|
457
|
-
@collapsed_curve ||= CurveCalculator.new(self).collapsed_curve
|
458
|
-
end
|
459
|
-
|
460
|
-
def curve
|
461
|
-
@curve ||=CurveCalculator.new(self).curve
|
462
|
-
end
|
463
|
-
|
464
|
-
def normalized_curve
|
465
|
-
@normalized_curve ||=CurveCalculator.new(self).normalized_curve
|
466
|
-
end
|
467
|
-
|
468
|
-
def collapsed_normalized_curve
|
469
|
-
@collapsed_normalized_curve ||=CurveCalculator.new(self).collapsed_normalized_curve
|
470
|
-
end
|
471
|
-
|
472
|
-
def empty?
|
473
|
-
@main.empty? && @sideboard.empty?
|
474
|
-
end
|
475
|
-
|
476
|
-
def any?
|
477
|
-
!empty?
|
478
|
-
end
|
479
|
-
|
480
|
-
def each(&block)
|
481
|
-
@main.each do |c|
|
482
|
-
block.call(c)
|
483
|
-
end
|
484
|
-
end
|
485
|
-
|
486
|
-
def add(c, target = :mainboard)
|
487
|
-
reset_counters
|
488
|
-
card = Card.new(c[:name], c[:count], @cmc_data_source)
|
489
|
-
card.tags = CardTagger.new(card, self).tags
|
490
|
-
|
491
|
-
if target == :mainboard
|
492
|
-
@main << card
|
493
|
-
else
|
494
|
-
@sideboard << card
|
495
|
-
end
|
496
|
-
|
497
|
-
if card.type.include?("Land")
|
498
|
-
@lands << card
|
499
|
-
elsif card.cmc.nil?
|
500
|
-
@x_to_cast << card
|
501
|
-
end
|
502
|
-
end
|
503
|
-
|
504
|
-
def reset_counters
|
505
|
-
@mainboard_size = nil
|
506
|
-
@sideboard_size = nil
|
507
|
-
@lands_count = nil
|
508
|
-
@x_to_cast_count = nil
|
509
|
-
@cards_count = nil
|
510
|
-
end
|
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
|
-
|
526
|
-
def mainboard_size
|
527
|
-
@mainboard_size ||= main.sum { |card| card.count }
|
528
|
-
end
|
529
|
-
|
530
|
-
def sideboard_size
|
531
|
-
@sideboard_size ||= sideboard.sum { |card| card.count }
|
532
|
-
end
|
533
|
-
|
534
|
-
def lands_count
|
535
|
-
@lands.sum { |card| card.count }
|
536
|
-
end
|
537
|
-
|
538
|
-
def x_to_cast_count
|
539
|
-
@x_to_cast.sum { |card| card.count }
|
540
|
-
end
|
541
|
-
|
542
|
-
def cards_count
|
543
|
-
mainboard_size + sideboard_size
|
544
|
-
end
|
545
|
-
|
546
|
-
def count_without_lands
|
547
|
-
cards_count - lands_count
|
548
|
-
end
|
549
|
-
|
550
|
-
def size
|
551
|
-
cards_count
|
552
|
-
end
|
553
|
-
|
554
|
-
def tribe
|
555
|
-
return @tribe if @tribe
|
556
|
-
@tribe = detect_tribe_for_deck
|
557
|
-
end
|
558
|
-
|
559
|
-
def detect_tribe_for_deck
|
560
|
-
subtype_counts = Hash.new(0)
|
561
|
-
total_creatures = 0
|
562
|
-
|
563
|
-
@main.each do |card|
|
564
|
-
next unless card.type&.include?("Creature")
|
565
|
-
|
566
|
-
total_creatures += 1
|
567
|
-
subtypes = card.type.split(/[—-]/).last.to_s.strip.split
|
568
|
-
subtypes.each do |type|
|
569
|
-
subtype_counts[type.downcase] += 1
|
570
|
-
end
|
571
|
-
end
|
572
|
-
|
573
|
-
return nil if total_creatures < 6 || subtype_counts.empty?
|
574
|
-
|
575
|
-
most_common = subtype_counts.max_by { |_, count| count }
|
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
|
584
|
-
end
|
585
|
-
|
586
|
-
return dominant_type.to_sym if count.to_f / total_creatures >= 0.4
|
587
|
-
nil
|
588
|
-
end
|
589
|
-
|
590
|
-
end
|
591
|
-
end
|
592
|
-
end
|
3
|
+
require_relative "decklist_parser/parser"
|
4
|
+
require_relative "decklist_parser/deck"
|
5
|
+
require_relative "decklist_parser/card"
|
6
|
+
require_relative "decklist_parser/card_tagger"
|
7
|
+
require_relative "decklist_parser/archetype_detector"
|
8
|
+
require_relative "decklist_parser/color_identity_resolver"
|