diff --git a/CHANGELOG.md b/CHANGELOG.md index 439425edb0..79ddaed8f2 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [keep a changelog](http://keepachangelog.com) and this pr ### Added - Allow HTTP key to be read from an HTTP request's Basic auth header if present. - Add prefix search for storage keys in console (key%). +- Runtime functions to build a leaderboardList cursor to start listing from a given rank. ### Changed - Use Steam partner API instead of public API for Steam profiles and friends requests. diff --git a/go.mod b/go.mod index 681fb2eea6..2a6bd18306 100644 --- a/go.mod +++ b/go.mod @@ -83,3 +83,5 @@ require ( google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect ) + +replace github.com/heroiclabs/nakama-common => ../nakama-common diff --git a/server/leaderboard_rank_cache.go b/server/leaderboard_rank_cache.go index 6b159df8e9..b174540026 100644 --- a/server/leaderboard_rank_cache.go +++ b/server/leaderboard_rank_cache.go @@ -17,6 +17,7 @@ package server import ( "context" "database/sql" + "errors" "fmt" "runtime" "sync" @@ -31,6 +32,7 @@ import ( type LeaderboardRankCache interface { Get(leaderboardId string, sortOrder int, score, subscore, expiryUnix int64, ownerID uuid.UUID) int64 + GetDataByRank(leaderboardId string, expiryUnix int64, sortOrder int, rank int64) (ownerID uuid.UUID, score, subscore int64, err error) Fill(leaderboardId string, sortOrder int, expiryUnix int64, records []*api.LeaderboardRecord) int64 Insert(leaderboardId string, sortOrder int, score, subscore int64, oldScore, oldSubscore *int64, expiryUnix int64, ownerID uuid.UUID) int64 Delete(leaderboardId string, sortOrder int, score, subscore, expiryUnix int64, ownerID uuid.UUID) bool @@ -212,6 +214,43 @@ func (l *LocalLeaderboardRankCache) Get(leaderboardId string, sortOrder int, sco return int64(rank) } +func (l *LocalLeaderboardRankCache) GetDataByRank(leaderboardId string, expiryUnix int64, sortOrder int, rank int64) (ownerID uuid.UUID, score, subscore int64, err error) { + if l.blacklistAll { + return uuid.Nil, 0, 0, errors.New("rank cache is disabled") + } + if _, ok := l.blacklistIds[leaderboardId]; ok { + return uuid.Nil, 0, 0, fmt.Errorf("rank cache is disabled for leaderboard: %s", leaderboardId) + } + key := LeaderboardWithExpiry{LeaderboardId: leaderboardId, Expiry: expiryUnix} + l.RLock() + rankCache, ok := l.cache[key] + l.RUnlock() + if !ok { + return uuid.Nil, 0, 0, fmt.Errorf("rank cache for leaderboard %q with expiry %d not found", leaderboardId, expiryUnix) + } + + recordData := rankCache.cache.GetElementByRank(int(rank)) + if recordData == nil { + return uuid.Nil, 0, 0, fmt.Errorf("rank entry %d not found for leaderboard %q with expiry %d", rank, leaderboardId, expiryUnix) + } + + if sortOrder == LeaderboardSortOrderDescending { + data, ok := recordData.Value.(RankDesc) + if !ok { + return uuid.Nil, 0, 0, fmt.Errorf("failed to type assert rank cache data") + } + + return data.OwnerId, data.Score, data.Subscore, nil + } else { + data, ok := recordData.Value.(RankAsc) + if !ok { + return uuid.Nil, 0, 0, fmt.Errorf("failed to type assert rank cache data") + } + + return data.OwnerId, data.Score, data.Subscore, nil + } +} + func (l *LocalLeaderboardRankCache) Fill(leaderboardId string, sortOrder int, expiryUnix int64, records []*api.LeaderboardRecord) int64 { if l.blacklistAll { // If all rank caching is disabled. @@ -508,7 +547,6 @@ func leaderboardCacheInitWorker( } func newRank(sortOrder int, score, subscore int64, ownerID uuid.UUID) skiplist.Interface { - if sortOrder == LeaderboardSortOrderDescending { return RankDesc{ OwnerId: ownerID, diff --git a/server/runtime_go_nakama.go b/server/runtime_go_nakama.go index f3f4579af9..cff25ef3cf 100644 --- a/server/runtime_go_nakama.go +++ b/server/runtime_go_nakama.go @@ -2361,6 +2361,60 @@ func (n *RuntimeGoNakamaModule) LeaderboardRecordsList(ctx context.Context, id s return list.Records, list.OwnerRecords, list.NextCursor, list.PrevCursor, nil } +// @group leaderboards +// @summary Build a cursor to be used with leaderboardRecordsList to fetch records starting at a given rank. Only available if rank cache is not disabled for the leaderboard. +// @param leaderboardID(type=string) The unique identifier of the leaderboard. +// @param rank(type=int64) The rank to start listing leaderboard records from. +// @param overrideExpiry(type=int64) Records with expiry in the past are not returned unless within this defined limit. Must be equal or greater than 0. +// @return leaderboardListCursor(string) A string cursor to be used with leaderboardRecordsList. +// @return error(error) An optional error value if an error occurred. +func (n *RuntimeGoNakamaModule) LeaderboardRecordsListCursorFromRank(id string, rank, expiry int64) (string, error) { + if id == "" { + return "", errors.New("invalid leaderboard id") + } + + if expiry < 0 { + return "", errors.New("expects expiry to equal or greater than 0") + } + + l := n.leaderboardCache.Get(id) + if l == nil { + return "", ErrLeaderboardNotFound + } + + expiryTime, ok := calculateExpiryOverride(expiry, l) + if !ok { + return "", errors.New("invalid expiry") + } + + rank-- // Fetch previous entry to include requested rank in the results + if rank == 0 { + return "", nil + } + + ownerId, score, subscore, err := n.leaderboardRankCache.GetDataByRank(id, expiryTime, l.SortOrder, rank) + if err != nil { + return "", fmt.Errorf("failed to get cursor from rank: %s", err.Error()) + } + + cursor := &leaderboardRecordListCursor{ + IsNext: true, + LeaderboardId: id, + ExpiryTime: expiryTime, + Score: score, + Subscore: subscore, + OwnerId: ownerId.String(), + Rank: rank, + } + + cursorStr, err := marshalLeaderboardRecordsListCursor(cursor) + if err != nil { + return "", fmt.Errorf("failed to marshal leaderboard cursor: %s", err.Error()) + } + + return cursorStr, nil +} + // @group leaderboards // @summary Use the preconfigured operator for the given leaderboard to submit a score for a particular user. // @param ctx(type=context.Context) The context object represents information about the server and requester. diff --git a/server/runtime_javascript_nakama.go b/server/runtime_javascript_nakama.go index 5f967f22f2..706ab98bb3 100644 --- a/server/runtime_javascript_nakama.go +++ b/server/runtime_javascript_nakama.go @@ -142,157 +142,158 @@ func (n *runtimeJavascriptNakamaModule) Constructor(r *goja.Runtime) (*goja.Obje func (n *runtimeJavascriptNakamaModule) mappings(r *goja.Runtime) map[string]func(goja.FunctionCall) goja.Value { return map[string]func(goja.FunctionCall) goja.Value{ - "event": n.event(r), - "metricsCounterAdd": n.metricsCounterAdd(r), - "metricsGaugeSet": n.metricsGaugeSet(r), - "metricsTimerRecord": n.metricsTimerRecord(r), - "uuidv4": n.uuidV4(r), - "cronNext": n.cronNext(r), - "sqlExec": n.sqlExec(r), - "sqlQuery": n.sqlQuery(r), - "httpRequest": n.httpRequest(r), - "base64Encode": n.base64Encode(r), - "base64Decode": n.base64Decode(r), - "base64UrlEncode": n.base64UrlEncode(r), - "base64UrlDecode": n.base64UrlDecode(r), - "base16Encode": n.base16Encode(r), - "base16Decode": n.base16Decode(r), - "jwtGenerate": n.jwtGenerate(r), - "aes128Encrypt": n.aes128Encrypt(r), - "aes128Decrypt": n.aes128Decrypt(r), - "aes256Encrypt": n.aes256Encrypt(r), - "aes256Decrypt": n.aes256Decrypt(r), - "md5Hash": n.md5Hash(r), - "sha256Hash": n.sha256Hash(r), - "hmacSha256Hash": n.hmacSHA256Hash(r), - "rsaSha256Hash": n.rsaSHA256Hash(r), - "bcryptHash": n.bcryptHash(r), - "bcryptCompare": n.bcryptCompare(r), - "authenticateApple": n.authenticateApple(r), - "authenticateCustom": n.authenticateCustom(r), - "authenticateDevice": n.authenticateDevice(r), - "authenticateEmail": n.authenticateEmail(r), - "authenticateFacebook": n.authenticateFacebook(r), - "authenticateFacebookInstantGame": n.authenticateFacebookInstantGame(r), - "authenticateGameCenter": n.authenticateGameCenter(r), - "authenticateGoogle": n.authenticateGoogle(r), - "authenticateSteam": n.authenticateSteam(r), - "authenticateTokenGenerate": n.authenticateTokenGenerate(r), - "accountGetId": n.accountGetId(r), - "accountsGetId": n.accountsGetId(r), - "accountUpdateId": n.accountUpdateId(r), - "accountDeleteId": n.accountDeleteId(r), - "accountExportId": n.accountExportId(r), - "usersGetId": n.usersGetId(r), - "usersGetUsername": n.usersGetUsername(r), - "usersGetRandom": n.usersGetRandom(r), - "usersBanId": n.usersBanId(r), - "usersUnbanId": n.usersUnbanId(r), - "linkApple": n.linkApple(r), - "linkCustom": n.linkCustom(r), - "linkDevice": n.linkDevice(r), - "linkEmail": n.linkEmail(r), - "linkFacebook": n.linkFacebook(r), - "linkFacebookInstantGame": n.linkFacebookInstantGame(r), - "linkGameCenter": n.linkGameCenter(r), - "linkGoogle": n.linkGoogle(r), - "linkSteam": n.linkSteam(r), - "unlinkApple": n.unlinkApple(r), - "unlinkCustom": n.unlinkCustom(r), - "unlinkDevice": n.unlinkDevice(r), - "unlinkEmail": n.unlinkEmail(r), - "unlinkFacebook": n.unlinkFacebook(r), - "unlinkFacebookInstantGame": n.unlinkFacebookInstantGame(r), - "unlinkGameCenter": n.unlinkGameCenter(r), - "unlinkGoogle": n.unlinkGoogle(r), - "unlinkSteam": n.unlinkSteam(r), - "streamUserList": n.streamUserList(r), - "streamUserGet": n.streamUserGet(r), - "streamUserJoin": n.streamUserJoin(r), - "streamUserUpdate": n.streamUserUpdate(r), - "streamUserLeave": n.streamUserLeave(r), - "streamUserKick": n.streamUserKick(r), - "streamCount": n.streamCount(r), - "streamClose": n.streamClose(r), - "streamSend": n.streamSend(r), - "streamSendRaw": n.streamSendRaw(r), - "sessionDisconnect": n.sessionDisconnect(r), - "sessionLogout": n.sessionLogout(r), - "matchCreate": n.matchCreate(r), - "matchGet": n.matchGet(r), - "matchList": n.matchList(r), - "matchSignal": n.matchSignal(r), - "notificationSend": n.notificationSend(r), - "notificationsSend": n.notificationsSend(r), - "notificationSendAll": n.notificationSendAll(r), - "notificationsDelete": n.notificationsDelete(r), - "walletUpdate": n.walletUpdate(r), - "walletsUpdate": n.walletsUpdate(r), - "walletLedgerUpdate": n.walletLedgerUpdate(r), - "walletLedgerList": n.walletLedgerList(r), - "storageList": n.storageList(r), - "storageRead": n.storageRead(r), - "storageWrite": n.storageWrite(r), - "storageDelete": n.storageDelete(r), - "multiUpdate": n.multiUpdate(r), - "leaderboardCreate": n.leaderboardCreate(r), - "leaderboardDelete": n.leaderboardDelete(r), - "leaderboardList": n.leaderboardList(r), - "leaderboardRecordsList": n.leaderboardRecordsList(r), - "leaderboardRecordWrite": n.leaderboardRecordWrite(r), - "leaderboardRecordDelete": n.leaderboardRecordDelete(r), - "leaderboardsGetId": n.leaderboardsGetId(r), - "leaderboardRecordsHaystack": n.leaderboardRecordsHaystack(r), - "purchaseValidateApple": n.purchaseValidateApple(r), - "purchaseValidateGoogle": n.purchaseValidateGoogle(r), - "purchaseValidateHuawei": n.purchaseValidateHuawei(r), - "purchaseGetByTransactionId": n.purchaseGetByTransactionId(r), - "purchasesList": n.purchasesList(r), - "subscriptionValidateApple": n.subscriptionValidateApple(r), - "subscriptionValidateGoogle": n.subscriptionValidateGoogle(r), - "subscriptionGetByProductId": n.subscriptionGetByProductId(r), - "subscriptionsList": n.subscriptionsList(r), - "tournamentCreate": n.tournamentCreate(r), - "tournamentDelete": n.tournamentDelete(r), - "tournamentAddAttempt": n.tournamentAddAttempt(r), - "tournamentJoin": n.tournamentJoin(r), - "tournamentList": n.tournamentList(r), - "tournamentsGetId": n.tournamentsGetId(r), - "tournamentRecordsList": n.tournamentRecordsList(r), - "tournamentRecordWrite": n.tournamentRecordWrite(r), - "tournamentRecordDelete": n.tournamentRecordDelete(r), - "tournamentRecordsHaystack": n.tournamentRecordsHaystack(r), - "groupsGetId": n.groupsGetId(r), - "groupCreate": n.groupCreate(r), - "groupUpdate": n.groupUpdate(r), - "groupDelete": n.groupDelete(r), - "groupUsersKick": n.groupUsersKick(r), - "groupUsersList": n.groupUsersList(r), - "userGroupsList": n.userGroupsList(r), - "friendsList": n.friendsList(r), - "friendsAdd": n.friendsAdd(r), - "friendsDelete": n.friendsDelete(r), - "friendsBlock": n.friendsBlock(r), - "groupUserJoin": n.groupUserJoin(r), - "groupUserLeave": n.groupUserLeave(r), - "groupUsersAdd": n.groupUsersAdd(r), - "groupUsersBan": n.groupUsersBan(r), - "groupUsersPromote": n.groupUsersPromote(r), - "groupUsersDemote": n.groupUsersDemote(r), - "groupsList": n.groupsList(r), - "groupsGetRandom": n.groupsGetRandom(r), - "fileRead": n.fileRead(r), - "localcacheGet": n.localcacheGet(r), - "localcachePut": n.localcachePut(r), - "localcacheDelete": n.localcacheDelete(r), - "channelMessageSend": n.channelMessageSend(r), - "channelMessageUpdate": n.channelMessageUpdate(r), - "channelMessageRemove": n.channelMessageRemove(r), - "channelMessagesList": n.channelMessagesList(r), - "channelIdBuild": n.channelIdBuild(r), - "binaryToString": n.binaryToString(r), - "stringToBinary": n.stringToBinary(r), - "storageIndexList": n.storageIndexList(r), + "event": n.event(r), + "metricsCounterAdd": n.metricsCounterAdd(r), + "metricsGaugeSet": n.metricsGaugeSet(r), + "metricsTimerRecord": n.metricsTimerRecord(r), + "uuidv4": n.uuidV4(r), + "cronNext": n.cronNext(r), + "sqlExec": n.sqlExec(r), + "sqlQuery": n.sqlQuery(r), + "httpRequest": n.httpRequest(r), + "base64Encode": n.base64Encode(r), + "base64Decode": n.base64Decode(r), + "base64UrlEncode": n.base64UrlEncode(r), + "base64UrlDecode": n.base64UrlDecode(r), + "base16Encode": n.base16Encode(r), + "base16Decode": n.base16Decode(r), + "jwtGenerate": n.jwtGenerate(r), + "aes128Encrypt": n.aes128Encrypt(r), + "aes128Decrypt": n.aes128Decrypt(r), + "aes256Encrypt": n.aes256Encrypt(r), + "aes256Decrypt": n.aes256Decrypt(r), + "md5Hash": n.md5Hash(r), + "sha256Hash": n.sha256Hash(r), + "hmacSha256Hash": n.hmacSHA256Hash(r), + "rsaSha256Hash": n.rsaSHA256Hash(r), + "bcryptHash": n.bcryptHash(r), + "bcryptCompare": n.bcryptCompare(r), + "authenticateApple": n.authenticateApple(r), + "authenticateCustom": n.authenticateCustom(r), + "authenticateDevice": n.authenticateDevice(r), + "authenticateEmail": n.authenticateEmail(r), + "authenticateFacebook": n.authenticateFacebook(r), + "authenticateFacebookInstantGame": n.authenticateFacebookInstantGame(r), + "authenticateGameCenter": n.authenticateGameCenter(r), + "authenticateGoogle": n.authenticateGoogle(r), + "authenticateSteam": n.authenticateSteam(r), + "authenticateTokenGenerate": n.authenticateTokenGenerate(r), + "accountGetId": n.accountGetId(r), + "accountsGetId": n.accountsGetId(r), + "accountUpdateId": n.accountUpdateId(r), + "accountDeleteId": n.accountDeleteId(r), + "accountExportId": n.accountExportId(r), + "usersGetId": n.usersGetId(r), + "usersGetUsername": n.usersGetUsername(r), + "usersGetRandom": n.usersGetRandom(r), + "usersBanId": n.usersBanId(r), + "usersUnbanId": n.usersUnbanId(r), + "linkApple": n.linkApple(r), + "linkCustom": n.linkCustom(r), + "linkDevice": n.linkDevice(r), + "linkEmail": n.linkEmail(r), + "linkFacebook": n.linkFacebook(r), + "linkFacebookInstantGame": n.linkFacebookInstantGame(r), + "linkGameCenter": n.linkGameCenter(r), + "linkGoogle": n.linkGoogle(r), + "linkSteam": n.linkSteam(r), + "unlinkApple": n.unlinkApple(r), + "unlinkCustom": n.unlinkCustom(r), + "unlinkDevice": n.unlinkDevice(r), + "unlinkEmail": n.unlinkEmail(r), + "unlinkFacebook": n.unlinkFacebook(r), + "unlinkFacebookInstantGame": n.unlinkFacebookInstantGame(r), + "unlinkGameCenter": n.unlinkGameCenter(r), + "unlinkGoogle": n.unlinkGoogle(r), + "unlinkSteam": n.unlinkSteam(r), + "streamUserList": n.streamUserList(r), + "streamUserGet": n.streamUserGet(r), + "streamUserJoin": n.streamUserJoin(r), + "streamUserUpdate": n.streamUserUpdate(r), + "streamUserLeave": n.streamUserLeave(r), + "streamUserKick": n.streamUserKick(r), + "streamCount": n.streamCount(r), + "streamClose": n.streamClose(r), + "streamSend": n.streamSend(r), + "streamSendRaw": n.streamSendRaw(r), + "sessionDisconnect": n.sessionDisconnect(r), + "sessionLogout": n.sessionLogout(r), + "matchCreate": n.matchCreate(r), + "matchGet": n.matchGet(r), + "matchList": n.matchList(r), + "matchSignal": n.matchSignal(r), + "notificationSend": n.notificationSend(r), + "notificationsSend": n.notificationsSend(r), + "notificationSendAll": n.notificationSendAll(r), + "notificationsDelete": n.notificationsDelete(r), + "walletUpdate": n.walletUpdate(r), + "walletsUpdate": n.walletsUpdate(r), + "walletLedgerUpdate": n.walletLedgerUpdate(r), + "walletLedgerList": n.walletLedgerList(r), + "storageList": n.storageList(r), + "storageRead": n.storageRead(r), + "storageWrite": n.storageWrite(r), + "storageDelete": n.storageDelete(r), + "multiUpdate": n.multiUpdate(r), + "leaderboardCreate": n.leaderboardCreate(r), + "leaderboardDelete": n.leaderboardDelete(r), + "leaderboardList": n.leaderboardList(r), + "leaderboardRecordsList": n.leaderboardRecordsList(r), + "leaderboardRecordsListCursorFromRank": n.leaderboardRecordsListCursorFromRank(r), + "leaderboardRecordWrite": n.leaderboardRecordWrite(r), + "leaderboardRecordDelete": n.leaderboardRecordDelete(r), + "leaderboardsGetId": n.leaderboardsGetId(r), + "leaderboardRecordsHaystack": n.leaderboardRecordsHaystack(r), + "purchaseValidateApple": n.purchaseValidateApple(r), + "purchaseValidateGoogle": n.purchaseValidateGoogle(r), + "purchaseValidateHuawei": n.purchaseValidateHuawei(r), + "purchaseGetByTransactionId": n.purchaseGetByTransactionId(r), + "purchasesList": n.purchasesList(r), + "subscriptionValidateApple": n.subscriptionValidateApple(r), + "subscriptionValidateGoogle": n.subscriptionValidateGoogle(r), + "subscriptionGetByProductId": n.subscriptionGetByProductId(r), + "subscriptionsList": n.subscriptionsList(r), + "tournamentCreate": n.tournamentCreate(r), + "tournamentDelete": n.tournamentDelete(r), + "tournamentAddAttempt": n.tournamentAddAttempt(r), + "tournamentJoin": n.tournamentJoin(r), + "tournamentList": n.tournamentList(r), + "tournamentsGetId": n.tournamentsGetId(r), + "tournamentRecordsList": n.tournamentRecordsList(r), + "tournamentRecordWrite": n.tournamentRecordWrite(r), + "tournamentRecordDelete": n.tournamentRecordDelete(r), + "tournamentRecordsHaystack": n.tournamentRecordsHaystack(r), + "groupsGetId": n.groupsGetId(r), + "groupCreate": n.groupCreate(r), + "groupUpdate": n.groupUpdate(r), + "groupDelete": n.groupDelete(r), + "groupUsersKick": n.groupUsersKick(r), + "groupUsersList": n.groupUsersList(r), + "userGroupsList": n.userGroupsList(r), + "friendsList": n.friendsList(r), + "friendsAdd": n.friendsAdd(r), + "friendsDelete": n.friendsDelete(r), + "friendsBlock": n.friendsBlock(r), + "groupUserJoin": n.groupUserJoin(r), + "groupUserLeave": n.groupUserLeave(r), + "groupUsersAdd": n.groupUsersAdd(r), + "groupUsersBan": n.groupUsersBan(r), + "groupUsersPromote": n.groupUsersPromote(r), + "groupUsersDemote": n.groupUsersDemote(r), + "groupsList": n.groupsList(r), + "groupsGetRandom": n.groupsGetRandom(r), + "fileRead": n.fileRead(r), + "localcacheGet": n.localcacheGet(r), + "localcachePut": n.localcachePut(r), + "localcacheDelete": n.localcacheDelete(r), + "channelMessageSend": n.channelMessageSend(r), + "channelMessageUpdate": n.channelMessageUpdate(r), + "channelMessageRemove": n.channelMessageRemove(r), + "channelMessagesList": n.channelMessagesList(r), + "channelIdBuild": n.channelIdBuild(r), + "binaryToString": n.binaryToString(r), + "stringToBinary": n.stringToBinary(r), + "storageIndexList": n.storageIndexList(r), } } @@ -5218,6 +5219,71 @@ func (n *runtimeJavascriptNakamaModule) leaderboardRecordsList(r *goja.Runtime) } } +// @group leaderboards +// @summary Build a cursor to be used with leaderboardRecordsList to fetch records starting at a given rank. Only available if rank cache is not disabled for the leaderboard. +// @param leaderboardID(type=string) The unique identifier of the leaderboard. +// @param rank(type=number) The rank to start listing leaderboard records from. +// @param overrideExpiry(type=number, optional=true) Records with expiry in the past are not returned unless within this defined limit. Must be equal or greater than 0. +// @return leaderboardListCursor(string) A string cursor to be used with leaderboardRecordsList. +// @return error(error) An optional error value if an error occurred. +func (n *runtimeJavascriptNakamaModule) leaderboardRecordsListCursorFromRank(r *goja.Runtime) func(goja.FunctionCall) goja.Value { + return func(f goja.FunctionCall) goja.Value { + leaderboardId := getJsString(r, f.Argument(0)) + rank := getJsInt(r, f.Argument(1)) + + if leaderboardId == "" { + panic(r.NewTypeError("invalid leaderboard id")) + } + + if rank < 1 { + panic(r.NewTypeError("invalid rank - must be > 1")) + } + + var overrideExpiry int64 + if !goja.IsUndefined(f.Argument(2)) && !goja.IsNull(f.Argument(2)) { + overrideExpiry = getJsInt(r, f.Argument(2)) + } + + l := n.leaderboardCache.Get(leaderboardId) + if l == nil { + panic(r.NewTypeError(ErrLeaderboardNotFound.Error())) + } + + expiryTime, ok := calculateExpiryOverride(overrideExpiry, l) + if !ok { + panic(r.NewTypeError("invalid expiry")) + } + + rank-- // Fetch previous entry to include requested rank in the results + + if rank == 0 { + return r.ToValue("") + } + + ownerId, score, subscore, err := n.rankCache.GetDataByRank(leaderboardId, expiryTime, l.SortOrder, rank) + if err != nil { + panic(r.NewGoError(fmt.Errorf("failed to get cursor from rank: %s", err.Error()))) + } + + cursor := &leaderboardRecordListCursor{ + IsNext: true, + LeaderboardId: leaderboardId, + ExpiryTime: expiryTime, + Score: score, + Subscore: subscore, + OwnerId: ownerId.String(), + Rank: rank, + } + + cursorStr, err := marshalLeaderboardRecordsListCursor(cursor) + if err != nil { + panic(r.NewGoError(fmt.Errorf("failed to marshal leaderboard cursor: %s", err.Error()))) + } + + return r.ToValue(cursorStr) + } +} + // @group leaderboards // @summary Use the preconfigured operator for the given leaderboard to submit a score for a particular user. // @param id(type=string) The unique identifier for the leaderboard to submit to. @@ -5322,7 +5388,7 @@ func (n *runtimeJavascriptNakamaModule) leaderboardRecordDelete(r *goja.Runtime) // @group leaderboards // @summary Fetch one or more leaderboards by ID. -// @param ids(type=string[]) The table array of leaderboard ids. +// @param ids(type=string[]) The array of leaderboard ids. // @return leaderboards(nkruntime.Leaderboard[]) The leaderboard records according to ID. // @return error(error) An optional error value if an error occurred. func (n *runtimeJavascriptNakamaModule) leaderboardsGetId(r *goja.Runtime) func(goja.FunctionCall) goja.Value { diff --git a/server/runtime_lua_nakama.go b/server/runtime_lua_nakama.go index c1257b1738..b5b8c8f804 100644 --- a/server/runtime_lua_nakama.go +++ b/server/runtime_lua_nakama.go @@ -254,56 +254,57 @@ func (n *RuntimeLuaNakamaModule) Loader(l *lua.LState) int { "leaderboard_delete": n.leaderboardDelete, "leaderboard_list": n.leaderboardList, "leaderboard_records_list": n.leaderboardRecordsList, - "leaderboard_record_write": n.leaderboardRecordWrite, - "leaderboard_records_haystack": n.leaderboardRecordsHaystack, - "leaderboard_record_delete": n.leaderboardRecordDelete, - "leaderboards_get_id": n.leaderboardsGetId, - "purchase_validate_apple": n.purchaseValidateApple, - "purchase_validate_google": n.purchaseValidateGoogle, - "purchase_validate_huawei": n.purchaseValidateHuawei, - "purchase_get_by_transaction_id": n.purchaseGetByTransactionId, - "purchases_list": n.purchasesList, - "subscription_validate_apple": n.subscriptionValidateApple, - "subscription_validate_gogle": n.subscriptionValidateGoogle, - "subscription_get_by_product_id": n.subscriptionGetByProductId, - "subscriptions_list": n.subscriptionsList, - "tournament_create": n.tournamentCreate, - "tournament_delete": n.tournamentDelete, - "tournament_add_attempt": n.tournamentAddAttempt, - "tournament_join": n.tournamentJoin, - "tournament_list": n.tournamentList, - "tournaments_get_id": n.tournamentsGetId, - "tournament_records_list": n.tournamentRecordsList, - "tournament_record_write": n.tournamentRecordWrite, - "tournament_record_delete": n.tournamentRecordDelete, - "tournament_records_haystack": n.tournamentRecordsHaystack, - "groups_get_id": n.groupsGetId, - "group_create": n.groupCreate, - "group_update": n.groupUpdate, - "group_delete": n.groupDelete, - "group_user_join": n.groupUserJoin, - "group_user_leave": n.groupUserLeave, - "group_users_add": n.groupUsersAdd, - "group_users_ban": n.groupUsersBan, - "group_users_promote": n.groupUsersPromote, - "group_users_demote": n.groupUsersDemote, - "group_users_list": n.groupUsersList, - "group_users_kick": n.groupUsersKick, - "groups_list": n.groupsList, - "groups_get_random": n.groupsGetRandom, - "user_groups_list": n.userGroupsList, - "friends_list": n.friendsList, - "friends_add": n.friendsAdd, - "friends_delete": n.friendsDelete, - "friends_block": n.friendsBlock, - "file_read": n.fileRead, - "channel_message_send": n.channelMessageSend, - "channel_message_update": n.channelMessageUpdate, - "channel_message_remove": n.channelMessageRemove, - "channel_messages_list": n.channelMessagesList, - "channel_id_build": n.channelIdBuild, - "storage_index_list": n.storageIndexList, - "get_satori": n.getSatori, + "leaderboard_records_list_cursor_from_rank": n.leaderboardRecordsListCursorFromRank, + "leaderboard_record_write": n.leaderboardRecordWrite, + "leaderboard_records_haystack": n.leaderboardRecordsHaystack, + "leaderboard_record_delete": n.leaderboardRecordDelete, + "leaderboards_get_id": n.leaderboardsGetId, + "purchase_validate_apple": n.purchaseValidateApple, + "purchase_validate_google": n.purchaseValidateGoogle, + "purchase_validate_huawei": n.purchaseValidateHuawei, + "purchase_get_by_transaction_id": n.purchaseGetByTransactionId, + "purchases_list": n.purchasesList, + "subscription_validate_apple": n.subscriptionValidateApple, + "subscription_validate_gogle": n.subscriptionValidateGoogle, + "subscription_get_by_product_id": n.subscriptionGetByProductId, + "subscriptions_list": n.subscriptionsList, + "tournament_create": n.tournamentCreate, + "tournament_delete": n.tournamentDelete, + "tournament_add_attempt": n.tournamentAddAttempt, + "tournament_join": n.tournamentJoin, + "tournament_list": n.tournamentList, + "tournaments_get_id": n.tournamentsGetId, + "tournament_records_list": n.tournamentRecordsList, + "tournament_record_write": n.tournamentRecordWrite, + "tournament_record_delete": n.tournamentRecordDelete, + "tournament_records_haystack": n.tournamentRecordsHaystack, + "groups_get_id": n.groupsGetId, + "group_create": n.groupCreate, + "group_update": n.groupUpdate, + "group_delete": n.groupDelete, + "group_user_join": n.groupUserJoin, + "group_user_leave": n.groupUserLeave, + "group_users_add": n.groupUsersAdd, + "group_users_ban": n.groupUsersBan, + "group_users_promote": n.groupUsersPromote, + "group_users_demote": n.groupUsersDemote, + "group_users_list": n.groupUsersList, + "group_users_kick": n.groupUsersKick, + "groups_list": n.groupsList, + "groups_get_random": n.groupsGetRandom, + "user_groups_list": n.userGroupsList, + "friends_list": n.friendsList, + "friends_add": n.friendsAdd, + "friends_delete": n.friendsDelete, + "friends_block": n.friendsBlock, + "file_read": n.fileRead, + "channel_message_send": n.channelMessageSend, + "channel_message_update": n.channelMessageUpdate, + "channel_message_remove": n.channelMessageRemove, + "channel_messages_list": n.channelMessagesList, + "channel_id_build": n.channelIdBuild, + "storage_index_list": n.storageIndexList, + "get_satori": n.getSatori, } mod := l.SetFuncs(l.CreateTable(0, len(functions)), functions) @@ -6735,6 +6736,73 @@ func (n *RuntimeLuaNakamaModule) leaderboardRecordsList(l *lua.LState) int { return leaderboardRecordsToLua(l, records.Records, records.OwnerRecords, records.PrevCursor, records.NextCursor, records.RankCount, false) } +// @group leaderboards +// @summary Build a cursor to be used with leaderboardRecordsList to fetch records starting at a given rank. Only available if rank cache is not disabled for the leaderboard. +// @param leaderboardID(type=string) The unique identifier of the leaderboard. +// @param rank(type=number) The rank to start listing leaderboard records from. +// @param overrideExpiry(type=number, optional=true) Records with expiry in the past are not returned unless within this defined limit. Must be equal or greater than 0. +// @return leaderboardListCursor(string) A string cursor to be used with leaderboardRecordsList. +// @return error(error) An optional error value if an error occurred. +func (n *RuntimeLuaNakamaModule) leaderboardRecordsListCursorFromRank(l *lua.LState) int { + id := l.CheckString(1) + if id == "" { + l.ArgError(1, "expects a leaderboard ID string") + return 0 + } + + rank := l.CheckInt64(2) + if rank < 1 { + l.ArgError(2, "invalid rank - must be > 1") + return 0 + } + + expiryOverride := l.OptInt64(3, 0) + + leaderboard := n.leaderboardCache.Get(id) + if l == nil { + l.RaiseError(ErrLeaderboardNotFound.Error()) + return 0 + } + + expiryTime, ok := calculateExpiryOverride(expiryOverride, leaderboard) + if !ok { + l.RaiseError("invalid expiry") + return 0 + } + + rank-- + + if rank == 0 { + l.Push(lua.LString("")) + return 1 + } + + ownerId, score, subscore, err := n.rankCache.GetDataByRank(id, expiryTime, leaderboard.SortOrder, rank) + if err != nil { + l.RaiseError("failed to get cursor from rank: %s", err.Error()) + return 0 + } + + cursor := &leaderboardRecordListCursor{ + IsNext: true, + LeaderboardId: id, + ExpiryTime: expiryTime, + Score: score, + Subscore: subscore, + OwnerId: ownerId.String(), + Rank: rank, + } + + cursorStr, err := marshalLeaderboardRecordsListCursor(cursor) + if err != nil { + l.RaiseError("failed to marshal leaderboard cursor: %s", err.Error()) + return 0 + } + + l.Push(lua.LString(cursorStr)) + return 1 +} + // @group leaderboards // @summary Use the preconfigured operator for the given leaderboard to submit a score for a particular user. // @param id(type=string) The unique identifier for the leaderboard to submit to. diff --git a/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go b/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go index c0db3a734f..2130625d61 100644 --- a/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go +++ b/vendor/github.com/heroiclabs/nakama-common/runtime/runtime.go @@ -1078,6 +1078,7 @@ type NakamaModule interface { LeaderboardDelete(ctx context.Context, id string) error LeaderboardList(limit int, cursor string) (*api.LeaderboardList, error) LeaderboardRecordsList(ctx context.Context, id string, ownerIDs []string, limit int, cursor string, expiry int64) (records []*api.LeaderboardRecord, ownerRecords []*api.LeaderboardRecord, nextCursor string, prevCursor string, err error) + LeaderboardRecordsListCursorFromRank(id string, rank, overrideExpiry int64) (string, error) LeaderboardRecordWrite(ctx context.Context, id, ownerID, username string, score, subscore int64, metadata map[string]interface{}, overrideOperator *int) (*api.LeaderboardRecord, error) LeaderboardRecordDelete(ctx context.Context, id, ownerID string) error LeaderboardsGetId(ctx context.Context, ids []string) ([]*api.Leaderboard, error) diff --git a/vendor/modules.txt b/vendor/modules.txt index c3876668cd..f1ed06d5b6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -156,7 +156,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/internal/genopena github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options github.com/grpc-ecosystem/grpc-gateway/v2/runtime github.com/grpc-ecosystem/grpc-gateway/v2/utilities -# github.com/heroiclabs/nakama-common v1.28.2-0.20231010150216-b178843845fa +# github.com/heroiclabs/nakama-common v1.28.2-0.20231010150216-b178843845fa => ../nakama-common ## explicit; go 1.19 github.com/heroiclabs/nakama-common/api github.com/heroiclabs/nakama-common/rtapi @@ -440,3 +440,4 @@ gopkg.in/natefinch/lumberjack.v2 # gopkg.in/yaml.v3 v3.0.1 ## explicit gopkg.in/yaml.v3 +# github.com/heroiclabs/nakama-common => ../nakama-common