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