Skip to main content

PlayerDataServiceExample

This item only works when running on the server. Server

This file contains an example setup of a PlayerDataService. This is not required to be used, but is recommended.

-- Authors: Logan Hunt
-- January 31, 2024
--[[
    @class PlayerDataService
]]

--// Services //--
local ReplicatedStorage = game:GetService("ReplicatedStorage")

--// Imports //--
local Import = require(ReplicatedStorage.Orion.Import)

local Roam = Import("Roam")
local Signal = Import("Signal")
local Janitor = Import("Janitor")
local Promise = Import("Promise")
local RailUtil = Import("RailUtil")
local TableManager = Import("TableManager")
local TableReplicator = Import("TableReplicator")
local PlayerProfileManager = Import("PlayerProfileManager")

--// Types //--
type Promise = Promise.Promise
type TableManager = TableManager.TableManager
type TableServerReplicator = TableReplicator.TableServerReplicator

type Profile = PlayerProfileManager.Profile
type PlayerProfileManager = PlayerProfileManager.PlayerProfileManager

--// Constants //--
local DEFAULT_TIMEOUT = 60
local DEFAULT_REPLICATED_DATA_KEY = "ReplicatedData"

local DATA_STORE_KEY = "PlayerData"
local TABLE_REPLICATOR_CLASSTOKEN = TableReplicator.newClassToken("PlayerData")

local DEFAULT_DATA_SCHEMA = {
    __VERSION = "0.0.0"; -- EVERY SCHEMA MUST HAVE THIS TOP LEVEL KEY FOR MIGRATIONS TO WORK
    [DEFAULT_REPLICATED_DATA_KEY] = {
        Currency = {
            Coins = 100;
        }
    };
}

--------------------------------------------------------------------------------
    --// Service Init //--
--------------------------------------------------------------------------------

local PlayerDataService = {}
PlayerDataService.PlayerDataReady = Signal.new()

--------------------------------------------------------------------------------
    --// Convenience Methods //--
--------------------------------------------------------------------------------

--[[
    Sets the value at the given path in the player's data table.
    Returns a promise which will resolve when the value has been set.
    Ideally you use the table manager to set the value directly, but this is a convenience method.
]]
function PlayerDataService:PromiseSet(player: Player, path: string | {string}, newValue: any): Promise
    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)
        return tableManager:Set(path, newValue)
    end)
end

--[[
    Gets the value at the given path in the player's data table.
    Returns a promise which will contain the value at the given path.
]]
function PlayerDataService:PromiseGet(player: Player, path: string | {string}): Promise
    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)
        return tableManager:Get(path)
    end)
end

--[[
    Increments the value at the given path in the player's data table.
    Returns a promise which will resolve when the value has been incremented.
]]
function PlayerDataService:PromiseIncrement(player: Player, path: string | {string}, amount: number): Promise
    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)
        return tableManager:Increment(path, amount)
    end)
end

--------------------------------------------------------------------------------
    --// Table Manager and Replicator Methods //--
--------------------------------------------------------------------------------
--[[
    Gets the TableManager for the given player if it exists.
]]
function PlayerDataService:GetTableManager(player: Player): TableManager?
    local replicator = self:GetTableReplicator(player) :: any
    return replicator and replicator:GetTableManager()
end

--[[
    Gets the TableReplicator for the given player if it exists.
]]
function PlayerDataService:GetTableReplicator(player: Player): TableServerReplicator?
    return self._PlayerTableReplicatorStorage[player]
end

--[[
    Returns a promise that resolves with the TableManager for the given player.
]]
function PlayerDataService:PromiseTableManager(player: Player): Promise
    return self:OnReady(player):andThen(function()
        return self:GetTableManager(player)
    end)
end

--[[
    Returns a promise that resolves with the TableReplicator for the given player.
]]
function PlayerDataService:PromiseTableReplicator(player: Player): Promise
    return self:OnReady(player):andThen(function()
        return self:GetTableReplicator(player)
    end)
end

--------------------------------------------------------------------------------
    --// General Use Methods //--
--------------------------------------------------------------------------------

--[[
    Returns whether the player's data is ready for read/write.
]]
function PlayerDataService:IsReady(player: Player): boolean
    return self:GetTableReplicator(player) ~= nil
end

--[[
    Promise that resolves when the player's data is ready to be used.
]]
function PlayerDataService:OnReady(player: Player): Promise
    if self:IsReady(player) then return Promise.resolve() end
    return Promise.fromEvent(self.PlayerDataReady, function(readiedPlayer: Player)
        return readiedPlayer == player
    end):timeout(DEFAULT_TIMEOUT, "PlayerData failed to become ready in time [60 Seconds].")
end

--[[
    Returns the PlayerProfileManager for this service.
]]
function PlayerDataService:GetPlayerProfileManager(): PlayerProfileManager
    return self._PlayerProfileManager
end

--[[
    This method will reset the player's data to the default schema.
    If shouldKick is true, the player will be kicked after their data is reset.
    This has some unusual behavior in order to preserve the same table being used 
    so that the profile can be properly updated.
]]
function PlayerDataService:ResetPlayerData(player: Player, shouldKick: boolean?): Promise
    warn("Attempting to reset player data for", player)
    local PPM = self:GetPlayerProfileManager()
    local SchemaCopy = RailUtil.Table.Copy(self._DEFAULT_DATA_SCHEMA, true)

    return self:PromiseTableManager(player):andThen(function(manager: TableManager)
        local profile = PPM:GetProfile(player)
        assert(profile, "Player does not have a profile")

        for k in pairs(profile.Data) do
            local newData = SchemaCopy[k]
            if k == DEFAULT_REPLICATED_DATA_KEY then
                manager:Set({}, SchemaCopy[DEFAULT_REPLICATED_DATA_KEY])
                newData = manager:Get({})
            end
            profile.Data[k] = newData
        end
        PPM:_reconcileProfile(player, profile)
        
        if shouldKick then
            player:Kick("Your data has been reset.")
        end
        warn("Player data reset for", player)
    end)
end

--------------------------------------------------------------------------------
    --// Private Methods //--
--------------------------------------------------------------------------------

--[[
    Creates a new TableManager and TableReplicator for the given player and profile.
    Feel free to adjust this method to fit your needs.

    @param player -- The player to create the TableManager and TableReplicator for.
    @param profile table -- The profile to create the TableManager and TableReplicator for.
    @return function -- A cleanup function that will release the TableManager and TableReplicator.
]]
function PlayerDataService:_createPlayerDataReplicator(player: Player, profile: Profile): () -> ()
    local tableToReplicate = profile.Data[DEFAULT_REPLICATED_DATA_KEY]
    --[[
        By default we will replicate the profile's ReplicatedData table. You will likely want to
        change this to only replicate the data you need on the client or splitting the data
        up across multiple TableManagers/TableReplicators.
    ]]
    local profileJanitor = Janitor.new()

    local tableManager = profileJanitor:Add(TableManager.new(tableToReplicate))
    local tableReplicator = profileJanitor:Add(TableReplicator.new({
        ClassToken = self._TABLE_REPLICATOR_CLASSTOKEN;
        TableManager = tableManager;
        Tags = {
            UserId = player.UserId;
        };
        ReplicationTargets = player;
    }))

    self._PlayerTableReplicatorStorage[player] = tableReplicator

    return function() -- Cleanup function called immediately prior to release
        profileJanitor:Cleanup()
        self._PlayerTableReplicatorStorage[player] = nil
    end
end

--------------------------------------------------------------------------------
    --// Service Core //--
--------------------------------------------------------------------------------

function PlayerDataService:RoamStart()
    local PPM = self:GetPlayerProfileManager()

    -- Setup the player data replicators for each existing and future player, you shouldnt need to touch this
    RailUtil.Player.forEachPlayer(function(player, janitor)
        janitor:AddPromise(PPM:PromiseProfile(player):andThen(function(profile: Profile)
            local cleanup = self:_createPlayerDataReplicator(player, profile)

            Promise.fromEvent(PPM:GetSignal("PlayerProfileReleasing"), function(releasingPlayer: Player)
                return releasingPlayer == player;
            end):andThen(cleanup)

            self.PlayerDataReady:Fire(player)
        end))
    end)
end

function PlayerDataService:RoamInit()
    -- Initialize constants
    self._DATA_STORE_KEY = DATA_STORE_KEY
    self._TABLE_REPLICATOR_CLASSTOKEN = TABLE_REPLICATOR_CLASSTOKEN
    self._DEFAULT_DATA_SCHEMA = DEFAULT_DATA_SCHEMA -- Replace this with your own schema

    self._MIGRATOR = {
        -- {
        --     FromVersion = "0.0.0";
        --     ToVersion = "0.0.1";
        --     Migrate = function(data: any, _: Player)
                
        --         return data
        --     end;
        -- }
    } :: { PlayerProfileManager.DataMigrator }

    -- Setup internal storage of player data managers/replicators
    self._PlayerTableReplicatorStorage = {} :: {[Player]: TableServerReplicator}

    -- Create the player data manager. This will handle loading/saving player data.
    self._PlayerProfileManager = PlayerProfileManager.new({
        DataStoreKey = self._DATA_STORE_KEY;
        DefaultDataSchema = self._DEFAULT_DATA_SCHEMA;
        Migrator = self._MIGRATOR;

        UseMock = false; -- use a mock datastore to prevent saving/loading data
        Debug = false; -- enables debug prints
    })
end


Roam.registerService(PlayerDataService, "PlayerDataService")
return PlayerDataService
Show raw api
{
    "functions": [],
    "properties": [],
    "types": [],
    "name": "PlayerDataServiceExample",
    "desc": "This file contains an example setup of a PlayerDataService. This is not required to be used, but is recommended.\n\n```lua\n-- Authors: Logan Hunt\n-- January 31, 2024\n--[[\n    @class PlayerDataService\n]]\n\n--// Services //--\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n\n--// Imports //--\nlocal Import = require(ReplicatedStorage.Orion.Import)\n\nlocal Roam = Import(\"Roam\")\nlocal Signal = Import(\"Signal\")\nlocal Janitor = Import(\"Janitor\")\nlocal Promise = Import(\"Promise\")\nlocal RailUtil = Import(\"RailUtil\")\nlocal TableManager = Import(\"TableManager\")\nlocal TableReplicator = Import(\"TableReplicator\")\nlocal PlayerProfileManager = Import(\"PlayerProfileManager\")\n\n--// Types //--\ntype Promise = Promise.Promise\ntype TableManager = TableManager.TableManager\ntype TableServerReplicator = TableReplicator.TableServerReplicator\n\ntype Profile = PlayerProfileManager.Profile\ntype PlayerProfileManager = PlayerProfileManager.PlayerProfileManager\n\n--// Constants //--\nlocal DEFAULT_TIMEOUT = 60\nlocal DEFAULT_REPLICATED_DATA_KEY = \"ReplicatedData\"\n\nlocal DATA_STORE_KEY = \"PlayerData\"\nlocal TABLE_REPLICATOR_CLASSTOKEN = TableReplicator.newClassToken(\"PlayerData\")\n\nlocal DEFAULT_DATA_SCHEMA = {\n    __VERSION = \"0.0.0\"; -- EVERY SCHEMA MUST HAVE THIS TOP LEVEL KEY FOR MIGRATIONS TO WORK\n    [DEFAULT_REPLICATED_DATA_KEY] = {\n        Currency = {\n            Coins = 100;\n        }\n    };\n}\n\n--------------------------------------------------------------------------------\n    --// Service Init //--\n--------------------------------------------------------------------------------\n\nlocal PlayerDataService = {}\nPlayerDataService.PlayerDataReady = Signal.new()\n\n--------------------------------------------------------------------------------\n    --// Convenience Methods //--\n--------------------------------------------------------------------------------\n\n--[[\n    Sets the value at the given path in the player's data table.\n    Returns a promise which will resolve when the value has been set.\n    Ideally you use the table manager to set the value directly, but this is a convenience method.\n]]\nfunction PlayerDataService:PromiseSet(player: Player, path: string | {string}, newValue: any): Promise\n    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)\n        return tableManager:Set(path, newValue)\n    end)\nend\n\n--[[\n    Gets the value at the given path in the player's data table.\n    Returns a promise which will contain the value at the given path.\n]]\nfunction PlayerDataService:PromiseGet(player: Player, path: string | {string}): Promise\n    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)\n        return tableManager:Get(path)\n    end)\nend\n\n--[[\n    Increments the value at the given path in the player's data table.\n    Returns a promise which will resolve when the value has been incremented.\n]]\nfunction PlayerDataService:PromiseIncrement(player: Player, path: string | {string}, amount: number): Promise\n    return self:PromiseTableManager(player):andThen(function(tableManager: TableManager)\n        return tableManager:Increment(path, amount)\n    end)\nend\n\n--------------------------------------------------------------------------------\n    --// Table Manager and Replicator Methods //--\n--------------------------------------------------------------------------------\n--[[\n    Gets the TableManager for the given player if it exists.\n]]\nfunction PlayerDataService:GetTableManager(player: Player): TableManager?\n    local replicator = self:GetTableReplicator(player) :: any\n    return replicator and replicator:GetTableManager()\nend\n\n--[[\n    Gets the TableReplicator for the given player if it exists.\n]]\nfunction PlayerDataService:GetTableReplicator(player: Player): TableServerReplicator?\n    return self._PlayerTableReplicatorStorage[player]\nend\n\n--[[\n    Returns a promise that resolves with the TableManager for the given player.\n]]\nfunction PlayerDataService:PromiseTableManager(player: Player): Promise\n    return self:OnReady(player):andThen(function()\n        return self:GetTableManager(player)\n    end)\nend\n\n--[[\n    Returns a promise that resolves with the TableReplicator for the given player.\n]]\nfunction PlayerDataService:PromiseTableReplicator(player: Player): Promise\n    return self:OnReady(player):andThen(function()\n        return self:GetTableReplicator(player)\n    end)\nend\n\n--------------------------------------------------------------------------------\n    --// General Use Methods //--\n--------------------------------------------------------------------------------\n\n--[[\n    Returns whether the player's data is ready for read/write.\n]]\nfunction PlayerDataService:IsReady(player: Player): boolean\n    return self:GetTableReplicator(player) ~= nil\nend\n\n--[[\n    Promise that resolves when the player's data is ready to be used.\n]]\nfunction PlayerDataService:OnReady(player: Player): Promise\n    if self:IsReady(player) then return Promise.resolve() end\n    return Promise.fromEvent(self.PlayerDataReady, function(readiedPlayer: Player)\n        return readiedPlayer == player\n    end):timeout(DEFAULT_TIMEOUT, \"PlayerData failed to become ready in time [60 Seconds].\")\nend\n\n--[[\n    Returns the PlayerProfileManager for this service.\n]]\nfunction PlayerDataService:GetPlayerProfileManager(): PlayerProfileManager\n    return self._PlayerProfileManager\nend\n\n--[[\n    This method will reset the player's data to the default schema.\n    If shouldKick is true, the player will be kicked after their data is reset.\n    This has some unusual behavior in order to preserve the same table being used \n    so that the profile can be properly updated.\n]]\nfunction PlayerDataService:ResetPlayerData(player: Player, shouldKick: boolean?): Promise\n    warn(\"Attempting to reset player data for\", player)\n    local PPM = self:GetPlayerProfileManager()\n    local SchemaCopy = RailUtil.Table.Copy(self._DEFAULT_DATA_SCHEMA, true)\n\n    return self:PromiseTableManager(player):andThen(function(manager: TableManager)\n        local profile = PPM:GetProfile(player)\n        assert(profile, \"Player does not have a profile\")\n\n        for k in pairs(profile.Data) do\n            local newData = SchemaCopy[k]\n            if k == DEFAULT_REPLICATED_DATA_KEY then\n                manager:Set({}, SchemaCopy[DEFAULT_REPLICATED_DATA_KEY])\n                newData = manager:Get({})\n            end\n            profile.Data[k] = newData\n        end\n        PPM:_reconcileProfile(player, profile)\n        \n        if shouldKick then\n            player:Kick(\"Your data has been reset.\")\n        end\n        warn(\"Player data reset for\", player)\n    end)\nend\n\n--------------------------------------------------------------------------------\n    --// Private Methods //--\n--------------------------------------------------------------------------------\n\n--[[\n    Creates a new TableManager and TableReplicator for the given player and profile.\n    Feel free to adjust this method to fit your needs.\n\n    @param player -- The player to create the TableManager and TableReplicator for.\n    @param profile table -- The profile to create the TableManager and TableReplicator for.\n    @return function -- A cleanup function that will release the TableManager and TableReplicator.\n]]\nfunction PlayerDataService:_createPlayerDataReplicator(player: Player, profile: Profile): () -> ()\n    local tableToReplicate = profile.Data[DEFAULT_REPLICATED_DATA_KEY]\n    --[[\n        By default we will replicate the profile's ReplicatedData table. You will likely want to\n        change this to only replicate the data you need on the client or splitting the data\n        up across multiple TableManagers/TableReplicators.\n    ]]\n    local profileJanitor = Janitor.new()\n\n    local tableManager = profileJanitor:Add(TableManager.new(tableToReplicate))\n    local tableReplicator = profileJanitor:Add(TableReplicator.new({\n        ClassToken = self._TABLE_REPLICATOR_CLASSTOKEN;\n        TableManager = tableManager;\n        Tags = {\n            UserId = player.UserId;\n        };\n        ReplicationTargets = player;\n    }))\n\n    self._PlayerTableReplicatorStorage[player] = tableReplicator\n\n    return function() -- Cleanup function called immediately prior to release\n        profileJanitor:Cleanup()\n        self._PlayerTableReplicatorStorage[player] = nil\n    end\nend\n\n--------------------------------------------------------------------------------\n    --// Service Core //--\n--------------------------------------------------------------------------------\n\nfunction PlayerDataService:RoamStart()\n    local PPM = self:GetPlayerProfileManager()\n\n    -- Setup the player data replicators for each existing and future player, you shouldnt need to touch this\n    RailUtil.Player.forEachPlayer(function(player, janitor)\n        janitor:AddPromise(PPM:PromiseProfile(player):andThen(function(profile: Profile)\n            local cleanup = self:_createPlayerDataReplicator(player, profile)\n\n            Promise.fromEvent(PPM:GetSignal(\"PlayerProfileReleasing\"), function(releasingPlayer: Player)\n                return releasingPlayer == player;\n            end):andThen(cleanup)\n\n            self.PlayerDataReady:Fire(player)\n        end))\n    end)\nend\n\nfunction PlayerDataService:RoamInit()\n    -- Initialize constants\n    self._DATA_STORE_KEY = DATA_STORE_KEY\n    self._TABLE_REPLICATOR_CLASSTOKEN = TABLE_REPLICATOR_CLASSTOKEN\n    self._DEFAULT_DATA_SCHEMA = DEFAULT_DATA_SCHEMA -- Replace this with your own schema\n\n    self._MIGRATOR = {\n        -- {\n        --     FromVersion = \"0.0.0\";\n        --     ToVersion = \"0.0.1\";\n        --     Migrate = function(data: any, _: Player)\n                \n        --         return data\n        --     end;\n        -- }\n    } :: { PlayerProfileManager.DataMigrator }\n\n    -- Setup internal storage of player data managers/replicators\n    self._PlayerTableReplicatorStorage = {} :: {[Player]: TableServerReplicator}\n\n    -- Create the player data manager. This will handle loading/saving player data.\n    self._PlayerProfileManager = PlayerProfileManager.new({\n        DataStoreKey = self._DATA_STORE_KEY;\n        DefaultDataSchema = self._DEFAULT_DATA_SCHEMA;\n        Migrator = self._MIGRATOR;\n\n        UseMock = false; -- use a mock datastore to prevent saving/loading data\n        Debug = false; -- enables debug prints\n    })\nend\n\n\nRoam.registerService(PlayerDataService, \"PlayerDataService\")\nreturn PlayerDataService\n```",
    "realm": [
        "Server"
    ],
    "source": {
        "line": 287,
        "path": "src/playerprofilemanager/src/Server/PlayerDataServiceExample.lua"
    }
}