-- Player profile: MMR, currency, gear ownership, stats. -- -- This replaces the six PlayerPrefs booleans the client currently writes in -- ClimbingGearSystem.SaveGearState. Everything here is server-owned -- (permission_write = 0) so a client cannot grant itself gear or currency. local nk = require("nakama") local config = require("config") local util = require("util") local M = {} local COLLECTION = config.COLLECTION_PROFILE local function default_profile() return { mmr = config.DEFAULT_MMR, currency = 1000, -- matches GameHUDController.startingCurrency coins = 0, gems = 0, rank_points = 0, gear = { glue_gloves = false, double_jump_shoes = false, dash = false, invisible_cloak = false, stamina_juice = false, }, loadout = { skin = "default", }, stats = { matches = 0, wins = 0, losses = 0, }, created_at = util.now(), } end -- Read a profile, creating it on first touch. Every other module goes through -- this so a brand-new account never produces a nil profile. function M.get(user_id) local value, version = util.storage_read(COLLECTION, config.KEY_PROFILE, user_id) if value ~= nil then return value, version end local fresh = default_profile() -- permission_read = 1 lets the owning client read its own profile directly. util.storage_write(COLLECTION, config.KEY_PROFILE, user_id, fresh, nil, 1) local _, new_version = util.storage_read(COLLECTION, config.KEY_PROFILE, user_id) return fresh, new_version end function M.mmr(user_id) local ok, profile = pcall(M.get, user_id) if not ok or profile == nil then return config.DEFAULT_MMR end return tonumber(profile.mmr) or config.DEFAULT_MMR end -- Apply a match result. Called from rpc_gs_match_ended, never from a client. function M.apply_result(user_id, won, rank_points_delta, mmr_delta) local profile, version = M.get(user_id) profile.stats.matches = (profile.stats.matches or 0) + 1 if won then profile.stats.wins = (profile.stats.wins or 0) + 1 else profile.stats.losses = (profile.stats.losses or 0) + 1 end profile.mmr = math.max(0, (tonumber(profile.mmr) or config.DEFAULT_MMR) + (mmr_delta or 0)) profile.rank_points = math.max(0, (profile.rank_points or 0) + (rank_points_delta or 0)) if not util.storage_write(COLLECTION, config.KEY_PROFILE, user_id, profile, version, 1) then -- Lost a race with a concurrent write (e.g. the player bought something -- mid-match). Retry once against fresh state rather than dropping the result. local retry, retry_version = M.get(user_id) retry.stats = profile.stats retry.mmr = profile.mmr retry.rank_points = profile.rank_points util.storage_write(COLLECTION, config.KEY_PROFILE, user_id, retry, retry_version, 1) end return profile end -- --------------------------------------------------------------------------- -- Client RPCs -- --------------------------------------------------------------------------- function M.rpc_get_profile(context, _payload) local user_id = util.assert_user(context) local profile = M.get(user_id) local account = nk.account_get_id(user_id) return util.ok({ user_id = user_id, username = account.user.username, profile = profile, }) end -- The only profile field a client may set directly: cosmetic loadout. Anything -- economic (currency, gear, mmr) stays server-side. function M.rpc_set_loadout(context, payload) local user_id = util.assert_user(context) local input = util.decode(payload) local profile, version = M.get(user_id) profile.loadout = profile.loadout or {} if type(input.skin) == "string" and #input.skin <= 64 then profile.loadout.skin = input.skin end util.storage_write(COLLECTION, config.KEY_PROFILE, user_id, profile, version, 1) return util.ok({ loadout = profile.loadout }) end function M.rpc_get_modes(context, _payload) local modes = {} for key, mode in pairs(config.MODES) do modes[key] = { key = mode.key, team_size = mode.team_size, players = mode.players, -- The pool, not a pick: which environment a given match lands in is -- decided when that match is created, so naming one here would be a guess. -- Under a MATCH_SCENE override the pool collapses to that one scene. scenes = config.scene_pool(context, mode), } end return util.ok({ modes = modes }) end return M