cw_card_utils 0.1.6 → 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.
@@ -1,11 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "singleton"
4
5
 
5
6
  module CwCardUtils
7
+ # Abstract base class for card data sources
8
+ class CardDataSource
9
+ def find_card(name)
10
+ raise NotImplementedError, "Subclasses must implement find_card"
11
+ end
12
+
13
+ def cmc_for_card(name)
14
+ card = find_card(name)
15
+ card&.dig("cmc")
16
+ rescue StandardError
17
+ nil
18
+ end
19
+
20
+ def color_identity_for_card(name)
21
+ card = find_card(name)
22
+ card&.dig("color_identity") || []
23
+ rescue StandardError
24
+ []
25
+ end
26
+
27
+ def keywords_for_card(name)
28
+ card = find_card(name)
29
+ card&.dig("keywords") || []
30
+ rescue StandardError
31
+ []
32
+ end
33
+
34
+ def toughness_for_card(name)
35
+ card = find_card(name)
36
+ card&.dig("toughness")
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def power_for_card(name)
42
+ card = find_card(name)
43
+ card&.dig("power")
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def oracle_text_for_card(name)
49
+ card = find_card(name)
50
+ card&.dig("oracle_text")
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ def type_for_card(name)
56
+ card = find_card(name)
57
+ type = card&.dig("type_line")
58
+ return nil if type&.include?("Land")
59
+ type
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+
65
+ # Example MongoDB data source implementation
66
+ # Users can implement their own data sources by inheriting from CardDataSource
67
+ class MongoCardDataSource < CardDataSource
68
+ def initialize(collection)
69
+ super()
70
+ @collection = collection
71
+ end
72
+
73
+ def find_card(name)
74
+ @collection.find_one({ "name" => name })
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+
6
80
  # Represents a card with Scryfall data
7
81
  class ScryfallCard
8
- def initialize(name, data_source = ScryfallCmcData.new)
82
+ def initialize(name, data_source = ScryfallCmcData.instance)
9
83
  @name = name
10
84
  @data = data_source.find_card(@name) || {}
11
85
  end
@@ -40,74 +114,47 @@ module CwCardUtils
40
114
  end
41
115
 
42
116
  # Handles conversion mana cost (CMC) data for Magic: The Gathering cards
43
- class ScryfallCmcData
117
+ # Uses Singleton pattern to avoid loading the large JSON file multiple times
118
+ class ScryfallCmcData < CardDataSource
119
+ include Singleton
120
+
44
121
  def initialize
45
- @data = JSON.parse(File.read(File.expand_path("../../data/scryfall.cards.cmc.json", __dir__)))
122
+ super
123
+ @data = nil
124
+ @card_index = nil
46
125
  @found_cards = {}
47
126
  end
48
127
 
49
128
  def find_card(name)
50
- @data.find { |card| card["name"] == name }
129
+ load_data_if_needed
130
+ @card_index[name] || @found_cards[name] ||= linear_search(name)
51
131
  rescue StandardError
52
132
  nil
53
133
  end
54
134
 
55
- def cmc_for_card(name)
56
- @found_cards[name] ||= find_card(name)
57
- @found_cards[name]["cmc"]
58
- rescue StandardError
59
- nil
60
- end
61
-
62
- def color_identity_for_card(name)
63
- @found_cards[name] ||= find_card(name)
64
- @found_cards[name]["color_identity"]
65
- rescue StandardError
66
- nil
67
- end
68
-
69
- def keywords_for_card(name)
70
- @found_cards[name] ||= find_card(name)
71
- @found_cards[name]["keywords"]
72
- rescue StandardError
73
- nil
135
+ def cmc_data
136
+ load_data_if_needed
137
+ @data
74
138
  end
75
139
 
76
- def toughness_for_card(name)
77
- @found_cards[name] ||= find_card(name)
78
- @found_cards[name]["toughness"]
79
- rescue StandardError
80
- nil
81
- end
140
+ private
82
141
 
83
- def power_for_card(name)
84
- @found_cards[name] ||= find_card(name)
85
- @found_cards[name]["power"]
86
- rescue StandardError
87
- nil
88
- end
142
+ def load_data_if_needed
143
+ return if @data
89
144
 
90
- def oracle_text_for_card(name)
91
- @found_cards[name] ||= find_card(name)
92
- @found_cards[name]["oracle_text"]
93
- rescue StandardError
94
- nil
145
+ @data = JSON.parse(File.read(File.expand_path("../../data/scryfall.cards.cmc.json", __dir__)))
146
+ build_card_index
95
147
  end
96
148
 
97
- def type_for_card(name)
98
- @found_cards[name] ||= find_card(name)
99
- type = @found_cards[name]["type_line"]
100
- if type.include?("Land")
101
- type = nil
149
+ def build_card_index
150
+ @card_index = {}
151
+ @data.each do |card|
152
+ @card_index[card["name"]] = card
102
153
  end
103
-
104
- type
105
- rescue StandardError
106
- nil
107
154
  end
108
155
 
109
- def cmc_data
110
- @data
156
+ def linear_search(name)
157
+ @data.find { |card| card["name"] == name }
111
158
  end
112
159
  end
113
160
  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.6"
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.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Stenhouse
@@ -40,7 +40,14 @@ files:
40
40
  - lib/cw_card_utils/curve_calculator.rb
41
41
  - lib/cw_card_utils/deck_comparator.rb
42
42
  - lib/cw_card_utils/decklist_parser.rb
43
+ - lib/cw_card_utils/decklist_parser/archetype_detector.rb
44
+ - lib/cw_card_utils/decklist_parser/card.rb
45
+ - lib/cw_card_utils/decklist_parser/card_tagger.rb
46
+ - lib/cw_card_utils/decklist_parser/color_identity_resolver.rb
47
+ - lib/cw_card_utils/decklist_parser/deck.rb
48
+ - lib/cw_card_utils/decklist_parser/parser.rb
43
49
  - lib/cw_card_utils/scryfall_cmc_data.rb
50
+ - lib/cw_card_utils/synergy_probability.rb
44
51
  - lib/cw_card_utils/version.rb
45
52
  - sig/cw_card_utils.rbs
46
53
  homepage: https://cracklingwit.com