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