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 +4 -4
- data/lib/cw_card_utils/deck_comparator.rb +112 -21
- data/lib/cw_card_utils/synergy_probability.rb +103 -0
- data/lib/cw_card_utils/version.rb +1 -1
- data/lib/cw_card_utils.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e301d659cb6e96974ef4b5aa075e3951d8b875b645a3518ec8ae7c77211a0d42
|
4
|
+
data.tar.gz: 638f1bb63aabb8db4d648df1070c156c34c6d9b8c5dc7770b0cde08fa4513f9d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
16
|
-
|
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
|
-
|
37
|
+
{
|
19
38
|
archetype_a: a_info[:archetype],
|
20
39
|
archetype_b: b_info[:archetype],
|
21
|
-
|
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
|
-
|
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
|
-
|
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
|
39
|
-
late
|
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
|
99
|
-
notes << "Deck B
|
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
|
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.
|
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
|