From 0f21d33f03f22448663e220fee9f54e97d868505 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Tue, 15 Apr 2025 13:48:20 -0400 Subject: [PATCH 01/13] Update tooling --- aftman.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/aftman.toml b/aftman.toml index 510d972..09e7aad 100644 --- a/aftman.toml +++ b/aftman.toml @@ -1,7 +1,6 @@ [tools] -rojo = "rojo-rbx/rojo@7.2.1" -selene = "Kampfkarren/selene@0.22.0" -remodel = "rojo-rbx/remodel@0.11.0" -mantle = "blake-mealey/mantle@0.11.6" +rojo = "rojo-rbx/rojo@7.4.4" +selene = "Kampfkarren/selene@0.28.0" +mantle = "blake-mealey/mantle@0.11.18" wally = "upliftgames/wally@0.3.1" lune = "filiptibell/lune@0.4.0" \ No newline at end of file From 3a86e6564c954462552a504467640a479038c731 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Thu, 19 Jun 2025 16:17:25 -0400 Subject: [PATCH 02/13] Bump system package version to 3.0.0 --- Client/wally.toml | 4 ++-- Server/wally.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Client/wally.toml b/Client/wally.toml index 01024bc..f0607b2 100644 --- a/Client/wally.toml +++ b/Client/wally.toml @@ -1,11 +1,11 @@ [package] name = "phoenixentertainment/playerdatasystem-client" description = "Player data system client" -version = "2.1.0" +version = "3.0.0" authors = ["Noble_Draconian"] realm = "shared" registry = "https://github.com/UpliftGames/wally-index" [dependencies] DragonEngine = "nobledraconian/dragon-engine@2.0.0" -roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" \ No newline at end of file +roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" diff --git a/Server/wally.toml b/Server/wally.toml index b9a7d69..11ca618 100644 --- a/Server/wally.toml +++ b/Server/wally.toml @@ -1,11 +1,11 @@ [package] name = "phoenixentertainment/playerdatasystem-server" description = "Player data system server" -version = "2.1.0" +version = "3.0.0" authors = ["Noble_Draconian"] realm = "server" registry = "https://github.com/UpliftGames/wally-index" [dependencies] DragonEngine = "nobledraconian/dragon-engine@2.0.0" -roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" \ No newline at end of file +roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" From 25e32d0148c237d83a274a3f1b6b0e64989b4f47 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Thu, 19 Jun 2025 16:18:12 -0400 Subject: [PATCH 03/13] Implement initial rewrite data-loading --- Client/src/OLD_INIT.lua | 137 +++ Client/src/init.lua | 132 +-- Server/src/OLD_INIT.lua | 1179 +++++++++++++++++++ Server/src/init.lua | 1297 +++++---------------- Server/wally.toml | 1 + TestEnv/Server/Scripts/SysTest.server.lua | 1 + TestEnv/Server/Services/DataService.lua | 37 +- 7 files changed, 1615 insertions(+), 1169 deletions(-) create mode 100644 Client/src/OLD_INIT.lua create mode 100644 Server/src/OLD_INIT.lua create mode 100644 TestEnv/Server/Scripts/SysTest.server.lua diff --git a/Client/src/OLD_INIT.lua b/Client/src/OLD_INIT.lua new file mode 100644 index 0000000..a89d5c7 --- /dev/null +++ b/Client/src/OLD_INIT.lua @@ -0,0 +1,137 @@ +--[[ + Data controller + Handles the fetching of the player's data +--]] + +local DataController = {} + +--------------------- +-- Roblox Services -- +--------------------- +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +------------------ +-- Dependencies -- +------------------ +local RobloxLibModules = require(script.Parent["roblox-libmodules"]) +local Table = require(RobloxLibModules.Utils.Table) +local DataService; + +------------- +-- Defines -- +------------- +local DataCache; +local PlayerData; +local IsDataLoaded = false + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : IsDataLoaded +-- @Description : Returns a bool describing whether or not the player's data has been fully replicated in +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:IsDataLoaded() + return IsDataLoaded +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : GetData +-- @Description : Gets the player's data +-- @Params : OPTIONAL bool "YieldForLoad" - A bool describing whether or not the API will yield for the data to exist +-- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:GetData(YieldForLoad,Format) + + if YieldForLoad ~= nil then + assert( + typeof(YieldForLoad) == "boolean", + ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead.") + :format(typeof(YieldForLoad)) + ) + end + if Format ~= nil then + assert( + typeof(Format) == "string", + ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead.") + :format(typeof(Format)) + ) + assert( + string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", + ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead.") + :format(Format) + ) + end + + if YieldForLoad then + while true do + if self:IsDataLoaded() then + break + else + RunService.Stepped:wait() + end + end + end + + if Format == nil then + return PlayerData,PlayerData:GetAttributes() + elseif string.upper(Format) == "TABLE" then + return Table.ConvertFolderToTable(PlayerData),PlayerData:GetAttributes() + elseif string.upper(Format) == "FOLDER" then + return PlayerData,PlayerData:GetAttributes() + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Init +-- @Description : Used to initialize controller state +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:Init() + self:DebugLog("[Data Controller] Initializing...") + + DataService = self:GetService("DataService") + + ----------------------------------- + -- Waiting for data to be loaded -- + ----------------------------------- + local Loaded = false + local LoadedID = DataService:GetDataLoadedQueueID() + + DataService.DataLoaded:connect(function(QueueID) + if QueueID == LoadedID then + Loaded = true + end + end) + + while true do + if Loaded then + break + else + RunService.Stepped:wait() + end + end + + local DescendantCount = DataService:GetDataFolderDescendantCount() + DataCache = ReplicatedStorage:WaitForChild("_DataCache") + PlayerData = DataCache:WaitForChild(tostring(Players.LocalPlayer.UserId)) + + while true do + if #self:GetData():GetDescendants() >= DescendantCount then + break + end + RunService.Stepped:wait() + end + IsDataLoaded = true + + self:DebugLog("[Data Controller] Initialized!") +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Start +-- @Description : Used to run the controller +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:Start() + self:DebugLog("[Data Controller] Running!") + +end + +return DataController \ No newline at end of file diff --git a/Client/src/init.lua b/Client/src/init.lua index a89d5c7..7d61aa5 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -1,137 +1,25 @@ ---[[ - Data controller - Handles the fetching of the player's data ---]] - local DataController = {} ---------------------- --- Roblox Services -- ---------------------- -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") -local RunService = game:GetService("RunService") - ------------------- --- Dependencies -- ------------------- -local RobloxLibModules = require(script.Parent["roblox-libmodules"]) -local Table = require(RobloxLibModules.Utils.Table) -local DataService; - -------------- --- Defines -- -------------- -local DataCache; -local PlayerData; -local IsDataLoaded = false - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataLoaded --- @Description : Returns a bool describing whether or not the player's data has been fully replicated in ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:IsDataLoaded() - return IsDataLoaded -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : GetData --- @Description : Gets the player's data --- @Params : OPTIONAL bool "YieldForLoad" - A bool describing whether or not the API will yield for the data to exist --- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:GetData(YieldForLoad,Format) - - if YieldForLoad ~= nil then - assert( - typeof(YieldForLoad) == "boolean", - ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead.") - :format(typeof(YieldForLoad)) - ) - end - if Format ~= nil then - assert( - typeof(Format) == "string", - ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead.") - :format(typeof(Format)) - ) - assert( - string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", - ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead.") - :format(Format) - ) - end - - if YieldForLoad then - while true do - if self:IsDataLoaded() then - break - else - RunService.Stepped:wait() - end - end - end +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- API Methods +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- - if Format == nil then - return PlayerData,PlayerData:GetAttributes() - elseif string.upper(Format) == "TABLE" then - return Table.ConvertFolderToTable(PlayerData),PlayerData:GetAttributes() - elseif string.upper(Format) == "FOLDER" then - return PlayerData,PlayerData:GetAttributes() - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Init --- @Description : Used to initialize controller state ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Description : Called when the service module is first loaded. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataController:Init() self:DebugLog("[Data Controller] Initializing...") - DataService = self:GetService("DataService") - - ----------------------------------- - -- Waiting for data to be loaded -- - ----------------------------------- - local Loaded = false - local LoadedID = DataService:GetDataLoadedQueueID() - - DataService.DataLoaded:connect(function(QueueID) - if QueueID == LoadedID then - Loaded = true - end - end) - - while true do - if Loaded then - break - else - RunService.Stepped:wait() - end - end - - local DescendantCount = DataService:GetDataFolderDescendantCount() - DataCache = ReplicatedStorage:WaitForChild("_DataCache") - PlayerData = DataCache:WaitForChild(tostring(Players.LocalPlayer.UserId)) - - while true do - if #self:GetData():GetDescendants() >= DescendantCount then - break - end - RunService.Stepped:wait() - end - IsDataLoaded = true - self:DebugLog("[Data Controller] Initialized!") end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Start --- @Description : Used to run the controller ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Description : Called after all services are loaded. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataController:Start() self:DebugLog("[Data Controller] Running!") - end -return DataController \ No newline at end of file +return DataController diff --git a/Server/src/OLD_INIT.lua b/Server/src/OLD_INIT.lua new file mode 100644 index 0000000..ed0e746 --- /dev/null +++ b/Server/src/OLD_INIT.lua @@ -0,0 +1,1179 @@ +--[[ + Data Service + + Handles the loading, saving and management of player data + + Backup system algorithm by @berezaa, modified and adapted by @Reshiram110 +--]] + +local DataService = { Client = {} } +DataService.Client.Server = DataService + +--------------------- +-- Roblox Services -- +--------------------- +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local DatastoreService = game:GetService("DataStoreService") + +------------------ +-- Dependencies -- +------------------ +local RobloxLibModules = require(script.Parent["roblox-libmodules"]) +local Table = require(RobloxLibModules.Utils.Table) +local Queue = require(RobloxLibModules.Classes.Queue) + +------------- +-- Defines -- +------------- +local DATASTORE_BASE_NAME = "Production" --The base name of the datastore to use +local DATASTORE_PRECISE_NAME = "PlayerData1" --The name of the datastore to append to DATASTORE_BASE_NAME +local DATASTORE_RETRY_ENABLED = true --Determines whether or not failed datastore calls will be retried +local DATASTORE_RETRY_INTERVAL = 3 --The time (in seconds) to wait between each retry +local DATASTORE_RETRY_LIMIT = 2 --The max amount of retries an operation can be retried before failing +local SESSION_LOCK_YIELD_INTERVAL = 5 -- The time (in seconds) at which the server will re-check a player's data session-lock. +--! The interval should not be below 5 seconds, since Roblox caches keys for 4 seconds. +local SESSION_LOCK_MAX_YIELD_INTERVALS = 5 -- The maximum amount of times the server will re-check a player's session-lock before ignoring it +local DATA_KEY_NAME = "SaveData" -- The name of the key to use when saving/loading data to/from a datastore +local DataFormat = {} +local DataFormatVersion = 1 +local DataFormatConversions = {} +local DataOperationsQueues = {} +local DataLoaded_IDs = {} +local DataCache = Instance.new("Folder") --Holds data for all players in ValueObject form +DataCache.Name = "_DataCache" +DataCache.Parent = ReplicatedStorage + +------------ +-- Events -- +------------ +local DataError --Fired on the server when there is an error handling the player's data +local DataCreated --Fired on the server when new data is created for a player. +local DataLoaded -- Fired to the client when its data is loaded into the server's cache + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- Helper functions +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +local function GetOperationsQueue(Player) + return DataOperationsQueues[tostring(Player.UserId)] +end + +local function GetTotalQueuesSize() + local QueuesSize = 0 + + for _, OperationsQueue in pairs(DataOperationsQueues) do + QueuesSize = QueuesSize + OperationsQueue:GetSize() + end + + return QueuesSize +end + +local function CreateDataCache(Player, Data, Metadata, CanSave) + local DataFolder = Table.ConvertTableToFolder(Data) + DataFolder.Name = tostring(Player.UserId) + + for Key, Value in pairs(Metadata) do + DataFolder:SetAttribute(Key, Value) + end + + DataFolder:SetAttribute("_CanSave", CanSave) + DataFolder.Parent = DataCache + + DataService:DebugLog( + ("[Data Service] Created data cache for player '%s', CanSave = %s!"):format(Player.Name, tostring(CanSave)) + ) +end + +local function RemoveDataCache(Player) + local DataFolder = DataCache[tostring(Player.UserId)] + + DataFolder:Destroy() + + DataService:DebugLog(("[Data Service] Removed data cache for player '%s'!"):format(Player.Name)) +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : IsDataLoaded +-- @Description : Returns a bool describing whether or not the specified player's data is loaded on the server or not. +-- @Params : Instance 'Player' - The player to check the data of +-- @Returns : bool "IsLoaded" - A bool describing whether or not the player's data is loaded on the server or not. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:IsDataLoaded(Player) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + + return DataCache:FindFirstChild(tostring(Player.UserId)) ~= nil +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : GetData +-- @Description : Returns the data for the specified player and returns it in the specified format +-- @Params : Instance 'Player' - The player to get the data of +-- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". +-- OPTIONAL bool "ShouldYield" - Whether or not the API should wait for the data to be fully loaded +-- @Returns : "Data" - The player's data +-- table "Metadata" - The metadata of the player's data +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:GetData(Player, ShouldYield, Format) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + if ShouldYield ~= nil then + assert( + typeof(ShouldYield) == "boolean", + ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead."):format( + typeof(ShouldYield) + ) + ) + end + if Format ~= nil then + assert( + typeof(Format) == "string", + ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead."):format( + typeof(Format) + ) + ) + assert( + string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", + ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead."):format( + Format + ) + ) + end + + local DataFolder = DataCache:FindFirstChild(tostring(Player.UserId)) + + if DataFolder == nil then --Player's data did not exist + if not ShouldYield then + self:Log( + ("[Data Service](GetData) Failed to get data for player '%s', their data did not exist!"):format( + Player.Name + ), + "Warning" + ) + + return nil + else + DataFolder = DataCache:WaitForChild(tostring(Player.UserId)) + end + end + + if Format == nil then + return DataFolder, DataFolder:GetAttributes() + elseif string.upper(Format) == "TABLE" then + return Table.ConvertFolderToTable(DataFolder), DataFolder:GetAttributes() + elseif string.upper(Format) == "FOLDER" then + return DataFolder, DataFolder:GetAttributes() + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Client.GetDataLoadedQueueID +-- @Description : Fetches & returns the unique queue ID associated with the queue action that loads the data for the client +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService.Client:GetDataLoadedQueueID(Player) + return DataLoaded_IDs[tostring(Player.UserId)] +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Client.GetDataFolderDescendantCount +-- @Description : Returns the number of descendants in the calling player's data folder +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService.Client:GetDataFolderDescendantCount(Player) + return #self.Server:GetData(Player):GetDescendants() +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : IsDataSessionlocked +-- @Description : Returns whether or not a player's data is session locked to another server +-- @Params : Instance 'Player' - The player to check the session lock status of +-- string "DatastoreName" - The name of the datastore to check the session lock in +-- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not +-- string "OperationMessage" - A message describing the result of the operation. can contain errors if the +-- operation fails. +-- bool "IsSessionlocked" - A bool describing whether or not the player's data is session-locked in another server. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:IsDataSessionlocked(Player, DatastoreName) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + assert( + typeof(DatastoreName) == "string", + ("[Data Service](IsDataSessionlocked) Bad argument #2 to 'IsDataSessionlocked', string expected, got %s instead."):format( + typeof(DatastoreName) + ) + ) + + self:DebugLog( + ("[Data Service](IsDataSessionlocked) Getting session lock for %s in datastore '%s'..."):format( + Player.Name, + DATASTORE_BASE_NAME .. "_" .. DatastoreName + ) + ) + + local SessionLock_Datastore = DatastoreService:GetDataStore( + DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", + tostring(Player.UserId) + ) + local SessionLocked = false + + local GetSessionLock_Success, GetSessionLock_Error = pcall(function() + SessionLocked = SessionLock_Datastore:GetAsync("SessionLock") + end) + + if GetSessionLock_Success then + self:DebugLog(("[Data Service](IsDataSessionLocked) Got session lock for %s!"):format(Player.Name)) + + return true, "Operation Success", SessionLocked + else + self:Log( + ("[Data Service](IsDataSessionlocked) An error occured while reading session-lock for '%s' : Could not read session-lock, %s"):format( + Player.Name, + GetSessionLock_Error + ), + "Warning" + ) + + return false, "Failed to read session-lock : Could not read session-lock, " .. GetSessionLock_Error + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : SessionlockData +-- @Description : Locks the data for the specified player to the current server +-- @Params : Instance 'Player' - The player to session lock the data of +-- string "DatastoreName" - The name of the datastore to lock the data in +-- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not +-- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the +-- operation fails. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:SessionlockData(Player, DatastoreName) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + assert( + typeof(DatastoreName) == "string", + ("[Data Service](SessionlockData) Bad argument #2 to 'SessionlockData', string expected, got %s instead."):format( + typeof(DatastoreName) + ) + ) + + self:DebugLog( + ("[Data Service](SessionlockData) Locking data for %s in datastore '%s'..."):format( + Player.Name, + DATASTORE_BASE_NAME .. "_" .. DatastoreName + ) + ) + + local SessionLock_Datastore = DatastoreService:GetDataStore( + DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", + tostring(Player.UserId) + ) + + local WriteLock_Success, WriteLock_Error = pcall(function() + SessionLock_Datastore:SetAsync("SessionLock", true) + end) + + if WriteLock_Success then + self:DebugLog(("[Data Service](SessionlockData) Locked data for %s!"):format(Player.Name)) + + return true, "Operation Success" + else + self:Log( + ("[Data Service](SessionlockData) An error occured while session-locking data for '%s' : Could not write session-lock, %s"):format( + Player.Name, + WriteLock_Error + ), + "Warning" + ) + + return false, "Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : UnSessionlockData +-- @Description : Unlocks the data for the specified player from the current server +-- @Params : Instance 'Player' - The player to un-session lock the data of +-- string "DatastoreName" - The name of the datastore to un-lock the data in +-- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not +-- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the +-- operation fails. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:UnSessionlockData(Player, DatastoreName) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + assert( + typeof(DatastoreName) == "string", + ("[Data Service](UnSessionlockData) Bad argument #2 to 'UnSessionlockData', string expected, got %s instead."):format( + typeof(DatastoreName) + ) + ) + + self:DebugLog( + ("[Data Service](UnSessionlockData) Unlocking data for %s in datastore '%s'..."):format( + Player.Name, + DATASTORE_BASE_NAME .. "_" .. DatastoreName + ) + ) + + local SessionLock_Datastore = DatastoreService:GetDataStore( + DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", + tostring(Player.UserId) + ) + + local WriteLock_Success, WriteLock_Error = pcall(function() + SessionLock_Datastore:SetAsync("SessionLock", false) + end) + + if WriteLock_Success then + self:DebugLog(("[Data Service](UnSessionlockData) Unlocked data for %s!"):format(Player.Name)) + + return true, "Operation Success" + else + self:Log( + ("[Data Service](UnSessionlockData) An error occured while un-session-locking data for '%s' : Could not write session-lock, %s"):format( + Player.Name, + WriteLock_Error + ), + "Warning" + ) + + return false, "Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : LoadData +-- @Description : Loads the data for the specified player and returns it as a table +-- @Params : Instance 'Player' - The player to load the data of +-- string "DatastoreName" - The name of the datastore to load the data from +-- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not +-- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the +-- operation fails. +-- table "Data" - The player's data. Will be default data if the operation fails. +-- table "Metadata" - Metadata for the player's data. Will be default metadata if the operation fails. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:LoadData(Player, DatastoreName) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + assert( + typeof(DatastoreName) == "string", + ("[Data Service](LoadData) Bad argument #2 to 'SaveData', string expected, got %s instead."):format( + typeof(DatastoreName) + ) + ) + + self:DebugLog( + ("[Data Service](LoadData) Loading data for %s from datastore '%s'..."):format( + Player.Name, + DATASTORE_BASE_NAME .. "_" .. DatastoreName + ) + ) + + ------------- + -- Defines -- + ------------- + local Data_Datastore = + DatastoreService:GetDataStore(DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_Data", tostring(Player.UserId)) + local Data -- Holds the player's data + local Data_Metadata -- Holds metadata for the save data + + ---------------------------------- + -- Fetching data from datastore -- + ---------------------------------- + local GetDataSuccess, GetDataErrorMessage = pcall(function() + local KeyInfo + + Data, KeyInfo = Data_Datastore:GetAsync(DATA_KEY_NAME) + + if Data ~= nil then + Data_Metadata = KeyInfo:GetMetadata() + end + end) + if not GetDataSuccess then --! An error occured while getting the player's data + self:Log( + ("[Data Service](LoadData) An error occured while loading data for player '%s' : %s"):format( + Player.Name, + GetDataErrorMessage + ), + "Warning" + ) + + Data = Table.Copy(DataFormat) + DataError:Fire(Player, "Load", "FetchData", Data) + self.Client.DataError:FireAllClients(Player, "Load", "FetchData", Data) + + return false, "Failed to load data : " .. GetDataErrorMessage, Data, { FormatVersion = DataFormatVersion } + else + if Data == nil then -- * It is the first time loading data from this datastore. Player must be new! + self:DebugLog( + ("[Data Service](LoadData) Data created for the first time for player '%s', they may be new!"):format( + Player.Name + ) + ) + + Data = Table.Copy(DataFormat) + DataCreated:Fire(Player, Data) + self.Client.DataCreated:FireAllClients(Player, Data) + + return true, "Operation Success", Data, { FormatVersion = DataFormatVersion } + end + end + + ------------------------------------------ + -- Updating the data's format if needed -- + ------------------------------------------ + if Data_Metadata.FormatVersion < DataFormatVersion then -- Data format is outdated, it needs to be updated. + self:DebugLog(("[Data Service](LoadData) %s's data format is outdated, updating..."):format(Player.Name)) + + local DataFormatUpdateSuccess, DataFormatUpdateErrorMessage = pcall(function() + for _ = Data_Metadata.FormatVersion, DataFormatVersion - 1 do + self:DebugLog( + ("[Data Service](LoadData) Updating %s's data from version %s to version %s..."):format( + Player.Name, + tostring(Data_Metadata.FormatVersion), + tostring(Data_Metadata.FormatVersion + 1) + ) + ) + + Data = DataFormatConversions[tostring(Data_Metadata.FormatVersion) .. " -> " .. tostring( + Data_Metadata.FormatVersion + 1 + )](Data) + Data_Metadata.FormatVersion = Data_Metadata.FormatVersion + 1 + end + end) + + if not DataFormatUpdateSuccess then --! An error occured while updating the player's data + self:Log( + ("[Data Service](LoadData) An error occured while updating the data for player '%s' : %s"):format( + Player.Name, + DataFormatUpdateErrorMessage + ), + "Warning" + ) + + Data = Table.Copy(DataFormat) + DataError:Fire(Player, "Load", "FormatUpdate", Data) + self.Client.DataError:FireAllClients(Player, "Load", "FormatUpdate", Data) + + return false, + "Failed to load data : Update failed, " .. DataFormatUpdateErrorMessage, + Data, + { FormatVersion = DataFormatVersion } + end + elseif Data_Metadata.FormatVersion == nil or Data_Metadata.FormatVersion > DataFormatVersion then -- Unreadable data format, do not load data. + self:Log( + ("[Data Service](LoadData) An error occured while loading the data for player '%s' : %s"):format( + Player.Name, + "Unknown data format" + ), + "Warning" + ) + + Data = Table.Copy(DataFormat) + DataError:Fire(Player, "Load", "UnknownDataFormat", Data) + + self.Client.DataError:FireAllClients(Player, "Load", "UnknownDatFormat", Data) + + return false, "Failed to load data : Unknown data format", Data, { FormatVersion = DataFormatVersion } + end + + self:DebugLog(("[Data Service](LoadData) Successfully loaded data for player '%s'!"):format(Player.Name)) + + return true, "Operation Success", Data, Data_Metadata +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : SaveData +-- @Description : Saves the data for the specified player into the specified datastore +-- @Params : Instance 'Player' - the player to save the data of +-- string "DatastoreName" - The name of the datastore to save the data to +-- table "Data" - The table containing the data to save +-- table "Data_Metadata" - The table containing the metadata of the data to save +-- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not +-- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the +-- operation fails. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:SaveData(Player, DatastoreName, Data, Data_Metadata) + ---------------- + -- Assertions -- + ---------------- + assert( + typeof(Player) == "Instance", + ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead."):format( + typeof(Player) + ) + ) + assert( + Player:IsA("Player"), + ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead."):format( + Player.ClassName + ) + ) + assert( + typeof(DatastoreName) == "string", + ("[Data Service](SaveData) Bad argument #2 to 'SaveData', string expected, got %s instead."):format( + typeof(DatastoreName) + ) + ) + assert(Data ~= nil, "[Data Service](SaveData) Bad argument #3 to 'SaveData', Data expected, got nil.") + assert(Data_Metadata ~= nil, "[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got nil.") + assert( + typeof(Data_Metadata) == "table", + ("[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got %s instead."):format( + typeof(Data_Metadata) + ) + ) + assert( + Data_Metadata.FormatVersion ~= nil, + "[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected, got nil." + ) + assert( + typeof(Data_Metadata.FormatVersion) == "number", + ("[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected as number, got %s instead."):format( + typeof(Data_Metadata.FormatVersion) + ) + ) + + self:DebugLog( + ("[Data Service](SaveData) Saving data for %s into datastore '%s'..."):format( + Player.Name, + DATASTORE_BASE_NAME .. "_" .. DatastoreName + ) + ) + + ------------- + -- Defines -- + ------------- + local Data_Datastore = + DatastoreService:GetDataStore(DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_Data", tostring(Player.UserId)) + local DatastoreSetOptions = Instance.new("DataStoreSetOptions") + + ---------------------------------------------- + -- Saving player's data to normal datastore -- + ---------------------------------------------- + local SaveDataSuccess, SaveDataErrorMessage = pcall(function() + DatastoreSetOptions:SetMetadata(Data_Metadata) + Data_Datastore:SetAsync(DATA_KEY_NAME, Data, {}, DatastoreSetOptions) + end) + if not SaveDataSuccess then --! An error occured while saving the player's data. + self:Log( + ("[Data Service](SaveData) An error occured while saving data for '%s' : %s"):format( + Player.Name, + SaveDataErrorMessage + ), + "Warning" + ) + + DataError:Fire(Player, "Save", "SaveData", Data) + self.Client.DataError:FireAllClients(Player, "Save", "SaveData", Data) + + return false, "Failed to save data : " .. SaveDataErrorMessage + end + + self:DebugLog( + ("[Data Service](SaveData) Data saved successfully into datastore '%s' for %s!"):format( + DATASTORE_BASE_NAME .. "_" .. DatastoreName, + Player.Name + ) + ) + + return true, "Operation Success" +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : SetConfigs +-- @Description : Sets this service's configs to the specified values +-- @Params : table "Configs" - A dictionary containing the new config values +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:SetConfigs(Configs) + DataFormatVersion = Configs.DataFormatVersion + DataFormat = Configs.DataFormat + DataFormatConversions = Configs.DataFormatConversions + DATASTORE_BASE_NAME = Configs.DatastoreBaseName + DATASTORE_PRECISE_NAME = Configs.DatastorePreciseName + DATASTORE_RETRY_ENABLED = Configs.DatastoreRetryEnabled + DATASTORE_RETRY_INTERVAL = Configs.DatastoreRetryInterval + DATASTORE_RETRY_LIMIT = Configs.DatastoreRetryLimit + SESSION_LOCK_YIELD_INTERVAL = Configs.SessionLockYieldInterval + SESSION_LOCK_MAX_YIELD_INTERVALS = Configs.SessionLockMaxYieldIntervals + DATA_KEY_NAME = Configs.DataKeyName +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Init +-- @Description : Called when the service module is first loaded. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:Init() + DataLoaded = self:RegisterServiceClientEvent("DataLoaded") + DataCreated = self:RegisterServiceServerEvent("DataCreated") + DataError = self:RegisterServiceServerEvent("DataError") + self.Client.DataCreated = self:RegisterServiceClientEvent("DataCreated") + self.Client.DataError = self:RegisterServiceClientEvent("DataError") + + self:DebugLog("[Data Service] Initialized!") +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Start +-- @Description : Called after all services are loaded. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:Start() + self:DebugLog("[Data Service] Started!") + + ------------------------------------------- + -- Loads player data into server's cache -- + ------------------------------------------- + local function LoadPlayerDataIntoServer(Player) + local WaitForSessionLock_Success = false -- Determines whether or not the session lock was waited for successfully + local SetSessionLock_Success = false -- Determines whether or not the session lock was successfully enabled for this server + local LoadData_Success = false -- Determines whether or not the player's data was fetched successfully + local PlayerData + local PlayerData_Metadata + + self:Log(("[Data Service] Loading data for player '%s'..."):format(Player.Name)) + + ---------------------------------------------------- + -- Waiting for other server's sessionlock removal -- + ---------------------------------------------------- + self:DebugLog( + ("[Data Service] Waiting for previous server to remove session lock for player '%s'..."):format(Player.Name) + ) + + for SessionLock_YieldCount = 1, SESSION_LOCK_MAX_YIELD_INTERVALS do + local GetLockSuccess + local OperationMessage + local IsLocked + + -------------------------------- + -- Reading session lock value -- + -------------------------------- + for RetryCount = 0, DATASTORE_RETRY_LIMIT do + self:DebugLog(("[Data Service] Reading session lock for player '%s'..."):format(Player.Name)) + + GetLockSuccess, OperationMessage, IsLocked = self:IsDataSessionlocked(Player, DATASTORE_PRECISE_NAME) + + if not GetLockSuccess then + self:Log( + ("[Data Service] Failed to read session lock for player '%s' : %s"):format( + Player.Name, + OperationMessage + ), + "Warning" + ) + + if RetryCount == DATASTORE_RETRY_LIMIT then + self:Log( + ("[Data Service] Max retries reached while attempting to read session lock for player '%s', aborting"):format( + Player.Name + ), + "Warning" + ) + + break + else + if DATASTORE_RETRY_ENABLED then + self:Log( + ("[Data Service] Attempting to read session lock for player '%s' %s more times."):format( + Player.Name, + tostring(DATASTORE_RETRY_LIMIT - RetryCount) + ) + ) + + task.wait(DATASTORE_RETRY_INTERVAL) + else + break + end + end + else + self:DebugLog(("[Data Service] Got session lock for player '%s'!"):format(Player.Name)) + + break + end + end + + -------------------------------------------- + -- Determining if sessionlock was removed -- + -------------------------------------------- + if not GetLockSuccess then + break + end + + if IsLocked then + if SessionLock_YieldCount == SESSION_LOCK_MAX_YIELD_INTERVALS then + self:Log( + ("[Data Service] Timeout reached while waiting for previous server to remove its sessionlock for player '%s', ignoring it."):format( + Player.Name + ), + "Warning" + ) + + WaitForSessionLock_Success = true + else + self:DebugLog( + ("[Data Service] Previous server hasn't removed session lock for player '%s' yet, waiting %s seconds before re-reading."):format( + Player.Name, + tostring(SESSION_LOCK_YIELD_INTERVAL) + ) + ) + end + + task.wait(SESSION_LOCK_YIELD_INTERVAL) + else + self:DebugLog( + ("[Data Service] Previous server removed session lock for player '%s'!"):format(Player.Name) + ) + + WaitForSessionLock_Success = true + break + end + end + + -------------------------- + -- Setting session lock -- + -------------------------- + if not WaitForSessionLock_Success then + self:Log( + ("[Data Service] Failed to set session lock to this server, giving player '%s' default data."):format( + Player.Name + ), + "Warning" + ) + + CreateDataCache(Player, Table.Copy(DataFormat), false) + return + else + self:DebugLog(("[Data Service] Setting session-lock for player '%s'..."):format(Player.Name)) + end + + for RetryCount = 1, DATASTORE_RETRY_LIMIT do + self:DebugLog( + ("[Data Service] Writing sessionlock to datastore '%s' for player '%s'..."):format( + DATASTORE_PRECISE_NAME, + Player.Name + ) + ) + + local SetLockSuccess, SetLockMessage = self:SessionlockData(Player, DATASTORE_PRECISE_NAME) + + if not SetLockSuccess then + self:Log( + ("[Data Service] Failed to set session-lock for player '%s' : %s"):format( + Player.Name, + SetLockMessage + ), + "Warning" + ) + + if DATASTORE_RETRY_ENABLED then + if RetryCount == DATASTORE_RETRY_LIMIT then + self:Log( + ("[Data Service] Max retries reached while trying to session-lock data for player '%s', no further attempts will be made."):format( + Player.Name + ), + "Warning" + ) + else + self:Log( + ("[Data Service] Retrying to session-lock data for player '%s', waiting %s seconds before retrying."):format( + Player.Name, + tostring(DATASTORE_RETRY_INTERVAL) + ), + "Warning" + ) + + task.wait(DATASTORE_RETRY_INTERVAL) + end + else + break + end + else + self:DebugLog(("[Data Service] Successfully session-locked data for player '%s'!"):format(Player.Name)) + + SetSessionLock_Success = true + break + end + end + + ---------------------------- + -- Fetching player's data -- + ---------------------------- + if not SetSessionLock_Success then + self:Log( + ("[Data Service] Failed to set session-lock, giving player '%s' default data."):format(Player.Name), + "Warning" + ) + + CreateDataCache(Player, Table.Copy(DataFormat), false) + return + else + self:DebugLog(("[Data Service] Fetching data for player '%s' from datastore..."):format(Player.Name)) + end + + for RetryCount = 1, DATASTORE_RETRY_LIMIT do + self:DebugLog( + ("[Data Service] Reading data from datastore '%s' for player '%s'..."):format( + DATASTORE_PRECISE_NAME, + Player.Name + ) + ) + + local FetchDataSuccess, FetchDataMessage, Data, Data_Metadata = + self:LoadData(Player, DATASTORE_PRECISE_NAME) + + if not FetchDataSuccess then + self:Log( + ("[Data Service] Failed to fetch data for player '%s' : %s"):format(Player.Name, FetchDataMessage), + "Warning" + ) + + if DATASTORE_RETRY_ENABLED then + if RetryCount == DATASTORE_RETRY_LIMIT then + self:Log( + ("[Data Service] Max retries reached while trying to load data for player '%s', no further attempts will be made."):format( + Player.Name + ), + "Warning" + ) + else + self:Log( + ("[Data Service] Retrying to fetch data for player '%s', waiting %s seconds before retrying."):format( + Player.Name, + tostring(DATASTORE_RETRY_INTERVAL) + ), + "Warning" + ) + + task.wait(DATASTORE_RETRY_INTERVAL) + end + else + break + end + else + self:DebugLog( + ("[Data Service] Successfully fetched data for player '%s' from datastores!"):format(Player.Name) + ) + + LoadData_Success = true + PlayerData = Data + PlayerData_Metadata = Data_Metadata + + break + end + end + + if not LoadData_Success then + self:Log( + ("[Data Service] Failed to load data for player '%s', player will be given default data."):format( + Player.Name + ), + "Warning" + ) + + CreateDataCache(Player, Table.Copy(DataFormat), { FormatVersion = DataFormatVersion }, false) + else + self:Log(("[Data Service] Successfully loaded data for player '%s'!"):format(Player.Name)) + + CreateDataCache(Player, PlayerData, PlayerData_Metadata, true) + end + end + + ------------------------------------------- + -- Saves player data from servers' cache -- + ------------------------------------------- + local function SavePlayerDataFromServer(Player) + local PlayerData, Data_Metadata = self:GetData(Player, false, "Table") + local WriteData_Success = false -- Determines whether or not the player's data was successfully saved to datastores + + Data_Metadata["_CanSave"] = nil + + self:Log(("[Data Service] Saving data for player '%s'..."):format(Player.Name)) + + ------------------------------- + -- Writing data to datastore -- + ------------------------------- + self:DebugLog(("[Data Service] Writing data to datastores for player '%s'..."):format(Player.Name)) + for RetryCount = 1, DATASTORE_RETRY_LIMIT do + self:DebugLog( + ("[Data Service] Writing data to datastore '%s' for player '%s'..."):format( + DATASTORE_PRECISE_NAME, + Player.Name + ) + ) + + local WriteDataSuccess, WriteDataMessage = + self:SaveData(Player, DATASTORE_PRECISE_NAME, PlayerData, Data_Metadata) + + if not WriteDataSuccess then + self:Log( + ("[Data Service] Failed to write data for player '%s' : %s"):format(Player.Name, WriteDataMessage), + "Warning" + ) + + if DATASTORE_RETRY_ENABLED then + if RetryCount == DATASTORE_RETRY_LIMIT then + self:Log( + ("[Data Service] Max retries reached while trying to write data for player '%s', no further attempts will be made."):format( + Player.Name + ), + "Warning" + ) + else + self:Log( + ("[Data Service] Retrying to write data for player '%s', waiting %s seconds before retrying."):format( + Player.Name, + tostring(DATASTORE_RETRY_INTERVAL) + ), + "Warning" + ) + + task.wait(DATASTORE_RETRY_INTERVAL) + end + else + break + end + else + self:DebugLog( + ("[Data Service] Successfully wrote data for player '%s' to datastores!"):format(Player.Name) + ) + + WriteData_Success = true + break + end + end + + if not WriteData_Success then + self:Log(("[Data Service] Failed to save data for player '%s'."):format(Player.Name), "Warning") + else + self:Log(("[Data Service] Successfully saved data for player '%s'!"):format(Player.Name)) + end + + ---------------------------- + -- Un-sessionlocking data -- + ---------------------------- + self:DebugLog(("[Data Service] Un-session locking data for player '%s'..."):format(Player.Name)) + + for RetryCount = 1, DATASTORE_RETRY_LIMIT do + self:DebugLog( + ("[Data Service] Removing sessionlock from datastore '%s' for player '%s'..."):format( + DATASTORE_PRECISE_NAME, + Player.Name + ) + ) + + local RemoveLockSuccess, RemoveLockMessage = self:UnSessionlockData(Player, DATASTORE_PRECISE_NAME) + + if not RemoveLockSuccess then + self:Log( + ("[Data Service] Failed to remove session-lock for player '%s' : %s"):format( + Player.Name, + RemoveLockMessage + ), + "Warning" + ) + + if DATASTORE_RETRY_ENABLED then + if RetryCount == DATASTORE_RETRY_LIMIT then + self:Log( + ("[Data Service] Max retries reached while trying to remove session-lock for player '%s', no further attempts will be made."):format( + Player.Name + ), + "Warning" + ) + else + self:Log( + ("[Data Service] Retrying to remove session-lock for player '%s', waiting %s seconds before retrying."):format( + Player.Name, + tostring(DATASTORE_RETRY_INTERVAL) + ), + "Warning" + ) + + task.wait(DATASTORE_RETRY_INTERVAL) + end + else + break + end + else + self:DebugLog(("[Data Service] Successfully removed session-lock for player '%s'!"):format(Player.Name)) + + break + end + end + + RemoveDataCache(Player) + end + + --------------------------------- + -- Loading player data on join -- + --------------------------------- + local function PlayerJoined(Player) + if GetOperationsQueue(Player) == nil then + DataOperationsQueues[tostring(Player.UserId)] = Queue.new() + end + + local DataOperationsQueue = GetOperationsQueue(Player) + + local QueueItemID = DataOperationsQueue:AddAction(function() + LoadPlayerDataIntoServer(Player) + end, function(ActionID) + DataLoaded:FireClient(Player, ActionID) + end) + DataLoaded_IDs[tostring(Player.UserId)] = QueueItemID + + if not DataOperationsQueue:IsExecuting() then + DataOperationsQueue:Execute() + end + end + Players.PlayerAdded:connect(PlayerJoined) + for _, Player in pairs(Players:GetPlayers()) do + coroutine.wrap(PlayerJoined)(Player) + end + + --------------------------------- + -- Saving player data on leave -- + --------------------------------- + local function PlayerLeaving(Player) + DataLoaded_IDs[tostring(Player.UserId)] = nil + + local DataOperationsQueue = GetOperationsQueue(Player) + + DataOperationsQueue:AddAction(function() + if self:GetData(Player, false):GetAttribute("_CanSave") == false then + self:Log( + ("[Data Service] Player '%s' left, but their data was marked as not saveable. Will not save data."):format( + Player.Name + ), + "Warning" + ) + + RemoveDataCache(Player) + + return + else + SavePlayerDataFromServer(Player) + end + end, function() + if DataOperationsQueue:GetSize() == 0 then + DataOperationsQueue:Destroy() + DataOperationsQueues[tostring(Player.UserId)] = nil + end + end) + + if not DataOperationsQueue:IsExecuting() then + DataOperationsQueue:Execute() + end + end + Players.PlayerRemoving:connect(PlayerLeaving) + + -------------------------------------------------------------------------------- + -- Ensuring that all player data is saved before letting the server shut down -- + -------------------------------------------------------------------------------- + game:BindToClose(function() + self:Log("[Data Service] Server shutting down, waiting for data operations queue to be empty...") + + while true do -- Wait for all player data to be saved + if GetTotalQueuesSize() == 0 then + break + end + RunService.Stepped:wait() + end + + self:Log("[Data Service] Operations queue is empty! Letting server shut down.") + end) +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Stop +-- @Description : Called when the service is being stopped. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:Stop() + self:Log("[Data Service] Stopped!") +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Unload +-- @Description : Called when the service is being unloaded. +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:Unload() + self:Log("[Data Service] Unloaded!") +end + +return DataService diff --git a/Server/src/init.lua b/Server/src/init.lua index f3d020a..cd8d95d 100644 --- a/Server/src/init.lua +++ b/Server/src/init.lua @@ -1,1130 +1,377 @@ ---[[ - Data Service +--!nocheck - Handles the loading, saving and management of player data - - Backup system algorithm by @berezaa, modified and adapted by @Reshiram110 ---]] - -local DataService={Client={}} -DataService.Client.Server=DataService +local DataService = {} --------------------- -- Roblox Services -- --------------------- -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") -local RunService = game:GetService("RunService") local DatastoreService = game:GetService("DataStoreService") +local Players = game:GetService("Players") +local HttpService = game:GetService("HttpService") ------------------ -- Dependencies -- ------------------ local RobloxLibModules = require(script.Parent["roblox-libmodules"]) -local Table = require(RobloxLibModules.Utils.Table) +local Table = require(script.Parent.Table) local Queue = require(RobloxLibModules.Classes.Queue) ------------- -- Defines -- ------------- -local DATASTORE_BASE_NAME = "Production" --The base name of the datastore to use -local DATASTORE_PRECISE_NAME = "PlayerData1" --The name of the datastore to append to DATASTORE_BASE_NAME -local DATASTORE_RETRY_ENABLED = true --Determines whether or not failed datastore calls will be retried -local DATASTORE_RETRY_INTERVAL = 3 --The time (in seconds) to wait between each retry -local DATASTORE_RETRY_LIMIT = 2 --The max amount of retries an operation can be retried before failing -local SESSION_LOCK_YIELD_INTERVAL = 5 -- The time (in seconds) at which the server will re-check a player's data session-lock. - --! The interval should not be below 5 seconds, since Roblox caches keys for 4 seconds. -local SESSION_LOCK_MAX_YIELD_INTERVALS = 5 -- The maximum amount of times the server will re-check a player's session-lock before ignoring it -local DATA_KEY_NAME = "SaveData" -- The name of the key to use when saving/loading data to/from a datastore -local DataFormat = {} -local DataFormatVersion = 1 -local DataFormatConversions = {} -local DataOperationsQueues = {} -local DataLoaded_IDs = {} -local DataCache = Instance.new('Folder') --Holds data for all players in ValueObject form -DataCache.Name = "_DataCache" -DataCache.Parent = ReplicatedStorage - ------------- --- Events -- ------------- -local DataError; --Fired on the server when there is an error handling the player's data -local DataCreated; --Fired on the server when new data is created for a player. -local DataLoaded; -- Fired to the client when its data is loaded into the server's cache +local OPERATION_MAX_RETRIES = 3 -- The number of times any data operation will be attempted before aborting +local OPERATION_RETRY_INTERVAL = 5 -- The number of seconds between each retry for any data operation. Recommended to keep this above 4 seconds, as Roblox GetAsync() calls cache data for 4 seconds. +local DATASTORE_NAME = "" +local DATA_SCHEMA = { + Version = 1, + Data = {}, + Migrators = {}, +} +local WasConfigured = false +local DataCaches = {} +local DataOperationQueues = {} ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- Helper functions +-- Helper methods ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -local function GetOperationsQueue(Player) - return DataOperationsQueues[tostring(Player.UserId)] -end - -local function GetTotalQueuesSize() - local QueuesSize = 0 - - for _,OperationsQueue in pairs(DataOperationsQueues) do - QueuesSize = QueuesSize + OperationsQueue:GetSize() - end - - return QueuesSize -end - -local function CreateDataCache(Player,Data,Metadata,CanSave) - local DataFolder = Table.ConvertTableToFolder(Data) - DataFolder.Name = tostring(Player.UserId) - - for Key,Value in pairs(Metadata) do - DataFolder:SetAttribute(Key,Value) - end - - DataFolder:SetAttribute("_CanSave",CanSave) - DataFolder.Parent = DataCache - - DataService:DebugLog( - ("[Data Service] Created data cache for player '%s', CanSave = %s!"):format(Player.Name,tostring(CanSave)) - ) -end - -local function RemoveDataCache(Player) - local DataFolder = DataCache[tostring(Player.UserId)] - - DataFolder:Destroy() - - DataService:DebugLog( - ("[Data Service] Removed data cache for player '%s'!"):format(Player.Name) - ) -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataLoaded --- @Description : Returns a bool describing whether or not the specified player's data is loaded on the server or not. --- @Params : Instance 'Player' - The player to check the data of --- @Returns : bool "IsLoaded" - A bool describing whether or not the player's data is loaded on the server or not. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:IsDataLoaded(Player) - - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - - return DataCache:FindFirstChild(tostring(Player.UserId)) ~= nil -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : GetData --- @Description : Returns the data for the specified player and returns it in the specified format --- @Params : Instance 'Player' - The player to get the data of --- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". --- OPTIONAL bool "ShouldYield" - Whether or not the API should wait for the data to be fully loaded --- @Returns : "Data" - The player's data --- table "Metadata" - The metadata of the player's data ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:GetData(Player,ShouldYield,Format) - - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - if ShouldYield ~= nil then - assert( - typeof(ShouldYield) == "boolean", - ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead.") - :format(typeof(ShouldYield)) - ) - end - if Format ~= nil then - assert( - typeof(Format) == "string", - ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead.") - :format(typeof(Format)) - ) - assert( - string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", - ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead.") - :format(Format) - ) - end - - local DataFolder = DataCache:FindFirstChild(tostring(Player.UserId)) - - if DataFolder == nil then --Player's data did not exist - if not ShouldYield then - self:Log( - ("[Data Service](GetData) Failed to get data for player '%s', their data did not exist!"):format(Player.Name), - "Warning" - ) +local function RetryOperation(Operation, RetryAmount, RetryInterval, OperationDescription) + DataService:DebugLog(("[Data Service] Attempting to %s..."):format(OperationDescription)) + + for TryCount = 1, RetryAmount do + local Success, Result = pcall(Operation) + + if not Success then + if TryCount ~= RetryAmount then + DataService:Log( + ("[Data Service] Failed to %s : %s | RETRYING IN %s SECONDS!"):format( + OperationDescription, + Result, + tostring(RetryInterval) + ), + "Warning" + ) - return nil + task.wait(RetryInterval) + else + DataService:Log( + ("[Data Service] Failed to %s : %s | MAX RETRIES REACHED, ABORTING!"):format( + OperationDescription, + Result + ), + "Warning" + ) + end else - DataFolder = DataCache:WaitForChild(tostring(Player.UserId)) + DataService:DebugLog("[Data Service] " .. OperationDescription .. " succeeded!") + + return true, Result end end - if Format == nil then - return DataFolder,DataFolder:GetAttributes() - elseif string.upper(Format) == "TABLE" then - return Table.ConvertFolderToTable(DataFolder),DataFolder:GetAttributes() - elseif string.upper(Format) == "FOLDER" then - return DataFolder,DataFolder:GetAttributes() - end + return false end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Client.GetDataLoadedQueueID --- @Description : Fetches & returns the unique queue ID associated with the queue action that loads the data for the client ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService.Client:GetDataLoadedQueueID(Player) - return DataLoaded_IDs[tostring(Player.UserId)] +local function AreQueuesEmpty() + return false end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Client.GetDataFolderDescendantCount --- @Description : Returns the number of descendants in the calling player's data folder ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService.Client:GetDataFolderDescendantCount(Player) - return #self.Server:GetData(Player):GetDescendants() +local function GetSaveStore() + return DatastoreService:GetDataStore(DATASTORE_NAME) end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataSessionlocked --- @Description : Returns whether or not a player's data is session locked to another server --- @Params : Instance 'Player' - The player to check the session lock status of --- string "DatastoreName" - The name of the datastore to check the session lock in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. can contain errors if the --- operation fails. --- bool "IsSessionlocked" - A bool describing whether or not the player's data is session-locked in another server. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:IsDataSessionlocked(Player,DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](IsDataSessionlocked) Bad argument #2 to 'IsDataSessionlocked', string expected, got %s instead.") - :format(typeof(DatastoreName)) - ) - - self:DebugLog( - ("[Data Service](IsDataSessionlocked) Getting session lock for %s in datastore '%s'...") - :format(Player.Name,DATASTORE_BASE_NAME .. "_" .. DatastoreName) - ) - - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) - ) - local SessionLocked = false - - local GetSessionLock_Success,GetSessionLock_Error = pcall(function() - SessionLocked = SessionLock_Datastore:GetAsync("SessionLock") - end) - - if GetSessionLock_Success then - self:DebugLog( - ("[Data Service](IsDataSessionLocked) Got session lock for %s!") - :format(Player.Name) - ) - - return true,"Operation Success",SessionLocked - else - self:Log( - ("[Data Service](IsDataSessionlocked) An error occured while reading session-lock for '%s' : Could not read session-lock, %s") - :format(Player.Name,GetSessionLock_Error), - "Warning" - ) - - return false,"Failed to read session-lock : Could not read session-lock, " .. GetSessionLock_Error - end +local function CreateSaveData(Player) + return { + CreatedTime = DateTime.now(), + UpdatedTime = DateTime.now(), + Version = 1, + UserIDs = { Player.UserId }, + Data = Table.Copy(DATA_SCHEMA.Data, true), + Metadata = { SchemaVersion = DATA_SCHEMA.Version }, + IsTemporary = true, --! This should ALWAYS be defined as true here - we don't want the data to be savable unless NO operations have failed. Read-only is a safe default for critical data. + } end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SessionlockData --- @Description : Locks the data for the specified player to the current server --- @Params : Instance 'Player' - The player to session lock the data of --- string "DatastoreName" - The name of the datastore to lock the data in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SessionlockData(Player,DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](SessionlockData) Bad argument #2 to 'SessionlockData', string expected, got %s instead.") - :format(typeof(DatastoreName)) - ) - - self:DebugLog( - ("[Data Service](SessionlockData) Locking data for %s in datastore '%s'..."):format(Player.Name,DATASTORE_BASE_NAME.."_"..DatastoreName) - ) +local function GetSessionLock(Player) + local SaveStore = GetSaveStore() + local Success, SessionLock = RetryOperation( + function() + local KeyValue = SaveStore:GetAsync(tostring(Player.UserId) .. "/SessionLock") - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) + return KeyValue + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("fetch sessionlock for player with ID '%s'"):format(tostring(Player.UserId)) ) - local WriteLock_Success,WriteLock_Error = pcall(function() - SessionLock_Datastore:SetAsync("SessionLock",true) - end) - - if WriteLock_Success then - self:DebugLog( - ("[Data Service](SessionlockData) Locked data for %s!") - :format(Player.Name) - ) - - return true,"Operation Success" - else - self:Log( - ("[Data Service](SessionlockData) An error occured while session-locking data for '%s' : Could not write session-lock, %s") - :format(Player.Name,WriteLock_Error), - "Warning" - ) - - return false,"Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error - end + return Success, SessionLock end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : UnSessionlockData --- @Description : Unlocks the data for the specified player from the current server --- @Params : Instance 'Player' - The player to un-session lock the data of --- string "DatastoreName" - The name of the datastore to un-lock the data in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:UnSessionlockData(Player,DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](UnSessionlockData) Bad argument #2 to 'UnSessionlockData', string expected, got %s instead.") - :format(typeof(DatastoreName)) - ) +local function WriteSessionLock(Player, SessionID) + local Success = RetryOperation( + function() + local SaveStore = GetSaveStore() - self:DebugLog( - ("[Data Service](UnSessionlockData) Unlocking data for %s in datastore '%s'..."):format(Player.Name,DATASTORE_BASE_NAME.."_"..DatastoreName) + SaveStore:SetAsync(tostring(Player.UserId) .. "/SessionLock", SessionID, { Player.UserId }) + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("write sessionlock for player with ID '%s'"):format(tostring(Player.UserId)) ) - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) - ) - - local WriteLock_Success,WriteLock_Error = pcall(function() - SessionLock_Datastore:SetAsync("SessionLock",false) - end) - - if WriteLock_Success then - self:DebugLog( - ("[Data Service](UnSessionlockData) Unlocked data for %s!") - :format(Player.Name) - ) - - return true,"Operation Success" - else - self:Log( - ("[Data Service](UnSessionlockData) An error occured while un-session-locking data for '%s' : Could not write session-lock, %s") - :format(Player.Name,WriteLock_Error), - "Warning" - ) - - return false,"Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error - end + return Success end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : LoadData --- @Description : Loads the data for the specified player and returns it as a table --- @Params : Instance 'Player' - The player to load the data of --- string "DatastoreName" - The name of the datastore to load the data from --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. --- table "Data" - The player's data. Will be default data if the operation fails. --- table "Metadata" - Metadata for the player's data. Will be default metadata if the operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:LoadData(Player,DatastoreName) - - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](LoadData) Bad argument #2 to 'SaveData', string expected, got %s instead.") - :format(typeof(DatastoreName)) - ) - - self:DebugLog( - ("[Data Service](LoadData) Loading data for %s from datastore '%s'..."):format(Player.Name,DATASTORE_BASE_NAME.."_"..DatastoreName) - ) - - ------------- - -- Defines -- - ------------- - local Data_Datastore = DatastoreService:GetDataStore(DATASTORE_BASE_NAME.."_"..DatastoreName.."_Data",tostring(Player.UserId)) - local Data; -- Holds the player's data - local Data_Metadata; -- Holds metadata for the save data - - ---------------------------------- - -- Fetching data from datastore -- - ---------------------------------- - local GetDataSuccess,GetDataErrorMessage = pcall(function() - local KeyInfo; - - Data,KeyInfo = Data_Datastore:GetAsync(DATA_KEY_NAME) - - if Data ~= nil then - Data_Metadata = KeyInfo:GetMetadata() - end - end) - if not GetDataSuccess then --! An error occured while getting the player's data - self:Log( - ("[Data Service](LoadData) An error occured while loading data for player '%s' : %s") - :format(Player.Name,GetDataErrorMessage), - "Warning" - ) - - Data = Table.Copy(DataFormat) - DataError:Fire(Player,"Load","FetchData",Data) - self.Client.DataError:FireAllClients(Player,"Load","FetchData",Data) - - return false,"Failed to load data : " .. GetDataErrorMessage,Data,{FormatVersion = DataFormatVersion} - else - if Data == nil then -- * It is the first time loading data from this datastore. Player must be new! - self:DebugLog( - ("[Data Service](LoadData) Data created for the first time for player '%s', they may be new!"):format(Player.Name) - ) - - Data = Table.Copy(DataFormat) - DataCreated:Fire(Player,Data) - self.Client.DataCreated:FireAllClients(Player,Data) - - return true,"Operation Success",Data,{FormatVersion = DataFormatVersion} - end - end - - ------------------------------------------ - -- Updating the data's format if needed -- - ------------------------------------------ - if Data_Metadata.FormatVersion < DataFormatVersion then -- Data format is outdated, it needs to be updated. - self:DebugLog( - ("[Data Service](LoadData) %s's data format is outdated, updating..."):format(Player.Name) - ) - - local DataFormatUpdateSuccess,DataFormatUpdateErrorMessage = pcall(function() - for _ = Data_Metadata.FormatVersion,DataFormatVersion - 1 do - self:DebugLog( - ("[Data Service](LoadData) Updating %s's data from version %s to version %s...") - :format(Player.Name,tostring(Data_Metadata.FormatVersion),tostring(Data_Metadata.FormatVersion + 1)) +local function FetchDataFromStore(Player) + local Success, FetchedSaveData = RetryOperation( + function() + error("Simulated GetAsync() error") + local SaveStore = GetSaveStore() + local KeyData, KeyInfo = SaveStore:GetAsync(tostring(Player.UserId) .. "/SaveData") + local SaveData = CreateSaveData(Player) + + if KeyData ~= nil then + SaveData.Data = KeyData + SaveData.CreatedTime = KeyInfo.CreatedTime + SaveData.UpdatedTime = KeyInfo.UpdatedTime + SaveData.Version = KeyInfo.Version + SaveData.Metadata = KeyInfo:GetMetadata() + SaveData.UserIDs = KeyInfo:GetUserIds() + else + DataService:DebugLog( + ("[Data Service] Data does not exist for player '%s', they may be a new player! Giving default data."):format( + tostring(Player.UserId) + ) ) - - Data = DataFormatConversions[tostring(Data_Metadata.FormatVersion) .. " -> " .. tostring(Data_Metadata.FormatVersion + 1)](Data) - Data_Metadata.FormatVersion = Data_Metadata.FormatVersion + 1 end - end) - - if not DataFormatUpdateSuccess then --! An error occured while updating the player's data - self:Log( - ("[Data Service](LoadData) An error occured while updating the data for player '%s' : %s") - :format(Player.Name,DataFormatUpdateErrorMessage), - "Warning" - ) - - Data = Table.Copy(DataFormat) - DataError:Fire(Player,"Load","FormatUpdate",Data) - self.Client.DataError:FireAllClients(Player,"Load","FormatUpdate",Data) - - return false,"Failed to load data : Update failed, " .. DataFormatUpdateErrorMessage,Data,{FormatVersion = DataFormatVersion} - end - elseif Data_Metadata.FormatVersion == nil or Data_Metadata.FormatVersion > DataFormatVersion then -- Unreadable data format, do not load data. - self:Log( - ("[Data Service](LoadData) An error occured while loading the data for player '%s' : %s") - :format(Player.Name,"Unknown data format"), - "Warning" - ) - Data = Table.Copy(DataFormat) - DataError:Fire(Player,"Load","UnknownDataFormat",Data) - - self.Client.DataError:FireAllClients(Player,"Load","UnknownDatFormat",Data) - - return false,"Failed to load data : Unknown data format",Data,{FormatVersion = DataFormatVersion} - end - - self:DebugLog( - ("[Data Service](LoadData) Successfully loaded data for player '%s'!"):format(Player.Name) + return SaveData + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("fetch data for player with ID '%s'"):format(tostring(Player.UserId)) ) - return true,"Operation Success",Data,Data_Metadata + return Success, FetchedSaveData end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SaveData --- @Description : Saves the data for the specified player into the specified datastore --- @Params : Instance 'Player' - the player to save the data of --- string "DatastoreName" - The name of the datastore to save the data to --- table "Data" - The table containing the data to save --- table "Data_Metadata" - The table containing the metadata of the data to save --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SaveData(Player,DatastoreName,Data,Data_Metadata) - - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead.") - :format(typeof(Player)) - ) - assert( - Player:IsA("Player"), - ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead.") - :format(Player.ClassName) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](SaveData) Bad argument #2 to 'SaveData', string expected, got %s instead.") - :format(typeof(DatastoreName)) - ) - assert( - Data ~= nil, - "[Data Service](SaveData) Bad argument #3 to 'SaveData', Data expected, got nil." - ) - assert( - Data_Metadata ~= nil, - "[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got nil." - ) - assert( - typeof(Data_Metadata) == "table", - ("[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got %s instead.") - :format(typeof(Data_Metadata)) - ) - assert( - Data_Metadata.FormatVersion ~= nil, - "[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected, got nil." - ) - assert( - typeof(Data_Metadata.FormatVersion) == "number", - ("[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected as number, got %s instead.") - :format(typeof(Data_Metadata.FormatVersion)) - ) - - self:DebugLog( - ("[Data Service](SaveData) Saving data for %s into datastore '%s'..."):format(Player.Name,DATASTORE_BASE_NAME.."_"..DatastoreName) - ) - - ------------- - -- Defines -- - ------------- - local Data_Datastore = DatastoreService:GetDataStore(DATASTORE_BASE_NAME.."_"..DatastoreName.."_Data",tostring(Player.UserId)) - local DatastoreSetOptions = Instance.new('DataStoreSetOptions') - - ---------------------------------------------- - -- Saving player's data to normal datastore -- - ---------------------------------------------- - local SaveDataSuccess,SaveDataErrorMessage = pcall(function() - DatastoreSetOptions:SetMetadata(Data_Metadata) - Data_Datastore:SetAsync(DATA_KEY_NAME,Data,{},DatastoreSetOptions) - end) - if not SaveDataSuccess then --! An error occured while saving the player's data. - self:Log( - ("[Data Service](SaveData) An error occured while saving data for '%s' : %s"):format(Player.Name,SaveDataErrorMessage), +local function WriteToStore(Player, SaveData) + if SaveData.IsTemporary then + DataService:Log( + ("[Data Service] Player '%s' had temporary session-only data, aborting save!"):format( + tostring(Player.UserId) + ), "Warning" ) - DataError:Fire(Player,"Save","SaveData",Data) - self.Client.DataError:FireAllClients(Player,"Save","SaveData",Data) - - return false,"Failed to save data : " .. SaveDataErrorMessage + return end - - self:DebugLog( - ("[Data Service](SaveData) Data saved successfully into datastore '%s' for %s!"):format(DATASTORE_BASE_NAME.."_"..DatastoreName,Player.Name) - ) - - return true,"Operation Success" end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SetConfigs --- @Description : Sets this service's configs to the specified values --- @Params : table "Configs" - A dictionary containing the new config values ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SetConfigs(Configs) - DataFormatVersion = Configs.DataFormatVersion - DataFormat = Configs.DataFormat - DataFormatConversions = Configs.DataFormatConversions - DATASTORE_BASE_NAME = Configs.DatastoreBaseName - DATASTORE_PRECISE_NAME = Configs.DatastorePreciseName - DATASTORE_RETRY_ENABLED = Configs.DatastoreRetryEnabled - DATASTORE_RETRY_INTERVAL = Configs.DatastoreRetryInterval - DATASTORE_RETRY_LIMIT = Configs.DatastoreRetryLimit - SESSION_LOCK_YIELD_INTERVAL = Configs.SessionLockYieldInterval - SESSION_LOCK_MAX_YIELD_INTERVALS = Configs.SessionLockMaxYieldIntervals - DATA_KEY_NAME = Configs.DataKeyName -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Init --- @Description : Called when the service module is first loaded. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Init() - DataLoaded = self:RegisterServiceClientEvent("DataLoaded") - DataCreated = self:RegisterServiceServerEvent("DataCreated") - DataError = self:RegisterServiceServerEvent("DataError") - self.Client.DataCreated = self:RegisterServiceClientEvent("DataCreated") - self.Client.DataError = self:RegisterServiceClientEvent("DataError") - - self:DebugLog("[Data Service] Initialized!") -end +local function PlayerAdded(Player) + local CurrentSessionID = HttpService:GenerateGUID(false) + local OperationsQueue ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Start --- @Description : Called after all services are loaded. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Start() - self:DebugLog("[Data Service] Started!") + DataService:Log( + ("[Data Service] Player '%s' has joined, caching their savedata..."):format(tostring(Player.UserId)) + ) - ------------------------------------------- - -- Loads player data into server's cache -- - ------------------------------------------- - local function LoadPlayerDataIntoServer(Player) - local WaitForSessionLock_Success = false -- Determines whether or not the session lock was waited for successfully - local SetSessionLock_Success = false -- Determines whether or not the session lock was successfully enabled for this server - local LoadData_Success = false -- Determines whether or not the player's data was fetched successfully - local PlayerData; - local PlayerData_Metadata; + --------------------------------------------- + -- Creating player's data operations queue -- + --------------------------------------------- + if DataOperationQueues[tostring(Player.UserId)] == nil then + OperationsQueue = Queue.new() + DataOperationQueues[tostring(Player.UserId)] = OperationsQueue - self:Log( - ("[Data Service] Loading data for player '%s'..."):format(Player.Name) + DataService:DebugLog( + ("[Data Service] Created data operations queue for player '%s'."):format(tostring(Player.UserId)) ) + else + OperationsQueue = DataOperationQueues[tostring(Player.UserId)] - ---------------------------------------------------- - -- Waiting for other server's sessionlock removal -- - ---------------------------------------------------- - self:DebugLog( - ("[Data Service] Waiting for previous server to remove session lock for player '%s'...") - :format(Player.Name) + DataService:DebugLog( + ("[Data Service] Using existing data operations queue for player '%s'."):format(tostring(Player.UserId)) ) + end - for SessionLock_YieldCount = 1,SESSION_LOCK_MAX_YIELD_INTERVALS do - local GetLockSuccess; - local OperationMessage; - local IsLocked; - - -------------------------------- - -- Reading session lock value -- - -------------------------------- - for RetryCount = 0, DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Reading session lock for player '%s'..."):format(Player.Name) - ) - - GetLockSuccess,OperationMessage,IsLocked = self:IsDataSessionlocked(Player,DATASTORE_PRECISE_NAME) - - if not GetLockSuccess then - self:Log( - ("[Data Service] Failed to read session lock for player '%s' : %s"):format(Player.Name,OperationMessage), - "Warning" - ) - - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while attempting to read session lock for player '%s', aborting") - :format(Player.Name), - "Warning" - ) - - break - else - if DATASTORE_RETRY_ENABLED then - self:Log( - ("[Data Service] Attempting to read session lock for player '%s' %s more times.") - :format(Player.Name,tostring(DATASTORE_RETRY_LIMIT - RetryCount)) - ) + OperationsQueue:AddAction(function() + ------------------------------------------------------------ + -- Waiting for sessionlock & setting one for this session -- + ------------------------------------------------------------ + RetryOperation( + function() + local _, SessionLock = GetSessionLock(Player) - task.wait(DATASTORE_RETRY_INTERVAL) - else - break - end - end + if SessionLock ~= nil then + error("Sessionlock still exists.") else - self:DebugLog( - ("[Data Service] Got session lock for player '%s'!"):format(Player.Name) - ) - - break + return end - end - - -------------------------------------------- - -- Determining if sessionlock was removed -- - -------------------------------------------- - if not GetLockSuccess then - break - end - - if IsLocked then - if SessionLock_YieldCount == SESSION_LOCK_MAX_YIELD_INTERVALS then - self:Log( - ("[Data Service] Timeout reached while waiting for previous server to remove its sessionlock for player '%s', ignoring it.") - :format(Player.Name), - "Warning" - ) - - WaitForSessionLock_Success = true - else - self:DebugLog( - ("[Data Service] Previous server hasn't removed session lock for player '%s' yet, waiting %s seconds before re-reading.") - :format(Player.Name, tostring(SESSION_LOCK_YIELD_INTERVAL)) + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("wait for sessionlock removal for player '%s'"):format(tostring(Player.UserId)) + ) + local LockSuccess = WriteSessionLock(Player, CurrentSessionID) + + -------------------------------------------------------------- + -- Fetching save data from datastore & migrating its schema -- + -------------------------------------------------------------- + local GetDataSuccess, SavedData = FetchDataFromStore(Player) + local MigrationSuccess, MigrationError = pcall(function() + if GetDataSuccess and SavedData.Metadata.SchemaVersion < DATA_SCHEMA.Version then + DataService:DebugLog( + ("[Data Service] Player '%s' has an outdated data schema, migrating to latest..."):format( + tostring(Player.UserId) ) - end - - task.wait(SESSION_LOCK_YIELD_INTERVAL) - else - self:DebugLog( - ("[Data Service] Previous server removed session lock for player '%s'!"):format(Player.Name) ) - WaitForSessionLock_Success = true - break - end - end - - -------------------------- - -- Setting session lock -- - -------------------------- - if not WaitForSessionLock_Success then - self:Log( - ("[Data Service] Failed to set session lock to this server, giving player '%s' default data."):format(Player.Name), - "Warning" - ) - - CreateDataCache(Player,Table.Copy(DataFormat),false) - return - else - self:DebugLog( - ("[Data Service] Setting session-lock for player '%s'..."):format(Player.Name) - ) - end - - for RetryCount = 1,DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Writing sessionlock to datastore '%s' for player '%s'..."):format(DATASTORE_PRECISE_NAME,Player.Name) - ) - - local SetLockSuccess,SetLockMessage = self:SessionlockData(Player,DATASTORE_PRECISE_NAME) - - if not SetLockSuccess then - self:Log( - ("[Data Service] Failed to set session-lock for player '%s' : %s") - :format(Player.Name,SetLockMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to session-lock data for player '%s', no further attempts will be made.") - :format(Player.Name), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to session-lock data for player '%s', waiting %s seconds before retrying.") - :format(Player.Name,tostring(DATASTORE_RETRY_INTERVAL)), - "Warning" + for SchemaVersion = SavedData.Metadata.SchemaVersion, DATA_SCHEMA.Version - 1 do + DataService:DebugLog( + ("[Data Service] Migrating data from schema %s to schema %s..."):format( + SavedData.Metadata.SchemaVersion, + DATA_SCHEMA.Version ) + ) - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break + SavedData = DATA_SCHEMA.Migrators[SchemaVersion .. "->" .. SchemaVersion + 1](SavedData) end - else - self:DebugLog( - ("[Data Service] Successfully session-locked data for player '%s'!"):format(Player.Name) - ) - - SetSessionLock_Success = true - break end - end + end) - ---------------------------- - -- Fetching player's data -- - ---------------------------- - if not SetSessionLock_Success then - self:Log( - ("[Data Service] Failed to set session-lock, giving player '%s' default data."):format(Player.Name), + if not MigrationSuccess then + DataService:Log( + ("[Data Service] Failed to migrate data for player '%s' : %s"):format( + tostring(Player.UserId), + MigrationError + ), "Warning" ) - - CreateDataCache(Player,Table.Copy(DataFormat),false) - return - else - self:DebugLog( - ("[Data Service] Fetching data for player '%s' from datastore..."):format(Player.Name) - ) - end - - for RetryCount = 1,DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Reading data from datastore '%s' for player '%s'...") - :format(DATASTORE_PRECISE_NAME,Player.Name) - ) - - local FetchDataSuccess,FetchDataMessage,Data,Data_Metadata = self:LoadData(Player,DATASTORE_PRECISE_NAME) - - if not FetchDataSuccess then - self:Log( - ("[Data Service] Failed to fetch data for player '%s' : %s") - :format(Player.Name,FetchDataMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to load data for player '%s', no further attempts will be made.") - :format(Player.Name), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to fetch data for player '%s', waiting %s seconds before retrying.") - :format(Player.Name,tostring(DATASTORE_RETRY_INTERVAL)), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog( - ("[Data Service] Successfully fetched data for player '%s' from datastores!"):format(Player.Name) - ) - - LoadData_Success = true - PlayerData = Data - PlayerData_Metadata = Data_Metadata - - break - end end - if not LoadData_Success then - self:Log( - ("[Data Service] Failed to load data for player '%s', player will be given default data.") - :format(Player.Name), + -------------------------------- + -- Caching player's save data -- + -------------------------------- + if not LockSuccess then + DataService:Log( + ("[Data Service] Couldn't sessionlock data for player '%s', data will be temporary."):format( + tostring(Player.UserId) + ), "Warning" ) - CreateDataCache(Player,Table.Copy(DataFormat),{FormatVersion = DataFormatVersion},false) - else - self:Log( - ("[Data Service] Successfully loaded data for player '%s'!"):format(Player.Name) - ) - - CreateDataCache(Player,PlayerData,PlayerData_Metadata,true) - end - end - - ------------------------------------------- - -- Saves player data from servers' cache -- - ------------------------------------------- - local function SavePlayerDataFromServer(Player) - local PlayerData,Data_Metadata = self:GetData(Player,false,"Table") - local WriteData_Success = false -- Determines whether or not the player's data was successfully saved to datastores - - Data_Metadata["_CanSave"] = nil - - self:Log( - ("[Data Service] Saving data for player '%s'..."):format(Player.Name) - ) - - ------------------------------- - -- Writing data to datastore -- - ------------------------------- - self:DebugLog( - ("[Data Service] Writing data to datastores for player '%s'..."):format(Player.Name) - ) - for RetryCount = 1,DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Writing data to datastore '%s' for player '%s'...") - :format(DATASTORE_PRECISE_NAME,Player.Name) - ) - - local WriteDataSuccess,WriteDataMessage = self:SaveData(Player,DATASTORE_PRECISE_NAME,PlayerData,Data_Metadata) - - if not WriteDataSuccess then - self:Log( - ("[Data Service] Failed to write data for player '%s' : %s") - :format(Player.Name,WriteDataMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to write data for player '%s', no further attempts will be made.") - :format(Player.Name), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to write data for player '%s', waiting %s seconds before retrying.") - :format(Player.Name,tostring(DATASTORE_RETRY_INTERVAL)), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog( - ("[Data Service] Successfully wrote data for player '%s' to datastores!"):format(Player.Name) - ) - - WriteData_Success = true - break - end - end - - if not WriteData_Success then - self:Log( - ("[Data Service] Failed to save data for player '%s'."):format(Player.Name), + DataCaches[tostring(Player.UserId)] = SavedData + elseif not MigrationSuccess then + DataService:Log( + ("[Data Service] Couldn't migrate data schema for player '%s', data will be temporary."):format( + tostring(Player.UserId) + ), "Warning" ) - else - self:Log( - ("[Data Service] Successfully saved data for player '%s'!"):format(Player.Name) - ) - end - ---------------------------- - -- Un-sessionlocking data -- - ---------------------------- - self:DebugLog( - ("[Data Service] Un-session locking data for player '%s'..."):format(Player.Name) - ) - - for RetryCount = 1,DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Removing sessionlock from datastore '%s' for player '%s'..."):format(DATASTORE_PRECISE_NAME,Player.Name) + DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) + elseif not GetDataSuccess then + DataService:Log( + ("[Data Service] Couldn't fetch save data from datastore for player '%s', data will be temporary."):format( + tostring(Player.UserId) + ), + "Warning" ) - local RemoveLockSuccess,RemoveLockMessage = self:UnSessionlockData(Player,DATASTORE_PRECISE_NAME) - - if not RemoveLockSuccess then - self:Log( - ("[Data Service] Failed to remove session-lock for player '%s' : %s") - :format(Player.Name,RemoveLockMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to remove session-lock for player '%s', no further attempts will be made.") - :format(Player.Name), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to remove session-lock for player '%s', waiting %s seconds before retrying.") - :format(Player.Name,tostring(DATASTORE_RETRY_INTERVAL)), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog( - ("[Data Service] Successfully removed session-lock for player '%s'!"):format(Player.Name) - ) - - break - end - end - - RemoveDataCache(Player) - end + DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) + else + DataService:Log(("[Data Service] Savedata cached for player '%s'!"):format(tostring(Player.UserId))) - --------------------------------- - -- Loading player data on join -- - --------------------------------- - local function PlayerJoined(Player) - if GetOperationsQueue(Player) == nil then - DataOperationsQueues[tostring(Player.UserId)] = Queue.new() + SavedData.IsTemporary = false + DataCaches[tostring(Player.UserId)] = SavedData end + end) - local DataOperationsQueue = GetOperationsQueue(Player) - - local QueueItemID = DataOperationsQueue:AddAction( - function() - LoadPlayerDataIntoServer(Player) - end, - function(ActionID) - DataLoaded:FireClient(Player,ActionID) - end - ) - DataLoaded_IDs[tostring(Player.UserId)] = QueueItemID - - if not DataOperationsQueue:IsExecuting() then - DataOperationsQueue:Execute() - end + if not OperationsQueue:IsExecuting() then + OperationsQueue:Execute() end - Players.PlayerAdded:connect(PlayerJoined) - for _,Player in pairs(Players:GetPlayers()) do - coroutine.wrap(PlayerJoined)(Player) - end - - --------------------------------- - -- Saving player data on leave -- - --------------------------------- - local function PlayerLeaving(Player) - DataLoaded_IDs[tostring(Player.UserId)] = nil - - local DataOperationsQueue = GetOperationsQueue(Player) - - DataOperationsQueue:AddAction( - function() - if self:GetData(Player,false):GetAttribute("_CanSave") == false then - self:Log( - ("[Data Service] Player '%s' left, but their data was marked as not saveable. Will not save data."):format(Player.Name), - "Warning" - ) - - RemoveDataCache(Player) - - return - else - SavePlayerDataFromServer(Player) - end - end, - function() - if DataOperationsQueue:GetSize() == 0 then - DataOperationsQueue:Destroy() - DataOperationsQueues[tostring(Player.UserId)] = nil - end - end - ) - if not DataOperationsQueue:IsExecuting() then - DataOperationsQueue:Execute() - end - end - Players.PlayerRemoving:connect(PlayerLeaving) + print("[Data]", DataCaches[tostring(Player.UserId)]) +end - -------------------------------------------------------------------------------- - -- Ensuring that all player data is saved before letting the server shut down -- - -------------------------------------------------------------------------------- - game:BindToClose(function() - self:Log("[Data Service] Server shutting down, waiting for data operations queue to be empty...") +local function PlayerRemoved(Player) end + +-- local function MigrateDataToLatestSchema(Data) +-- -- DataService:Log( +-- -- ("[Data Service] Migrating data for player with ID '%s', their data schema is outdated..."):format( +-- -- tostring(Player.UserId) +-- -- ) +-- -- ) + +-- local Success, Error = pcall(function() +-- for SchemaVersion = Data.Metadata.SchemaVersion, DATA_SCHEMA.Version - 1 do +-- DataService:DebugLog( +-- ("[Data Service] Migrating data from schema %s to schema %s..."):format( +-- Data.Metadata.SchemaVersion, +-- DATA_SCHEMA.Version +-- ) +-- ) +-- Data = DATA_SCHEMA.Migrators[SchemaVersion .. "->" .. SchemaVersion + 1](Data) +-- end +-- end) + +-- if not Success then +-- DataService:Log( +-- ("[Data Service] Failed to migrate data from schema %s to schema %s : %s"):format( +-- Data.Metadata.SchemaVersion, +-- DATA_SCHEMA.Version, +-- Error +-- ), +-- "Warning" +-- ) + +-- return false +-- end + +-- return true, Data +-- end - while true do -- Wait for all player data to be saved - if GetTotalQueuesSize() == 0 then - break - end - RunService.Stepped:wait() - end +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- API Methods +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- - self:Log("[Data Service] Operations queue is empty! Letting server shut down.") - end) +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Configure +-- @Description : Sets the name of the datastore this service will use, as well as the schema & schema migration functions. +-- @Paarams : Table "Configs" - A table containing the configs for this service +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:Configure(Configs) + DATASTORE_NAME = Configs.DatastoreName + DATA_SCHEMA = Configs.Schema + WasConfigured = true end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Stop --- @Description : Called when the service is being stopped. +-- @Name : Init +-- @Description : Called when the service module is first loaded. ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Stop() +function DataService:Init() + self:DebugLog("[Data Service] Initializing...") + + if not WasConfigured then + self:Log("[Data Service] The data service must be configured with Configure() before being used!", "Error") + end - self:Log("[Data Service] Stopped!") + self:DebugLog("[Data Service] Initialized!") end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Unload --- @Description : Called when the service is being unloaded. +-- @Name : Start +-- @Description : Called after all services are loaded. ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Unload() +function DataService:Start() + self:DebugLog("[Data Service] Running!") - self:Log("[Data Service] Unloaded!") + for _, Player in pairs(Players:GetPlayers()) do + task.spawn(PlayerAdded, Player) + end + Players.PlayerAdded:connect(PlayerAdded) + Players.PlayerRemoving:connect(PlayerRemoved) end -return DataService \ No newline at end of file +return DataService diff --git a/Server/wally.toml b/Server/wally.toml index 11ca618..a7ada9e 100644 --- a/Server/wally.toml +++ b/Server/wally.toml @@ -9,3 +9,4 @@ registry = "https://github.com/UpliftGames/wally-index" [dependencies] DragonEngine = "nobledraconian/dragon-engine@2.0.0" roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" +Table = "sleitnick/table-util@1.2.1" diff --git a/TestEnv/Server/Scripts/SysTest.server.lua b/TestEnv/Server/Scripts/SysTest.server.lua new file mode 100644 index 0000000..b8b383a --- /dev/null +++ b/TestEnv/Server/Scripts/SysTest.server.lua @@ -0,0 +1 @@ +-- systest diff --git a/TestEnv/Server/Services/DataService.lua b/TestEnv/Server/Services/DataService.lua index 7b6e268..1a11b76 100644 --- a/TestEnv/Server/Services/DataService.lua +++ b/TestEnv/Server/Services/DataService.lua @@ -2,28 +2,21 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local DataService = require(ReplicatedStorage.Packages["playerdatasystem-server"]) -DataService:SetConfigs({ - DataFormatVersion = 2, - DataFormat = { - Coins = 0, - XP = 0, - Color = "Purple" +DataService:Configure({ + DatastoreName = "SysTest1", + Schema = { + Version = 1, + Data = { + OwnedItems = { + Weapons = { "Red Crystal Sword" }, + Consumables = { "Health potion" }, + }, + Gold = 100, + Level = 10, + XP = 0, + }, + Migrators = {}, }, - DataFormatConversions = { - ["1 -> 2"] = function(Data) - Data.Color = "Purple" - - return Data - end - }, - DatastoreBaseName = "Test", - DatastorePreciseName = "PlayerData1", - DatastoreRetryEnabled = true, - DatastoreRetryInterval = 3, - DatastoreRetryLimit = 2, - SessionLockYieldInterval = 5, - SessionLockMaxYieldIntervals = 5, - DataKeyName = "SaveData" }) -return DataService \ No newline at end of file +return DataService From c78af8491497c7445cf7314822d611179bb81412 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Tue, 24 Jun 2025 11:34:06 -0400 Subject: [PATCH 04/13] Implement initial rewrite data-saving --- Server/src/init.lua | 174 +++++++++++++++++++++++++++++++------------- 1 file changed, 125 insertions(+), 49 deletions(-) diff --git a/Server/src/init.lua b/Server/src/init.lua index cd8d95d..c588698 100644 --- a/Server/src/init.lua +++ b/Server/src/init.lua @@ -72,7 +72,21 @@ local function RetryOperation(Operation, RetryAmount, RetryInterval, OperationDe end local function AreQueuesEmpty() - return false + for _, OperationsQueue in pairs(DataOperationQueues) do + if OperationsQueue:IsExecuting() then + return false + end + end + + return true +end + +local function IsDataCacheEmpty() + for _, _ in pairs(DataCaches) do + return false + end + + return true end local function GetSaveStore() @@ -122,10 +136,24 @@ local function WriteSessionLock(Player, SessionID) return Success end +local function RemoveSessionLock(Player) + local Success = RetryOperation( + function() + local SaveStore = GetSaveStore() + + SaveStore:RemoveAsync(tostring(Player.UserId) .. "/SessionLock") + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("remove sessionlock for player with ID '%s'"):format(tostring(Player.UserId)) + ) + + return Success +end + local function FetchDataFromStore(Player) local Success, FetchedSaveData = RetryOperation( function() - error("Simulated GetAsync() error") local SaveStore = GetSaveStore() local KeyData, KeyInfo = SaveStore:GetAsync(tostring(Player.UserId) .. "/SaveData") local SaveData = CreateSaveData(Player) @@ -155,7 +183,7 @@ local function FetchDataFromStore(Player) return Success, FetchedSaveData end -local function WriteToStore(Player, SaveData) +local function WriteDataToStore(Player, SaveData) if SaveData.IsTemporary then DataService:Log( ("[Data Service] Player '%s' had temporary session-only data, aborting save!"):format( @@ -164,8 +192,23 @@ local function WriteToStore(Player, SaveData) "Warning" ) - return + return true end + + local Success = RetryOperation( + function() + local SaveStore = GetSaveStore() + local SetOptions = Instance.new("DataStoreSetOptions") + + SetOptions:SetMetadata(SaveData.Metadata) + SaveStore:SetAsync(tostring(Player.UserId) .. "/SaveData", SaveData.Data, { Player.UserId }, SetOptions) + end, + OPERATION_MAX_RETRIES, + OPERATION_RETRY_INTERVAL, + ("save data for player with ID '%s'"):format(tostring(Player.UserId)) + ) + + return Success end local function PlayerAdded(Player) @@ -234,7 +277,7 @@ local function PlayerAdded(Player) ) ) - SavedData = DATA_SCHEMA.Migrators[SchemaVersion .. "->" .. SchemaVersion + 1](SavedData) + SavedData.Data = DATA_SCHEMA.Migrators[SchemaVersion .. " -> " .. SchemaVersion + 1](SavedData.Data) end end end) @@ -252,15 +295,15 @@ local function PlayerAdded(Player) -------------------------------- -- Caching player's save data -- -------------------------------- - if not LockSuccess then + if not GetDataSuccess then DataService:Log( - ("[Data Service] Couldn't sessionlock data for player '%s', data will be temporary."):format( + ("[Data Service] Couldn't fetch save data from datastore for player '%s', data will be temporary."):format( tostring(Player.UserId) ), "Warning" ) - DataCaches[tostring(Player.UserId)] = SavedData + DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) elseif not MigrationSuccess then DataService:Log( ("[Data Service] Couldn't migrate data schema for player '%s', data will be temporary."):format( @@ -270,66 +313,85 @@ local function PlayerAdded(Player) ) DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) - elseif not GetDataSuccess then + elseif not LockSuccess then DataService:Log( - ("[Data Service] Couldn't fetch save data from datastore for player '%s', data will be temporary."):format( + ("[Data Service] Couldn't sessionlock data for player '%s', data will be temporary."):format( tostring(Player.UserId) ), "Warning" ) - DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) + DataCaches[tostring(Player.UserId)] = SavedData else DataService:Log(("[Data Service] Savedata cached for player '%s'!"):format(tostring(Player.UserId))) SavedData.IsTemporary = false DataCaches[tostring(Player.UserId)] = SavedData end + + print("[Data]", DataCaches[tostring(Player.UserId)]) end) if not OperationsQueue:IsExecuting() then + DataService:DebugLog( + ("[Data Service] Executing operations queue for player '%s'..."):format(tostring(Player.UserId)) + ) OperationsQueue:Execute() + DataService:DebugLog( + ("[Data Service] Operations queue for player '%s' has finished, preserving in cache for later save operations."):format( + tostring(Player.UserId) + ) + ) end - - print("[Data]", DataCaches[tostring(Player.UserId)]) end -local function PlayerRemoved(Player) end - --- local function MigrateDataToLatestSchema(Data) --- -- DataService:Log( --- -- ("[Data Service] Migrating data for player with ID '%s', their data schema is outdated..."):format( --- -- tostring(Player.UserId) --- -- ) --- -- ) - --- local Success, Error = pcall(function() --- for SchemaVersion = Data.Metadata.SchemaVersion, DATA_SCHEMA.Version - 1 do --- DataService:DebugLog( --- ("[Data Service] Migrating data from schema %s to schema %s..."):format( --- Data.Metadata.SchemaVersion, --- DATA_SCHEMA.Version --- ) --- ) --- Data = DATA_SCHEMA.Migrators[SchemaVersion .. "->" .. SchemaVersion + 1](Data) --- end --- end) - --- if not Success then --- DataService:Log( --- ("[Data Service] Failed to migrate data from schema %s to schema %s : %s"):format( --- Data.Metadata.SchemaVersion, --- DATA_SCHEMA.Version, --- Error --- ), --- "Warning" --- ) - --- return false --- end - --- return true, Data --- end +local function PlayerRemoved(Player) + local OperationsQueue + + DataService:Log( + ("[Data Service] Player '%s' has left, writing their savedata to datastores and removing their cache..."):format( + tostring(Player.UserId) + ) + ) + + --------------------------------------------- + -- Getting player's data operations queue -- + --------------------------------------------- + OperationsQueue = DataOperationQueues[tostring(Player.UserId)] + + DataService:DebugLog( + ("[Data Service] Using existing data operations queue for player '%s'."):format(tostring(Player.UserId)) + ) + + OperationsQueue:AddAction(function() + ------------------------------------ + -- Writing save data to datastore -- + ------------------------------------ + WriteDataToStore(Player, DataCaches[tostring(Player.UserId)]) + + ------------------------- + -- Clearing data cache -- + ------------------------- + DataCaches[tostring(Player.UserId)] = nil + + --------------------------- + -- Removing session lock -- + --------------------------- + RemoveSessionLock(Player) + end) + + if not OperationsQueue:IsExecuting() then + DataService:DebugLog( + ("[Data Service] Executing operations queue for player '%s'..."):format(tostring(Player.UserId)) + ) + OperationsQueue:Execute() + DataService:DebugLog( + ("[Data Service] Operations queue for player '%s' has finished, destroying queue & removing from queue cache!"):format( + tostring(Player.UserId) + ) + ) + end +end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- API Methods @@ -357,6 +419,20 @@ function DataService:Init() self:Log("[Data Service] The data service must be configured with Configure() before being used!", "Error") end + game:BindToClose(function() + self:Log("[Data Service] Server is shuting down, keeping it open so any cached data can save...") + + while true do + if not AreQueuesEmpty() then + task.wait() + else + break + end + end + + self:Log("[Data Service] Data cache is empty and operation queues are empty, allowing shutdown!") + end) + self:DebugLog("[Data Service] Initialized!") end From 378401f22b1f1ef1389ae3600014bfce898a0148 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Tue, 1 Jul 2025 15:38:29 -0400 Subject: [PATCH 05/13] Data replication + data change listeners --- Client/src/init.lua | 82 +++++++++++ Client/testenv.project.json | 162 ++++++++++++---------- Client/wally.toml | 1 + Server/src/init.lua | 101 +++++++++++++- Server/testenv.project.json | 157 ++++++++++++--------- TestEnv/Server/Scripts/SysTest.server.lua | 33 ++++- TestEnv/Server/Services/DataService.lua | 20 ++- TestEnv/Shared/DataHandlers.lua | 16 +++ 8 files changed, 427 insertions(+), 145 deletions(-) create mode 100644 TestEnv/Shared/DataHandlers.lua diff --git a/Client/src/init.lua b/Client/src/init.lua index 7d61aa5..257738d 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -1,9 +1,58 @@ local DataController = {} +--------------------- +-- Roblox Services -- +--------------------- +local Players = game:GetService("Players") + +------------------ +-- Dependencies -- +------------------ +local DataService +local DataHandlers +local Table = require(script.Parent.Table) + +------------- +-- Defines -- +------------- +local DATA_READERS = {} +local DATA_WRITERS = {} +local EVENTS = {} +local DataCache = {} +local CurrentDataSessionID = "" +local LocalPlayer = Players.LocalPlayer + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- Helper functions +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +local function WriteData(Writer, ...) + DATA_WRITERS[Writer](DataCache, ...) +end + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- API Methods ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : ReadData +-- @Description : Calls the specified reader function which reads the given player's savedata +-- @Params : string "Reader" - The name of the reader function to call +-- Tuple "Args" - The arguments to pass to the specified reader function +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:ReadData(Reader, ...) + while true do + if not LocalPlayer:IsDescendantOf(game) then + return nil + elseif DataCache ~= nil then + break + end + + task.wait() + end + + return DATA_READERS[Reader](Table.Copy(DataCache, true), ...) +end + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Init -- @Description : Called when the service module is first loaded. @@ -11,6 +60,11 @@ local DataController = {} function DataController:Init() self:DebugLog("[Data Controller] Initializing...") + DataService = self:GetService("DataService") + DataHandlers = require(DataService:GetDataHandlerModule()) + DATA_WRITERS = DataHandlers.Writers + DATA_READERS = DataHandlers.Readers + self:DebugLog("[Data Controller] Initialized!") end @@ -20,6 +74,34 @@ end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataController:Start() self:DebugLog("[Data Controller] Running!") + + -------------------------------- + -- Getting current session ID -- + -------------------------------- + while true do + local DataSessionID = DataService:GetDataSessionID() + + if DataSessionID ~= nil then + CurrentDataSessionID = DataSessionID + + break + end + task.wait(0.5) + end + + DataService.DataLoaded:connect(function(SessionID) + if SessionID == CurrentDataSessionID then + DataCache = DataService:RequestRawData() + + print("[Data]", DataCache) + end + end) + + DataService.DataWritten:connect(function(Writer, ...) + WriteData(Writer, ...) + + print("[New Data]", DataCache) + end) end return DataController diff --git a/Client/testenv.project.json b/Client/testenv.project.json index f9fb8d5..f866e3c 100644 --- a/Client/testenv.project.json +++ b/Client/testenv.project.json @@ -1,88 +1,108 @@ { - "name" : "systemname-client", - "tree" : { - "$className" : "DataModel", - + "name": "systemname-client", + "tree": { + "$className": "DataModel", "ReplicatedStorage": { - "$className" : "ReplicatedStorage", - - "Packages" : { - "$path" : "standalone.project.json", - - "playerdatasystem-server" : {"$path" : "../Server/default.project.json"} + "$className": "ReplicatedStorage", + "$path": "../TestEnv/Shared", + "Packages": { + "$path": "standalone.project.json", + "playerdatasystem-server": { + "$path": "../Server/default.project.json" + } } }, - - "ServerScriptService" : { - "$className" : "ServerScriptService", - - "Scripts" : {"$path" : "../TestEnv/Server/Scripts"}, - "Services" : {"$path" : "../TestEnv/Server/Services"} + "ServerScriptService": { + "$className": "ServerScriptService", + "Scripts": { + "$path": "../TestEnv/Server/Scripts" + }, + "Services": { + "$path": "../TestEnv/Server/Services" + } }, - - "StarterPlayer" : { - "$className" : "StarterPlayer", - - "StarterPlayerScripts" : { - "$className" : "StarterPlayerScripts", - - "Controllers" : {"$path" : "../TestEnv/Client/Controllers"}, - "Scripts" : {"$path" : "../TestEnv/Client/Scripts"} + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "Controllers": { + "$path": "../TestEnv/Client/Controllers" + }, + "Scripts": { + "$path": "../TestEnv/Client/Scripts" + } } }, - - "Players" : { - "$className" : "Players", - - "$properties" : { - "CharacterAutoLoads" : false + "Players": { + "$className": "Players", + "$properties": { + "CharacterAutoLoads": false } }, - - "Workspace" : { - "$className" : "Workspace", - "$properties" : { - "AllowThirdPartySales" : false, - "FallenPartsDestroyHeight" : -500, - "FilteringEnabled" : true, - "Gravity" : 196.2, - "StreamingEnabled" : false, - "StreamingMinRadius" : 64, - "StreamingPauseMode" : "Default", - "StreamingTargetRadius" : 1024, - "TouchesUseCollisionGroups" : true, - "HumanoidOnlySetCollisionsOnStateChange" : { - "Enum" : 2 + "Workspace": { + "$className": "Workspace", + "$properties": { + "AllowThirdPartySales": false, + "FallenPartsDestroyHeight": -500, + "FilteringEnabled": true, + "Gravity": 196.2, + "StreamingEnabled": false, + "StreamingMinRadius": 64, + "StreamingPauseMode": "Default", + "StreamingTargetRadius": 1024, + "TouchesUseCollisionGroups": true, + "HumanoidOnlySetCollisionsOnStateChange": { + "Enum": 2 }, - "AnimationWeightedBlendFix" : { - "Enum" : 2 + "AnimationWeightedBlendFix": { + "Enum": 2 }, - "ReplicateInstanceDestroySetting" : { - "Enum" : 2 + "ReplicateInstanceDestroySetting": { + "Enum": 2 } }, - - "Map" : {"$path" : "../TestEnv/Assets/Map.rbxm"} + "Map": { + "$path": "../TestEnv/Assets/Map.rbxm" + } }, - - "Lighting" : { - "$className" : "Lighting", - "$path" : "../TestEnv/Assets/Lighting", - "$properties" : { - "Technology" : "ShadowMap", - "ClockTime" : 14, - "GeographicLatitude" : 41.733, - "Brightness" : 2, - "ExposureCompensation" : 0, - "Ambient" : [0.54117647058824,0.54117647058824,0.54117647058824], - "OutdoorAmbient" : [0.50196078431373,0.50196078431373,0.50196078431373], - "ColorShift_Bottom" : [0,0,0], - "ColorShift_Top" : [0,0,0], - "FogColor" : [0.14901960784314,0.14901960784314,0.14901960784314], - "FogEnd" : 1000, - "FogStart" : 0, - "GlobalShadows" : true, - "ShadowSoftness" : 0.2 + "Lighting": { + "$className": "Lighting", + "$path": "../TestEnv/Assets/Lighting", + "$properties": { + "Technology": "ShadowMap", + "ClockTime": 14, + "GeographicLatitude": 41.733, + "Brightness": 2, + "ExposureCompensation": 0, + "Ambient": [ + 0.54117647058824, + 0.54117647058824, + 0.54117647058824 + ], + "OutdoorAmbient": [ + 0.50196078431373, + 0.50196078431373, + 0.50196078431373 + ], + "ColorShift_Bottom": [ + 0, + 0, + 0 + ], + "ColorShift_Top": [ + 0, + 0, + 0 + ], + "FogColor": [ + 0.14901960784314, + 0.14901960784314, + 0.14901960784314 + ], + "FogEnd": 1000, + "FogStart": 0, + "GlobalShadows": true, + "ShadowSoftness": 0.2 } } } diff --git a/Client/wally.toml b/Client/wally.toml index f0607b2..65ad867 100644 --- a/Client/wally.toml +++ b/Client/wally.toml @@ -9,3 +9,4 @@ registry = "https://github.com/UpliftGames/wally-index" [dependencies] DragonEngine = "nobledraconian/dragon-engine@2.0.0" roblox-libmodules = "nobledraconian/roblox-libmodules@3.1.0" +Table = "sleitnick/table-util@1.2.1" diff --git a/Server/src/init.lua b/Server/src/init.lua index c588698..969d0b5 100644 --- a/Server/src/init.lua +++ b/Server/src/init.lua @@ -1,6 +1,7 @@ --!nocheck -local DataService = {} +local DataService = { Client = {} } +DataService.Client.Server = DataService --------------------- -- Roblox Services -- @@ -27,9 +28,15 @@ local DATA_SCHEMA = { Data = {}, Migrators = {}, } +local DATA_HANDLER_MODULE = nil +local DATA_WRITERS = {} +local DATA_READERS = {} +local EVENTS = {} local WasConfigured = false local DataCaches = {} +local DataSessionIDs = {} local DataOperationQueues = {} +local ChangedCallbacks = {} ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Helper methods @@ -214,6 +221,7 @@ end local function PlayerAdded(Player) local CurrentSessionID = HttpService:GenerateGUID(false) local OperationsQueue + DataSessionIDs[tostring(Player.UserId)] = CurrentSessionID DataService:Log( ("[Data Service] Player '%s' has joined, caching their savedata..."):format(tostring(Player.UserId)) @@ -279,6 +287,8 @@ local function PlayerAdded(Player) SavedData.Data = DATA_SCHEMA.Migrators[SchemaVersion .. " -> " .. SchemaVersion + 1](SavedData.Data) end + + SavedData.Metadata.SchemaVersion = DATA_SCHEMA.Version end end) @@ -329,6 +339,7 @@ local function PlayerAdded(Player) DataCaches[tostring(Player.UserId)] = SavedData end + EVENTS.DataLoaded:FireClient(Player, CurrentSessionID) print("[Data]", DataCaches[tostring(Player.UserId)]) end) @@ -397,17 +408,100 @@ end -- API Methods ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : WriteData +-- @Description : Calls the specified writer function which writes the given data to the player's savedata +-- @Params : Instance "Player" - The player whose data should be modified +-- string "Writer" - The name of the writer function to call +-- Tuple "Args" - The arguments to pass to the specified writer function +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:WriteData(Player, Writer, ...) + local ChangedParams = table.pack(DATA_WRITERS[Writer](DataCaches[tostring(Player.UserId)].Data, ...)) + local DataName = ChangedParams[1] + ChangedParams[1] = Player + + EVENTS.DataWritten:FireClient(Player, Writer, ...) + + if ChangedCallbacks[DataName] ~= nil then + for _, Callback in pairs(ChangedCallbacks[DataName]) do + Callback(table.unpack(ChangedParams)) + end + end +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : ReadData +-- @Description : Calls the specified reader function which reads the given player's savedata +-- @Params : Instance "Player" - The player whose data should be read from +-- string "Reader" - The name of the reader function to call +-- Tuple "Args" - The arguments to pass to the specified reader function +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:ReadData(Player, Reader, ...) + while true do + if not Player:IsDescendantOf(game) then + return nil + elseif DataCaches[tostring(Player.UserId)] ~= nil then + break + end + + task.wait() + end + + return DATA_READERS[Reader](Table.Copy(DataCaches[tostring(Player.UserId)].Data, true), ...) +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : OnDataChanged +-- @Description : Invokes the given callback when the specified data is changed +-- @Params : string "DataName" - The name of the data that should be listened to for changes +-- function "ChangedCallback" - The function to invoke when the specified data is changed +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService:OnDataChanged(DataName, ChangedCallback) + if ChangedCallbacks[DataName] ~= nil then + table.insert(ChangedCallbacks[DataName], ChangedCallback) + else + ChangedCallbacks[DataName] = { ChangedCallback } + end +end + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Configure -- @Description : Sets the name of the datastore this service will use, as well as the schema & schema migration functions. --- @Paarams : Table "Configs" - A table containing the configs for this service +-- @Params : Table "Configs" - A table containing the configs for this service ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataService:Configure(Configs) DATASTORE_NAME = Configs.DatastoreName DATA_SCHEMA = Configs.Schema + DATA_HANDLER_MODULE = Configs.DataHandlers + DATA_WRITERS = require(Configs.DataHandlers).Writers + DATA_READERS = require(Configs.DataHandlers).Readers WasConfigured = true end +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Client.GetDataHandlerModule +-- @Description : Returns a reference to the data handler module to the calling client +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService.Client:GetDataHandlerModule() + return DATA_HANDLER_MODULE +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Client.GetDataSessionID +-- @Description : Returns the ID of the calling player's data session +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService.Client:GetDataSessionID(Player) + return DataSessionIDs[tostring(Player.UserId)] +end + +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : Client.RequestRawData +-- @Description : Returns the calling player's savedata to their client +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataService.Client:RequestRawData(Player) + return DataCaches[tostring(Player.UserId)].Data +end + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Init -- @Description : Called when the service module is first loaded. @@ -415,6 +509,9 @@ end function DataService:Init() self:DebugLog("[Data Service] Initializing...") + EVENTS.DataWritten = self:RegisterServiceClientEvent("DataWritten") + EVENTS.DataLoaded = self:RegisterServiceClientEvent("DataLoaded") + if not WasConfigured then self:Log("[Data Service] The data service must be configured with Configure() before being used!", "Error") end diff --git a/Server/testenv.project.json b/Server/testenv.project.json index f7a8fe8..fef66bd 100644 --- a/Server/testenv.project.json +++ b/Server/testenv.project.json @@ -1,84 +1,105 @@ { - "name" : "systemname-server", - "tree" : { - "$className" : "DataModel", - + "name": "systemname-server", + "tree": { + "$className": "DataModel", "ReplicatedStorage": { - "$className" : "ReplicatedStorage", - - "Packages" : {"$path" : "standalone.project.json"} + "$className": "ReplicatedStorage", + "$path": "../TestEnv/Shared", + "Packages": { + "$path": "standalone.project.json" + } }, - - "ServerScriptService" : { - "$className" : "ServerScriptService", - - "Scripts" : {"$path" : "../TestEnv/Server/Scripts"}, - "Services" : {"$path" : "../TestEnv/Server/Services"} + "ServerScriptService": { + "$className": "ServerScriptService", + "Scripts": { + "$path": "../TestEnv/Server/Scripts" + }, + "Services": { + "$path": "../TestEnv/Server/Services" + } }, - - "StarterPlayer" : { - "$className" : "StarterPlayer", - - "StarterPlayerScripts" : { - "$className" : "StarterPlayerScripts", - - "Controllers" : {"$className" : "Folder"}, - "Scripts" : {"$path" : "../TestEnv/Client/Scripts"} + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "Controllers": { + "$className": "Folder" + }, + "Scripts": { + "$path": "../TestEnv/Client/Scripts" + } } }, - - "Players" : { - "$className" : "Players", - - "$properties" : { - "CharacterAutoLoads" : false + "Players": { + "$className": "Players", + "$properties": { + "CharacterAutoLoads": false } }, - - "Workspace" : { - "$className" : "Workspace", - "$properties" : { - "AllowThirdPartySales" : false, - "FallenPartsDestroyHeight" : -500, - "FilteringEnabled" : true, - "Gravity" : 196.2, - "StreamingEnabled" : false, - "StreamingMinRadius" : 64, - "StreamingPauseMode" : "Default", - "StreamingTargetRadius" : 1024, - "TouchesUseCollisionGroups" : true, - "HumanoidOnlySetCollisionsOnStateChange" : { - "Enum" : 2 + "Workspace": { + "$className": "Workspace", + "$properties": { + "AllowThirdPartySales": false, + "FallenPartsDestroyHeight": -500, + "FilteringEnabled": true, + "Gravity": 196.2, + "StreamingEnabled": false, + "StreamingMinRadius": 64, + "StreamingPauseMode": "Default", + "StreamingTargetRadius": 1024, + "TouchesUseCollisionGroups": true, + "HumanoidOnlySetCollisionsOnStateChange": { + "Enum": 2 }, - "AnimationWeightedBlendFix" : { - "Enum" : 2 + "AnimationWeightedBlendFix": { + "Enum": 2 }, - "ReplicateInstanceDestroySetting" : { - "Enum" : 2 + "ReplicateInstanceDestroySetting": { + "Enum": 2 } }, - - "Map" : {"$path" : "../TestEnv/Assets/Map.rbxm"} + "Map": { + "$path": "../TestEnv/Assets/Map.rbxm" + } }, - - "Lighting" : { - "$className" : "Lighting", - "$path" : "../TestEnv/Assets/Lighting", - "$properties" : { - "Technology" : "ShadowMap", - "ClockTime" : 14, - "GeographicLatitude" : 41.733, - "Brightness" : 2, - "ExposureCompensation" : 0, - "Ambient" : [0.54117647058824,0.54117647058824,0.54117647058824], - "OutdoorAmbient" : [0.50196078431373,0.50196078431373,0.50196078431373], - "ColorShift_Bottom" : [0,0,0], - "ColorShift_Top" : [0,0,0], - "FogColor" : [0.14901960784314,0.14901960784314,0.14901960784314], - "FogEnd" : 1000, - "FogStart" : 0, - "GlobalShadows" : true, - "ShadowSoftness" : 0.2 + "Lighting": { + "$className": "Lighting", + "$path": "../TestEnv/Assets/Lighting", + "$properties": { + "Technology": "ShadowMap", + "ClockTime": 14, + "GeographicLatitude": 41.733, + "Brightness": 2, + "ExposureCompensation": 0, + "Ambient": [ + 0.54117647058824, + 0.54117647058824, + 0.54117647058824 + ], + "OutdoorAmbient": [ + 0.50196078431373, + 0.50196078431373, + 0.50196078431373 + ], + "ColorShift_Bottom": [ + 0, + 0, + 0 + ], + "ColorShift_Top": [ + 0, + 0, + 0 + ], + "FogColor": [ + 0.14901960784314, + 0.14901960784314, + 0.14901960784314 + ], + "FogEnd": 1000, + "FogStart": 0, + "GlobalShadows": true, + "ShadowSoftness": 0.2 } } } diff --git a/TestEnv/Server/Scripts/SysTest.server.lua b/TestEnv/Server/Scripts/SysTest.server.lua index b8b383a..7f08763 100644 --- a/TestEnv/Server/Scripts/SysTest.server.lua +++ b/TestEnv/Server/Scripts/SysTest.server.lua @@ -1 +1,32 @@ --- systest +local Players = game:GetService("Players") + +while true do + if shared.DragonEngine ~= nil then + break + else + task.wait() + end +end + +local DataService = shared.DragonEngine:GetService("DataService") + +local function PlayerAdded(Player) + print("Reading data...") + print(DataService:ReadData(Player, "GetOwnedWeapons")) + + while true do + task.wait(1) + DataService:WriteData(Player, "GiveGold", 1) + end +end + +for _, Player in pairs(Players:GetPlayers()) do + task.spawn(PlayerAdded, Player) +end +Players.PlayerAdded:connect(PlayerAdded) + +DataService:OnDataChanged("Currency", function(Player, CurrencyType, NewGold, OldGold) + if CurrencyType == "Gold" then + print("GoldChange", Player, NewGold, OldGold) + end +end) diff --git a/TestEnv/Server/Services/DataService.lua b/TestEnv/Server/Services/DataService.lua index 1a11b76..84c6573 100644 --- a/TestEnv/Server/Services/DataService.lua +++ b/TestEnv/Server/Services/DataService.lua @@ -3,19 +3,33 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local DataService = require(ReplicatedStorage.Packages["playerdatasystem-server"]) DataService:Configure({ + DataHandlers = ReplicatedStorage.DataHandlers, DatastoreName = "SysTest1", Schema = { - Version = 1, + Version = 2, Data = { OwnedItems = { Weapons = { "Red Crystal Sword" }, Consumables = { "Health potion" }, }, - Gold = 100, + Currency = { + Gold = 100, + Gems = 0, + }, Level = 10, XP = 0, }, - Migrators = {}, + Migrators = { + ["1 -> 2"] = function(Data) + Data.Currency = { + Gold = Data.Gold, + Gems = 0, + } + Data.Gold = nil + + return Data + end, + }, }, }) diff --git a/TestEnv/Shared/DataHandlers.lua b/TestEnv/Shared/DataHandlers.lua new file mode 100644 index 0000000..1b0a837 --- /dev/null +++ b/TestEnv/Shared/DataHandlers.lua @@ -0,0 +1,16 @@ +return { + Writers = { + GiveGold = function(Data, GoldToAdd) + local OldGold = Data.Currency.Gold + + Data.Currency.Gold = Data.Currency.Gold + GoldToAdd + + return "Currency", "Gold", Data.Currency.Gold, OldGold + end, + }, + Readers = { + GetOwnedWeapons = function(Data) + return Data.OwnedItems.Weapons + end, + }, +} From a83ff976d0742ae041678f0f5efcab143249190d Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 18:23:55 -0400 Subject: [PATCH 06/13] Only allow accessing the latest session's data --- Client/src/init.lua | 9 ++++---- Server/src/init.lua | 53 +++++++++++++++++++++++---------------------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/Client/src/init.lua b/Client/src/init.lua index 257738d..12cfa1f 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -18,7 +18,7 @@ local Table = require(script.Parent.Table) local DATA_READERS = {} local DATA_WRITERS = {} local EVENTS = {} -local DataCache = {} +local DataCache local CurrentDataSessionID = "" local LocalPlayer = Players.LocalPlayer @@ -79,7 +79,7 @@ function DataController:Start() -- Getting current session ID -- -------------------------------- while true do - local DataSessionID = DataService:GetDataSessionID() + local DataSessionID = LocalPlayer:GetAttribute("SaveSessionID") if DataSessionID ~= nil then CurrentDataSessionID = DataSessionID @@ -98,8 +98,9 @@ function DataController:Start() end) DataService.DataWritten:connect(function(Writer, ...) - WriteData(Writer, ...) - + if DataCache ~= nil then + WriteData(Writer, ...) + end print("[New Data]", DataCache) end) end diff --git a/Server/src/init.lua b/Server/src/init.lua index 969d0b5..127d900 100644 --- a/Server/src/init.lua +++ b/Server/src/init.lua @@ -34,7 +34,6 @@ local DATA_READERS = {} local EVENTS = {} local WasConfigured = false local DataCaches = {} -local DataSessionIDs = {} local DataOperationQueues = {} local ChangedCallbacks = {} @@ -221,10 +220,11 @@ end local function PlayerAdded(Player) local CurrentSessionID = HttpService:GenerateGUID(false) local OperationsQueue - DataSessionIDs[tostring(Player.UserId)] = CurrentSessionID + + Player:SetAttribute("SaveSessionID", CurrentSessionID) DataService:Log( - ("[Data Service] Player '%s' has joined, caching their savedata..."):format(tostring(Player.UserId)) + ("[Data Service] Player '%s' has joined, queued caching their savedata..."):format(tostring(Player.UserId)) ) --------------------------------------------- @@ -313,7 +313,7 @@ local function PlayerAdded(Player) "Warning" ) - DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) + DataCaches[Player:GetAttribute("SaveSessionID")] = CreateSaveData(Player) elseif not MigrationSuccess then DataService:Log( ("[Data Service] Couldn't migrate data schema for player '%s', data will be temporary."):format( @@ -322,7 +322,7 @@ local function PlayerAdded(Player) "Warning" ) - DataCaches[tostring(Player.UserId)] = CreateSaveData(Player) + DataCaches[Player:GetAttribute("SaveSessionID")] = CreateSaveData(Player) elseif not LockSuccess then DataService:Log( ("[Data Service] Couldn't sessionlock data for player '%s', data will be temporary."):format( @@ -331,16 +331,15 @@ local function PlayerAdded(Player) "Warning" ) - DataCaches[tostring(Player.UserId)] = SavedData + DataCaches[Player:GetAttribute("SaveSessionID")] = SavedData else DataService:Log(("[Data Service] Savedata cached for player '%s'!"):format(tostring(Player.UserId))) SavedData.IsTemporary = false - DataCaches[tostring(Player.UserId)] = SavedData + DataCaches[Player:GetAttribute("SaveSessionID")] = SavedData end EVENTS.DataLoaded:FireClient(Player, CurrentSessionID) - print("[Data]", DataCaches[tostring(Player.UserId)]) end) if not OperationsQueue:IsExecuting() then @@ -360,7 +359,7 @@ local function PlayerRemoved(Player) local OperationsQueue DataService:Log( - ("[Data Service] Player '%s' has left, writing their savedata to datastores and removing their cache..."):format( + ("[Data Service] Player '%s' has left, queued writing their savedata to datastores and removing their savedata cache..."):format( tostring(Player.UserId) ) ) @@ -378,12 +377,13 @@ local function PlayerRemoved(Player) ------------------------------------ -- Writing save data to datastore -- ------------------------------------ - WriteDataToStore(Player, DataCaches[tostring(Player.UserId)]) + WriteDataToStore(Player, DataCaches[Player:GetAttribute("SaveSessionID")]) ------------------------- -- Clearing data cache -- ------------------------- - DataCaches[tostring(Player.UserId)] = nil + DataCaches[Player:GetAttribute("SaveSessionID")] = nil + DataService:Log(("[Data Service] Removed savedata cache for player '%s'!"):format(tostring(Player.UserId))) --------------------------- -- Removing session lock -- @@ -416,15 +416,24 @@ end -- Tuple "Args" - The arguments to pass to the specified writer function ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataService:WriteData(Player, Writer, ...) - local ChangedParams = table.pack(DATA_WRITERS[Writer](DataCaches[tostring(Player.UserId)].Data, ...)) - local DataName = ChangedParams[1] - ChangedParams[1] = Player + while true do + if not Player:IsDescendantOf(game) then + return + elseif DataCaches[Player:GetAttribute("SaveSessionID")] ~= nil then + break + end + task.wait() + end + + local DataChanges = table.pack(DATA_WRITERS[Writer](DataCaches[Player:GetAttribute("SaveSessionID")].Data, ...)) + local DataName = DataChanges[1] + DataChanges[1] = Player EVENTS.DataWritten:FireClient(Player, Writer, ...) if ChangedCallbacks[DataName] ~= nil then for _, Callback in pairs(ChangedCallbacks[DataName]) do - Callback(table.unpack(ChangedParams)) + Callback(table.unpack(DataChanges)) end end end @@ -440,14 +449,14 @@ function DataService:ReadData(Player, Reader, ...) while true do if not Player:IsDescendantOf(game) then return nil - elseif DataCaches[tostring(Player.UserId)] ~= nil then + elseif DataCaches[Player:GetAttribute("SaveSessionID")] ~= nil then break end task.wait() end - return DATA_READERS[Reader](Table.Copy(DataCaches[tostring(Player.UserId)].Data, true), ...) + return DATA_READERS[Reader](Table.Copy(DataCaches[Player:GetAttribute("SaveSessionID")].Data, true), ...) end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -486,20 +495,12 @@ function DataService.Client:GetDataHandlerModule() return DATA_HANDLER_MODULE end ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Client.GetDataSessionID --- @Description : Returns the ID of the calling player's data session ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService.Client:GetDataSessionID(Player) - return DataSessionIDs[tostring(Player.UserId)] -end - ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Client.RequestRawData -- @Description : Returns the calling player's savedata to their client ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- function DataService.Client:RequestRawData(Player) - return DataCaches[tostring(Player.UserId)].Data + return DataCaches[Player:GetAttribute("SaveSessionID")].Data end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- From 07e64ab320b49c9d7899687f3f383a485684536d Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 18:24:40 -0400 Subject: [PATCH 07/13] Implement data change listening on the client --- Client/src/init.lua | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Client/src/init.lua b/Client/src/init.lua index 12cfa1f..24b85cd 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -19,6 +19,7 @@ local DATA_READERS = {} local DATA_WRITERS = {} local EVENTS = {} local DataCache +local ChangedCallbacks = {} local CurrentDataSessionID = "" local LocalPlayer = Players.LocalPlayer @@ -26,7 +27,15 @@ local LocalPlayer = Players.LocalPlayer -- Helper functions ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- local function WriteData(Writer, ...) - DATA_WRITERS[Writer](DataCache, ...) + local DataChanges = table.pack(DATA_WRITERS[Writer](DataCache, ...)) + local DataName = DataChanges[1] + DataChanges[1] = LocalPlayer + + if ChangedCallbacks[DataName] ~= nil then + for _, Callback in pairs(ChangedCallbacks[DataName]) do + Callback(table.unpack(DataChanges)) + end + end end ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -53,6 +62,20 @@ function DataController:ReadData(Reader, ...) return DATA_READERS[Reader](Table.Copy(DataCache, true), ...) end +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- @Name : OnDataChanged +-- @Description : Invokes the given callback when the specified data is changed +-- @Params : string "DataName" - The name of the data that should be listened to for changes +-- function "ChangedCallback" - The function to invoke when the specified data is changed +---------------------------------------------------------------------------------------------------------------------------------------------------------------------- +function DataController:OnDataChanged(DataName, ChangedCallback) + if ChangedCallbacks[DataName] ~= nil then + table.insert(ChangedCallbacks[DataName], ChangedCallback) + else + ChangedCallbacks[DataName] = { ChangedCallback } + end +end + ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- @Name : Init -- @Description : Called when the service module is first loaded. From c6dfa673e7ea9c6784b2a409e36a1510faa6ab59 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 18:25:10 -0400 Subject: [PATCH 08/13] Remove debug output --- Client/src/init.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/Client/src/init.lua b/Client/src/init.lua index 24b85cd..7162535 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -115,8 +115,6 @@ function DataController:Start() DataService.DataLoaded:connect(function(SessionID) if SessionID == CurrentDataSessionID then DataCache = DataService:RequestRawData() - - print("[Data]", DataCache) end end) @@ -124,7 +122,6 @@ function DataController:Start() if DataCache ~= nil then WriteData(Writer, ...) end - print("[New Data]", DataCache) end) end From 57509f015013bb8d2ce9752df3c3b973c4ba6bcd Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 18:25:26 -0400 Subject: [PATCH 09/13] Update test env --- TestEnv/Client/Scripts/DebugUI.client.lua | 32 +++++++++++++++++++++++ TestEnv/Server/Scripts/SysTest.server.lua | 10 ++++--- TestEnv/Server/Services/DataService.lua | 21 ++++++++++++++- TestEnv/Shared/DataHandlers.lua | 4 +++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 TestEnv/Client/Scripts/DebugUI.client.lua diff --git a/TestEnv/Client/Scripts/DebugUI.client.lua b/TestEnv/Client/Scripts/DebugUI.client.lua new file mode 100644 index 0000000..8aa6373 --- /dev/null +++ b/TestEnv/Client/Scripts/DebugUI.client.lua @@ -0,0 +1,32 @@ +local Players = game:GetService("Players") + +while true do + if shared.DragonEngine ~= nil then + break + else + task.wait() + end +end + +local DataController = shared.DragonEngine:GetController("DataController") + +local LocalPlayer = Players.LocalPlayer +local UI = Instance.new("ScreenGui") +local TextLabel = Instance.new("TextLabel") + +DataController:OnDataChanged("Currency", function(Player, CurrencyType, NewGold, OldGold) + if CurrencyType == "Gold" then + print("GoldChange", Player, NewGold, OldGold) + TextLabel.Text = "Gold: " .. tostring(DataController:ReadData("GetGold")) + end +end) + +TextLabel.Parent = UI +TextLabel.Text = "Gold: " .. tostring(DataController:ReadData("GetGold")) +TextLabel.Size = UDim2.fromScale(0.5, 0.1) +TextLabel.AnchorPoint = Vector2.new(0, 1) +TextLabel.Position = UDim2.fromScale(0, 1) +TextLabel.TextScaled = true +TextLabel.BackgroundTransparency = 1 +TextLabel.TextColor3 = Color3.fromRGB(255, 255, 255) +UI.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") diff --git a/TestEnv/Server/Scripts/SysTest.server.lua b/TestEnv/Server/Scripts/SysTest.server.lua index 7f08763..e3d3d08 100644 --- a/TestEnv/Server/Scripts/SysTest.server.lua +++ b/TestEnv/Server/Scripts/SysTest.server.lua @@ -11,12 +11,14 @@ end local DataService = shared.DragonEngine:GetService("DataService") local function PlayerAdded(Player) - print("Reading data...") - print(DataService:ReadData(Player, "GetOwnedWeapons")) - while true do + if Player:IsDescendantOf(game) then + DataService:WriteData(Player, "GiveGold", 1) + else + break + end + task.wait(1) - DataService:WriteData(Player, "GiveGold", 1) end end diff --git a/TestEnv/Server/Services/DataService.lua b/TestEnv/Server/Services/DataService.lua index 84c6573..58003a5 100644 --- a/TestEnv/Server/Services/DataService.lua +++ b/TestEnv/Server/Services/DataService.lua @@ -6,7 +6,7 @@ DataService:Configure({ DataHandlers = ReplicatedStorage.DataHandlers, DatastoreName = "SysTest1", Schema = { - Version = 2, + Version = 5, Data = { OwnedItems = { Weapons = { "Red Crystal Sword" }, @@ -29,6 +29,25 @@ DataService:Configure({ return Data end, + + ["2 -> 3"] = function(Data) + Data.DebugName = "" + + return Data + end, + + ["3 -> 4"] = function(Data) + Data.Gold = 0 + Data.DebugName = nil + + return Data + end, + + ["4 -> 5"] = function(Data) + Data.Gold = nil + + return Data + end, }, }, }) diff --git a/TestEnv/Shared/DataHandlers.lua b/TestEnv/Shared/DataHandlers.lua index 1b0a837..c26880f 100644 --- a/TestEnv/Shared/DataHandlers.lua +++ b/TestEnv/Shared/DataHandlers.lua @@ -12,5 +12,9 @@ return { GetOwnedWeapons = function(Data) return Data.OwnedItems.Weapons end, + + GetGold = function(Data) + return Data.Currency.Gold + end, }, } From 1ba07a978adbf57c1b2a2a5bc2840abb3f3b12b3 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 19:17:12 -0400 Subject: [PATCH 10/13] Remove lune --- aftman.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/aftman.toml b/aftman.toml index 09e7aad..9fdbe48 100644 --- a/aftman.toml +++ b/aftman.toml @@ -1,6 +1,4 @@ [tools] rojo = "rojo-rbx/rojo@7.4.4" selene = "Kampfkarren/selene@0.28.0" -mantle = "blake-mealey/mantle@0.11.18" wally = "upliftgames/wally@0.3.1" -lune = "filiptibell/lune@0.4.0" \ No newline at end of file From 4290ac21727c611e251c73d975ce602105441723 Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 19:21:44 -0400 Subject: [PATCH 11/13] Fix lints --- Client/src/init.lua | 1 - Server/src/init.lua | 8 -------- 2 files changed, 9 deletions(-) diff --git a/Client/src/init.lua b/Client/src/init.lua index 7162535..7d4576a 100644 --- a/Client/src/init.lua +++ b/Client/src/init.lua @@ -17,7 +17,6 @@ local Table = require(script.Parent.Table) ------------- local DATA_READERS = {} local DATA_WRITERS = {} -local EVENTS = {} local DataCache local ChangedCallbacks = {} local CurrentDataSessionID = "" diff --git a/Server/src/init.lua b/Server/src/init.lua index 127d900..1bf8450 100644 --- a/Server/src/init.lua +++ b/Server/src/init.lua @@ -87,14 +87,6 @@ local function AreQueuesEmpty() return true end -local function IsDataCacheEmpty() - for _, _ in pairs(DataCaches) do - return false - end - - return true -end - local function GetSaveStore() return DatastoreService:GetDataStore(DATASTORE_NAME) end From 1a66304dbc3cefc8596f3fe0aca20bc2644bcdfe Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 19:23:34 -0400 Subject: [PATCH 12/13] Delete old system modules --- Client/src/OLD_INIT.lua | 137 ----- Server/src/OLD_INIT.lua | 1179 --------------------------------------- 2 files changed, 1316 deletions(-) delete mode 100644 Client/src/OLD_INIT.lua delete mode 100644 Server/src/OLD_INIT.lua diff --git a/Client/src/OLD_INIT.lua b/Client/src/OLD_INIT.lua deleted file mode 100644 index a89d5c7..0000000 --- a/Client/src/OLD_INIT.lua +++ /dev/null @@ -1,137 +0,0 @@ ---[[ - Data controller - Handles the fetching of the player's data ---]] - -local DataController = {} - ---------------------- --- Roblox Services -- ---------------------- -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") -local RunService = game:GetService("RunService") - ------------------- --- Dependencies -- ------------------- -local RobloxLibModules = require(script.Parent["roblox-libmodules"]) -local Table = require(RobloxLibModules.Utils.Table) -local DataService; - -------------- --- Defines -- -------------- -local DataCache; -local PlayerData; -local IsDataLoaded = false - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataLoaded --- @Description : Returns a bool describing whether or not the player's data has been fully replicated in ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:IsDataLoaded() - return IsDataLoaded -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : GetData --- @Description : Gets the player's data --- @Params : OPTIONAL bool "YieldForLoad" - A bool describing whether or not the API will yield for the data to exist --- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:GetData(YieldForLoad,Format) - - if YieldForLoad ~= nil then - assert( - typeof(YieldForLoad) == "boolean", - ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead.") - :format(typeof(YieldForLoad)) - ) - end - if Format ~= nil then - assert( - typeof(Format) == "string", - ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead.") - :format(typeof(Format)) - ) - assert( - string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", - ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead.") - :format(Format) - ) - end - - if YieldForLoad then - while true do - if self:IsDataLoaded() then - break - else - RunService.Stepped:wait() - end - end - end - - if Format == nil then - return PlayerData,PlayerData:GetAttributes() - elseif string.upper(Format) == "TABLE" then - return Table.ConvertFolderToTable(PlayerData),PlayerData:GetAttributes() - elseif string.upper(Format) == "FOLDER" then - return PlayerData,PlayerData:GetAttributes() - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Init --- @Description : Used to initialize controller state ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:Init() - self:DebugLog("[Data Controller] Initializing...") - - DataService = self:GetService("DataService") - - ----------------------------------- - -- Waiting for data to be loaded -- - ----------------------------------- - local Loaded = false - local LoadedID = DataService:GetDataLoadedQueueID() - - DataService.DataLoaded:connect(function(QueueID) - if QueueID == LoadedID then - Loaded = true - end - end) - - while true do - if Loaded then - break - else - RunService.Stepped:wait() - end - end - - local DescendantCount = DataService:GetDataFolderDescendantCount() - DataCache = ReplicatedStorage:WaitForChild("_DataCache") - PlayerData = DataCache:WaitForChild(tostring(Players.LocalPlayer.UserId)) - - while true do - if #self:GetData():GetDescendants() >= DescendantCount then - break - end - RunService.Stepped:wait() - end - IsDataLoaded = true - - self:DebugLog("[Data Controller] Initialized!") -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Start --- @Description : Used to run the controller ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataController:Start() - self:DebugLog("[Data Controller] Running!") - -end - -return DataController \ No newline at end of file diff --git a/Server/src/OLD_INIT.lua b/Server/src/OLD_INIT.lua deleted file mode 100644 index ed0e746..0000000 --- a/Server/src/OLD_INIT.lua +++ /dev/null @@ -1,1179 +0,0 @@ ---[[ - Data Service - - Handles the loading, saving and management of player data - - Backup system algorithm by @berezaa, modified and adapted by @Reshiram110 ---]] - -local DataService = { Client = {} } -DataService.Client.Server = DataService - ---------------------- --- Roblox Services -- ---------------------- -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Players = game:GetService("Players") -local RunService = game:GetService("RunService") -local DatastoreService = game:GetService("DataStoreService") - ------------------- --- Dependencies -- ------------------- -local RobloxLibModules = require(script.Parent["roblox-libmodules"]) -local Table = require(RobloxLibModules.Utils.Table) -local Queue = require(RobloxLibModules.Classes.Queue) - -------------- --- Defines -- -------------- -local DATASTORE_BASE_NAME = "Production" --The base name of the datastore to use -local DATASTORE_PRECISE_NAME = "PlayerData1" --The name of the datastore to append to DATASTORE_BASE_NAME -local DATASTORE_RETRY_ENABLED = true --Determines whether or not failed datastore calls will be retried -local DATASTORE_RETRY_INTERVAL = 3 --The time (in seconds) to wait between each retry -local DATASTORE_RETRY_LIMIT = 2 --The max amount of retries an operation can be retried before failing -local SESSION_LOCK_YIELD_INTERVAL = 5 -- The time (in seconds) at which the server will re-check a player's data session-lock. ---! The interval should not be below 5 seconds, since Roblox caches keys for 4 seconds. -local SESSION_LOCK_MAX_YIELD_INTERVALS = 5 -- The maximum amount of times the server will re-check a player's session-lock before ignoring it -local DATA_KEY_NAME = "SaveData" -- The name of the key to use when saving/loading data to/from a datastore -local DataFormat = {} -local DataFormatVersion = 1 -local DataFormatConversions = {} -local DataOperationsQueues = {} -local DataLoaded_IDs = {} -local DataCache = Instance.new("Folder") --Holds data for all players in ValueObject form -DataCache.Name = "_DataCache" -DataCache.Parent = ReplicatedStorage - ------------- --- Events -- ------------- -local DataError --Fired on the server when there is an error handling the player's data -local DataCreated --Fired on the server when new data is created for a player. -local DataLoaded -- Fired to the client when its data is loaded into the server's cache - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- Helper functions ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -local function GetOperationsQueue(Player) - return DataOperationsQueues[tostring(Player.UserId)] -end - -local function GetTotalQueuesSize() - local QueuesSize = 0 - - for _, OperationsQueue in pairs(DataOperationsQueues) do - QueuesSize = QueuesSize + OperationsQueue:GetSize() - end - - return QueuesSize -end - -local function CreateDataCache(Player, Data, Metadata, CanSave) - local DataFolder = Table.ConvertTableToFolder(Data) - DataFolder.Name = tostring(Player.UserId) - - for Key, Value in pairs(Metadata) do - DataFolder:SetAttribute(Key, Value) - end - - DataFolder:SetAttribute("_CanSave", CanSave) - DataFolder.Parent = DataCache - - DataService:DebugLog( - ("[Data Service] Created data cache for player '%s', CanSave = %s!"):format(Player.Name, tostring(CanSave)) - ) -end - -local function RemoveDataCache(Player) - local DataFolder = DataCache[tostring(Player.UserId)] - - DataFolder:Destroy() - - DataService:DebugLog(("[Data Service] Removed data cache for player '%s'!"):format(Player.Name)) -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataLoaded --- @Description : Returns a bool describing whether or not the specified player's data is loaded on the server or not. --- @Params : Instance 'Player' - The player to check the data of --- @Returns : bool "IsLoaded" - A bool describing whether or not the player's data is loaded on the server or not. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:IsDataLoaded(Player) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](IsDataLoaded) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - - return DataCache:FindFirstChild(tostring(Player.UserId)) ~= nil -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : GetData --- @Description : Returns the data for the specified player and returns it in the specified format --- @Params : Instance 'Player' - The player to get the data of --- OPTIONAL string "Format" - The format to return the data in. Acceptable formats are "Table" and "Folder". --- OPTIONAL bool "ShouldYield" - Whether or not the API should wait for the data to be fully loaded --- @Returns : "Data" - The player's data --- table "Metadata" - The metadata of the player's data ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:GetData(Player, ShouldYield, Format) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](GetData) Bad argument #1 to 'GetData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - if ShouldYield ~= nil then - assert( - typeof(ShouldYield) == "boolean", - ("[Data Service](GetData) Bad argument #2 to 'GetData', bool expected, got %s instead."):format( - typeof(ShouldYield) - ) - ) - end - if Format ~= nil then - assert( - typeof(Format) == "string", - ("[Data Service](GetData) Bad argument #3 to 'GetData', string expected, got %s instead."):format( - typeof(Format) - ) - ) - assert( - string.upper(Format) == "FOLDER" or string.upper(Format) == "TABLE", - ("[Data Service](GetData) Bad argument #3 to 'GetData', invalid format. Valid formats are 'Table' or 'Folder', got '%s' instead."):format( - Format - ) - ) - end - - local DataFolder = DataCache:FindFirstChild(tostring(Player.UserId)) - - if DataFolder == nil then --Player's data did not exist - if not ShouldYield then - self:Log( - ("[Data Service](GetData) Failed to get data for player '%s', their data did not exist!"):format( - Player.Name - ), - "Warning" - ) - - return nil - else - DataFolder = DataCache:WaitForChild(tostring(Player.UserId)) - end - end - - if Format == nil then - return DataFolder, DataFolder:GetAttributes() - elseif string.upper(Format) == "TABLE" then - return Table.ConvertFolderToTable(DataFolder), DataFolder:GetAttributes() - elseif string.upper(Format) == "FOLDER" then - return DataFolder, DataFolder:GetAttributes() - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Client.GetDataLoadedQueueID --- @Description : Fetches & returns the unique queue ID associated with the queue action that loads the data for the client ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService.Client:GetDataLoadedQueueID(Player) - return DataLoaded_IDs[tostring(Player.UserId)] -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Client.GetDataFolderDescendantCount --- @Description : Returns the number of descendants in the calling player's data folder ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService.Client:GetDataFolderDescendantCount(Player) - return #self.Server:GetData(Player):GetDescendants() -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : IsDataSessionlocked --- @Description : Returns whether or not a player's data is session locked to another server --- @Params : Instance 'Player' - The player to check the session lock status of --- string "DatastoreName" - The name of the datastore to check the session lock in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. can contain errors if the --- operation fails. --- bool "IsSessionlocked" - A bool describing whether or not the player's data is session-locked in another server. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:IsDataSessionlocked(Player, DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](IsDataSessionlocked) Bad argument #1 to 'IsDataSessionlocked', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](IsDataSessionlocked) Bad argument #2 to 'IsDataSessionlocked', string expected, got %s instead."):format( - typeof(DatastoreName) - ) - ) - - self:DebugLog( - ("[Data Service](IsDataSessionlocked) Getting session lock for %s in datastore '%s'..."):format( - Player.Name, - DATASTORE_BASE_NAME .. "_" .. DatastoreName - ) - ) - - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) - ) - local SessionLocked = false - - local GetSessionLock_Success, GetSessionLock_Error = pcall(function() - SessionLocked = SessionLock_Datastore:GetAsync("SessionLock") - end) - - if GetSessionLock_Success then - self:DebugLog(("[Data Service](IsDataSessionLocked) Got session lock for %s!"):format(Player.Name)) - - return true, "Operation Success", SessionLocked - else - self:Log( - ("[Data Service](IsDataSessionlocked) An error occured while reading session-lock for '%s' : Could not read session-lock, %s"):format( - Player.Name, - GetSessionLock_Error - ), - "Warning" - ) - - return false, "Failed to read session-lock : Could not read session-lock, " .. GetSessionLock_Error - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SessionlockData --- @Description : Locks the data for the specified player to the current server --- @Params : Instance 'Player' - The player to session lock the data of --- string "DatastoreName" - The name of the datastore to lock the data in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SessionlockData(Player, DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](SessionlockData) Bad argument #1 to 'SessionlockData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](SessionlockData) Bad argument #2 to 'SessionlockData', string expected, got %s instead."):format( - typeof(DatastoreName) - ) - ) - - self:DebugLog( - ("[Data Service](SessionlockData) Locking data for %s in datastore '%s'..."):format( - Player.Name, - DATASTORE_BASE_NAME .. "_" .. DatastoreName - ) - ) - - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) - ) - - local WriteLock_Success, WriteLock_Error = pcall(function() - SessionLock_Datastore:SetAsync("SessionLock", true) - end) - - if WriteLock_Success then - self:DebugLog(("[Data Service](SessionlockData) Locked data for %s!"):format(Player.Name)) - - return true, "Operation Success" - else - self:Log( - ("[Data Service](SessionlockData) An error occured while session-locking data for '%s' : Could not write session-lock, %s"):format( - Player.Name, - WriteLock_Error - ), - "Warning" - ) - - return false, "Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : UnSessionlockData --- @Description : Unlocks the data for the specified player from the current server --- @Params : Instance 'Player' - The player to un-session lock the data of --- string "DatastoreName" - The name of the datastore to un-lock the data in --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:UnSessionlockData(Player, DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](UnSessionlockData) Bad argument #1 to 'UnSessionlockData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](UnSessionlockData) Bad argument #2 to 'UnSessionlockData', string expected, got %s instead."):format( - typeof(DatastoreName) - ) - ) - - self:DebugLog( - ("[Data Service](UnSessionlockData) Unlocking data for %s in datastore '%s'..."):format( - Player.Name, - DATASTORE_BASE_NAME .. "_" .. DatastoreName - ) - ) - - local SessionLock_Datastore = DatastoreService:GetDataStore( - DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_SessionLocks", - tostring(Player.UserId) - ) - - local WriteLock_Success, WriteLock_Error = pcall(function() - SessionLock_Datastore:SetAsync("SessionLock", false) - end) - - if WriteLock_Success then - self:DebugLog(("[Data Service](UnSessionlockData) Unlocked data for %s!"):format(Player.Name)) - - return true, "Operation Success" - else - self:Log( - ("[Data Service](UnSessionlockData) An error occured while un-session-locking data for '%s' : Could not write session-lock, %s"):format( - Player.Name, - WriteLock_Error - ), - "Warning" - ) - - return false, "Failed to session-lock data : Could not write session-lock, " .. WriteLock_Error - end -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : LoadData --- @Description : Loads the data for the specified player and returns it as a table --- @Params : Instance 'Player' - The player to load the data of --- string "DatastoreName" - The name of the datastore to load the data from --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. --- table "Data" - The player's data. Will be default data if the operation fails. --- table "Metadata" - Metadata for the player's data. Will be default metadata if the operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:LoadData(Player, DatastoreName) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](LoadData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](LoadData) Bad argument #2 to 'SaveData', string expected, got %s instead."):format( - typeof(DatastoreName) - ) - ) - - self:DebugLog( - ("[Data Service](LoadData) Loading data for %s from datastore '%s'..."):format( - Player.Name, - DATASTORE_BASE_NAME .. "_" .. DatastoreName - ) - ) - - ------------- - -- Defines -- - ------------- - local Data_Datastore = - DatastoreService:GetDataStore(DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_Data", tostring(Player.UserId)) - local Data -- Holds the player's data - local Data_Metadata -- Holds metadata for the save data - - ---------------------------------- - -- Fetching data from datastore -- - ---------------------------------- - local GetDataSuccess, GetDataErrorMessage = pcall(function() - local KeyInfo - - Data, KeyInfo = Data_Datastore:GetAsync(DATA_KEY_NAME) - - if Data ~= nil then - Data_Metadata = KeyInfo:GetMetadata() - end - end) - if not GetDataSuccess then --! An error occured while getting the player's data - self:Log( - ("[Data Service](LoadData) An error occured while loading data for player '%s' : %s"):format( - Player.Name, - GetDataErrorMessage - ), - "Warning" - ) - - Data = Table.Copy(DataFormat) - DataError:Fire(Player, "Load", "FetchData", Data) - self.Client.DataError:FireAllClients(Player, "Load", "FetchData", Data) - - return false, "Failed to load data : " .. GetDataErrorMessage, Data, { FormatVersion = DataFormatVersion } - else - if Data == nil then -- * It is the first time loading data from this datastore. Player must be new! - self:DebugLog( - ("[Data Service](LoadData) Data created for the first time for player '%s', they may be new!"):format( - Player.Name - ) - ) - - Data = Table.Copy(DataFormat) - DataCreated:Fire(Player, Data) - self.Client.DataCreated:FireAllClients(Player, Data) - - return true, "Operation Success", Data, { FormatVersion = DataFormatVersion } - end - end - - ------------------------------------------ - -- Updating the data's format if needed -- - ------------------------------------------ - if Data_Metadata.FormatVersion < DataFormatVersion then -- Data format is outdated, it needs to be updated. - self:DebugLog(("[Data Service](LoadData) %s's data format is outdated, updating..."):format(Player.Name)) - - local DataFormatUpdateSuccess, DataFormatUpdateErrorMessage = pcall(function() - for _ = Data_Metadata.FormatVersion, DataFormatVersion - 1 do - self:DebugLog( - ("[Data Service](LoadData) Updating %s's data from version %s to version %s..."):format( - Player.Name, - tostring(Data_Metadata.FormatVersion), - tostring(Data_Metadata.FormatVersion + 1) - ) - ) - - Data = DataFormatConversions[tostring(Data_Metadata.FormatVersion) .. " -> " .. tostring( - Data_Metadata.FormatVersion + 1 - )](Data) - Data_Metadata.FormatVersion = Data_Metadata.FormatVersion + 1 - end - end) - - if not DataFormatUpdateSuccess then --! An error occured while updating the player's data - self:Log( - ("[Data Service](LoadData) An error occured while updating the data for player '%s' : %s"):format( - Player.Name, - DataFormatUpdateErrorMessage - ), - "Warning" - ) - - Data = Table.Copy(DataFormat) - DataError:Fire(Player, "Load", "FormatUpdate", Data) - self.Client.DataError:FireAllClients(Player, "Load", "FormatUpdate", Data) - - return false, - "Failed to load data : Update failed, " .. DataFormatUpdateErrorMessage, - Data, - { FormatVersion = DataFormatVersion } - end - elseif Data_Metadata.FormatVersion == nil or Data_Metadata.FormatVersion > DataFormatVersion then -- Unreadable data format, do not load data. - self:Log( - ("[Data Service](LoadData) An error occured while loading the data for player '%s' : %s"):format( - Player.Name, - "Unknown data format" - ), - "Warning" - ) - - Data = Table.Copy(DataFormat) - DataError:Fire(Player, "Load", "UnknownDataFormat", Data) - - self.Client.DataError:FireAllClients(Player, "Load", "UnknownDatFormat", Data) - - return false, "Failed to load data : Unknown data format", Data, { FormatVersion = DataFormatVersion } - end - - self:DebugLog(("[Data Service](LoadData) Successfully loaded data for player '%s'!"):format(Player.Name)) - - return true, "Operation Success", Data, Data_Metadata -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SaveData --- @Description : Saves the data for the specified player into the specified datastore --- @Params : Instance 'Player' - the player to save the data of --- string "DatastoreName" - The name of the datastore to save the data to --- table "Data" - The table containing the data to save --- table "Data_Metadata" - The table containing the metadata of the data to save --- @Returns : bool "OperationSucceeded" - A bool describing if the operation was successful or not --- string "OperationMessage" - A message describing the result of the operation. Can contain errors if the --- operation fails. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SaveData(Player, DatastoreName, Data, Data_Metadata) - ---------------- - -- Assertions -- - ---------------- - assert( - typeof(Player) == "Instance", - ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got %s instead."):format( - typeof(Player) - ) - ) - assert( - Player:IsA("Player"), - ("[Data Service](SaveData) Bad argument #1 to 'SaveData', Instance 'Player' expected, got Instance '%s' instead."):format( - Player.ClassName - ) - ) - assert( - typeof(DatastoreName) == "string", - ("[Data Service](SaveData) Bad argument #2 to 'SaveData', string expected, got %s instead."):format( - typeof(DatastoreName) - ) - ) - assert(Data ~= nil, "[Data Service](SaveData) Bad argument #3 to 'SaveData', Data expected, got nil.") - assert(Data_Metadata ~= nil, "[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got nil.") - assert( - typeof(Data_Metadata) == "table", - ("[Data Service](SaveData) Bad argument #4 to 'SaveData', table expected, got %s instead."):format( - typeof(Data_Metadata) - ) - ) - assert( - Data_Metadata.FormatVersion ~= nil, - "[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected, got nil." - ) - assert( - typeof(Data_Metadata.FormatVersion) == "number", - ("[Data Service](SaveData) Bad argument #4 to 'SaveData', key `FormatVersion` expected as number, got %s instead."):format( - typeof(Data_Metadata.FormatVersion) - ) - ) - - self:DebugLog( - ("[Data Service](SaveData) Saving data for %s into datastore '%s'..."):format( - Player.Name, - DATASTORE_BASE_NAME .. "_" .. DatastoreName - ) - ) - - ------------- - -- Defines -- - ------------- - local Data_Datastore = - DatastoreService:GetDataStore(DATASTORE_BASE_NAME .. "_" .. DatastoreName .. "_Data", tostring(Player.UserId)) - local DatastoreSetOptions = Instance.new("DataStoreSetOptions") - - ---------------------------------------------- - -- Saving player's data to normal datastore -- - ---------------------------------------------- - local SaveDataSuccess, SaveDataErrorMessage = pcall(function() - DatastoreSetOptions:SetMetadata(Data_Metadata) - Data_Datastore:SetAsync(DATA_KEY_NAME, Data, {}, DatastoreSetOptions) - end) - if not SaveDataSuccess then --! An error occured while saving the player's data. - self:Log( - ("[Data Service](SaveData) An error occured while saving data for '%s' : %s"):format( - Player.Name, - SaveDataErrorMessage - ), - "Warning" - ) - - DataError:Fire(Player, "Save", "SaveData", Data) - self.Client.DataError:FireAllClients(Player, "Save", "SaveData", Data) - - return false, "Failed to save data : " .. SaveDataErrorMessage - end - - self:DebugLog( - ("[Data Service](SaveData) Data saved successfully into datastore '%s' for %s!"):format( - DATASTORE_BASE_NAME .. "_" .. DatastoreName, - Player.Name - ) - ) - - return true, "Operation Success" -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : SetConfigs --- @Description : Sets this service's configs to the specified values --- @Params : table "Configs" - A dictionary containing the new config values ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:SetConfigs(Configs) - DataFormatVersion = Configs.DataFormatVersion - DataFormat = Configs.DataFormat - DataFormatConversions = Configs.DataFormatConversions - DATASTORE_BASE_NAME = Configs.DatastoreBaseName - DATASTORE_PRECISE_NAME = Configs.DatastorePreciseName - DATASTORE_RETRY_ENABLED = Configs.DatastoreRetryEnabled - DATASTORE_RETRY_INTERVAL = Configs.DatastoreRetryInterval - DATASTORE_RETRY_LIMIT = Configs.DatastoreRetryLimit - SESSION_LOCK_YIELD_INTERVAL = Configs.SessionLockYieldInterval - SESSION_LOCK_MAX_YIELD_INTERVALS = Configs.SessionLockMaxYieldIntervals - DATA_KEY_NAME = Configs.DataKeyName -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Init --- @Description : Called when the service module is first loaded. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Init() - DataLoaded = self:RegisterServiceClientEvent("DataLoaded") - DataCreated = self:RegisterServiceServerEvent("DataCreated") - DataError = self:RegisterServiceServerEvent("DataError") - self.Client.DataCreated = self:RegisterServiceClientEvent("DataCreated") - self.Client.DataError = self:RegisterServiceClientEvent("DataError") - - self:DebugLog("[Data Service] Initialized!") -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Start --- @Description : Called after all services are loaded. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Start() - self:DebugLog("[Data Service] Started!") - - ------------------------------------------- - -- Loads player data into server's cache -- - ------------------------------------------- - local function LoadPlayerDataIntoServer(Player) - local WaitForSessionLock_Success = false -- Determines whether or not the session lock was waited for successfully - local SetSessionLock_Success = false -- Determines whether or not the session lock was successfully enabled for this server - local LoadData_Success = false -- Determines whether or not the player's data was fetched successfully - local PlayerData - local PlayerData_Metadata - - self:Log(("[Data Service] Loading data for player '%s'..."):format(Player.Name)) - - ---------------------------------------------------- - -- Waiting for other server's sessionlock removal -- - ---------------------------------------------------- - self:DebugLog( - ("[Data Service] Waiting for previous server to remove session lock for player '%s'..."):format(Player.Name) - ) - - for SessionLock_YieldCount = 1, SESSION_LOCK_MAX_YIELD_INTERVALS do - local GetLockSuccess - local OperationMessage - local IsLocked - - -------------------------------- - -- Reading session lock value -- - -------------------------------- - for RetryCount = 0, DATASTORE_RETRY_LIMIT do - self:DebugLog(("[Data Service] Reading session lock for player '%s'..."):format(Player.Name)) - - GetLockSuccess, OperationMessage, IsLocked = self:IsDataSessionlocked(Player, DATASTORE_PRECISE_NAME) - - if not GetLockSuccess then - self:Log( - ("[Data Service] Failed to read session lock for player '%s' : %s"):format( - Player.Name, - OperationMessage - ), - "Warning" - ) - - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while attempting to read session lock for player '%s', aborting"):format( - Player.Name - ), - "Warning" - ) - - break - else - if DATASTORE_RETRY_ENABLED then - self:Log( - ("[Data Service] Attempting to read session lock for player '%s' %s more times."):format( - Player.Name, - tostring(DATASTORE_RETRY_LIMIT - RetryCount) - ) - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - else - break - end - end - else - self:DebugLog(("[Data Service] Got session lock for player '%s'!"):format(Player.Name)) - - break - end - end - - -------------------------------------------- - -- Determining if sessionlock was removed -- - -------------------------------------------- - if not GetLockSuccess then - break - end - - if IsLocked then - if SessionLock_YieldCount == SESSION_LOCK_MAX_YIELD_INTERVALS then - self:Log( - ("[Data Service] Timeout reached while waiting for previous server to remove its sessionlock for player '%s', ignoring it."):format( - Player.Name - ), - "Warning" - ) - - WaitForSessionLock_Success = true - else - self:DebugLog( - ("[Data Service] Previous server hasn't removed session lock for player '%s' yet, waiting %s seconds before re-reading."):format( - Player.Name, - tostring(SESSION_LOCK_YIELD_INTERVAL) - ) - ) - end - - task.wait(SESSION_LOCK_YIELD_INTERVAL) - else - self:DebugLog( - ("[Data Service] Previous server removed session lock for player '%s'!"):format(Player.Name) - ) - - WaitForSessionLock_Success = true - break - end - end - - -------------------------- - -- Setting session lock -- - -------------------------- - if not WaitForSessionLock_Success then - self:Log( - ("[Data Service] Failed to set session lock to this server, giving player '%s' default data."):format( - Player.Name - ), - "Warning" - ) - - CreateDataCache(Player, Table.Copy(DataFormat), false) - return - else - self:DebugLog(("[Data Service] Setting session-lock for player '%s'..."):format(Player.Name)) - end - - for RetryCount = 1, DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Writing sessionlock to datastore '%s' for player '%s'..."):format( - DATASTORE_PRECISE_NAME, - Player.Name - ) - ) - - local SetLockSuccess, SetLockMessage = self:SessionlockData(Player, DATASTORE_PRECISE_NAME) - - if not SetLockSuccess then - self:Log( - ("[Data Service] Failed to set session-lock for player '%s' : %s"):format( - Player.Name, - SetLockMessage - ), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to session-lock data for player '%s', no further attempts will be made."):format( - Player.Name - ), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to session-lock data for player '%s', waiting %s seconds before retrying."):format( - Player.Name, - tostring(DATASTORE_RETRY_INTERVAL) - ), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog(("[Data Service] Successfully session-locked data for player '%s'!"):format(Player.Name)) - - SetSessionLock_Success = true - break - end - end - - ---------------------------- - -- Fetching player's data -- - ---------------------------- - if not SetSessionLock_Success then - self:Log( - ("[Data Service] Failed to set session-lock, giving player '%s' default data."):format(Player.Name), - "Warning" - ) - - CreateDataCache(Player, Table.Copy(DataFormat), false) - return - else - self:DebugLog(("[Data Service] Fetching data for player '%s' from datastore..."):format(Player.Name)) - end - - for RetryCount = 1, DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Reading data from datastore '%s' for player '%s'..."):format( - DATASTORE_PRECISE_NAME, - Player.Name - ) - ) - - local FetchDataSuccess, FetchDataMessage, Data, Data_Metadata = - self:LoadData(Player, DATASTORE_PRECISE_NAME) - - if not FetchDataSuccess then - self:Log( - ("[Data Service] Failed to fetch data for player '%s' : %s"):format(Player.Name, FetchDataMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to load data for player '%s', no further attempts will be made."):format( - Player.Name - ), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to fetch data for player '%s', waiting %s seconds before retrying."):format( - Player.Name, - tostring(DATASTORE_RETRY_INTERVAL) - ), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog( - ("[Data Service] Successfully fetched data for player '%s' from datastores!"):format(Player.Name) - ) - - LoadData_Success = true - PlayerData = Data - PlayerData_Metadata = Data_Metadata - - break - end - end - - if not LoadData_Success then - self:Log( - ("[Data Service] Failed to load data for player '%s', player will be given default data."):format( - Player.Name - ), - "Warning" - ) - - CreateDataCache(Player, Table.Copy(DataFormat), { FormatVersion = DataFormatVersion }, false) - else - self:Log(("[Data Service] Successfully loaded data for player '%s'!"):format(Player.Name)) - - CreateDataCache(Player, PlayerData, PlayerData_Metadata, true) - end - end - - ------------------------------------------- - -- Saves player data from servers' cache -- - ------------------------------------------- - local function SavePlayerDataFromServer(Player) - local PlayerData, Data_Metadata = self:GetData(Player, false, "Table") - local WriteData_Success = false -- Determines whether or not the player's data was successfully saved to datastores - - Data_Metadata["_CanSave"] = nil - - self:Log(("[Data Service] Saving data for player '%s'..."):format(Player.Name)) - - ------------------------------- - -- Writing data to datastore -- - ------------------------------- - self:DebugLog(("[Data Service] Writing data to datastores for player '%s'..."):format(Player.Name)) - for RetryCount = 1, DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Writing data to datastore '%s' for player '%s'..."):format( - DATASTORE_PRECISE_NAME, - Player.Name - ) - ) - - local WriteDataSuccess, WriteDataMessage = - self:SaveData(Player, DATASTORE_PRECISE_NAME, PlayerData, Data_Metadata) - - if not WriteDataSuccess then - self:Log( - ("[Data Service] Failed to write data for player '%s' : %s"):format(Player.Name, WriteDataMessage), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to write data for player '%s', no further attempts will be made."):format( - Player.Name - ), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to write data for player '%s', waiting %s seconds before retrying."):format( - Player.Name, - tostring(DATASTORE_RETRY_INTERVAL) - ), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog( - ("[Data Service] Successfully wrote data for player '%s' to datastores!"):format(Player.Name) - ) - - WriteData_Success = true - break - end - end - - if not WriteData_Success then - self:Log(("[Data Service] Failed to save data for player '%s'."):format(Player.Name), "Warning") - else - self:Log(("[Data Service] Successfully saved data for player '%s'!"):format(Player.Name)) - end - - ---------------------------- - -- Un-sessionlocking data -- - ---------------------------- - self:DebugLog(("[Data Service] Un-session locking data for player '%s'..."):format(Player.Name)) - - for RetryCount = 1, DATASTORE_RETRY_LIMIT do - self:DebugLog( - ("[Data Service] Removing sessionlock from datastore '%s' for player '%s'..."):format( - DATASTORE_PRECISE_NAME, - Player.Name - ) - ) - - local RemoveLockSuccess, RemoveLockMessage = self:UnSessionlockData(Player, DATASTORE_PRECISE_NAME) - - if not RemoveLockSuccess then - self:Log( - ("[Data Service] Failed to remove session-lock for player '%s' : %s"):format( - Player.Name, - RemoveLockMessage - ), - "Warning" - ) - - if DATASTORE_RETRY_ENABLED then - if RetryCount == DATASTORE_RETRY_LIMIT then - self:Log( - ("[Data Service] Max retries reached while trying to remove session-lock for player '%s', no further attempts will be made."):format( - Player.Name - ), - "Warning" - ) - else - self:Log( - ("[Data Service] Retrying to remove session-lock for player '%s', waiting %s seconds before retrying."):format( - Player.Name, - tostring(DATASTORE_RETRY_INTERVAL) - ), - "Warning" - ) - - task.wait(DATASTORE_RETRY_INTERVAL) - end - else - break - end - else - self:DebugLog(("[Data Service] Successfully removed session-lock for player '%s'!"):format(Player.Name)) - - break - end - end - - RemoveDataCache(Player) - end - - --------------------------------- - -- Loading player data on join -- - --------------------------------- - local function PlayerJoined(Player) - if GetOperationsQueue(Player) == nil then - DataOperationsQueues[tostring(Player.UserId)] = Queue.new() - end - - local DataOperationsQueue = GetOperationsQueue(Player) - - local QueueItemID = DataOperationsQueue:AddAction(function() - LoadPlayerDataIntoServer(Player) - end, function(ActionID) - DataLoaded:FireClient(Player, ActionID) - end) - DataLoaded_IDs[tostring(Player.UserId)] = QueueItemID - - if not DataOperationsQueue:IsExecuting() then - DataOperationsQueue:Execute() - end - end - Players.PlayerAdded:connect(PlayerJoined) - for _, Player in pairs(Players:GetPlayers()) do - coroutine.wrap(PlayerJoined)(Player) - end - - --------------------------------- - -- Saving player data on leave -- - --------------------------------- - local function PlayerLeaving(Player) - DataLoaded_IDs[tostring(Player.UserId)] = nil - - local DataOperationsQueue = GetOperationsQueue(Player) - - DataOperationsQueue:AddAction(function() - if self:GetData(Player, false):GetAttribute("_CanSave") == false then - self:Log( - ("[Data Service] Player '%s' left, but their data was marked as not saveable. Will not save data."):format( - Player.Name - ), - "Warning" - ) - - RemoveDataCache(Player) - - return - else - SavePlayerDataFromServer(Player) - end - end, function() - if DataOperationsQueue:GetSize() == 0 then - DataOperationsQueue:Destroy() - DataOperationsQueues[tostring(Player.UserId)] = nil - end - end) - - if not DataOperationsQueue:IsExecuting() then - DataOperationsQueue:Execute() - end - end - Players.PlayerRemoving:connect(PlayerLeaving) - - -------------------------------------------------------------------------------- - -- Ensuring that all player data is saved before letting the server shut down -- - -------------------------------------------------------------------------------- - game:BindToClose(function() - self:Log("[Data Service] Server shutting down, waiting for data operations queue to be empty...") - - while true do -- Wait for all player data to be saved - if GetTotalQueuesSize() == 0 then - break - end - RunService.Stepped:wait() - end - - self:Log("[Data Service] Operations queue is empty! Letting server shut down.") - end) -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Stop --- @Description : Called when the service is being stopped. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Stop() - self:Log("[Data Service] Stopped!") -end - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- --- @Name : Unload --- @Description : Called when the service is being unloaded. ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- -function DataService:Unload() - self:Log("[Data Service] Unloaded!") -end - -return DataService From be1eedfbea0efa4913f1aba6ac61805b2fcc8fbf Mon Sep 17 00:00:00 2001 From: Noble_Draconian Date: Wed, 2 Jul 2025 20:34:14 -0400 Subject: [PATCH 13/13] Add disclaimer & fix license badge --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aed9f94..024473a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/PhoenixEntertainment/PlayerDataSystem?include_prereleases&label=Latest%20Release) [![Lua linting](https://github.com/PhoenixEntertainment/PlayerDataSystem/actions/workflows/lua-lint.yml/badge.svg)](https://github.com/PhoenixEntertainment/PlayerDataSystem/actions/workflows/lua-lint.yml) -![GitHub](https://img.shields.io/github/license/PhoenixEntertainment/UserInputSystem?label=License) +![GitHub](https://img.shields.io/github/license/PhoenixEntertainment/PlayerDataSystem?label=License) # Player data system -Handles the loading, saving & schema migration of player save-data. \ No newline at end of file +Handles the loading, saving & schema migration of player save-data. + +🐉 HERE BE DRAGONS 🐉 + +The legacy version of this system is battle-tested and used in a live game, but the newer versions are not. While they have been tested and used heavily in closed environments, their stability cannot be guaranteed. Use at your own risk! \ No newline at end of file