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.
@@ -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