일일 보상 #

플레이어를 참여시키고 유지하는 일반적인 방법은 매일 게임에 로그인하면 일일 보상을 주는 것입니다.

이 예에서는 서버 런타임 코드를 사용하여 일일 보상 시스템을 구현하는 방법을 배웁니다.

필수 조건 #

튜토리얼을 쉽게 따라하기 위해서는 진행하기 전에 다음을 실시합니다:

RPC 등록 #

일일 보상 샘플은 자격을 확인하고 보상을 발행하기 위해 두 개의 RPC(원격 프로시저 호출)를 정의합니다.

그런 다음 RPC는 또는 main.ts, main.go 또는 main.lua의 Nakama 이벤트에 등록됩니다.

Server
1
2
3
4
5
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
    initializer.registerRpc('canclaimdailyreward_js', rpcCanClaimDailyReward);
    initializer.registerRpc('claimdailyreward_js', rpcClaimDailyReward);
    logger.info('JavaScript logic loaded.');
}
Server
 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
package main

import (
    "context"
    "database/sql"
    "time"

    "github.com/heroiclabs/nakama-common/runtime"
    "github.com/heroiclabs/unity-devrel-samples/modules"
)

const (
    rpcIdCanClaimDailyReward = "canclaimdailyreward_go"
    rpcIdClaimDailyReward    = "claimdailyreward_go"
)

//noinspection GoUnusedExportedFunction
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
    initStart := time.Now()

    if err := initializer.RegisterRpc(rpcIdCanClaimDailyReward, modules.RpcCanClaimDailyReward); err != nil {
        return err
    }

    if err := initializer.RegisterRpc(rpcIdClaimDailyReward, modules.RpcClaimDailyReward); err != nil {
        return err
    }

    logger.Info("Plugin loaded in '%d' msec.", time.Since(initStart).Milliseconds())
    return nil
}
Server
1
2
3
4
5
local nk = require("nakama")
local daily_reward = require("daily_reward")

nk.register_rpc(daily_reward.rpc_can_claim_daily_reward, "canclaimdailyreward_lua")
nk.register_rpc(daily_reward.rpc_claim_daily_reward, "claimdailyreward_lua")

RPC를 서버에 등록하려면 클라이언트가 RPC를 호출할 때 실행할 함수와 문자열 식별자를 지정해야 합니다.

RPC 구현 #

RPC는 다음 로직을 구현합니다:

canClaimDailyReward #

  • Nakama 저장소 엔진에서 최신 일일 보상 객체 받기
  • 사용자가 마지막으로 보상을 청구한 시간이 00:00 이전인지 확인합니다.
  • 사용자가 일일 보상을 청구할 수 있는지 여부를 나타내는 JSON 응답을 반환합니다.

claimDailyReward #

  • Nakama 저장소 엔진에서 최신 일일 보상 객체 받기
  • 사용자가 마지막으로 보상을 청구한 시간이 00:00 이전인지 확인합니다.
  • 사용자의 지갑 업데이트
  • 사용자에게 알림 보내기
  • Nakama 저장소 엔진에서 일일 보상 객체 업데이트 또는 생성
  • 받은 코인 수와 함께 JSON 응답 반환

모듈 코드 #

이 섹션은 특히 Go 또는 Lua 사용자를 위한 것입니다. 각 언어의 일일 보상 모듈 스크립트에 포함해야 하는 몇 가지 추가 코드가 있습니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package modules

import (
    "context"
    "database/sql"
    "encoding/json"
    "time"

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

var (
    errInternalError  = runtime.NewError("internal server error", 13) // INTERNAL
    errMarshal        = runtime.NewError("cannot marshal type", 13)   // INTERNAL
    errNoInputAllowed = runtime.NewError("no input allowed", 3)       // INVALID_ARGUMENT
    errNoUserIdFound  = runtime.NewError("no user ID in context", 3)  // INVALID_ARGUMENT
    errUnmarshal      = runtime.NewError("cannot unmarshal type", 13) // INTERNAL
)
Server
1
2
3
4
5
6
7
8
local nk = require("nakama")

local M = {
}

-- Module code goes here

return M

마지막 일일 보상 객체 얻기 #

Nakama 저장소 엔진에서 최신 일일 보상 객체를 검색하는 코드를 살펴보겠습니다.

Server
 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
function getLastDailyRewardObject(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) : any {
    if (!context.userId) {
        throw Error('No user ID in context');
    }

    if (payload) {
        throw Error('No input allowed');
    }

    var objectId: nkruntime.StorageReadRequest = {
        collection: 'reward',
        key: 'daily',
        userId: context.userId,
    }

    var objects: nkruntime.StorageObject[];
    try {
        objects = nk.storageRead([ objectId ]);
    } catch (error) {
        logger.error('storageRead error: %s', error);
        throw error;
    }

    var dailyReward: any = {
        lastClaimUnix: 0,
    }

    objects.forEach(function (object) {
        if (object.key == 'daily') {
            dailyReward = object.value;
        }
    });

    return dailyReward;
}
Server
 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
type dailyReward struct {
    LastClaimUnix int64 `json:"last_claim_unix"` // The last time the user claimed the reward in UNIX time.
}

func getLastDailyRewardObject(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, payload string) (dailyReward, *api.StorageObject, error) {
    var d dailyReward
    d.LastClaimUnix = 0

    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return d, nil, errNoUserIdFound
    }

    if len(payload) > 0 {
        return d, nil, errNoInputAllowed
    }

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

    var o *api.StorageObject
    for _, object := range objects {
        switch object.GetKey() {
        case "daily":
            if err := json.Unmarshal([]byte(object.GetValue()), &d); err != nil {
                logger.Error("Unmarshal error: %v", err)
                return d, nil, errUnmarshal
            }
            return d, object, nil
        }
    }

    return d, o, nil
}
Server
 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
function get_last_daily_reward_object(context, payload)
    if (not context.user_id or #context.user_id < 1) then
        error({ "no user ID in context", 3 })
    end

    if (#payload > 0) then
        error({ "no input allowed", 3 })
    end

    local objectid = {
        collection = "reward",
        key = "daily",
        user_id = context.user_id
    }
    local success, objects = pcall(nk.storage_read, { objectid })
    if (not success) then
        nk.logger_error(string.format("storage_read error: %q", objects))
        error({ "internal server error", 13 })
    end

    local daily_reward = {
        ["last_claim_unix"] = 0
    }
    for _, object in ipairs(objects)
    do
        if (object.key == "daily") then
            daily_reward = object.value
            break
        end
    end

    return daily_reward
end

사용하는 언어에 관계없이 핵심 로직은 동일합니다.

  • 컨텍스트를 확인하여 유효한 사용자 ID가 있는지 확인
  • 사용자가 페이로드에서 아무 것도 전달하지 않았는지 확인
  • 저장소 엔진에 대해 reward 모음의 daily 객체 쿼리
  • 일일 보상 객체 또는 기본 객체 반환

사용자가 일일 보상을 받을 자격이 있는지 확인 #

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function canUserClaimDailyReward(dailyReward: any) {
    if (!dailyReward.lastClaimUnix) {
        dailyReward.lastClaimUnix = 0;
    }

    var d = new Date();
    d.setHours(0, 0, 0, 0);

    return dailyReward.lastClaimUnix < msecToSec(d.getTime());
}

function msecToSec(n: number): number {
    return Math.floor(n / 1000);
}
Server
1
2
3
4
5
func canUserClaimDailyReward(d dailyReward) bool {
    t := time.Now()
    midnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
    return time.Unix(d.LastClaimUnix, 0).Before(midnight)
}
Server
1
2
3
4
5
function can_user_claim_daily_reward(daily_reward)
    local dt = os.date("*t")
    local elapsed_sec_from_midnight = (dt.hour * 3600 + dt.min * 60 + dt.sec) % 86400
    return daily_reward.last_claim_unix < (os.time() - elapsed_sec_from_midnight)
end

이 함수는 일일 보상 객체의 마지막 청구 Unix 타임스탬프 값을 확인합니다. 전날 자정의 타임스탬프 이전이면 true를 반환하고 그렇지 않으면 false를 반환합니다.

CanClaimDailyReward RPC #

두 개의 도우미 함수가 완료되면 첫 번째 RPC를 구현할 차례입니다. 이 RPC는 JSON 객체로 사용자의 자격을 확인하는 도우미 함수의 값을 반환합니다.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function rpcCanClaimDailyReward(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
    var dailyReward = getLastDailyRewardObject(context, logger, nk, payload);
    var response = {
        canClaimDailyReward: canUserClaimDailyReward(dailyReward)
    }

    var result = JSON.stringify(response);
    logger.debug('rpcCanClaimDailyReward response: %q', result);

    return result;
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func RpcCanClaimDailyReward(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    var resp struct {
        CanClaimDailyReward bool `json:"canClaimDailyReward"`
    }

    dailyReward, _, err := getLastDailyRewardObject(ctx, logger, nk, payload)
    if err != nil {
        logger.Error("Error getting daily reward: %v", err)
        return "", errInternalError
    }

    resp.CanClaimDailyReward = canUserClaimDailyReward(dailyReward)

    out, err := json.Marshal(resp)
    if err != nil {
        logger.Error("Marshal error: %v", err)
        return "", errMarshal
    }

    logger.Debug("rpcCanClaimDailyReward resp: %v", string(out))
    return string(out), nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function M.rpc_can_claim_daily_reward(context, payload)
    local daily_reward = get_last_daily_reward_object(context, payload)
    local resp = {
        ["canClaimDailyReward"] = can_user_claim_daily_reward(daily_reward)
    }

    local success, result = pcall(nk.json_encode, resp)
    if (not success) then
        nk.logger_error(string.format("json_encode error: %q", result))
        error({ "internal server error", 13 })
    end

    nk.logger_debug(string.format("rpc_can_claim_daily_reward resp: %q", result))
    return result
end

ClaimDailyReward RPC #

이 RPC는 사용자가 일일 보상을 받을 자격이 있는지 확인하고 사용자의 지갑을 업데이트하고 알림을 보낸 다음 저장소 엔진에서 사용자의 일일 보상을 업데이트합니다.

Server
 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
function rpcClaimDailyReward(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
    var response = { coinsReceived: 0 };

    var dailyReward = getLastDailyRewardObject(context, logger, nk, payload);
    if (canUserClaimDailyReward(dailyReward)) {
        response.coinsReceived = 500;

        var changeset = {
            coins: response.coinsReceived,
        }

        try {
            nk.walletUpdate(context.userId, changeset, {}, false);
        } catch (error) {
            logger.error('walletUpdate error: %q', error);
            throw error;
        }

        var notification: nkruntime.NotificationRequest = {
            code: 1001,
            content: changeset,
            persistent: true,
            subject: "You've received your daily reward!",
            userId: context.userId,
        }

        try {
            nk.notificationsSend([notification]);
        } catch (error) {
            logger.error('notificationsSend error: %q', error);
            throw error;
        }

        dailyReward.lastClaimUnix = msecToSec(Date.now());

        var write: nkruntime.StorageWriteRequest = {
            collection: 'reward',
            key: 'daily',
            permissionRead: 1,
            permissionWrite: 0,
            value: dailyReward,
            userId: context.userId,
        }

        if (dailyReward.version) {
            // Use OCC to prevent concurrent writes.
            write.version = dailyReward.version
        }

        // Update daily reward storage object for user.
        try {
            nk.storageWrite([ write ])
        } catch (error) {
            logger.error('storageWrite error: %q', error);
            throw error;
        }
    }

    var result = JSON.stringify(response);
    logger.debug('rpcClaimDailyReward response: %q', result)

    return result;
}
Server
 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
func RpcClaimDailyReward(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return "", errNoUserIdFound
    }

    var resp struct {
        CoinsReceived int64 `json:"coinsReceived"`
    }
    resp.CoinsReceived = int64(0)

    dailyReward, dailyRewardObject, err := getLastDailyRewardObject(ctx, logger, nk, payload)
    if err != nil {
        logger.Error("Error getting daily reward: %v", err)
        return "", errInternalError
    }

    if canUserClaimDailyReward(dailyReward) {
        resp.CoinsReceived = 500

        // Update player wallet.
        changeset := map[string]int64{
            "coins": resp.CoinsReceived,
        }
        if _, _, err := nk.WalletUpdate(ctx, userID, changeset, map[string]interface{}{}, false); err != nil {
            logger.Error("WalletUpdate error: %v", err)
            return "", errInternalError
        }

        err := nk.NotificationsSend(ctx, []*runtime.NotificationSend{{
            Code: 1001,
            Content: map[string]interface{}{
                "coins": changeset["coins"],
            },
            Persistent: true,
            Sender:     "", // Server sent.
            Subject:    "You've received your daily reward!",
            UserID:     userID,
        }})
        if err != nil {
            logger.Error("NotificationsSend error: %v", err)
            return "", errInternalError
        }

        dailyReward.LastClaimUnix = time.Now().Unix()

        object, err := json.Marshal(dailyReward)
        if err != nil {
            logger.Error("Marshal error: %v", err)
            return "", errInternalError
        }

        version := ""
        if dailyRewardObject != nil {
            // Use OCC to prevent concurrent writes.
            version = dailyRewardObject.GetVersion()
        }

        // Update daily reward storage object for user.
        _, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
            Collection:      "reward",
            Key:             "daily",
            PermissionRead:  1,
            PermissionWrite: 0, // No client write.
            Value:           string(object),
            Version:         version,
            UserID:          userID,
        }})
        if err != nil {
            logger.Error("StorageWrite error: %v", err)
            return "", errInternalError
        }
    }

    out, err := json.Marshal(resp)
    if err != nil {
        logger.Error("Marshal error: %v", err)
        return "", errMarshal
    }

    logger.Debug("rpcClaimDailyReward resp: %v", string(out))
    return string(out), nil
}
Server
 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
function M.rpc_claim_daily_reward(context, payload)
    local resp = {
        ["coinsReceived"] = 0
    }

    local daily_reward = get_last_daily_reward_object(context, payload)

    -- If last claimed is before the new day grant a new reward!
    if (can_user_claim_daily_reward(daily_reward)) then
        resp.coinsReceived = 500

        -- Update player wallet.
        local changeset = {
            ["coins"] = resp.coinsReceived
        }
        local success, result = pcall(nk.wallet_update, context.user_id, changeset, {}, false)
        if (not success) then
            nk.logger_error(string.format("wallet_update error: %q", result))
            error({ "internal server error", 13 })
        end

        local notification = {
            code = 1001,
            content = changeset,
            persistent = true,
            sender = "",
            subject = "You've received your daily reward!",
            user_id = context.user_id
        }
        local success, result = pcall(nk.notifications_send, { notification })
        if (not success) then
            nk.logger_error(string.format("notifications_send error: %q", result))
            error({ "internal server error", 13 })
        end

        daily_reward.last_claim_unix = os.time()

        local version = nil
        if (daily_reward.version) then
            -- Use OCC to prevent concurrent writes.
            version = daily_reward.version
        end

        -- Update daily reward storage object for user.
        local write = {
            collection = "reward",
            key = "daily",
            permission_read = 1,
            permission_write = 0,
            value = daily_reward,
            version = version,
            user_id = context.user_id
        }
        local success, result = pcall(nk.storage_write, { write })
        if (not success) then
            nk.logger_error(string.format("storage_write error: %q", result))
            error({ "internal server error", 13 })
        end
    end

    local success, result = pcall(nk.json_encode, resp)
    if (not success) then
        nk.logger_error(string.format("json_encode error: %q", result))
        error({ "internal server error", 13 })
    end

    nk.logger_debug(string.format("rpc_claim_daily_reward resp: %q", result))
    return result
end

Nakama 콘솔에서 탐색 #

(Docker를 사용하여) 서버를 가동하고 Nakama 콘솔을 사용하여 RPC를 테스트합니다.

브라우저를 열고 http://localhost:7351로 이동하여 Nakama 콘솔에 액세스할 수 있습니다.

그런 다음 드롭다운에서 선택하고 사용자 ID를 컨텍스트로 지정하여 API 탐색기를 통해 RPC와 상호 작용할 수 있습니다.

마무리 #

이 두 개의 RPC가 구현되어 이제 간단한 일일 보상 시스템이 만들어졌습니다. 축하합니다!

더 복잡한 자격 기준이나 기타 기능을 추가하여 원하는 대로 실험해 보세요.