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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6190122b537e6cb162c5a2bc4005b960ce0182955e958817d4c92375b7cceb4
4
- data.tar.gz: '08a0e11c0648db7ff053f6941d9bf0001ee6f88657136ada38b013343cd4cf16'
3
+ metadata.gz: 542ae86bcb4458bd4fd9a53552089133dad00a4f716e5cf5bc73de11a52da217
4
+ data.tar.gz: 835722e23e181a4f4eeb945a18ca5ebb523825d097c42aa8533ed86cb278a042
5
5
  SHA512:
6
- metadata.gz: e1fe28a4668d7df9915b717a77ede85cc8d97494640f80b2838ebc42c527c181c365df59f8d65f434a6394e164b95d45c1884eeaba98e28f4fe44fbf8b16e37f
7
- data.tar.gz: 62cfd74b58393017358a6d3242c521c38a2d988d1d4c9a7230e9e5e60ef2379f8e9836cc0721deef065339562f3394f0cbc9f23a274c4bb48e39a4d9570c9753
6
+ metadata.gz: 9ff3819a39ee37ee199013b2cb32c5f08f16e139a2d9138ee39d833123f7b40de7fcd4ab20238cd49ef683faca8319166d96cb10df252e88edae953965ae66d4
7
+ data.tar.gz: e867a1dbe6cda9971d7d28e976c610a250d0799b730fe1f107330e2fe6f81cad29021716893bd1b51fa6b5f45b900392b39c04236785ba93a66c0821825e1b8c
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+
5
+ class DeckComparator
6
+ attr_reader :deck_a, :deck_b, :analysis
7
+
8
+ def initialize(deck_a, deck_b)
9
+ @deck_a = deck_a
10
+ @deck_b = deck_b
11
+ @analysis = {}
12
+ end
13
+
14
+ def compare
15
+ a_info = analyze_deck(deck_a)
16
+ b_info = analyze_deck(deck_b)
17
+
18
+ matchup = {
19
+ archetype_a: a_info[:archetype],
20
+ archetype_b: b_info[:archetype],
21
+ likely_winner: predict_matchup(a_info, b_info),
22
+ interaction_overlap: compare_interaction_density(a_info, b_info),
23
+ notes: generate_notes(a_info, b_info),
24
+ }
25
+
26
+ @analysis = matchup
27
+ end
28
+
29
+ private
30
+
31
+ def analyze_deck(deck)
32
+ detector = CwCardUtils::DecklistParser::ArchetypeDetector.new(deck)
33
+ archetype = detector.detect
34
+
35
+ curve = deck.normalized_curve
36
+
37
+ early = ((curve[0] || 0) + (curve[1] || 0)).round(2)
38
+ mid = ((curve[2] || 0) + (curve[3] || 0)).round(2)
39
+ late = ((curve[4] || 0) + (curve[5] || 0) + (curve[6] || 0) + (curve[7] || 0)).round(2)
40
+
41
+ {
42
+ archetype: archetype,
43
+ color: detector.colors,
44
+ early_curve: early,
45
+ mid_curve: mid,
46
+ late_curve: late,
47
+ tag_ratios: detector.tag_ratios,
48
+ tag_counts: detector.tag_counts,
49
+ tribe: deck.tribe,
50
+ format: deck.format,
51
+ }
52
+ end
53
+
54
+ def predict_matchup(a, b)
55
+ return "Deck A (Aggro)" if is_aggro?(a) && is_control?(b)
56
+ return "Deck B (Aggro)" if is_aggro?(b) && is_control?(a)
57
+ return "Deck A (Faster curve)" if a[:early_curve] > b[:early_curve] + 0.15
58
+ return "Deck B (Faster curve)" if b[:early_curve] > a[:early_curve] + 0.15
59
+ "Even Matchup"
60
+ end
61
+
62
+ def is_aggro?(info)
63
+ info[:archetype].downcase.include?("aggro") || info[:early_curve] > 0.5
64
+ end
65
+
66
+ def is_control?(info)
67
+ info[:archetype].downcase.include?("control") || info[:late_curve] > 0.4
68
+ end
69
+
70
+ def compare_interaction_density(a, b)
71
+ a_interact = a[:tag_ratios][:interaction].to_f.round(2)
72
+ b_interact = b[:tag_ratios][:interaction].to_f.round(2)
73
+
74
+ if (a_interact - b_interact).abs < 0.05
75
+ "Similar density"
76
+ elsif a_interact > b_interact
77
+ "Deck A has more removal"
78
+ else
79
+ "Deck B has more removal"
80
+ end
81
+ end
82
+
83
+ def generate_notes(a, b)
84
+ notes = []
85
+
86
+ if a[:tribe] && b[:tribe]
87
+ notes << "Both decks are tribal: #{a[:tribe].capitalize} vs #{b[:tribe].capitalize}"
88
+ end
89
+
90
+ if a[:format] != b[:format]
91
+ notes << "Decks are from different formats: #{a[:format]} vs #{b[:format]}"
92
+ end
93
+
94
+ if a[:color] == b[:color]
95
+ notes << "Color overlap may result in symmetrical strategies"
96
+ end
97
+
98
+ notes << "Deck A has a stronger early game" if a[:early_curve] > b[:early_curve] + 0.2
99
+ notes << "Deck B has a stronger early game" if b[:early_curve] > a[:early_curve] + 0.2
100
+
101
+ notes << "Deck A has more combo elements" if a[:tag_ratios][:combo_piece].to_f > 0.1
102
+ notes << "Deck B has more combo elements" if b[:tag_ratios][:combo_piece].to_f > 0.1
103
+
104
+ notes
105
+ end
106
+ end
107
+
108
+ end
@@ -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