sorare-rewards 0.1.0.beta11

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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.gitlab-ci.yml +43 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +87 -0
  6. data/Gemfile +8 -0
  7. data/Gemfile.lock +90 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +42 -0
  10. data/Rakefile +8 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/lib/sorare/rewards/allocation_configuration.yml +165 -0
  14. data/lib/sorare/rewards/configuration.rb +25 -0
  15. data/lib/sorare/rewards/interactors/allocations/compute_for_game_week.rb +35 -0
  16. data/lib/sorare/rewards/interactors/allocations/compute_for_league.rb +40 -0
  17. data/lib/sorare/rewards/interactors/allocations/compute_for_quality.rb +96 -0
  18. data/lib/sorare/rewards/interactors/allocations/compute_for_rarity.rb +34 -0
  19. data/lib/sorare/rewards/interactors/build.rb +33 -0
  20. data/lib/sorare/rewards/interactors/cards/pick_for_division.rb +44 -0
  21. data/lib/sorare/rewards/interactors/cards/pick_for_division_and_rarity.rb +37 -0
  22. data/lib/sorare/rewards/interactors/cards/pick_for_division_rarity_and_quality.rb +49 -0
  23. data/lib/sorare/rewards/interactors/cards/pick_for_game_week.rb +40 -0
  24. data/lib/sorare/rewards/interactors/cards/pick_for_league.rb +64 -0
  25. data/lib/sorare/rewards/interactors/pick.rb +34 -0
  26. data/lib/sorare/rewards/interactors/supply/compute_for_game_week.rb +28 -0
  27. data/lib/sorare/rewards/interactors/supply/compute_for_league.rb +24 -0
  28. data/lib/sorare/rewards/interactors/supply/compute_for_quality.rb +37 -0
  29. data/lib/sorare/rewards/interactors/supply/compute_for_rarity.rb +73 -0
  30. data/lib/sorare/rewards/interactors/tiers/qualify_players.rb +69 -0
  31. data/lib/sorare/rewards/interactors/tiers/qualify_supply.rb +40 -0
  32. data/lib/sorare/rewards/random.rb +14 -0
  33. data/lib/sorare/rewards/transposer.rb +28 -0
  34. data/lib/sorare/rewards/version.rb +7 -0
  35. data/lib/sorare/rewards.rb +38 -0
  36. data/sorare-rewards.gemspec +44 -0
  37. metadata +220 -0
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'interactor'
5
+
6
+ module Sorare
7
+ module Rewards
8
+ module Allocations
9
+ # ComputeForQuality computes the reward allocations for a given quality supply between the divisions of a league
10
+ class ComputeForQuality
11
+ include Interactor
12
+
13
+ delegate :supply, :config, :randomizer, :allocated, to: :context
14
+
15
+ def call
16
+ check_config!
17
+
18
+ context.allocated = Array.new(config.length, 0)
19
+ allocate!
20
+ context.quality_allocations = allocations
21
+ end
22
+
23
+ def allocations
24
+ config.length.times.map do |division|
25
+ [Sorare::Rewards.configuration.transform_division.call(division + 1), allocated[division]]
26
+ end.to_h
27
+ end
28
+
29
+ def allocate!
30
+ allocate_cards
31
+ allocate_pct if has?('pct')
32
+ allocate_extra if has?('pct')
33
+ allocate_loop if has?('loop')
34
+ end
35
+
36
+ def allocate_divisions(&block)
37
+ config.each_with_index(&block)
38
+ end
39
+
40
+ def allocate_cards
41
+ allocate_divisions do |division_config, division|
42
+ count = [remaining_supply, division_config['cards'] || 0].min
43
+
44
+ allocated[division] += count
45
+ end
46
+ end
47
+
48
+ def allocate_pct
49
+ allocate_divisions do |division_config, division|
50
+ count = [remaining_supply, (supply * (division_config['pct'] || 0)).floor].min
51
+
52
+ allocated[division] += count
53
+ end
54
+ end
55
+
56
+ def allocate_loop
57
+ while remaining_supply.positive?
58
+ allocate_divisions do |division_config, division|
59
+ allocated[division] += [division_config['loop'] || 0, remaining_supply].min
60
+ end
61
+ end
62
+ end
63
+
64
+ def allocate_extra
65
+ allocate_divisions do |division_config, division|
66
+ count = pick_extra_supply((supply * (division_config['pct'] || 0)).modulo(1))
67
+
68
+ allocated[division] += count
69
+ end
70
+ end
71
+
72
+ def remaining_supply
73
+ supply - allocated.sum
74
+ end
75
+
76
+ def pick_extra_supply(probability)
77
+ reward_probability = randomizer.rand
78
+ return 0 unless remaining_supply.positive?
79
+ return 0 unless reward_probability < probability
80
+
81
+ 1
82
+ end
83
+
84
+ def has?(key)
85
+ !config.first[key].nil?
86
+ end
87
+
88
+ def check_config!
89
+ return if config && [0, 1].include?(config.sum { |q| q['pct'] || 0 })
90
+
91
+ context.fail!(error: 'Invalid config')
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Allocations
10
+ # ComputeForRarity computes the reward allocations for a rarity in a league
11
+ class ComputeForRarity
12
+ include Interactor
13
+
14
+ delegate :supply, :config, to: :context
15
+
16
+ def call
17
+ context.fail!(error: 'Invalid config') unless config
18
+
19
+ context.rarity_allocations = supply.each_with_index.map do |tier_supply, tier|
20
+ ComputeForQuality.call!(
21
+ **context.to_h,
22
+ supply: tier_supply,
23
+ config: tier_config(tier)
24
+ ).quality_allocations
25
+ end
26
+ end
27
+
28
+ def tier_config(tier)
29
+ config[Sorare::Rewards.configuration.transform_tier.call(tier)]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/hash/indifferent_access'
5
+ require 'active_support/core_ext/module/delegation'
6
+ require 'interactor'
7
+
8
+ module Sorare
9
+ module Rewards
10
+ # Build builds the rewards structure for a given game week
11
+ class Build
12
+ include Interactor
13
+
14
+ delegate :data, :salt, to: :context
15
+
16
+ def call
17
+ context.game_week_supply = game_week_supply
18
+ context.allocations = allocate!
19
+ end
20
+
21
+ def allocate!
22
+ Allocations::ComputeForGameWeek.call!(
23
+ **data.to_h, supply: game_week_supply, salt: salt
24
+ ).game_week_allocations
25
+ end
26
+
27
+ def game_week_supply
28
+ @game_week_supply ||= Supply::ComputeForGameWeek.call!(**data.to_h, salt: salt)
29
+ .game_week_supply
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'interactor'
6
+ require 'yaml'
7
+
8
+ module Sorare
9
+ module Rewards
10
+ module Cards
11
+ # PickForDivision picks the rewards for a given division
12
+ # Receive a list of eligible qualified cards
13
+ # {
14
+ # 'rare' => {
15
+ # 0 => ['kylian-mbappe-lottin', 'kylian-mbappe-lottin', 'keylor-navas', ...]
16
+ # }
17
+ # }
18
+ # A game week league supply
19
+ # {
20
+ # 'rare' => { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...},
21
+ # 'super_rare' => { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...}
22
+ # }
23
+ # And a reward allocations
24
+ # {
25
+ # 'rare' => [5, 15, 0, 0],
26
+ # 'super_rare' => [1, 4, 0, 0],
27
+ # 'unique' => [0, 0, 1, 0]
28
+ # }
29
+ class PickForDivision
30
+ include Interactor
31
+
32
+ delegate :allocations, :cards, :qualified_players, :supply, to: :context
33
+
34
+ def call
35
+ context.division_picks = allocations.keys.index_with do |rarity|
36
+ PickForDivisionAndRarity.call!(
37
+ **context.to_h, cards: cards[rarity], allocations: allocations[rarity], supply: supply[rarity]
38
+ ).rarity_picks
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Cards
10
+ # PickForDivisionAndRarity picks the rewards for a given division and rarity
11
+ # Receive a randomizer, a list of eligible qualified cards of the corresponding rarity
12
+ # {
13
+ # 'tier_0' => ['kylian-mbappe-lottin', 'kylian-mbappe-lottin', 'keylor-navas', ...]
14
+ # }
15
+ # A game week league rarity supply
16
+ # { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...}
17
+ # And a reward allocations
18
+ # { 'tier_0' => 1, 'tier_1' => 2 }
19
+ class PickForDivisionAndRarity
20
+ include Interactor
21
+
22
+ delegate :allocations, :cards, to: :context
23
+
24
+ def call
25
+ context.rarity_picks = supply!
26
+ end
27
+
28
+ def supply!
29
+ allocations.keys.index_with do |tier|
30
+ PickForDivisionRarityAndQuality.call!(**context.to_h, allocations: allocations[tier],
31
+ cards: cards[tier]).picks
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'interactor'
5
+
6
+ module Sorare
7
+ module Rewards
8
+ module Cards
9
+ # PickForDivisionRarityAndQuality picks the rewards for a given division, rarity, and quality
10
+ # Receive a list of eligible qualified cards
11
+ # ['kylian-mbappe-lottin', 'kylian-mbappe-lottin', 'keylor-navas', ...]
12
+ # A game week league rarity supply
13
+ # { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...}
14
+ # And a reward allocations
15
+ # 15
16
+ class PickForDivisionRarityAndQuality
17
+ include Interactor
18
+
19
+ MISSING_CARDS = 'There are not enough cards to fulfill the requirements'
20
+
21
+ delegate :cards, :allocations, :supply, :force, :picks, :randomizer, to: :context
22
+
23
+ def call
24
+ context.picks = []
25
+
26
+ pick!
27
+ check_length! unless force
28
+ reorder!
29
+ end
30
+
31
+ def pick!
32
+ while picks.length != allocations && cards.length.positive?
33
+ picks.push(cards.delete_at(randomizer.rand * cards.length))
34
+ end
35
+ end
36
+
37
+ def reorder!
38
+ picks.sort! { |a, b| supply[a]['rank'].to_i <=> supply[b]['rank'].to_i }
39
+ end
40
+
41
+ def check_length!
42
+ return if picks.length == allocations
43
+
44
+ context.fail!(error: MISSING_CARDS)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'interactor'
6
+ require 'yaml'
7
+
8
+ module Sorare
9
+ module Rewards
10
+ module Cards
11
+ # PickForGameWeek picks the rewards for a given game week
12
+ # Receive a Game Week reward supply data
13
+ # { public_seed: 123, playing_players: [...], supply: {...} }
14
+ # And a reward allocations
15
+ # {
16
+ # 'global-all_star' => {
17
+ # 'D1' => {
18
+ # 'rare' => { 'tier_0': 1, 'tier_1': 1 }
19
+ # }
20
+ # }
21
+ # }
22
+ class PickForGameWeek
23
+ include Interactor
24
+
25
+ delegate :supply, :allocations, :randomizer, :public_seed, :salt, to: :context
26
+
27
+ def call
28
+ context.randomizer = Sorare::Rewards::Random.new(public_seed, salt)
29
+ context.picks = picks
30
+ end
31
+
32
+ def picks
33
+ allocations.keys.index_with do |league|
34
+ PickForLeague.call!(**context.to_h, supply: supply[league], allocations: allocations[league]).league_picks
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'active_support/core_ext/enumerable'
5
+ require 'interactor'
6
+ require 'yaml'
7
+
8
+ module Sorare
9
+ module Rewards
10
+ module Cards
11
+ # PickForLeague picks the rewards for a given league
12
+ # Receive a game week league supply
13
+ # {
14
+ # 'rare' => { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...},
15
+ # 'super_rare' => { 'kylian-mbappe-lottin' => { 'rank' => 1 }, ...}
16
+ # }
17
+ # And a reward allocations
18
+ # {
19
+ # 'D1' => {
20
+ # 'rare' => { 'tier_0' => 1, 'tier_1' => 2 }
21
+ # }
22
+ # }
23
+ class PickForLeague
24
+ include Interactor
25
+
26
+ delegate :supply, :allocations, :cards, to: :context
27
+
28
+ def call
29
+ context.cards = {}
30
+ add_cards!
31
+
32
+ context.league_picks = allocations.keys.index_with do |division|
33
+ PickForDivision.call!(
34
+ **context.to_h, cards: cards, allocations: allocations[division]
35
+ ).division_picks
36
+ end
37
+ end
38
+
39
+ def add_cards!
40
+ supply.keys.index_with do |rarity|
41
+ cards[rarity] = {}
42
+ qualify_and_add!(rarity)
43
+ end
44
+ end
45
+
46
+ def qualify_and_add!(rarity)
47
+ qualified_players = Tiers::QualifyPlayers.call!(sorted_supply: supply[rarity]).players
48
+ qualified_players.each_with_index.map do |players, tier|
49
+ transformed_tier = Sorare::Rewards.configuration.transform_tier.call(tier)
50
+ cards[rarity][transformed_tier] = []
51
+
52
+ players.each do |player_slug|
53
+ add(player_slug, rarity, transformed_tier)
54
+ end
55
+ end
56
+ end
57
+
58
+ def add(player_slug, rarity, tier)
59
+ cards[rarity][tier] += Array.new(supply[rarity][player_slug]['supply'], player_slug)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/hash/indifferent_access'
5
+ require 'active_support/core_ext/module/delegation'
6
+ require 'interactor'
7
+
8
+ module Sorare
9
+ module Rewards
10
+ # Pick the rewards for a given game week
11
+ # Receive a Game Week reward supply data
12
+ # And a reward allocations
13
+ # {
14
+ # 'global-all_star' => {
15
+ # 'rare' => {
16
+ # 'D1' => { 'tier_0' => 0, 'tier_1' => 1 }
17
+ # }
18
+ # }
19
+ # }
20
+ class Pick
21
+ include Interactor
22
+
23
+ delegate :allocations, :data, :salt, to: :context
24
+
25
+ # Depending on the way the allocations interpreter is implemented it could be done by him or we
26
+ # update the allocations process to match the structure
27
+ def call
28
+ context.picks = Sorare::Rewards::Cards::PickForGameWeek.call!(
29
+ **data.to_h, salt: salt, allocations: Transposer.transpose_allocations(allocations)
30
+ ).picks
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/module/delegation'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Supply
10
+ # ComputeForGameWeek computes the rewardable supply of a game week based on the overall supply
11
+ class ComputeForGameWeek
12
+ include Interactor
13
+
14
+ delegate :public_seed, :salt, :supply, :playing_players, :game_week_supply, to: :context
15
+
16
+ def call
17
+ context.game_week_supply = supply.keys.index_with do |league|
18
+ ComputeForLeague.call!(**context.to_h, randomizer: randomizer, supply: supply[league]).league_supply
19
+ end
20
+ end
21
+
22
+ def randomizer
23
+ @randomizer ||= Sorare::Rewards::Random.new(public_seed, salt)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/module/delegation'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Supply
10
+ # ComputeForLeague computes the rewardable supply of a league
11
+ class ComputeForLeague
12
+ include Interactor
13
+
14
+ delegate :randomizer, :supply, :playing_players, to: :context
15
+
16
+ def call
17
+ context.league_supply = supply.keys.index_with do |rarity|
18
+ ComputeForRarity.call!(**context.to_h, supply: supply[rarity]).rarity_supply
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/module/delegation'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Supply
10
+ # ComputeForQuality computes the rewardable supply for a given quality between the divisions of a league
11
+ class ComputeForQuality
12
+ include Interactor
13
+
14
+ delegate :randomizer, :total_supply, :tier_supply, :rewarded, :rewardable, to: :context
15
+
16
+ def call
17
+ context.quality_supply = total_supply.zero? ? 0 : quality_supply
18
+ end
19
+
20
+ def quality_supply
21
+ plain, extra = (tier_supply.to_f * rewardable).divmod(total_supply)
22
+
23
+ plain + remaining_supply(plain, extra)
24
+ end
25
+
26
+ def remaining_supply(plain, extra)
27
+ reward_probability = randomizer.rand
28
+ return 0 if rewarded.sum + plain >= rewardable
29
+ return 0 if extra.zero?
30
+ return 0 if reward_probability > (extra.to_f / total_supply)
31
+
32
+ 1
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/module/delegation'
5
+ require 'interactor'
6
+
7
+ module Sorare
8
+ module Rewards
9
+ module Supply
10
+ # ComputeForRarity computes the rewardable supply for a given rarity
11
+ class ComputeForRarity
12
+ include Interactor
13
+
14
+ delegate :randomizer, :supply, :playing_players, :rewardable, :rarity_supply,
15
+ :minimum_remaining_games, to: :context
16
+
17
+ def call
18
+ context.rarity_supply = []
19
+
20
+ context.rewardable = compute_rewardable_supply!
21
+ distribute_in_tiers!
22
+ end
23
+
24
+ def compute_rewardable_supply!
25
+ total_supply = playing_players.keys.sum do |player|
26
+ player_supply(player)
27
+ end
28
+
29
+ rounded_supply(total_supply)
30
+ end
31
+
32
+ def distribute_in_tiers!
33
+ ctx = Tiers::QualifySupply.call!(sorted_supply: supply)
34
+ ctx.supply.each do |tier_supply|
35
+ rarity_supply.push(
36
+ ComputeForQuality.call!(
37
+ randomizer: randomizer,
38
+ total_supply: ctx.count,
39
+ tier_supply: tier_supply,
40
+ rewardable: rewardable,
41
+ rewarded: rarity_supply
42
+ ).quality_supply
43
+ )
44
+ end
45
+ end
46
+
47
+ def player_supply(slug)
48
+ remaining_games = playing_players[slug] || 0
49
+ return 0 unless remaining_games.positive?
50
+ return 0 unless supply[slug]
51
+
52
+ supply[slug]['supply'].to_f / corrected_remaining_games(remaining_games)
53
+ end
54
+
55
+ def rounded_supply(float_supply)
56
+ float_supply.floor + remaining_supply(float_supply.modulo(1))
57
+ end
58
+
59
+ # To be improved to handle properly seasons overlap
60
+ def corrected_remaining_games(remaining_games)
61
+ [remaining_games, minimum_remaining_games || 10].max
62
+ end
63
+
64
+ def remaining_supply(reward_probability)
65
+ probability = randomizer.rand
66
+ return 0 unless probability < reward_probability
67
+
68
+ 1
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'interactor'
5
+
6
+ module Sorare
7
+ module Rewards
8
+ module Tiers
9
+ # QualifyPlayers qualifies a list of sorted supply in tiers based on its rank or a provided tier
10
+ # Returns an array of tiers (array) containing the list of slugs for that tier
11
+ class QualifyPlayers
12
+ include Interactor
13
+
14
+ delegate :sorted_supply, to: :context
15
+
16
+ def call
17
+ context.players = by_rank ? qualified_players_by_rank : qualified_players_by_tier
18
+ end
19
+
20
+ # Use the tier specified within the data
21
+ def qualified_players_by_tier
22
+ tiers = Sorare::Rewards.configuration.tiers.times.map { |_| [] }
23
+ sorted_supply.each do |slug, data|
24
+ tiers[data['tier']] << slug
25
+ end
26
+
27
+ tiers
28
+ end
29
+
30
+ # Use the rank in the array
31
+ def qualified_players_by_rank
32
+ nb_of_tiers.times.map do |tier|
33
+ sorted_supply.keys[tier_index(tier)..tier_index(tier + 1) - 1]
34
+ end
35
+ end
36
+
37
+ def tier_depth
38
+ @tier_depth ||= sorted_supply.keys.length / (nb_of_tiers**2 - 1)
39
+ end
40
+
41
+ def nb_of_tiers
42
+ @nb_of_tiers ||= begin
43
+ default = Sorare::Rewards.configuration.tiers
44
+ default -= 1 while (default**2 - 1) > sorted_supply.keys.length
45
+
46
+ default
47
+ end
48
+ end
49
+
50
+ def tier_index(tier)
51
+ return 0 unless tier.positive?
52
+ return sorted_supply.keys.length if tier == nb_of_tiers
53
+
54
+ tier_index(tier - 1) + tier_depth * tier_size(tier - 1)
55
+ end
56
+
57
+ def tier_size(tier)
58
+ return 1 unless tier.positive?
59
+
60
+ tier_size(tier - 1) * 2
61
+ end
62
+
63
+ def by_rank
64
+ @by_rank ||= sorted_supply.values.first&.dig('tier').nil?
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end