# 分桶排行榜

**URL:** https://heroiclabs.com/docs/zh/nakama/guides/concepts/bucketed-leaderboards/
**Summary:** 关于实现分桶排行榜的指南，其中玩家使用Nakama的存储引擎、排行榜和锦标赛功能，与有限的其他玩家而非全局排行榜进行比赛。

---


# 分桶排行榜

{{< note "important" >}}
如果还没有阅读[排行榜](../../../concepts/leaderboards/)文档，请阅读并熟悉相关的概念和功能后再继续。
{{< / note >}}

随着游戏玩家群体的扩大，网络游戏中的社交互动出现了一个根本性问题。全局排行榜和一般排行榜逐渐形成静态局面，特定的技能或分数水平几乎没有变化。这会导致新玩家失去或缺乏参与度，从而对游戏失去兴趣。采用分桶排行榜可以解决这一问题。

在分桶排行榜中，玩家不会与所有其他玩家比赛，而是会看到有限的其他玩家（通常为25-50个）。他们与该群组竞争，直到排行榜到期或滚动到另一个开始时间。这些较小的玩家群体被称为队列或“玩家桶”，分桶排行榜的名称因此而产生。

在许多流行的游戏中都可以看到分桶排行榜，其中包括Rovio的Angry Birds系列中的几款游戏。
使用Nakama的[存储引擎](../../../concepts/storage/collections/)、[排行榜](../../../concepts/leaderboards/)和[锦标赛](../../../concepts/tournaments/)功能，可以通过服务器运行时代码、特别是RPC函数在游戏中实现分桶排行榜。

Nakama排行榜API已经允许您输入一组用户ID（或用户名），这些ID将成为筛选器，用于生成排行榜的“分桶视图”，其中只有这些玩家。

这样就只剩下如何为特定玩组成用户ID集合，以及此集合是否被个性化或遵循其他游戏条件。这是成功实现的关键，它取决于游戏的特定机制。您可能希望“视图”显示排名相似的玩家、VIP玩家、用户的朋友或同一地区的玩家。

在本示例中，我们使用“随机”选择的用户作为特定玩家将要看到的分桶。我们首先浏览代码的关键部分，并在最后提供完整的文件供参考。

## 插件分桶排行榜

此处我们创建每周滚动的新的分桶排行榜`Bucketed Weekly #1`。

{{< code type="server" >}}
```go
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
  // Set up the bucketed leaderboards tournament
  id := "bucketed_weekly"
  authoritative := true
  sortOrder := "desc"
  operator := "incr"
  resetSchedule := "0 0 * * 0"
  metadata := map[string]interface{}{}
  title := "Bucketed Weekly #1"
  description := ""
  category := 1
  startTime := 0
  endTime := 0
  duration := 604800
  maxSize := 100000000
  maxNumScore := 100000000
  joinRequired := false

  if err := nk.TournamentCreate(ctx, id, authoritative, sortOrder, operator, resetSchedule, metadata, title, description, category, startTime, endTime, duration, maxSize, maxNumScore, joinRequired); err != nil {
    return err
  }
  if err := initializer.RegisterRpc("get_bucket_records", rpcGetBucketRecordsFn([]string{id}, 2000)); err != nil {
    return err
  }
  return nil
}
```
{{< / code >}}

{{< code type="server" >}}
```typescript
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  // Set up the bucketed leaderboards tournament
  const id = 'bucketed_weekly';
  const authoritative = true;
  const sortOrder = nkruntime.SortOrder.DESCENDING;
  const operator = nkruntime.Operator.INCREMENTAL;
  const duration = 604800;
  const resetSchedule = '0 0 * * 0';
  const metadata = {};
  const title = 'Bucketed Weekly #1';
  const description = '';
  const category = 1;
  const startTime = 0;
  const endTime = 0;
  const maxSize = 100000000;
  const maxNumScore = 10000000;
  const joinRequired = false;

  nk.tournamentCreate(id, authoritative, sortOrder, operator, duration, resetSchedule, metadata, title, description, category, startTime, endTime, maxSize, maxNumScore, joinRequired);
  initializer.registerRpc('get_bucket_records', RpcGetBucketRecords([id], 2000));
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 生成用户分桶

每个玩家都将有一个单独的分桶 — 他们与之对抗的对手组 — 这是单独为他们创建的。首先我们定义分桶存储对象：

{{< code type="server" >}}
```go
// Define the bucketed leaderboard storage object
type userBucketStorageObject struct {
  ResetTimeUnix uint32   `json:"resetTimeUnix"`
  UserIDs       []string `json:"userIds"`
}
```
{{< / code >}}

{{< code type="server" >}}
```typescript
// Define the bucketed leaderboard storage object
interface UserBucketStorageObject {
  resetTimeUnix: number,
  userIds: string[]
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

然后我们定义一个RPC函数来获取玩家的ID并检查是否存在任何分桶。如找到分桶则获取它，如未找到则创建新的分桶。最后，我们获取实际排行榜。

{{< code type="server" >}}
```go
// Get a user's bucket (records) and generate a new bucket if needed
func RpcGetBucketRecordsFn(ids []string, bucketSize int) func(context.Context, runtime.Logger, *sql.DB, runtime.NakamaModule, string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    if len(payload) > 0 {
      return "", ErrNoInputAllowed
    }

    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      return "", ErrNoUserIdFound
    }

    collection := "buckets"
    key := "bucket"

    objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{
      {
        Collection: collection,
        Key:        key,
        UserID:     userID,
      },
    })
    if err != nil {
      logger.Error("nk.StorageRead error: %v", err)
      return "", ErrInternalError
    }

    // Fetch any existing bucket or create one if none exist
    userBucket := &userBucketStorageObject{ResetTimeUnix: 0, UserIDs: []string{}}
    if len(objects) > 0 {
      if err := json.Unmarshal([]byte(objects[0].GetValue()), userBucket); err != nil {
        logger.Error("json.Unmarshal error: %v", err)
        return "", ErrUnmarshal
      }
    }

    // Fetch the leaderboard
    leaderboards, err := nk.LeaderboardsGetId(ctx, ids)
    if err != nil {
      logger.Error("nk.LeaderboardsGetId error: %v", err)
      return "", ErrInternalError
    }
```
{{< / code >}}

{{< code type="server" >}}
```typescript
const RpcGetBucketRecordsFn = function (ids: string[], bucketSize: number) {
  return function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
    if (!payload) {
      throw new Error('no payload input allowed');
    }

    const collection = 'buckets';
    const key = 'bucket';

    const objects = nk.storageRead([
      {
        collection,
        key,
        userId: ctx.userId
      }
    ]);

    // Fetch any existing bucket or create one if none exist
    let userBucket: UserBucketStorageObject = { resetTimeUnix: 0, userIds: [] };

    if (objects.length > 0) {
      userBucket = objects[0].value as UserBucketStorageObject;
    }

    // Fetch the tournament leaderboard
    const leaderboards = nk.tournamentsGetId(ids);
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 检查分桶状态

在继续之前，我们会检查排行榜是否已重置或是否没有对手。如果是其中一种情况，必须生成一个新的对手组。

{{< code type="server" >}}
```go
// Leaderboard has reset or no current bucket exists for user
if userBucket.ResetTimeUnix != leaderboards[0].GetPrevReset() || len(userBucket.UserIDs) < 1 {
  logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

  // Code below goes here...
}
```
{{< / code >}}

{{< code type="server" >}}
```typescript
// Leaderboard has reset or no current bucket exists for user
if (userBucket.resetTimeUnix != leaderboards[0].endActive || userBucket.userIds.length < 1) {
  logger.debug(`RpcGetBucketRecordsFn new bucket for ${ctx.userId}`);

  // Code below goes here
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 生成对手组

为生成随机对手列表，我们使用Nakama 3.5.0中的`UsersGetRandom`函数。

{{< code type="server" >}}
```go
userBucket.UserIDs = nil
logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

users, err := nk.UsersGetRandom(ctx, bucketSize)
if err != nil {
  logger.Error("Error getting random users.")
  return "", ErrInternalError
}

for _, user := range users {
  userBucket.UserIDs = append(userBucket.UserIDs, user.Id)
}
```
{{< / code >}}

{{< code type="server" >}}
```typescript
const users = nk.usersGetRandom(bucketSize);
users.forEach(function (user: nkruntime.User) {
  userBucket.userIds.push(user.userId);
});
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

如果最终想要得到一个明确定义的对手列表（例如，仅限10-20级玩家），建议在数据库查询中按所需分桶大小的因子进行扫描，然后根据相关条件（玩家元数据）在应用层过滤器中进行扫描。

## 写入新分桶

在生成新的对手列表后，我们写入这个新的用户分桶，首先将分桶重置和排行榜结束时间设置到比赛。

{{< code type="server" >}}
```go
// Set the Reset and Bucket end times to be in sync
userBucket.ResetTimeUnix = leaderboards[0].GetNextReset()

value, err := json.Marshal(userBucket)
if err != nil {
  return "", ErrMarshal
}

// Store generated bucket for the user
if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{
  {
    Collection:      collection,
    Key:             key,
    PermissionRead:  0,
    PermissionWrite: 0,
    UserID:          userID,
    Value:           string(value),
  },
}); err != nil {
  logger.Error("nk.StorageWrite error: %v", err)
  return "", ErrInternalError
}
```
{{< / code >}}

{{< code type="server" >}}
```typescript
// Set the Reset and Bucket end times to be in sync
userBucket.resetTimeUnix = leaderboards[0].endActive;

// Store generated bucket for the user
nk.storageWrite([{
  collection,
  key,
  userId: ctx.userId,
  value: userBucket,
  permissionRead: 0,
  permissionWrite: 0
}]);
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

最后，由于用户列表是伪随机生成的，因此用户本身可能会也可能不会被包括在其中，因此我们在列出记录之前，也会明确地将用户添加到分桶排行榜中。

{{< code type="server" >}}
```go
// Add self to the list of leaderboard records to fetch
userBucket.UserIDs = append(userBucket.UserIDs, userID)

_, records, _, _, err := nk.LeaderboardRecordsList(ctx, ids[0], userBucket.UserIDs, bucketSize, "", 0)
if err != nil {
  logger.Error("nk.LeaderboardRecordsList error: %v", err)
  return "", ErrInternalError
}

result := &api.LeaderboardRecordList{Records: records}
encoded, err := json.Marshal(result)
if err != nil {
  return "", ErrMarshal
}

logger.Debug("rpcGetBucketRecordsFn resp: %s", encoded)
return string(encoded), nil
```
{{< / code >}}

{{< code type="server" >}}
```typescript
// Add self to the list of leaderboard records to fetch
userBucket.userIds.push(ctx.userId);

// Get the leaderboard records
const records = nk.tournamentRecordsList(ids[0], userBucket.userIds, bucketSize);

const result = JSON.stringify(records);
logger.debug(`RpcGetBucketRecordsFn resp: ${result}`);

return JSON.stringify(records);
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

您可以使用[Nakama控制台](../../../getting-started/console/)验证排行榜及其设置：

![分桶排行榜]({{< fingerprint_image "/images/pages/nakama/guides/concepts/bucketed-leaderboards/bucketed-leaderboard-console.png" >}})

## 示例文件

下载完整实例文件[bucketed_leaderboards.go](snippets/bucket.go)、[bucketed_leaderboards.ts](snippets/bucket.ts)。
