cw_card_utils 0.1.7 → 0.1.8

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: 542ae86bcb4458bd4fd9a53552089133dad00a4f716e5cf5bc73de11a52da217
4
- data.tar.gz: 835722e23e181a4f4eeb945a18ca5ebb523825d097c42aa8533ed86cb278a042
3
+ metadata.gz: e301d659cb6e96974ef4b5aa075e3951d8b875b645a3518ec8ae7c77211a0d42
4
+ data.tar.gz: 638f1bb63aabb8db4d648df1070c156c34c6d9b8c5dc7770b0cde08fa4513f9d
5
5
  SHA512:
6
- metadata.gz: 9ff3819a39ee37ee199013b2cb32c5f08f16e139a2d9138ee39d833123f7b40de7fcd4ab20238cd49ef683faca8319166d96cb10df252e88edae953965ae66d4
7
- data.tar.gz: e867a1dbe6cda9971d7d28e976c610a250d0799b730fe1f107330e2fe6f81cad29021716893bd1b51fa6b5f45b900392b39c04236785ba93a66c0821825e1b8c
6
+ metadata.gz: afd81df332b4f152ab7acd249aeff97d482b2fbc8300eeb6b6f42edec278575b2e33333ad09140d1651f80e2cfd4de7ad3dd18d7fc8c04111d60bb8711c22536
7
+ data.tar.gz: db60344938a90eebb336e84e944dd6528699e227b2231cac4bca997dbaeddc3f0ad705511a72726c5a87ee7e4557939bf2334b94338729acee5b1360ff6e837f
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CwCardUtils
4
-
5
4
  class DeckComparator
6
5
  attr_reader :deck_a, :deck_b, :analysis
7
6
 
7
+ WEIGHTS = {
8
+ archetype: 0.30,
9
+ curve: 0.25,
10
+ synergy: 0.25,
11
+ interaction: 0.20,
12
+ }.freeze
13
+
8
14
  def initialize(deck_a, deck_b)
9
15
  @deck_a = deck_a
10
16
  @deck_b = deck_b
@@ -12,31 +18,49 @@ module CwCardUtils
12
18
  end
13
19
 
14
20
  def compare
15
- a_info = analyze_deck(deck_a)
16
- b_info = analyze_deck(deck_b)
21
+ results = {
22
+ on_play: matchup_scenario(on_play: true),
23
+ on_draw: matchup_scenario(on_play: false),
24
+ }
25
+ @analysis = results
26
+ end
27
+
28
+ private
29
+
30
+ def matchup_scenario(on_play:)
31
+ a_info = analyze_deck(deck_a, on_play: on_play)
32
+ b_info = analyze_deck(deck_b, on_play: !on_play)
33
+
34
+ win_rate_a = weighted_score(a_info, b_info)
35
+ win_rate_b = (1.0 - win_rate_a).round(3)
17
36
 
18
- matchup = {
37
+ {
19
38
  archetype_a: a_info[:archetype],
20
39
  archetype_b: b_info[:archetype],
21
- likely_winner: predict_matchup(a_info, b_info),
40
+ win_rate_a: win_rate_a,
41
+ win_rate_b: win_rate_b,
42
+ favored: if win_rate_a > win_rate_b
43
+ "Deck A"
44
+ else
45
+ ((win_rate_b > win_rate_a) ? "Deck B" : "Even")
46
+ end,
22
47
  interaction_overlap: compare_interaction_density(a_info, b_info),
23
- notes: generate_notes(a_info, b_info),
48
+ synergy_a: a_info[:synergy_hit_rate],
49
+ synergy_b: b_info[:synergy_hit_rate],
50
+ notes: generate_notes(a_info, b_info, on_play: on_play),
24
51
  }
25
-
26
- @analysis = matchup
27
52
  end
28
53
 
29
- private
30
-
31
- def analyze_deck(deck)
54
+ def analyze_deck(deck, on_play:)
32
55
  detector = CwCardUtils::DecklistParser::ArchetypeDetector.new(deck)
33
56
  archetype = detector.detect
34
-
35
57
  curve = deck.normalized_curve
36
58
 
37
59
  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)
60
+ mid = ((curve[2] || 0) + (curve[3] || 0)).round(2)
61
+ late = ((curve[4] || 0) + (curve[5] || 0) + (curve[6] || 0) + (curve[7] || 0)).round(2)
62
+
63
+ synergy_hit_rate = calc_synergy_hit_rate(deck, on_play: on_play)
40
64
 
41
65
  {
42
66
  archetype: archetype,
@@ -46,16 +70,86 @@ module CwCardUtils
46
70
  late_curve: late,
47
71
  tag_ratios: detector.tag_ratios,
48
72
  tag_counts: detector.tag_counts,
73
+ synergy_hit_rate: synergy_hit_rate,
49
74
  tribe: deck.tribe,
50
75
  format: deck.format,
51
76
  }
52
77
  end
53
78
 
79
+ def calc_synergy_hit_rate(deck, on_play:)
80
+ synergy_pairs = extract_synergy_pairs(deck)
81
+ return 0 if synergy_pairs.empty?
82
+
83
+ synergy_checker = CwCardUtils::SynergyProbability.new(deck, deck_size: deck.main.map(&:count).sum)
84
+
85
+ probs = synergy_pairs.map do |pair|
86
+ synergy_checker.prob_combo(pair, draws_by_turn(5, on_play: on_play))
87
+ end
88
+ (probs.sum / probs.size).round(2)
89
+ end
90
+
91
+ def extract_synergy_pairs(deck)
92
+ synergy_cards = deck.main.select do |card|
93
+ (card.tags & [:synergistic_finisher, :tribal_synergy, :scaling_threat]).any?
94
+ end
95
+ return [] if synergy_cards.size < 2
96
+ synergy_cards.map(&:name).combination(2).to_a
97
+ end
98
+
99
+ def draws_by_turn(turn, on_play:)
100
+ on_play ? (7 + (turn - 1)) : (7 + turn)
101
+ end
102
+
103
+ def weighted_score(a, b)
104
+ # Archetype advantage
105
+ archetype_score = case predict_matchup(a, b)
106
+ when "Deck A (Aggro)", "Deck A (Faster curve)", "Deck A (Synergy)" then 1.0
107
+ when "Deck B (Aggro)", "Deck B (Faster curve)", "Deck B (Synergy)" then 0.0
108
+ else 0.5
109
+ end
110
+
111
+ # Curve advantage
112
+ curve_score = if a[:early_curve] > b[:early_curve] + 0.15
113
+ 1.0
114
+ elsif b[:early_curve] > a[:early_curve] + 0.15
115
+ 0.0
116
+ else
117
+ 0.5
118
+ end
119
+
120
+ # Synergy advantage
121
+ synergy_score = if a[:synergy_hit_rate] > b[:synergy_hit_rate] + 0.1
122
+ 1.0
123
+ elsif b[:synergy_hit_rate] > a[:synergy_hit_rate] + 0.1
124
+ 0.0
125
+ else
126
+ 0.5
127
+ end
128
+
129
+ # Interaction advantage
130
+ interaction_score = if a[:tag_ratios][:interaction].to_f > b[:tag_ratios][:interaction].to_f + 0.05
131
+ 1.0
132
+ elsif b[:tag_ratios][:interaction].to_f > a[:tag_ratios][:interaction].to_f + 0.05
133
+ 0.0
134
+ else
135
+ 0.5
136
+ end
137
+
138
+ (
139
+ archetype_score * WEIGHTS[:archetype] +
140
+ curve_score * WEIGHTS[:curve] +
141
+ synergy_score * WEIGHTS[:synergy] +
142
+ interaction_score * WEIGHTS[:interaction]
143
+ ).round(3)
144
+ end
145
+
54
146
  def predict_matchup(a, b)
55
147
  return "Deck A (Aggro)" if is_aggro?(a) && is_control?(b)
56
148
  return "Deck B (Aggro)" if is_aggro?(b) && is_control?(a)
57
149
  return "Deck A (Faster curve)" if a[:early_curve] > b[:early_curve] + 0.15
58
150
  return "Deck B (Faster curve)" if b[:early_curve] > a[:early_curve] + 0.15
151
+ return "Deck A (Synergy)" if a[:synergy_hit_rate] > b[:synergy_hit_rate] + 0.1
152
+ return "Deck B (Synergy)" if b[:synergy_hit_rate] > a[:synergy_hit_rate] + 0.1
59
153
  "Even Matchup"
60
154
  end
61
155
 
@@ -80,8 +174,9 @@ module CwCardUtils
80
174
  end
81
175
  end
82
176
 
83
- def generate_notes(a, b)
177
+ def generate_notes(a, b, on_play:)
84
178
  notes = []
179
+ notes << (on_play ? "Deck A is on the play" : "Deck B is on the play")
85
180
 
86
181
  if a[:tribe] && b[:tribe]
87
182
  notes << "Both decks are tribal: #{a[:tribe].capitalize} vs #{b[:tribe].capitalize}"
@@ -95,14 +190,10 @@ module CwCardUtils
95
190
  notes << "Color overlap may result in symmetrical strategies"
96
191
  end
97
192
 
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
193
+ notes << "Deck A synergy hit rate: #{(a[:synergy_hit_rate] * 100).round}%"
194
+ notes << "Deck B synergy hit rate: #{(b[:synergy_hit_rate] * 100).round}%"
103
195
 
104
196
  notes
105
197
  end
106
198
  end
107
-
108
199
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CwCardUtils
4
+ # Calculates probability of drawing specific card combinations for synergy analysis
5
+ class SynergyProbability
6
+ def initialize(deck, deck_size: 60)
7
+ @deck = deck
8
+ @deck_size = deck_size
9
+ end
10
+
11
+ # Probability of drawing at least one of the target cards
12
+ def prob_single(target_names, draws)
13
+ total_copies = count_copies(target_names)
14
+ 1 - hypergeometric(@deck_size - total_copies, draws).to_f / hypergeometric(@deck_size, draws)
15
+ end
16
+
17
+ # Probability of drawing ALL cards in the targets list (synergy pair/trio)
18
+ def prob_combo(target_names, draws)
19
+ case target_names.size
20
+ when 1
21
+ prob_single(target_names, draws)
22
+ when 2
23
+ prob_two_card_combo(target_names, draws)
24
+ when 3
25
+ prob_three_card_combo(target_names, draws)
26
+ else
27
+ # For >3 cards, fallback to approximation
28
+ approx_combo(target_names, draws)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Exact for 2-card combos
35
+ def prob_two_card_combo(names, draws)
36
+ copies_a = count_copies([names[0]])
37
+ copies_b = count_copies([names[1]])
38
+
39
+ total = hypergeometric(@deck_size, draws).to_f
40
+
41
+ # Probability missing A
42
+ miss_a = hypergeometric(@deck_size - copies_a, draws) / total
43
+ # Probability missing B
44
+ miss_b = hypergeometric(@deck_size - copies_b, draws) / total
45
+ # Probability missing both
46
+ miss_both = hypergeometric(@deck_size - (copies_a + copies_b), draws) / total
47
+
48
+ # Inclusion–exclusion
49
+ [1 - (miss_a + miss_b - miss_both), 0].max
50
+ end
51
+
52
+ # Exact for 3-card combos
53
+ def prob_three_card_combo(names, draws)
54
+ copies_a = count_copies([names[0]])
55
+ copies_b = count_copies([names[1]])
56
+ copies_c = count_copies([names[2]])
57
+
58
+ total = hypergeometric(@deck_size, draws).to_f
59
+
60
+ miss_a = hypergeometric(@deck_size - copies_a, draws) / total
61
+ miss_b = hypergeometric(@deck_size - copies_b, draws) / total
62
+ miss_c = hypergeometric(@deck_size - copies_c, draws) / total
63
+
64
+ miss_ab = hypergeometric(@deck_size - (copies_a + copies_b), draws) / total
65
+ miss_ac = hypergeometric(@deck_size - (copies_a + copies_c), draws) / total
66
+ miss_bc = hypergeometric(@deck_size - (copies_b + copies_c), draws) / total
67
+
68
+ miss_abc = hypergeometric(@deck_size - (copies_a + copies_b + copies_c), draws) / total
69
+
70
+ # Inclusion–exclusion for 3 sets
71
+ [1 - (miss_a + miss_b + miss_c) +
72
+ (miss_ab + miss_ac + miss_bc) -
73
+ miss_abc, 0].max
74
+ end
75
+
76
+ # Approximation for >3 cards (same as old method)
77
+ def approx_combo(target_names, draws)
78
+ prob_missing = 0.0
79
+ target_names.each do |name|
80
+ copies = count_copies([name])
81
+ miss_prob = hypergeometric(@deck_size - copies, draws).to_f / hypergeometric(@deck_size, draws)
82
+ prob_missing += miss_prob
83
+ end
84
+ [1 - prob_missing, 0].max
85
+ end
86
+
87
+ # Utility: count how many copies of given cards are in the deck
88
+ def count_copies(names)
89
+ @deck.main.sum { |card| names.include?(card.name) ? card.count : 0 }
90
+ end
91
+
92
+ # Hypergeometric combination helper
93
+ def hypergeometric(n, k)
94
+ return 0 if k > n
95
+ factorial(n) / (factorial(k) * factorial(n - k))
96
+ end
97
+
98
+ def factorial(n)
99
+ return 1 if n.zero?
100
+ (1..n).reduce(1, :*)
101
+ end
102
+ end
103
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CwCardUtils
4
- VERSION = "0.1.7"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/cw_card_utils.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "cw_card_utils/version"
4
4
  require_relative "cw_card_utils/curve_calculator"
5
5
  require_relative "cw_card_utils/decklist_parser"
6
6
  require_relative "cw_card_utils/scryfall_cmc_data"
7
+ require_relative "cw_card_utils/synergy_probability"
7
8
  require_relative "cw_card_utils/deck_comparator"
8
9
 
9
10
  module CwCardUtils
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cw_card_utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Stenhouse
@@ -47,6 +47,7 @@ files:
47
47
  - lib/cw_card_utils/decklist_parser/deck.rb
48
48
  - lib/cw_card_utils/decklist_parser/parser.rb
49
49
  - lib/cw_card_utils/scryfall_cmc_data.rb
50
+ - lib/cw_card_utils/synergy_probability.rb
50
51
  - lib/cw_card_utils/version.rb
51
52
  - sig/cw_card_utils.rbs
52
53
  homepage: https://cracklingwit.com