aboutsummaryrefslogtreecommitdiff
path: root/rankings
diff options
context:
space:
mode:
authorArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2024-09-12 00:25:15 +0300
committerArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2024-09-12 00:25:15 +0300
commitdf6f6cb5ff8957be8f01d58d60857da2c094a3d9 (patch)
tree5ec5a8a95633d7fa6cce62654a9bc6fc6204f788 /rankings
parentrefactor: fix module ver (diff)
downloadlphub-df6f6cb5ff8957be8f01d58d60857da2c094a3d9.tar.gz
lphub-df6f6cb5ff8957be8f01d58d60857da2c094a3d9.tar.bz2
lphub-df6f6cb5ff8957be8f01d58d60857da2c094a3d9.zip
refactor: unofficial rankings implementation
Diffstat (limited to 'rankings')
-rw-r--r--rankings/.env.example1
-rw-r--r--rankings/.gitignore2
-rw-r--r--rankings/README.md35
-rw-r--r--rankings/export.go21
-rw-r--r--rankings/fetch.go185
-rw-r--r--rankings/filter.go81
-rw-r--r--rankings/go.mod8
-rw-r--r--rankings/go.sum4
-rw-r--r--rankings/input/overrides.json44
-rw-r--r--rankings/input/records.json604
-rw-r--r--rankings/main.go45
-rw-r--r--rankings/models.go51
-rw-r--r--rankings/prefetch.go44
13 files changed, 1125 insertions, 0 deletions
diff --git a/rankings/.env.example b/rankings/.env.example
new file mode 100644
index 0000000..48204cd
--- /dev/null
+++ b/rankings/.env.example
@@ -0,0 +1 @@
API_KEY=
diff --git a/rankings/.gitignore b/rankings/.gitignore
new file mode 100644
index 0000000..764d23d
--- /dev/null
+++ b/rankings/.gitignore
@@ -0,0 +1,2 @@
1.env
2output/ \ No newline at end of file
diff --git a/rankings/README.md b/rankings/README.md
new file mode 100644
index 0000000..5532c88
--- /dev/null
+++ b/rankings/README.md
@@ -0,0 +1,35 @@
1# Rankings Algorithm
2
3Unofficial rankings are fetched from Steam. The reason that LPHUB considers Steam leaderboards unofficial is that entries do not require proof, and it is very easy to cheat the portal count in terms of in-game commands and/or otherwise.
4
5The algorithm is close clone of [@NeKz](https://github.com/NeKzor)'s implementation of their lp boards, without including the video showcases and tie counts for each map.
6
7## Fetch Manual Inputs
8- records.json
9 - Contains all map ids and wrs
10- overrides.json
11 - Used to replace invalid scores by legit players
12
13## Fetch 5000 Scores for Each Map
14- Dictionary of players and map entries are created during the period of fetching all of the maps.
15 - First initialize the players dictionary with all players that finished Portal Gun and Doors in their limit portal count. This results in ~200K players as of 2024 Q4.
16- Iterate over the rest of the maps and increase the category score and iteration count for each player.
17 - If a player from an entry does not exist in the initial dictionary, they are skipped.
18 - If a player has a score that is lower than the WR, they are skipped since the score is invalid.
19 - If they have an override however, their entry is overriden and becomes valid.
20 - If there are more than 5000 scores that have WR for that map, the search goes on until every WR holder is fetched.
21 - If there is a specified map limit in the records.json, then all of the scores up to and including that map limit is fetched. Any score above the map limit is skipped.
22
23## Filtering Players Dictionary
24- Create seperate arrays for singleplayer, multiplayer, overall rankings.
25- Iterate over the players dictionary and add players that completed at least one category to their respective arrays.
26 - If player has 51 sp entries, add to sp rankings.
27 - If player has 48 mp entries, add to mp rankings.
28 - If player has 51 sp entries and 48 mp entries, add to overall rankings.
29 - If none of the above, remove player from the dictionary.
30- Iterate over the dictionary to get Steam data for each player that has at least one category complete. This results in one API call for each ~300 players as of 2024 Q4.
31- Sort the sp, mp, overall rankings arrays by score counts of their respective category.
32- Iterate over each rankings arrays and calculate the ranks for each player.
33
34## Exporting Rankings Arrays
35- Marshall each array into JSON and output into JSON files. \ No newline at end of file
diff --git a/rankings/export.go b/rankings/export.go
new file mode 100644
index 0000000..20dfebe
--- /dev/null
+++ b/rankings/export.go
@@ -0,0 +1,21 @@
1package main
2
3import (
4 "encoding/json"
5 "os"
6)
7
8func exportAll(spRankings, mpRankings, overallRankings *[]*Player) {
9 sp, _ := os.Create("./output/sp.json")
10 spRankingsOut, _ := json.Marshal(*spRankings)
11 sp.Write(spRankingsOut)
12 sp.Close()
13 mp, _ := os.Create("./output/mp.json")
14 mpRankingsOut, _ := json.Marshal(*mpRankings)
15 mp.Write(mpRankingsOut)
16 mp.Close()
17 overall, _ := os.Create("./output/overall.json")
18 overallRankingsOut, _ := json.Marshal(*overallRankings)
19 overall.Write(overallRankingsOut)
20 overall.Close()
21}
diff --git a/rankings/fetch.go b/rankings/fetch.go
new file mode 100644
index 0000000..ee5d5bb
--- /dev/null
+++ b/rankings/fetch.go
@@ -0,0 +1,185 @@
1package main
2
3import (
4 "encoding/json"
5 "encoding/xml"
6 "fmt"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "strconv"
12)
13
14func fetchLeaderboard(records *[]Record, overrides *map[string]map[string]int) *map[string]*Player {
15 log.Println("fetching leaderboard")
16 players := map[string]*Player{}
17 // first init players map with records from portal gun and doors
18 fetchAnotherPage := true
19 start := 0
20 end := 5000
21
22 for fetchAnotherPage {
23 portalGunEntries := fetchRecordsFromMap(47459, 0, 5000)
24 fetchAnotherPage = portalGunEntries.needsAnotherPage(&(*records)[0])
25 if fetchAnotherPage {
26 start = end + 1
27 end = start + 5000
28 }
29 for _, entry := range portalGunEntries.Entries.Entry {
30 if entry.Score < 0 {
31 continue // ban
32 }
33 players[entry.SteamID] = &Player{
34 SteamID: entry.SteamID,
35 Entries: []PlayerEntry{
36 {
37 MapID: 47459,
38 MapScore: entry.Score,
39 },
40 },
41 SpScoreCount: entry.Score,
42 SpIterations: 1,
43 }
44 }
45 }
46
47 fetchAnotherPage = true
48 start = 0
49 end = 5000
50
51 for fetchAnotherPage {
52 doorsEntries := fetchRecordsFromMap(47740, start, end)
53 fetchAnotherPage = doorsEntries.needsAnotherPage(&(*records)[51])
54 if fetchAnotherPage {
55 start = end + 1
56 end = start + 5000
57 }
58 for _, entry := range doorsEntries.Entries.Entry {
59 if entry.Score < 0 {
60 continue // ban
61 }
62 player, ok := players[entry.SteamID]
63 if !ok {
64 players[entry.SteamID] = &Player{
65 SteamID: entry.SteamID,
66 Entries: []PlayerEntry{
67 {
68 MapID: 47740,
69 MapScore: entry.Score,
70 },
71 },
72 MpScoreCount: entry.Score,
73 MpIterations: 1,
74 }
75 } else {
76 player.Entries = append(player.Entries, PlayerEntry{
77 MapID: 47740,
78 MapScore: entry.Score,
79 })
80 player.MpScoreCount = entry.Score
81 player.MpIterations++
82 }
83 }
84 }
85
86 for _, record := range *records {
87 if record.MapID == 47459 || record.MapID == 47740 {
88 continue
89 }
90
91 fetchAnotherPage := true
92 start := 0
93 end := 5000
94
95 for fetchAnotherPage {
96 entries := fetchRecordsFromMap(record.MapID, start, end)
97 fetchAnotherPage = entries.needsAnotherPage(&record)
98 if fetchAnotherPage {
99 start = end + 1
100 end = start + 5000
101 }
102 for _, entry := range (*entries).Entries.Entry {
103 player, ok := players[entry.SteamID]
104 if !ok {
105 continue
106 }
107 score := entry.Score
108 if entry.Score < record.MapWR {
109 _, ok := (*overrides)[entry.SteamID]
110 if ok {
111 _, ok := (*overrides)[entry.SteamID][strconv.Itoa(record.MapID)]
112 if ok {
113 score = (*overrides)[entry.SteamID][strconv.Itoa(record.MapID)]
114 } else {
115 continue // ban
116 }
117 } else {
118 continue // ban
119 }
120 }
121 if record.MapLimit != nil && score > *record.MapLimit {
122 continue // ignore above limit
123 }
124 player.Entries = append(player.Entries, PlayerEntry{
125 MapID: record.MapID,
126 MapScore: score,
127 })
128 if record.MapMode == 1 {
129 player.SpScoreCount += score
130 player.SpIterations++
131 } else if record.MapMode == 2 {
132 player.MpScoreCount += score
133 player.MpIterations++
134 }
135 }
136 }
137
138 }
139 return &players
140}
141
142func fetchRecordsFromMap(mapID int, start int, end int) *Leaderboard {
143 resp, err := http.Get(fmt.Sprintf("https://steamcommunity.com/stats/Portal2/leaderboards/%d?xml=1&start=%d&end=%d", mapID, start, end))
144 if err != nil {
145 log.Fatalln(err.Error())
146 }
147 respBytes, err := io.ReadAll(resp.Body)
148 if err != nil {
149 log.Fatalln(err.Error())
150 }
151 leaderboard := Leaderboard{}
152 err = xml.Unmarshal(respBytes, &leaderboard)
153 if err != nil {
154 log.Fatalln(err.Error())
155 }
156 return &leaderboard
157}
158
159func fetchPlayerInfo(player *Player) {
160 url := fmt.Sprintf("http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", os.Getenv("API_KEY"), player.SteamID)
161 resp, err := http.Get(url)
162 if err != nil {
163 log.Fatalln(err.Error())
164 }
165 body, err := io.ReadAll(resp.Body)
166 if err != nil {
167 log.Fatalln(err.Error())
168 }
169 type PlayerSummary struct {
170 PersonaName string `json:"personaname"`
171 AvatarFull string `json:"avatarfull"`
172 }
173
174 type Result struct {
175 Response struct {
176 Players []PlayerSummary `json:"players"`
177 } `json:"response"`
178 }
179 var data Result
180 if err := json.Unmarshal(body, &data); err != nil {
181 log.Fatalln(err.Error())
182 }
183 player.AvatarLink = data.Response.Players[0].AvatarFull
184 player.Username = data.Response.Players[0].PersonaName
185}
diff --git a/rankings/filter.go b/rankings/filter.go
new file mode 100644
index 0000000..5f54a81
--- /dev/null
+++ b/rankings/filter.go
@@ -0,0 +1,81 @@
1package main
2
3import (
4 "log"
5 "sort"
6)
7
8func filterRankings(spRankings, mpRankings, overallRankings *[]*Player, players *map[string]*Player) {
9 for k, p := range *players {
10 if p.SpIterations == 51 {
11 *spRankings = append(*spRankings, p)
12 }
13 if p.MpIterations == 48 {
14 *mpRankings = append(*mpRankings, p)
15 }
16 if p.SpIterations == 51 && p.MpIterations == 48 {
17 p.OverallScoreCount = p.SpScoreCount + p.MpScoreCount
18 *overallRankings = append(*overallRankings, p)
19 }
20 if p.SpIterations < 51 && p.MpIterations < 48 {
21 delete(*players, k)
22 }
23 }
24
25 log.Println("getting player summaries")
26 for _, v := range *players {
27 fetchPlayerInfo(v)
28 }
29
30 log.Println("sorting the ranks")
31 sort.Slice(*spRankings, func(i, j int) bool {
32 return (*spRankings)[i].SpScoreCount < (*spRankings)[j].SpScoreCount
33 })
34
35 rank := 1
36
37 for idx := 0; idx < len(*spRankings); idx++ {
38 if idx == 0 {
39 (*spRankings)[idx].SpRank = rank
40 continue
41 }
42 if (*spRankings)[idx-1].SpScoreCount != (*spRankings)[idx].SpScoreCount {
43 rank++
44 }
45 (*spRankings)[idx].SpRank = rank
46 }
47
48 sort.Slice(*mpRankings, func(i, j int) bool {
49 return (*mpRankings)[i].MpScoreCount < (*mpRankings)[j].MpScoreCount
50 })
51
52 rank = 1
53
54 for idx := 0; idx < len(*mpRankings); idx++ {
55 if idx == 0 {
56 (*mpRankings)[idx].MpRank = rank
57 continue
58 }
59 if (*mpRankings)[idx-1].MpScoreCount != (*mpRankings)[idx].MpScoreCount {
60 rank++
61 }
62 (*mpRankings)[idx].MpRank = rank
63 }
64
65 sort.Slice(*overallRankings, func(i, j int) bool {
66 return (*overallRankings)[i].OverallScoreCount < (*overallRankings)[j].OverallScoreCount
67 })
68
69 rank = 1
70
71 for idx := 0; idx < len(*overallRankings); idx++ {
72 if idx == 0 {
73 (*overallRankings)[idx].OverallRank = rank
74 continue
75 }
76 if (*overallRankings)[idx-1].OverallScoreCount != (*overallRankings)[idx].OverallScoreCount {
77 rank++
78 }
79 (*overallRankings)[idx].OverallRank = rank
80 }
81}
diff --git a/rankings/go.mod b/rankings/go.mod
new file mode 100644
index 0000000..2a395e0
--- /dev/null
+++ b/rankings/go.mod
@@ -0,0 +1,8 @@
1module rankings
2
3go 1.23.0
4
5require (
6 github.com/joho/godotenv v1.5.1
7 github.com/robfig/cron/v3 v3.0.1
8)
diff --git a/rankings/go.sum b/rankings/go.sum
new file mode 100644
index 0000000..cb2af12
--- /dev/null
+++ b/rankings/go.sum
@@ -0,0 +1,4 @@
1github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
2github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
3github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
4github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
diff --git a/rankings/input/overrides.json b/rankings/input/overrides.json
new file mode 100644
index 0000000..9c2e2a3
--- /dev/null
+++ b/rankings/input/overrides.json
@@ -0,0 +1,44 @@
1{
2 "76561198997027314": {
3 "47467": 3
4 },
5 "76561198103821970": {
6 "47750": 2,
7 "47753": 4
8 },
9 "76561198389681125": {
10 "47827": 4,
11 "52716": 4
12 },
13 "76561198136477838": {
14 "47838": 2,
15 "47850": 3,
16 "47467": 13,
17 "47753": 5,
18 "47749": 8,
19 "47747": 2,
20 "47107": 3,
21 "47772": 2,
22 "47781": 3,
23 "47750": 2,
24 "47745": 2,
25 "47777": 6,
26 "47778": 7,
27 "47782": 7,
28 "47822": 4,
29 "47823": 9,
30 "47457": 9,
31 "47809": 2,
32 "47816": 16,
33 "47471": 8,
34 "47792": 4,
35 "47820": 4,
36 "47805": 2,
37 "47785": 9,
38 "47799": 5,
39 "47794": 2,
40 "47796": 29,
41 "47754": 5,
42 "47767": 12
43 }
44}
diff --git a/rankings/input/records.json b/rankings/input/records.json
new file mode 100644
index 0000000..276310f
--- /dev/null
+++ b/rankings/input/records.json
@@ -0,0 +1,604 @@
1[
2 {
3 "id": 47459,
4 "name": "Portal Gun",
5 "mode": 1,
6 "wr": 0
7 },
8 {
9 "id": 47454,
10 "name": "Smooth Jazz",
11 "mode": 1,
12 "wr": 0,
13 "limit": 1
14 },
15 {
16 "id": 47451,
17 "name": "Cube Momentum",
18 "mode": 1,
19 "wr": 1
20 },
21 {
22 "id": 47107,
23 "name": "Future Starter",
24 "mode": 1,
25 "wr": 2
26 },
27 {
28 "id": 47734,
29 "name": "Incinerator",
30 "mode": 1,
31 "wr": 0
32 },
33 {
34 "id": 47737,
35 "name": "Laser Stairs",
36 "mode": 1,
37 "wr": 0
38 },
39 {
40 "id": 47739,
41 "name": "Dual Lasers",
42 "mode": 1,
43 "wr": 2
44 },
45 {
46 "id": 47743,
47 "name": "Laser Over Goo",
48 "mode": 1,
49 "wr": 0
50 },
51 {
52 "id": 47745,
53 "name": "Trust Fling",
54 "mode": 1,
55 "wr": 0,
56 "limit": 2
57 },
58 {
59 "id": 47466,
60 "name": "Pit Flings",
61 "mode": 1,
62 "wr": 0
63 },
64 {
65 "id": 47747,
66 "name": "Fizzler Intro",
67 "mode": 1,
68 "wr": 0,
69 "limit": 2
70 },
71 {
72 "id": 47749,
73 "name": "Ceiling Catapult",
74 "mode": 1,
75 "wr": 0
76 },
77 {
78 "id": 47750,
79 "name": "Ricochet",
80 "mode": 1,
81 "wr": 0
82 },
83 {
84 "id": 47753,
85 "name": "Bridge Intro",
86 "mode": 1,
87 "wr": 2
88 },
89 {
90 "id": 47754,
91 "name": "Bridge the Gap",
92 "mode": 1,
93 "wr": 0
94 },
95 {
96 "id": 47757,
97 "name": "Turret Intro",
98 "mode": 1,
99 "wr": 0
100 },
101 {
102 "id": 47758,
103 "name": "Laser Relays",
104 "mode": 1,
105 "wr": 0,
106 "limit": 2
107 },
108 {
109 "id": 47761,
110 "name": "Turret Blocker",
111 "mode": 1,
112 "wr": 0
113 },
114 {
115 "id": 47762,
116 "name": "Laser vs Turret",
117 "mode": 1,
118 "wr": 0
119 },
120 {
121 "id": 47765,
122 "name": "Pull the Rug",
123 "mode": 1,
124 "wr": 0,
125 "limit": 3
126 },
127 {
128 "id": 47767,
129 "name": "Column Blocker",
130 "mode": 1,
131 "wr": 0,
132 "limit": 2
133 },
134 {
135 "id": 47769,
136 "name": "Laser Chaining",
137 "mode": 1,
138 "wr": 0
139 },
140 {
141 "id": 47771,
142 "name": "Triple Laser",
143 "mode": 1,
144 "wr": 0
145 },
146 {
147 "id": 47772,
148 "name": "Jail Break",
149 "mode": 1,
150 "wr": 2
151 },
152 {
153 "id": 47775,
154 "name": "Escape",
155 "mode": 1,
156 "wr": 0
157 },
158 {
159 "id": 47777,
160 "name": "Turret Factory",
161 "mode": 1,
162 "wr": 5
163 },
164 {
165 "id": 47778,
166 "name": "Turret Sabotage",
167 "mode": 1,
168 "wr": 4
169 },
170 {
171 "id": 47781,
172 "name": "Neurotoxin Sabotage",
173 "mode": 1,
174 "wr": 0,
175 "limit": 3
176 },
177 {
178 "id": 47782,
179 "name": "Underground",
180 "mode": 1,
181 "wr": 2
182 },
183 {
184 "id": 47785,
185 "name": "Cave Johnson",
186 "mode": 1,
187 "wr": 4
188 },
189 {
190 "id": 47786,
191 "name": "Repulsion Intro",
192 "mode": 1,
193 "wr": 0
194 },
195 {
196 "id": 47467,
197 "name": "Bomb Flings",
198 "mode": 1,
199 "wr": 3
200 },
201 {
202 "id": 47470,
203 "name": "Crazy Box",
204 "mode": 1,
205 "wr": 0
206 },
207 {
208 "id": 47471,
209 "name": "PotatOS",
210 "mode": 1,
211 "wr": 5
212 },
213 {
214 "id": 47792,
215 "name": "Propulsion Intro",
216 "mode": 1,
217 "wr": 2
218 },
219 {
220 "id": 47794,
221 "name": "Propulsion Flings",
222 "mode": 1,
223 "wr": 0
224 },
225 {
226 "id": 47796,
227 "name": "Conversion Intro",
228 "mode": 1,
229 "wr": 8
230 },
231 {
232 "id": 47799,
233 "name": "Three Gels",
234 "mode": 1,
235 "wr": 4
236 },
237 {
238 "id": 47801,
239 "name": "Funnel Intro",
240 "mode": 1,
241 "wr": 0
242 },
243 {
244 "id": 47803,
245 "name": "Ceiling Button",
246 "mode": 1,
247 "wr": 0
248 },
249 {
250 "id": 47805,
251 "name": "Wall Button",
252 "mode": 1,
253 "wr": 0
254 },
255 {
256 "id": 47807,
257 "name": "Polarity",
258 "mode": 1,
259 "wr": 0
260 },
261 {
262 "id": 47809,
263 "name": "Funnel Catch",
264 "mode": 1,
265 "wr": 2
266 },
267 {
268 "id": 47812,
269 "name": "Stop the Box",
270 "mode": 1,
271 "wr": 0
272 },
273 {
274 "id": 47814,
275 "name": "Laser Catapult",
276 "mode": 1,
277 "wr": 0
278 },
279 {
280 "id": 47816,
281 "name": "Laser Platform",
282 "mode": 1,
283 "wr": 3
284 },
285 {
286 "id": 47818,
287 "name": "Propulsion Catch",
288 "mode": 1,
289 "wr": 0
290 },
291 {
292 "id": 47820,
293 "name": "Repulsion Polarity",
294 "mode": 1,
295 "wr": 2
296 },
297 {
298 "id": 47822,
299 "name": "Finale 2",
300 "mode": 1,
301 "wr": 2
302 },
303 {
304 "id": 47823,
305 "name": "Finale 3",
306 "mode": 1,
307 "wr": 6
308 },
309 {
310 "id": 47457,
311 "name": "Finale 4",
312 "mode": 1,
313 "wr": 4
314 },
315 {
316 "id": 47740,
317 "name": "Doors",
318 "mode": 2,
319 "wr": 0,
320 "limit": 2
321 },
322 {
323 "id": 47826,
324 "name": "Buttons",
325 "mode": 2,
326 "wr": 2
327 },
328 {
329 "id": 47827,
330 "name": "Lasers",
331 "mode": 2,
332 "wr": 2
333 },
334 {
335 "id": 47830,
336 "name": "Rat Maze",
337 "mode": 2,
338 "wr": 2
339 },
340 {
341 "id": 45466,
342 "name": "Laser Crusher",
343 "mode": 2,
344 "wr": 0
345 },
346 {
347 "id": 46361,
348 "name": "Behind The Scenes",
349 "mode": 2,
350 "wr": 0
351 },
352 {
353 "id": 47832,
354 "name": "Flings",
355 "mode": 2,
356 "wr": 4
357 },
358 {
359 "id": 47834,
360 "name": "Infinifling",
361 "mode": 2,
362 "wr": 0
363 },
364 {
365 "id": 47836,
366 "name": "Team Retrieval",
367 "mode": 2,
368 "wr": 0
369 },
370 {
371 "id": 47838,
372 "name": "Vertical Flings",
373 "mode": 2,
374 "wr": 2
375 },
376 {
377 "id": 47839,
378 "name": "Catapults",
379 "mode": 2,
380 "wr": 4
381 },
382 {
383 "id": 47842,
384 "name": "Multifling",
385 "mode": 2,
386 "wr": 2
387 },
388 {
389 "id": 47843,
390 "name": "Fling Crushers",
391 "mode": 2,
392 "wr": 0
393 },
394 {
395 "id": 47846,
396 "name": "Industrial Fan",
397 "mode": 2,
398 "wr": 0
399 },
400 {
401 "id": 47847,
402 "name": "Cooperative Bridges",
403 "mode": 2,
404 "wr": 3
405 },
406 {
407 "id": 47850,
408 "name": "Bridge Swap",
409 "mode": 2,
410 "wr": 2
411 },
412 {
413 "id": 47855,
414 "name": "Fling Block",
415 "mode": 2,
416 "wr": 0
417 },
418 {
419 "id": 47857,
420 "name": "Catapult Block",
421 "mode": 2,
422 "wr": 4
423 },
424 {
425 "id": 47859,
426 "name": "Bridge Fling",
427 "mode": 2,
428 "wr": 2
429 },
430 {
431 "id": 47860,
432 "name": "Turret Walls",
433 "mode": 2,
434 "wr": 4
435 },
436 {
437 "id": 52641,
438 "name": "Turret Assassin",
439 "mode": 2,
440 "wr": 0
441 },
442 {
443 "id": 52659,
444 "name": "Bridge Testing",
445 "mode": 2,
446 "wr": 0
447 },
448 {
449 "id": 52661,
450 "name": "Cooperative Funnels",
451 "mode": 2,
452 "wr": 0
453 },
454 {
455 "id": 52664,
456 "name": "Funnel Drill",
457 "mode": 2,
458 "wr": 0
459 },
460 {
461 "id": 52666,
462 "name": "Funnel Catch",
463 "mode": 2,
464 "wr": 0
465 },
466 {
467 "id": 52668,
468 "name": "Funnel Laser",
469 "mode": 2,
470 "wr": 0
471 },
472 {
473 "id": 52672,
474 "name": "Cooperative Polarity",
475 "mode": 2,
476 "wr": 0
477 },
478 {
479 "id": 52688,
480 "name": "Funnel Hop",
481 "mode": 2,
482 "wr": 0
483 },
484 {
485 "id": 52690,
486 "name": "Advanced Polarity",
487 "mode": 2,
488 "wr": 0
489 },
490 {
491 "id": 52692,
492 "name": "Funnel Maze",
493 "mode": 2,
494 "wr": 0
495 },
496 {
497 "id": 52778,
498 "name": "Turret Warehouse",
499 "mode": 2,
500 "wr": 0
501 },
502 {
503 "id": 52693,
504 "name": "Repulsion Jumps",
505 "mode": 2,
506 "wr": 0
507 },
508 {
509 "id": 52712,
510 "name": "Double Bounce",
511 "mode": 2,
512 "wr": 2
513 },
514 {
515 "id": 52713,
516 "name": "Bridge Repulsion",
517 "mode": 2,
518 "wr": 2
519 },
520 {
521 "id": 52716,
522 "name": "Wall Repulsion",
523 "mode": 2,
524 "wr": 2
525 },
526 {
527 "id": 52718,
528 "name": "Propulsion Crushers",
529 "mode": 2,
530 "wr": 0
531 },
532 {
533 "id": 52736,
534 "name": "Turret Ninja",
535 "mode": 2,
536 "wr": 0
537 },
538 {
539 "id": 52737,
540 "name": "Propulsion Retrieval",
541 "mode": 2,
542 "wr": 0
543 },
544 {
545 "id": 52739,
546 "name": "Vault Entrance",
547 "mode": 2,
548 "wr": 0
549 },
550 {
551 "id": 49342,
552 "name": "Separation",
553 "mode": 2,
554 "wr": 0
555 },
556 {
557 "id": 49344,
558 "name": "Triple Axis",
559 "mode": 2,
560 "wr": 0
561 },
562 {
563 "id": 49346,
564 "name": "Catapult Catch",
565 "mode": 2,
566 "wr": 0
567 },
568 {
569 "id": 49348,
570 "name": "Bridge Gels",
571 "mode": 2,
572 "wr": 2
573 },
574 {
575 "id": 49350,
576 "name": "Maintenance",
577 "mode": 2,
578 "wr": 0
579 },
580 {
581 "id": 49352,
582 "name": "Bridge Catch",
583 "mode": 2,
584 "wr": 0
585 },
586 {
587 "id": 52758,
588 "name": "Double Lift",
589 "mode": 2,
590 "wr": 0
591 },
592 {
593 "id": 52760,
594 "name": "Gel Maze",
595 "mode": 2,
596 "wr": 0
597 },
598 {
599 "id": 48288,
600 "name": "Crazier Box",
601 "mode": 2,
602 "wr": 0
603 }
604] \ No newline at end of file
diff --git a/rankings/main.go b/rankings/main.go
new file mode 100644
index 0000000..dfafb0c
--- /dev/null
+++ b/rankings/main.go
@@ -0,0 +1,45 @@
1package main
2
3import (
4 "log"
5 "os"
6 "os/signal"
7 "syscall"
8
9 "github.com/joho/godotenv"
10 "github.com/robfig/cron/v3"
11)
12
13func main() {
14 err := godotenv.Load()
15 if err != nil {
16 log.Fatalln("Error loading .env file:", err.Error())
17 }
18 c := cron.New()
19 _, err = c.AddFunc("0 0 * * *", run)
20 if err != nil {
21 log.Fatalln("Error scheduling daily reminder:", err.Error())
22 }
23 c.Start()
24 log.Println("ready for jobs")
25 sc := make(chan os.Signal, 1)
26 signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
27 <-sc
28}
29
30func run() {
31 log.Println("started job")
32 records := readRecords()
33 overrides := readOverrides()
34 players := fetchLeaderboard(records, overrides)
35
36 spRankings := []*Player{}
37 mpRankings := []*Player{}
38 overallRankings := []*Player{}
39
40 log.Println("filtering rankings")
41 filterRankings(&spRankings, &mpRankings, &overallRankings, players)
42
43 log.Println("exporting jsons")
44 exportAll(&spRankings, &mpRankings, &overallRankings)
45}
diff --git a/rankings/models.go b/rankings/models.go
new file mode 100644
index 0000000..1b349b0
--- /dev/null
+++ b/rankings/models.go
@@ -0,0 +1,51 @@
1package main
2
3type Record struct {
4 MapID int `json:"id"`
5 MapName string `json:"name"`
6 MapMode int `json:"mode"`
7 MapWR int `json:"wr"`
8 MapLimit *int `json:"limit"`
9}
10
11type Leaderboard struct {
12 Entries LeaderboardEntries `xml:"entries"`
13}
14
15func (l *Leaderboard) needsAnotherPage(record *Record) bool {
16 if l.Entries.Entry[len(l.Entries.Entry)-1].Score == record.MapWR {
17 return true
18 } else if record.MapLimit != nil && l.Entries.Entry[len(l.Entries.Entry)-1].Score <= *record.MapLimit {
19 return true
20 }
21 return false
22}
23
24type LeaderboardEntries struct {
25 Entry []LeaderboardEntry `xml:"entry"`
26}
27
28type LeaderboardEntry struct {
29 SteamID string `xml:"steamid"`
30 Score int `xml:"score"`
31}
32
33type Player struct {
34 Username string `json:"user_name"`
35 AvatarLink string `json:"avatar_link"`
36 SteamID string `json:"steam_id"`
37 Entries []PlayerEntry `json:"-"`
38 SpScoreCount int `json:"sp_score"`
39 MpScoreCount int `json:"mp_score"`
40 OverallScoreCount int `json:"overall_score"`
41 SpRank int `json:"sp_rank"`
42 MpRank int `json:"mp_rank"`
43 OverallRank int `json:"overall_rank"`
44 SpIterations int `json:"-"`
45 MpIterations int `json:"-"`
46}
47
48type PlayerEntry struct {
49 MapID int
50 MapScore int
51}
diff --git a/rankings/prefetch.go b/rankings/prefetch.go
new file mode 100644
index 0000000..487a76f
--- /dev/null
+++ b/rankings/prefetch.go
@@ -0,0 +1,44 @@
1package main
2
3import (
4 "encoding/json"
5 "io"
6 "log"
7 "os"
8)
9
10func readRecords() *[]Record {
11 recordsFile, err := os.Open("./input/records.json")
12 if err != nil {
13 log.Fatalln(err.Error())
14 }
15 defer recordsFile.Close()
16 recordFileBytes, err := io.ReadAll(recordsFile)
17 if err != nil {
18 log.Fatalln(err.Error())
19 }
20 records := []Record{}
21 err = json.Unmarshal(recordFileBytes, &records)
22 if err != nil {
23 log.Fatalln(err.Error())
24 }
25 return &records
26}
27
28func readOverrides() *map[string]map[string]int {
29 overridesFile, err := os.Open("./input/overrides.json")
30 if err != nil {
31 log.Fatalln(err.Error())
32 }
33 defer overridesFile.Close()
34 overridesFileBytes, err := io.ReadAll(overridesFile)
35 if err != nil {
36 log.Fatalln(err.Error())
37 }
38 overrides := map[string]map[string]int{}
39 err = json.Unmarshal(overridesFileBytes, &overrides)
40 if err != nil {
41 log.Fatalln(err.Error())
42 }
43 return &overrides
44}