forked from sgt-kabukiman/srapi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgame.go
More file actions
434 lines (349 loc) · 12.5 KB
/
game.go
File metadata and controls
434 lines (349 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
// Copyright (c) 2015, Sgt. Kabukiman | MIT licensed
package srapi
import (
"net/url"
"strconv"
"time"
)
// Game represents a single game or romhack.
type Game struct {
// unique ID of this game
ID string
// contains the japanese and international names; japanese is relatively
// rare, international names are always present
Names struct {
International string
Japanese string
}
// unique abbreviation of the game, e.g. "smw" for Super Mario World
Abbreviation string
// link to the game page on speedrun.com
Weblink string
// year in which the game was released
Released int
// ruleset for the game
Ruleset struct {
ShowMilliseconds bool `json:"show-milliseconds"`
RequireVerification bool `json:"require-verification"`
RequireVideo bool `json:"require-video"`
RunTimes []TimingMethod `json:"run-times"`
DefaultTime TimingMethod `json:"default-time"`
EmulatorsAllowed bool `json:"emulators-allowed"`
}
// whether or not this is a romhack
Romhack bool
// date and time when the game was added on speedrun.com; can be an empty
// string for old games
Created *time.Time
// list of assets (images) for the game page design on speedrun.com, like
// icons for trophies, background images etc.
Assets map[string]*AssetLink
// API links to related resources
Links []Link
// do not use this field directly, use the available methods
PlatformsData interface{} `json:"platforms"`
// do not use this field directly, use the available methods
RegionsData interface{} `json:"regions"`
// do not use this field directly, use the available methods
ModeratorsData interface{} `json:"moderators"`
// do not use this field directly, use the available methods
CategoriesData interface{} `json:"categories"`
// do not use this field directly, use the available methods
LevelsData interface{} `json:"levels"`
// do not use this field directly, use the available methods
VariablesData interface{} `json:"variables"`
}
// toGame transforms a data blob to a Game struct, if possible.
// Returns nil if casting the data was not successful or if data was nil.
func toGame(data interface{}, isResponse bool) *Game {
if data == nil {
return nil
}
if isResponse {
dest := gameResponse{}
if recast(data, &dest) == nil {
return &dest.Data
}
} else {
dest := Game{}
if recast(data, &dest) == nil {
return &dest
}
}
return nil
}
// gameResponse models the actual API response from the server
type gameResponse struct {
// the one game contained in the response
Data Game
}
// GameByID tries to fetch a single game or romhack, identified by its ID.
// When an error is returned, the returned game is nil.
func GameByID(id string, embeds string) (*Game, *Error) {
return fetchGame(request{"GET", "/games/" + id, nil, nil, nil, embeds})
}
// GameByAbbreviation tries to fetch a single game or romhack, identified by its
// abbreviation. This is convenient for resolving abbreviations, but as they can
// change (in constrast to the ID, which is fixed), it should be used with
// caution.
// When an error is returned, the returned game is nil.
func GameByAbbreviation(abbrev string, embeds string) (*Game, *Error) {
return GameByID(abbrev, embeds)
}
// Series fetches the series the game belongs to. This returns only nil if there
// is broken data on speedrun.com.
func (g *Game) Series(embeds string) (*Series, *Error) {
return fetchOneSeriesLink(firstLink(g, "series"), embeds)
}
// PlatformIDs returns a list of platform IDs this game is assigned to. This is
// always available; when the platforms are embedded, the IDs are collected from
// the respective objects.
func (g *Game) PlatformIDs() ([]string, *Error) {
var result []string
switch asserted := g.PlatformsData.(type) {
// list of IDs (strings)
case []interface{}:
for _, something := range asserted {
id, okay := something.(string)
if okay {
result = append(result, id)
}
}
// sub-resource due to embeds, aka "{data:....}"
// TODO: skip the conversion back and forth and just assert our way through the available data
case map[string]interface{}:
platforms, err := g.Platforms()
if err != nil {
return result, err
}
for _, platform := range platforms.Platforms() {
result = append(result, platform.ID)
}
}
return result, nil
}
// Platforms returns a list of pointers to platform structs. If platforms were
// not embedded, they are fetched from the network, causing one request per
// platform.
func (g *Game) Platforms() (*PlatformCollection, *Error) {
var result *PlatformCollection
switch asserted := g.PlatformsData.(type) {
// list of IDs (strings)
case []interface{}:
ids, err := g.PlatformIDs()
if err != nil {
return result, err
}
result = &PlatformCollection{}
for _, id := range ids {
platform, err := PlatformByID(id)
if err != nil {
return result, err
}
result.Data = append(result.Data, *platform)
}
// sub-resource due to embeds, aka "{data:....}"
case map[string]interface{}:
result = toPlatformCollection(asserted)
}
return result, nil
}
// RegionIDs returns a list of region IDs this game is assigned to. This is
// always available; when the regions are embedded, the IDs are collected from
// the respective objects.
func (g *Game) RegionIDs() ([]string, *Error) {
var result []string
switch asserted := g.RegionsData.(type) {
// list of IDs (strings)
case []interface{}:
for _, something := range asserted {
id, okay := something.(string)
if okay {
result = append(result, id)
}
}
// sub-resource due to embeds, aka "{data:....}"
// TODO: skip the conversion back and forth and just assert our way through the available data
case map[string]interface{}:
regions, err := g.Regions()
if err != nil {
return result, err
}
for _, region := range regions.Regions() {
result = append(result, region.ID)
}
}
return result, nil
}
// Regions returns a list of pointers to region structs. If regions were
// not embedded, they are fetched from the network, causing one request per
// region.
func (g *Game) Regions() (*RegionCollection, *Error) {
var result *RegionCollection
switch asserted := g.RegionsData.(type) {
// list of IDs (strings)
case []interface{}:
ids, err := g.RegionIDs()
if err != nil {
return result, err
}
result = &RegionCollection{}
for _, id := range ids {
region, err := RegionByID(id)
if err != nil {
return result, err
}
result.Data = append(result.Data, *region)
}
// sub-resource due to embeds, aka "{data:....}"
case map[string]interface{}:
result = toRegionCollection(asserted)
}
return result, nil
}
// Categories returns the list of categories for this game. If they were not
// embedded, one additional request is performed and only then are filter and
// sort taken into account.
func (g *Game) Categories(filter *CategoryFilter, sort *Sorting, embeds string) (*CategoryCollection, *Error) {
if g.CategoriesData == nil {
return fetchCategoriesLink(firstLink(g, "categories"), filter, sort, embeds)
}
return toCategoryCollection(g.CategoriesData), nil
}
// Levels returns the list of levels for this game. If they were not embedded,
// one additional request is performed and only then is sort taken into account.
func (g *Game) Levels(sort *Sorting, embeds string) (*LevelCollection, *Error) {
if g.LevelsData == nil {
return fetchLevelsLink(firstLink(g, "levels"), nil, sort, embeds)
}
return toLevelCollection(g.LevelsData), nil
}
// Variables returns the list of variables for this game. If they were not
// embedded, one additional request is performed and only then is sort taken
// into account.
func (g *Game) Variables(sort *Sorting) (*VariableCollection, *Error) {
if g.VariablesData == nil {
return fetchVariablesLink(firstLink(g, "variables"), nil, sort)
}
return toVariableCollection(g.VariablesData), nil
}
// Romhacks returns a game collection containing the romhacks for the game.
// It always returns a collection, even when there are no romhacks or the game
// is itself a romhack.
func (g *Game) Romhacks(embeds string) (*GameCollection, *Error) {
return fetchGamesLink(firstLink(g, "romhacks"), nil, nil, embeds)
}
// ModeratorMap returns a map of user IDs to their respective moderation levels.
// Note that due to limitations of the speedrun.com API, the mod levels are not
// available when moderators have been embedded. In this case, the resulting
// map containts UnknownModLevel for every user. If you need both, there is no
// other way than to perform two requests.
func (g *Game) ModeratorMap() map[string]GameModLevel {
return recastToModeratorMap(g.ModeratorsData)
}
// Moderators returns a list of users that are moderators of the game. If
// moderators were not embedded, they will be fetched individually from the
// network.
func (g *Game) Moderators() (*UserCollection, *Error) {
return recastToModerators(g.ModeratorsData)
}
// PrimaryLeaderboard fetches the primary leaderboard, if any, for the game.
// The result can be nil.
func (g *Game) PrimaryLeaderboard(options *LeaderboardOptions, embeds string) (*Leaderboard, *Error) {
return fetchLeaderboardLink(firstLink(g, "leaderboard"), options, embeds)
}
// Records fetches a list of leaderboards for the game. This includes (by default)
// full-game and per-level leaderboards and is therefore paginated as a collection.
// This function always returns a LeaderboardCollection.
func (g *Game) Records(filter *LeaderboardFilter, embeds string) (*LeaderboardCollection, *Error) {
return fetchLeaderboardsLink(firstLink(g, "records"), filter, nil, embeds)
}
// Runs fetches a list of runs done in the given game, optionally filtered
// and sorted. This function always returns a RunCollection.
func (g *Game) Runs(filter *RunFilter, sort *Sorting, embeds string) (*RunCollection, *Error) {
return fetchRunsLink(firstLink(g, "runs"), filter, sort, embeds)
}
// for the 'hasLinks' interface
func (g *Game) links() []Link {
return g.Links
}
// GameFilter represents the possible filtering options when fetching a list
// of games.
type GameFilter struct {
Name string
Abbreviation string
Released int
Platform string
Region string
Moderator string
Romhack OptionalFlag
}
// applyToURL merged the filter into a URL.
func (gf *GameFilter) applyToURL(u *url.URL) {
if gf == nil {
return
}
values := u.Query()
if len(gf.Name) > 0 {
values.Set("name", gf.Name)
}
if len(gf.Abbreviation) > 0 {
values.Set("abbreviation", gf.Abbreviation)
}
if gf.Released > 0 {
values.Set("released", strconv.Itoa(gf.Released))
}
if len(gf.Platform) > 0 {
values.Set("platform", gf.Platform)
}
if len(gf.Region) > 0 {
values.Set("region", gf.Region)
}
if len(gf.Moderator) > 0 {
values.Set("moderator", gf.Moderator)
}
gf.Romhack.applyToQuery("romhack", &values)
u.RawQuery = values.Encode()
}
// Games retrieves a collection of games from the entire set of games on
// speedrun.com. In most cases, you will filter the game, as paging through
// *all* games takes A LOT of requests. For this, you should use BulkMode, which
// is not yet supported by this API.
func Games(f *GameFilter, s *Sorting, c *Cursor, embeds string) (*GameCollection, *Error) {
return fetchGames(request{"GET", "/games", f, s, c, embeds})
}
// fetchGame fetches a single game from the network. If the request failed,
// the returned game is nil. Otherwise, the error is nil.
func fetchGame(request request) (*Game, *Error) {
result := &gameResponse{}
err := httpClient.do(request, result)
if err != nil {
return nil, err
}
return &result.Data, nil
}
// fetchGameLink tries to fetch a given link and interpret the response as
// a single game. If the link is nil or the game could not be fetched,
// nil is returned.
func fetchGameLink(link requestable, embeds string) (*Game, *Error) {
if !link.exists() {
return nil, nil
}
return fetchGame(link.request(nil, nil, embeds))
}
// fetchGames fetches a list of games from the network. It always
// returns a collection, even when an error is returned.
func fetchGames(request request) (*GameCollection, *Error) {
result := &GameCollection{}
err := httpClient.do(request, result)
return result, err
}
// fetchGamesLink tries to fetch a given link and interpret the response as
// a list of games. It always returns a collection, even when an error is
// returned or the given link is nil.
func fetchGamesLink(link requestable, filter filter, sort *Sorting, embeds string) (*GameCollection, *Error) {
if !link.exists() {
return &GameCollection{}, nil
}
return fetchGames(link.request(filter, sort, embeds))
}