锦标赛 #

锦标赛是对手争夺奖品的短时间比赛。

本文档概述了 Nakama 服务器的锦标赛设计。其涵盖的规则涉及玩家如何查找锦标赛,他们可以参加哪些锦标赛,允许他们何时提交分数,以及锦标赛结束时如何分配奖励。

规则 #

创建的锦标赛具有可选的重置时间表和持续时间。这些值允许灵活控制锦标赛重置为下一个持续时间之前最多可以玩多长时间。例如,可以创建每天中午开始,比赛时间为一小时的比赛。这可以表达为 CRON 表达式 (“0 12 * * *”) 和 3600 秒的持续时间。

锦标赛可以限制允许的对手数量(即先到先得),并执行可选的加入要求。例如,每个对手必须先加入,然后才能提交分数,只允许前 10000 名对手加入。

锦标赛是以编程方式创建的,可以在未来开始,也可以在创建后立即开始,所有锦标赛表示为具有特殊配置的排行榜。

并不是用户的对手也可以玩锦标赛。例如,在实施的公会锦标赛中,可按公会 ID 提交分数。

列出锦标赛 #

查找在服务器上创建的锦标赛。可以通过类别和起止时间筛选锦标赛。

忽略起止时间会返回进行中和未来的锦标赛。

将结束时间设置为 0,结果中将仅有没有结束时间的锦标赛。

将结束时间设置为 > 0 Unix 时间戳,相当于有了上限,将仅返回在此之前结束的锦标赛(排除无结束时间的锦标赛)。

将开始时间设置为 > 0 Unix 时间戳,将返回在此时间后开始的任何锦标赛。

Client
1
2
curl -X GET "http://127.0.0.1:7350/v2/tournament?category_start=<category_start>&category_end=<category_end>&start_time=<start_time>&end_time=<end_time>&limit=<limit>&cursor=<cursor>" \
  -H 'Authorization: Bearer <session token>'
Client
1
2
3
4
5
6
7
var categoryStart = 1;
var categoryEnd = 2;
var startTime = 1538147711;
var endTime = null; // all tournaments from the start time
var limit = 100; // number to list per page
var cursor = null;
var result = await client.listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor);
Client
1
2
3
4
5
6
7
var categoryStart = 1;
var categoryEnd = 2;
var startTime = 1538147711;
var endTime = null; // all tournaments from the start time
var limit = 100; // number to list per page
var cursor = null;
var result = await client.ListTournamentsAsync(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor);
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
auto successCallback = [](NTournamentListPtr list)
{
    std::cout << "Tournament count " << list->tournaments.size() << std::endl;

    for (auto& tournament : list->tournaments)
    {
        std::cout << "Tournament ID " << tournament.id << ", title " << tournament.title << std::endl);
    }
};

uint32_t categoryStart = 1;
uint32_t categoryEnd = 2;
uint32_t startTime = 1538147711;
uint32_t endTime = null; // all tournaments from the start time
int32_t limit = 100; // number to list per page

client->listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, opt::nullopt, successCallback);
Client
1
2
3
4
5
6
7
int categoryStart = 1;
int categoryEnd = 2;
int startTime = 1538147711;
int endTime = null; // all tournaments from the start time
int limit = 100; // number to list per page
String cursor = null;
TournamentList tournaments = client.listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor).get();
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var category_start = 1
var category_end = 2
var start_time = 1538147711
var end_time = null # all tournaments from the start time
var limit = 100 # number to list per page
var cursor = null
var result : NakamaAPI.ApiTournamentList = yield(client.list_tournaments_async(session, category_start, category_end, start_time, end_time, limit, cursor), "completed")
if result.is_exception():
    print("An error occurred: %s" % result)
    return
print("Tournaments: %s" % [result])
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
GET /v2/tournament
  ?category_start=<category_start>
  &category_end=<category_end>
  &start_time=<start_time>
  &end_time=<end_time>
  &limit=<limit>
  &cursor=<cursor>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.

加入锦标赛 #

需要加入锦标赛后,所有者才能提交分数。此操作可重复执行,即使所有者已经加入锦标赛,他们重复执行此操作也总可以成功。

Client
1
2
curl -X POST "http://127.0.0.1:7350/v2/tournament/<tournament_id>/join" \
  -H 'Authorization: Bearer <session token>'
Client
1
2
var id = "someid";
var success = await client.joinTournament(session, id);
Client
1
2
var id = "someid";
var success = await client.JoinTournamentAsync(session, id);
Client
1
2
3
4
5
6
7
auto successCallback = []()
{
    std::cout << "Successfully joined tournament" << std::cout;
};

string id = "someid";
client->joinTournament(session, id, successCallback);
Client
1
2
String id = "someid";
client.joinTournament(session, id).get();
Client
1
2
3
4
5
6
var id = "someid"
var success : NakamaAsyncResult = yield(client.join_tournament_async(session, id), "completed")
if success.is_exception():
    print("An error occurred: %s" % success)
    return
print("Joined tournament")
Client
1
2
3
4
5
POST /v2/tournament/<tournament_id>/join
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.

列出锦标赛记录 #

获取属于特定所有者的锦标赛记录和一批记录的混合列表。这有助于建立排行榜视图,其中显示前 100 名玩家以及当前用户与其好友的分数。

Client
1
2
curl -X GET "http://127.0.0.1:7350/v2/tournament/<tournament_id>?owner_ids=<owner_ids>&limit=<limit>&cursor=<cursor>" \
  -H 'Authorization: Bearer <session token>'
Client
1
2
3
4
5
6
var id = "someid";
var ownerIds = ["some", "friends", "user ids"];
var result = await client.listTournamentRecords(session, id, owenrIds);
result.records.forEach(function(record) {
  console.log("Record username %o and score %o", record.username, record.score);
});
Client
1
2
3
4
var id = "someid";
var limit = 100;
var cursor = null;
var result = await client.ListTournamentRecordsAsync(session, id, new []{ session.UserId }, limit, cursor);
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
auto successCallback = [](NTournamentRecordListPtr list)
{
    for (auto& record : list->records)
    {
        std::cout << "Record username " << record.username << " and score " << record.score << std::endl;
    }
};

string id = "someid";
client->listTournamentRecords(session, id, opt::nullopt, opt::nullopt, {}, successCallback);
Client
1
2
String id = "someid";
LeaderboardRecordList records = client.listLeaderboardRecords(session, id, session.getUserId()).get();
Client
1
2
3
4
5
6
7
8
var id = "someid"
var limit = 100
var cursor = null
var result : NakamaAPI.ApiTournamentRecordList = yield(client.list_tournament_records_async(session, id, [session.user_id], limit, cursor), "completed")
if result.is_exception():
    print("An error occurred: %s" % result)
    return
print("Records: %s" % [result])
Client
1
2
3
4
5
GET /v2/tournament/<tournament_id>?owner_ids=<owner_ids>&limit=<limit>&cursor=<cursor>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.

列出围绕所有者的锦标赛记录 #

获取围绕所有者的锦标赛记录列表。

Client
1
2
curl -X GET "http://127.0.0.1:7350/v2/tournament/<tournament_id>/owner/<owner_id>?limit=<limit>" \
  -H 'Authorization: Bearer <session token>'
Client
1
2
3
4
var id = "someid";
var ownerId = "some user ID";
var limit = 100;
var result = await client.listTournamentRecordsAroundOwner(session, id, ownerId, limit);
Client
1
2
3
4
var id = "someid";
var ownerId = session.UserId;
var limit = 100;
var result = await client.ListTournamentRecordsAroundOwnerAsync(session, id, ownerId, limit);
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
auto successCallback = [](NTournamentRecordListPtr list)
{
    for (auto& record : list->records)
    {
        std::cout << "Record username " << record.username << " and score " << record.score << std::endl;
    }
};

string id = "someid";
string ownerId = session->getUserId();
int32_t limit = 100;
client->listTournamentRecordsAroundOwner(session, id, ownerId, limit, successCallback);
Client
1
2
3
4
5
String id = "someid";
String ownerId = session.getUserId();
int expiry = -1;
int limit = 100;
TournamentRecordList records = client.listTournamentRecordsAroundOwner(session, id, ownerId, expiry, limit).get();
Client
1
2
3
4
5
6
7
8
var id = "someid"
var owner_id = "some user ID"
var limit = 100
var result : NakamaAPI.ApiTournamentRecordList = yield(client.list_tournament_records_around_owner_async(session, id, owner_id, limit), "completed")
if result.is_exception():
    print("An error occurred: %s" % result)
    return
print("Records: %s" % [result])
Client
1
2
3
4
5
GET /v2/tournament/<tournament_id>/owner/<owner_id>?limit=<limit>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.

写入锦标赛记录 #

向锦标赛排行榜提交分数和子分数。如果锦标赛的配置要求加入,除非所有者已经加入锦标赛,否则本操作将失败。

Client
1
2
3
curl -X PUT "http://127.0.0.1:7350/v2/tournament/<tournament_id>" \
  -H 'Authorization: Bearer <session token>' \
  -d '{"score": 100, "subscore": 10, "metadata": "{"weather_conditions": "sunny", "track_name" : "Silverstone" }"}'
Client
1
2
3
4
5
6
7
8
var id = "someid";
var score = 100;
var subscore = 10;
var metadata = {
  "weather_conditions": "sunny",
  "track_name": "Silverstone"
}
var newrecord = client.writeTournamentRecord(session, id, score, subscore, metadata);
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var id = "someid";
var score = 100L;
var subscore = 10L;
// using Nakama.TinyJson;
var metadata = new Dictionary<string, string>()
{
    { "weather_conditions", "sunny" },
    { "track_name", "Silverstone" }
}.ToJson();
var newRecord = await client.WriteTournamentRecordAsync(session, id, score, subscore, metadata);
Console.WriteLine(newRecord);
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
auto successCallback = [this](const NLeaderboardRecord& record)
{
    std::cout << "written tournament record" << std::endl;
};

string id = "someid";
int64_t score = 100;
int64_t subscore = 10;
string metadata = "{"weather_conditions": "sunny", "track_name" : "Silverstone" }";
client->writeTournamentRecord(session, id, score, subscore, metadata, successCallback);
Client
1
2
3
4
5
string id = "someid";
int score = 10;
int subscore = 20;
final String metadata = "{"tarmac": "wet"}";
LeaderboardRecord record = client.writeTournamentRecord(session, id, score, subscore, metadata).get();
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var id = "someid"
var score = 100
var subscore = 10
var metadata = JSON.print({
    "weather_conditions": "sunny",
    "track_name": "Silverstone"
})
var new_record : NakamaAPI.ApiLeaderboardRecord = yield(client.write_tournament_record_async(session, id, score, subscore, metadata), "completed")
if new_record.is_exception():
    print("An error occurred: %s" % new_record)
    return
print("Record: %s" % [new_record])
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PUT /v2/tournament/<tournament_id>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

{
  "score": 100,
  "subscore": 10,
  "metadata": "{"weather_conditions": "sunny", "track_name" : "Silverstone" }"
}
Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.

权威功能 #

运行时函数可以通过服务器框架访问,并允许使用自定义逻辑将附加规则应用于锦标赛的各个方面。例如,可以要求仅允许高于某个级别的对手加入锦标赛。

创建锦标赛 #

使用所有配置选项创建锦标赛

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
local id = "4ec4f126-3f9d-11e7-84ef-b7c182b36521"
local sort = "desc"     -- one of: "desc", "asc"
local operator = "best" -- one of: "best", "set", "incr"
local reset = "0 12 * * *" -- noon UTC each day
local metadata = {
  weather_conditions = "rain"
}
title = "Daily Dash"
description = "Dash past your opponents for high scores and big rewards!"
category = 1
start_time = nk.time() / 1000 -- starts now in seconds
end_time = 0                  -- never end, repeat the tournament each day forever
duration = 3600               -- in seconds
max_size = 10000              -- first 10,000 players who join
max_num_score = 3             -- each player can have 3 attempts to score
join_required = true          -- must join to compete
nk.tournament_create(id, sort, operator, duration, reset, metadata, title, description, category, start_time, end_time, max_size, max_num_score, join_required)
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// import "github.com/gofrs/uuid"
id := uuid.Must(uuid.NewV4())
sortOrder := "desc"  // one of: "desc", "asc"
operator := "best"   // one of: "best", "set", "incr"
resetSchedule := "0 12 * * *" // noon UTC each day
metadata := map[string]interface{}{}
title := "Daily Dash"
description := "Dash past your opponents for high scores and big rewards!"
category := 1
startTime := int(time.Now().UTC().Unix()) // start now
endTime := 0                         // never end, repeat the tournament each day forever
duration := 3600                     // in seconds
maxSize := 10000                     // first 10,000 players who join
maxNumScore := 3                     // each player can have 3 attempts to score
joinRequired := true                 // must join to compete
err := nk.TournamentCreate(ctx, id.String(), sortOrder, operator, resetSchedule, metadata, title, description, category, startTime, endTime, duration, maxSize, maxNumScore, joinRequired)
if err != nil {
      logger.Printf("unable to create tournament: %q", err.Error())
      return "", runtime.NewError("failed to create tournament", 3)
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let id = '4ec4f126-3f9d-11e7-84ef-b7c182b36521';
let sortOrder = nkruntime.SortOrder.DESCENDING;
let operator = nkruntime.Operator.BEST;
let duration = 3600;     // In seconds.
let resetSchedule = '0 12 * * *'; // Noon UTC each day.
let metadata = {
  weatherConditions: 'rain',
};
let title = 'Daily Dash';
let description = "Dash past your opponents for high scores and big rewards!";
let category = 1;
let startTime = 0;       // Start now.
let endTime = 0;         // Never end, repeat the tournament each day forever.

let maxSize = 10000;     // First 10,000 players who join.
let maxNumScore = 3;     // Each player can have 3 attempts to score.
let joinRequired = true; // Must join to compete.

try {
  nk.tournamentCreate(id, sortOrder, operator, duration, resetSchedule, metadata, title, description, category, startTime, endTime, maxSize, maxNumScore, joinRequired);
} catch (error) {
  // Handle error
}
如果您创建的锦标赛没有重置时间表,则您必须提供一个结束时间。

删除锦标赛 #

按 ID 删除锦标赛。

Server
1
2
local id = "4ec4f126-3f9d-11e7-84ef-b7c182b36521"
nk.tournament_delete(id)
Server
1
2
3
4
5
err := nk.TournamentDelete(ctx, id)
if err != nil {
    logger.Printf("unable to delete tournament: %q", err.Error())
    return "", runtime.NewError("failed to delete tournament", 3)
}
Server
1
2
3
4
5
6
let id = '4ec4f126-3f9d-11e7-84ef-b7c182b36521';
try {
  nk.tournamentDelete(id);
} catch (error) {
  // Handle error
}

添加分数尝试 #

向所有者的锦标赛记录添加更多分数的尝试次数。这将覆盖此所有者的锦标赛允许的最大加分尝试数。

Server
1
2
3
4
local id = "someid"
local owner = "someuserid"
local attempts = 10
nk.tournament_add_attempt(id, owner, attempts)
Server
1
2
3
4
5
6
7
8
id := "someid"
userID := "someuserid"
attempts := 10
err := nk.TournamentAddAttempt(ctx, id, userID, attempts)
if err != nil {
    logger.Printf("unable to update user %v record attempts: %q", userID, err.Error())
    return "", runtime.NewError("failed to add tournament attempts", 3)
}
Server
1
2
3
4
5
6
7
8
let id = '4ec4f126-3f9d-11e7-84ef-b7c182b36521';
let owner = 'leaderboard-record-owner';
let count = -10;
try {
  nk.tournamentAddAttempt(id, owner, count);
} catch (error) {
  // Handle error
}

奖励分发 #

当锦标赛的有效期结束时,将调用服务器上注册的某个函数来传递过期的记录,以便用于计算和向所有者分发奖励。

要在 Go 中注册奖励分发函数,请使用 initializer

Server
1
2
3
4
5
local nk = require("nakama")
local function distribute_rewards(_context, tournament, session_end, expiry)
  -- ...
end
nk.register_tournament_end(distribute_rewards)
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import (
    "context"
    "database/sql"
    "log"

    "github.com/heroiclabs/nakama/api"
    "github.com/heroiclabs/nakama/runtime"
)

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
    if err := initializer.RegisterTournamentEnd(distributeRewards); err != nil {
        return err
    }
}

func distributeRewards(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, tournament *api.Tournament, end int64, reset int64) error {
    // ...
    return nil
}
Server
1
2
3
4
5
6
let distributeTournamentRewards: nkruntime.TournamentEndFunction = function(ctx: Context, logger: Logger, nk: Nakama, tournament: Tournament, end: number, reset: number) {
  // ...
}

// Inside InitModule function
initializer.registerTournamentEnd(tournamentEndFn);

以下是一个简单的奖励分发函数,向前十名玩家发送持久通知,让他们知道自己赢了,并将硬币添加到他们的虚拟钱包中:

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local nk = require("nakama")
local function distribute_rewards(_context, tournament, session_end, expiry)
  local notifications = {}
  local wallet_updates = {}
  local records, owner_records, nc, pc = nk.leaderboard_records_list(tournament.id, nil, 10, nil, expiry)
  for i = 1, #records do
    notifications[i] = {
      code = 1,
      content = { coins = 100 },
      persistent = true,
      subject = "Winner",
      user_id = records[i].owner_id
    }
    wallet_updates[i] = {
      user_id = records[i].owner_id,
      changeset = { coins = 100 },
      metadata = {}
    }
  end

  nk.wallets_update(wallet_updates, true)
  nk.notifications_send(notifications)
end
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func distributeRewards(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, tournament *api.Tournament, end int64, reset int64) error {
    wallets := []*runtime.WalletUpdate{}
    notifications := []*runtime.NotificationSend{}
    content := map[string]interface{}{}
    changeset := map[string]int64{"coins": 100}
    records, _, _, _, err := nk.LeaderboardRecordsList(ctx, tournament.Id, []string{}, 10, "", reset)
    for _, record := range records {
        wallets = append(wallets, &runtime.WalletUpdate{record.OwnerId, changeset, content})
        notifications = append(notifications, &runtime.NotificationSend{record.OwnerId, "Leaderboard winner", content, 1, "", true})
    }
    _, err = nk.WalletsUpdate(ctx, wallets, false)
    if err != nil {
        logger.Error("failed to update winner wallets: %v", err)
        return err
    }
    err = nk.NotificationsSend(ctx, notifications)
    if err != nil {
        logger.Error("failed to send winner notifications: %v", err)
        return err
    }
    return nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
let distributeTournamentRewards: nkruntime.TournamentEndFunction = function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, tournament: nkruntime.Tournament, end: number, reset: number) {
  let notifications: nkruntime.NotificationRequest[] = [];
  let walletUpdates: nkruntime.WalletUpdate[] = []
  let results = nk.leaderboardRecordsList(tournament.id, [], 10, '', reset);
  results.records?.forEach(function (r) {
    notifications.push({
      code: 1,
      content: { coins: 100 },
      persistent: true,
      subject: "Winner",
      userId: r.ownerId,
    });

    walletUpdates.push({
      userId: r.ownerId,
      changeset: { coins: 100 },
    });
  });

  nk.walletsUpdate(walletUpdates, true)
  nk.notificationsSend(notifications)
}

高级 #

可以用锦标赛来实现联赛系统。联赛和锦标赛之间的主要区别在于联赛通常是季节性的,并且包含了一个阶梯或层次结构,供对手依次晋级。

可以将联赛设计为一系列锦标赛,这些锦标赛具有相同的重置时间表和持续时间。可以用奖励分发功能在每个重置时间表之间的一场锦标赛和下一场锦标赛之间让各对手取得进展。

请参阅分层联赛指南示例。

锦标赛元数据 #

每个锦标赛和锦标赛记录都可以选择性地包含锦标赛本身或提交的分数和分数所有者的附加数据。附加字段必须采用 JSON 编码,且以元数据提交。

元数据的一个用例示例是驾驶游戏中的比赛状况,例如天气,这可以在用户列出记录时提供额外的 UI 提示。

1
2
3
4
5
{
  "surface": "wet",
  "timeOfDay": "night",
  "car": "Porsche 918 Spyder"
}