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.
@@ -1,592 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module CwCardUtils
4
- class CardTagger
5
- def initialize(card, deck)
6
- @card = card
7
- @text = card.oracle_text.to_s.downcase
8
- @type_line = card.type.to_s.downcase
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"