cw_card_utils 0.1.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8d8eb329c364d67fedf00a49698b9f145f06b6e08f7effae3eb92fb5c22d244
4
- data.tar.gz: 8cc571695d9d0b3b07c2da11abffe0a047d08591c8b490fbaffdf88a21226fe5
3
+ metadata.gz: 542ae86bcb4458bd4fd9a53552089133dad00a4f716e5cf5bc73de11a52da217
4
+ data.tar.gz: 835722e23e181a4f4eeb945a18ca5ebb523825d097c42aa8533ed86cb278a042
5
5
  SHA512:
6
- metadata.gz: 38000b90cc9c72b84efdf4c8eb892d20748fe4050af8874c99847a9e4b572b4fcbe573cfba49a964fcda60b8d177bbf9195f3f9b89c9f92c4f08d716d18c41ca
7
- data.tar.gz: 9f62737b9b7fde768f4f990b0367b94b6e86d8bcab4a4925e1c91a8c6abfaae71a2ac56bd8ecfe5ffb74ec956010ad3c6d8658e102c032eeb3514e8488e0e79f
6
+ metadata.gz: 9ff3819a39ee37ee199013b2cb32c5f08f16e139a2d9138ee39d833123f7b40de7fcd4ab20238cd49ef683faca8319166d96cb10df252e88edae953965ae66d4
7
+ data.tar.gz: e867a1dbe6cda9971d7d28e976c610a250d0799b730fe1f107330e2fe6f81cad29021716893bd1b51fa6b5f45b900392b39c04236785ba93a66c0821825e1b8c
@@ -29,7 +29,7 @@ module CwCardUtils
29
29
  private
30
30
 
31
31
  def analyze_deck(deck)
32
- detector = ArchetypeDetector.new(deck)
32
+ detector = CwCardUtils::DecklistParser::ArchetypeDetector.new(deck)
33
33
  archetype = detector.detect
34
34
 
35
35
  curve = deck.normalized_curve
@@ -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
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+ # A Deck is a collection of cards.
6
+ class Deck
7
+ IGNORED_TRIBAL_TYPES = %w[
8
+ human wizard soldier scout shaman warrior cleric rogue advisor knight druid
9
+ ].freeze
10
+
11
+ def initialize(cmc_data_source)
12
+ @main = []
13
+ @sideboard = []
14
+ @lands = []
15
+ @x_to_cast = []
16
+
17
+ @cmc_data_source = cmc_data_source
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ mainboard: main.map(&:to_h),
23
+ sideboard: sideboard.map(&:to_h),
24
+ tribe: tribe,
25
+ }
26
+ end
27
+
28
+ def color_identity
29
+ @color_identity ||= main.map(&:color_identity).flatten.uniq
30
+ end
31
+
32
+ def color_identity_string
33
+ @color_identity_string ||= CwCardUtils::DecklistParser::ColorIdentityResolver.resolve(color_identity)
34
+ end
35
+
36
+ def to_json(*_args)
37
+ to_h.to_json
38
+ end
39
+
40
+ def main
41
+ tag_cards(@main)
42
+ end
43
+
44
+ def sideboard
45
+ tag_cards(@sideboard)
46
+ end
47
+
48
+ def tag_cards(cards)
49
+ cards.map do |c|
50
+ c.tags = CwCardUtils::DecklistParser::CardTagger.new(c, self).tags
51
+ c
52
+ end
53
+ end
54
+
55
+ def archetype
56
+ @archetype ||= CwCardUtils::DecklistParser::ArchetypeDetector.new(self).detect
57
+ end
58
+
59
+ def inspect
60
+ "<Deck: main: #{mainboard_size} sideboard: #{sideboard_size} lands: #{lands_count} x_to_cast: #{x_to_cast_count} cards: #{cards_count}>"
61
+ end
62
+
63
+ def collapsed_curve
64
+ @collapsed_curve ||= CurveCalculator.new(self).collapsed_curve
65
+ end
66
+
67
+ def curve
68
+ @curve ||= CurveCalculator.new(self).curve
69
+ end
70
+
71
+ def normalized_curve
72
+ @normalized_curve ||= CurveCalculator.new(self).normalized_curve
73
+ end
74
+
75
+ def collapsed_normalized_curve
76
+ @collapsed_normalized_curve ||= CurveCalculator.new(self).collapsed_normalized_curve
77
+ end
78
+
79
+ def empty?
80
+ @main.empty? && @sideboard.empty?
81
+ end
82
+
83
+ def any?
84
+ !empty?
85
+ end
86
+
87
+ def each(&block)
88
+ @main.each do |c|
89
+ block.call(c)
90
+ end
91
+ end
92
+
93
+ def add(c, target = :mainboard)
94
+ reset_counters
95
+ card = Card.new(c[:name], c[:count], @cmc_data_source)
96
+ card.tags = CwCardUtils::DecklistParser::CardTagger.new(card, self).tags
97
+
98
+ if target == :mainboard
99
+ @main << card
100
+ else
101
+ @sideboard << card
102
+ end
103
+
104
+ if card.type.include?("Land")
105
+ @lands << card
106
+ elsif card.cmc.nil?
107
+ @x_to_cast << card
108
+ end
109
+ end
110
+
111
+ def reset_counters
112
+ @mainboard_size = nil
113
+ @sideboard_size = nil
114
+ @lands_count = nil
115
+ @x_to_cast_count = nil
116
+ @cards_count = nil
117
+ end
118
+
119
+ def format
120
+ @format ||= detect_format_for_deck
121
+ end
122
+
123
+ def detect_format_for_deck
124
+ if mainboard_size >= 100
125
+ :commander
126
+ elsif mainboard_size >= 60
127
+ # Check if this looks like Commander (60+ cards, singleton except for basic lands)
128
+ if mainboard_size >= 60 && is_singleton_deck?
129
+ :commander
130
+ else
131
+ :standard
132
+ end
133
+ else
134
+ :modern
135
+ end
136
+ end
137
+
138
+ def is_singleton_deck?
139
+ # Count non-basic land cards and check for duplicates
140
+ non_basic_lands = @main.reject { |card| is_basic_land?(card) }
141
+
142
+ # Check if any non-basic land card appears more than once
143
+ card_counts = Hash.new(0)
144
+ non_basic_lands.each do |card|
145
+ card_counts[card.name] += card.count
146
+ end
147
+
148
+ # All non-basic land cards should appear only once
149
+ card_counts.values.all? { |count| count == 1 }
150
+ end
151
+
152
+ def is_basic_land?(card)
153
+ basic_land_names = %w[Plains Island Swamp Mountain Forest Wastes]
154
+ basic_land_names.include?(card.name)
155
+ end
156
+
157
+ def mainboard_size
158
+ @mainboard_size ||= main.sum { |card| card.count }
159
+ end
160
+
161
+ def sideboard_size
162
+ @sideboard_size ||= sideboard.sum { |card| card.count }
163
+ end
164
+
165
+ def lands_count
166
+ @lands.sum { |card| card.count }
167
+ end
168
+
169
+ def x_to_cast_count
170
+ @x_to_cast.sum { |card| card.count }
171
+ end
172
+
173
+ def cards_count
174
+ mainboard_size + sideboard_size
175
+ end
176
+
177
+ def count_without_lands
178
+ cards_count - lands_count
179
+ end
180
+
181
+ def size
182
+ cards_count
183
+ end
184
+
185
+ def tribe
186
+ return @tribe if @tribe
187
+ @tribe = detect_tribe_for_deck
188
+ end
189
+
190
+ def detect_tribe_for_deck
191
+ subtype_counts = Hash.new(0)
192
+ total_creatures = 0
193
+
194
+ @main.each do |card|
195
+ next unless card.type&.include?("Creature")
196
+
197
+ total_creatures += 1
198
+ subtypes = card.type.split(/[—-]/).last.to_s.strip.split
199
+ subtypes.each do |type|
200
+ subtype_counts[type.downcase] += 1
201
+ end
202
+ end
203
+
204
+ return nil if total_creatures < 6 || subtype_counts.empty?
205
+
206
+ most_common = subtype_counts.max_by { |_, count| count }
207
+ return nil if most_common.nil?
208
+
209
+ dominant_type = most_common[0]
210
+ count = most_common[1]
211
+
212
+ # Suppress only if it's a boring type *and* barely dominant
213
+ if IGNORED_TRIBAL_TYPES.include?(dominant_type) && count.to_f / total_creatures < 0.7
214
+ return nil
215
+ end
216
+
217
+ return dominant_type.to_sym if count.to_f / total_creatures >= 0.4
218
+ nil
219
+ end
220
+
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ module DecklistParser
5
+ # Parses a decklist and returns a Deck object.
6
+ class Parser
7
+ attr_reader :deck
8
+
9
+ def initialize(decklist, cmc_data_source = CwCardUtils::ScryfallCmcData.instance)
10
+ @decklist = decklist.is_a?(IO) ? decklist.read : decklist
11
+ @deck = Deck.new(cmc_data_source)
12
+ end
13
+
14
+ def inspect
15
+ "<DecklistParser::Parser: #{@decklist.length}>"
16
+ end
17
+
18
+ # Parses the decklist and returns a Deck object.
19
+ def parse
20
+ return @deck if @deck.any?
21
+
22
+ sideboard = false
23
+
24
+ @decklist.each_line do |line|
25
+ line = line.strip
26
+ next if line.empty?
27
+
28
+ sideboard = true if line.downcase.start_with?("sideboard") && (sideboard == false)
29
+
30
+ if line.match?(/^(Deck|Sideboard|About|Commander|Creatures|Lands|Spells|Artifacts|Enchantments|Planeswalkers|Mainboard|Maybeboard|Companion)/i)
31
+ next
32
+ end
33
+ next if line.start_with?("#") # skip comment-style lines
34
+ next if line.start_with?("//") # skip comment-style lines
35
+ next if line.start_with?("Name") # skip deck name
36
+
37
+ # Match patterns like: "4 Lightning Bolt", "2 Sol Ring (CMM) 452", "1 Atraxa, Praetors' Voice"
38
+ next unless match = line.match(/^(\d+)\s+(.+?)(?:\s+\(.*?\)\s+\d+)?$/) # rubocop:disable Lint/AssignmentInCondition
39
+
40
+ count = match[1].to_i
41
+ name = match[2].strip
42
+ target = sideboard ? :sideboard : :mainboard
43
+ @deck.add({ count: count, name: name }, target)
44
+ end
45
+
46
+ @deck
47
+ end
48
+
49
+ end
50
+ end
51
+ end