--===============================================-- --== API ==-- --===============================================-- --[[ The module returns an object 'PlayerDataStore' with the following methods: PlayerDataStore:GetSaveData(Player player) -> returns SaveData Returns the SaveData structure for a given player who is currently in the server. Will yield until the player's data is actually ready before returning. PlayerDataStore:GetSaveDataById(integer userId) -> returns SaveData Returns the SaveData structure for a player with a given userId, who may or may not currently be in the server. Will yield until the user's data is actually ready before returning. PlayerDataStore:FlushAll() Saves all unsaved changes in all SaveData structures in the place at the time of calling. Will yield until all of the save calls have completed. The SaveData structures have the following methods: SaveData:Get(string key) -> returns stored value Gets the value associated with |key| in this player's stored data. The cached value for this server will be returned, so this call will always return immediately. SaveData:Set(string key, variant value) Sets the cached value associated with |key| on this server. This cached value will be saved to the DataStore at some point in the future: either when the passive save runs, when the player leaves the server, or when you call Flush() on he SaveData, whichever happens first. The call returns immediately. SaveData:Update((array of strings) keys, function updateFunction) Atomically update multiple keys of a SaveData at once, and update the results in the data store, ensuring that no data is lost. Will yield until the save to the data store has actually completed. EG, for an important developer product purchase: saveData:Update({'PurchaseCount', 'Money'}, function(oldPurchaseCount, oldMoney) if not oldPurchaseCount then oldPurchaseCount = 0 end if not oldMoney then oldMoney = 0 end oldMoney = oldMoney + developerProductAmount oldPurchaseCount = oldPurchaseCount + 1 return oldPurchaseCount, oldMoney end) In general you should only be using this function to handle developer product purchases, as the data store throttling limit is quite low, and you will run into it if your users are hitting part of your functionality that uses this with a lot of requests. ]]-- --===============================================-- --== Global Module Settings ==-- --===============================================-- -- How long to keep cached data for a given player -- before letting it be thrown away if there are -- no other references to it in your code. -- Note1: Even after the expiry time, as long as -- there is Lua code in your project somewhere -- holding onto a reference to the SaveData object -- for a player, the cached data for that player -- will be kept. -- Note2: Data for a given play will be cached for -- as long as that player is in the server whether -- or not you actually have a reference to it. The -- expiry time is only relevant for the data of -- players who are not in the server. local CACHE_EXPIRY_TIME = 60*1 --10 minutes -- How often to save unsaved changes to a player's -- data store if it has not been manually Flush'd -- by calling the Flush method on their SaveData, -- or Flush'd via the player leaving the place. local PASSIVE_SAVE_FREQUENCY = 60*1 -- once every 1 minute -- How accurately to clear cache entries / do passive -- saves. That is, how often to check if those things -- need to be done. local PASSIVE_GRANULARITY = 5 -- check once every 5 seconds -- Optional key serialization. This specifies how -- a given key should be saved to or loaded from -- the actual data store. If not specified in the -- tables then the key will just be directly -- passed to the DataStore:Get/Set/UpdateAsync -- methods. local SERIALIZE = {} local DESERIALIZE = {} -- Put your entries here -- --vvvvv-- --^^^^^-- ---------------------------- -- EG: -- SERIALIZE.ScoreObject = function(scoreObject) -- return scoreObject.Value -- end -- DESERIALIZE.ScoreObject = function(value) -- local object = Instance.new('IntValue') -- object.Name = 'ScoreIntValue' -- object.Value = value -- return object -- end -- ...usage (Note, you would never actually want to do this with an -- IntValue object as shown, you could just be storing a straight -- number instead, and it wouldn't work very well because -- if you just set the IntValue's value the PlayerDataStore -- would not know that it had changed, and would not save the -- change. You'd actually have to call Set(...) to make the -- change save): -- local PlayerDataStore = require(game.ServerStorage.PlayerDataStore) -- local saveData = PlayerDataStore:GetSaveData(player) -- local valueObject = saveData:Get('ScoreObject') -- print(valueObject.Name, valueObject.Value) -> ScoreIntValue -- local newScoreObject = Instance.new('IntValue') -- newScoreObject.Value = 4 -- -- Even though normally you cannot save objects to the data -- -- store, the custom serialize you provided handles it -- saveData:Set('ScoreObject', newScoreObject) -- saveData:Flush() -- The name of the data store to use to store the -- player data. local DATASTORE_NAME = '__SuP3R_n0sTa1Gi4_z0nE__' -- Guarantee a save ASAP when a player leaves a server. -- This ensures that if they go to another server of the -- same place the save will almost certainly have already -- completed when they enter the new place. You can leave -- this off to be lighter on the save quota if you are -- not possibly storing very important stuff in the data -- store right before a player leaves. local SAVE_ON_LEAVE = true -- Debug flag, for internal debugging local DEBUG = false -- Check if we are able to use DataStores. if game.GameId < 1 then warn("Game is not connected to a universe, cannot load DataStores.") return { Success = false } end local success,errorMsg = pcall(function () local DataStoreService = game:GetService("DataStoreService") wait() DataStoreService:GetGlobalDataStore() end) if not success then warn("DataStore is unavailable: " .. tostring(errorMsg)) return { Success = false } end --===============================================-- --== Utility Functions ==-- --===============================================-- -- Deep copy a table without circular references local function DeepCopy(tb) if type(tb) == 'table' then local new = {} for k, v in pairs(tb) do new[k] = DeepCopy(v) end return new else return tb end end -- Spawn a new thread and run it immedately up to -- the first yield before returning local function SpawnNow(func) local ev = Instance.new('BindableEvent') ev.Event:connect(func) ev:Fire() end --===============================================-- --== SaveData Class ==-- --===============================================-- -- Holds the cached saved data for a given player. local SaveData = {} function SaveData.new(playerDataStore, userId) local this = {} --===============================================-- --== Private Data ==-- --===============================================-- this.userId = userId this.lastSaved = 0 -- Locked status, so that saves and updates cannot -- end up fighting over the same data this.locked = false this.unlocked = Instance.new('BindableEvent') -- The actual data for this SaveData structure this.dataSet = nil -- Keys that have unsaved changes this.dirtyKeySet = {} -- keys that we "own", that is, ones we have -- written to or read from on this SaveData. this.ownedKeySet = {} --===============================================-- --== Private Implementation ==-- --===============================================-- local function ownKey(key) this.ownedKeySet[key] = true end local function dirtyKey(key) this.dirtyKeySet[key] = true end local function markAsTouched(key) ownKey(key) playerDataStore:markAsTouched(this) end local function markAsDirty(key) ownKey(key) dirtyKey(key) playerDataStore:markAsDirty(this) end -- Load in the data for the struct function this:makeReady(data) this.dataSet = data this.lastSaved = tick() playerDataStore:markAsTouched(this) end function this:waitForUnlocked() while this.locked do this.unlocked.Event:wait() end end function this:lock() this.locked = true end function this:unlock() this.locked = false this.unlocked:Fire() end --===============================================-- --== Public API ==-- --===============================================-- -- Getter and setter function to manipulate keys -- for this player. function this:Get(key) if type(key) ~= 'string' then error("Bad argument #1 to SaveData::Get() (string expected)", 2) end if DEBUG then print("SaveData<"..this.userId..">::Get("..key..")") end markAsTouched(key) local value = this.dataSet[key] if value == nil and DESERIALIZE[key] then -- If there's no current value, and the key -- has serialization, then we should get the -- null deserialized state. local v = DESERIALIZE[key](nil) -- Note: we don't markAsDirty here, that's -- intentional, as we don't want to save -- if we don't have to, and we don't need -- to here, as Deserialize(key, nil) should -- return back the same thing every time. -- However, we still need cache the value, -- because deserialize(key, nil) might still -- be expensive or the caller might expect -- the same reference back each time. this.dataSet[key] = v return v else return value end end -- Set(key, value, allowErase) -- Note: If allowErase is not set to true, then -- the call will error on value = nil, this is -- to prevent you accidentally erasing data when -- you don't mean to. If you do want to erase, -- then call with allowErase = true function this:Set(key, value, allowErase) if type(key) ~= 'string' then error("Bad argument #1 to SaveData::Set() (string expected)", 2) end if value == nil and not allowErase then error("Attempt to SaveData::Set('"..key.."', nil) without allowErase = true", 2) end if DEBUG then print("SaveData<"..this.userId..">::Set("..key..", "..tostring(value)..")") end markAsDirty(key) this.dataSet[key] = value end -- For important atomic transactions, update data -- store. For example, for any Developer Product -- based purchases you should use this to ensure -- that the changes are saved right away, and -- correctly. -- Note: Update() will automatically Flush any -- unsaved changes while doing the update. function this:Update(keyList, func) if type(keyList) ~= 'table' then error("Bad argument #1 to SaveData::Update() (table of keys expected)", 2) end if type(func) ~= 'function' then error("Bad argument #2 to SaveData::Update() (function expected)", 2) end if DEBUG then print("SaveData<"..this.userId..">::Update("..table.concat(keyList, ", ")..", "..tostring(func)..")") end playerDataStore:doUpdate(this, keyList, func) end -- Flush all unsaved changes out to the data -- store for this player. -- Note: This call will yield and not return -- until the data has actually been saved if -- there were any unsaved changes. function this:Flush() if DEBUG then print("SaveData<"..this.userId..">::Flush()") end playerDataStore:doSave(this) end return this end --===============================================-- --== PlayerDataStore Class ==-- --===============================================-- -- A singleton that manages all of the player data -- saving and loading in a place. local PlayerDataStore = {} function PlayerDataStore.new() local this = {} --===============================================-- --== Private Data ==-- --===============================================-- -- The actual data store we are writing to local DataStoreService = game:GetService('DataStoreService') local mDataStore = DataStoreService:GetDataStore(DATASTORE_NAME) -- The weak-reference to each player's data, so -- that as long as the place owner keeps a ref -- to the data we will have it in this cache, and -- won't reload a second copy. local mUserIdSaveDataCache = setmetatable({}, {__mode = 'v'}) -- {UserId -> SaveData} -- Strong-reference to recently touched data, to -- implement the cache expiry time. local mTouchedSaveDataCacheSet = {} -- {SaveData} -- Strong-reference to the data of players who are -- online, we always want to keep a reference to -- their data. local mOnlinePlayerSaveDataMap = {} -- {Player -> SaveData} -- Dirty save datas, that is, ones that have -- unsaved changes. local mDirtySaveDataSet = {} -- {SaveData} -- Players whose data is currently being requested local mOnRequestUserIdSet = {} -- {UserId} local mRequestCompleted = Instance.new('BindableEvent') -- Number of save functions still running -- used on server shutdown to know how long to keep the -- server alive for after the last player has left. local mSavingCount = 0 --===============================================-- --== Private Implementation ==-- --===============================================-- -- transform a userId into a data store key local function userIdToKey(userId) if script:FindFirstChild("DebugUserId") and game.JobId == "" then return "PlayerList$" .. script.DebugUserId.Value else return 'PlayerList$'..userId end end function this:markAsTouched(saveData) if DEBUG then print("PlayerDataStore::markAsTouched("..saveData.userId..")") end mTouchedSaveDataCacheSet[saveData] = true saveData.lastTouched = tick() mUserIdSaveDataCache[saveData.userId] = saveData end function this:markAsDirty(saveData) if DEBUG then print("PlayerDataStore::markAsDirty("..saveData.userId..")") end this:markAsTouched(saveData) mDirtySaveDataSet[saveData] = true mUserIdSaveDataCache[saveData.userId] = saveData end -- the initial data to record for a given userId local function initialData(userId) return {} end -- collect and clear out the dirty key set of a save data local function collectDataToSave(saveData) local toSave = {} local toErase = {} for key, _ in pairs(saveData.dirtyKeySet) do local value = saveData.dataSet[key] if value ~= nil then -- Get the value to be saved if SERIALIZE[key] then -- If there is a seralization function provided, then use -- that to serialize out the value into the data to be -- stored. toSave[key] = SERIALIZE[key](value) else -- If no serialiation is provided, still do at least a deep -- copy of the value, so that further changes to the SaveData -- after the save call will not interfear with the call if -- it takes multiple tries to update the DataStore data. toSave[key] = DeepCopy(value) end else -- no value, add to the list of keys to erase table.insert(toErase, key) end -- Turn off the dirty flag for the key, we are working on saving it saveData.dirtyKeySet[key] = nil end return toSave, toErase end -- Main saving functions that push out unsaved -- changes to the actual data store function this:doSave(saveData) if DEBUG then print("PlayerDataStore::doSave("..saveData.userId..") {") end -- update save time and dirty status in my -- listing even if there arn't any changes -- to save. saveData.lastSaved = tick() mDirtySaveDataSet[saveData] = nil -- are there any dirty keys? if next(saveData.dirtyKeySet) then -- cache the data to save local toSave, toErase = collectDataToSave(saveData) -- update the data with all the dirty keys saveData:waitForUnlocked() saveData:lock() mSavingCount = mSavingCount + 1 local attempts = 0 for i = 1,10 do local success = pcall(function () mDataStore:UpdateAsync(userIdToKey(saveData.userId), function(oldData) -- Init the data if there is none yet if not oldData then oldData = initialData(saveData.userId) end if DEBUG then print("\tattempting save:") end -- For each dirty key to be saved, update it for key, data in pairs(toSave) do if DEBUG then print("\t\tsaving `"..key.."` = "..tostring(data)) end oldData[key] = data end -- For each key to erase, erase it for _, key in pairs(toErase) do if DEBUG then print("\t\tsaving `"..key.."` = nil [ERASING])") end oldData[key] = nil end -- Return back the updated data return oldData end) end) if success then break else attempts = attempts + 1 warn("save failed, trying again...", attempts) end end if DEBUG then print("\t saved.") end mSavingCount = mSavingCount - 1 saveData:unlock() elseif DEBUG then print("\tnothing to save") end if DEBUG then print("}") end end function this:doUpdate(saveData, keyList, updaterFunc) if DEBUG then print("PlayerDataStore::doUpdate("..saveData.userId..", {"..table.concat(keyList, ", ").."}, "..tostring(updaterFunc)..") {") end -- updates happen all at once, lock right away saveData:waitForUnlocked() saveData:lock() mSavingCount = mSavingCount + 1 -- Unflag this SaveData as dirty saveData.lastSaved = tick() mDirtySaveDataSet[saveData] = nil -- turn the keyList into a key set as well -- also own all of the keys in it. local updateKeySet = {} for _, key in pairs(keyList) do saveData.ownedKeySet[key] = true updateKeySet[key] = true end -- gather the data to save currently in the saveData. There -- may be some or none. local toSave, toErase = collectDataToSave(saveData) -- do the actual update mDataStore:UpdateAsync(userIdToKey(saveData.userId), function(oldData) if DEBUG then print("\ttrying update:") end -- Init the data if there is none yet if not oldData then oldData = initialData(saveData.userId) end -- gather current values to pass to the the updater func local valueList = {} for i, key in pairs(keyList) do local value = saveData.dataSet[key] if value == nil and DESERIALIZE[key] then valueList[i] = DESERIALIZE[key](nil) else valueList[i] = value end end -- call the updaterFunc and get the results back local results = {updaterFunc(unpack(valueList, 1, #keyList))} -- Save the results to the data store and SaveData cache for i, result in pairs(results) do local key = keyList[i] -- Serialize if needed, and save to the result for the data store if SERIALIZE[key] then local serialized = SERIALIZE[key](result) if DEBUG then print("\t\tsaving result: `"..key.."` = "..tostring(serialized).." [SERIALIZED]") end oldData[key] = serialized else if DEBUG then print("\t\tsaving result: `"..key.."` = "..tostring(result)) end oldData[key] = result end -- also save the result to the SaveData cache: saveData.dataSet[key] = result end -- Also while we're at it, save the dirty values to the data store -- but only if they weren't in the set that we just updated. for key, value in pairs(toSave) do -- Serialize if needed. if not updateKeySet[key] then if DEBUG then print("\t\tsaving unsaved value: `"..key.."` = "..tostring(value)) end oldData[key] = value --(note, value is already serialized) end end for _, key in pairs(toErase) do if not updateKeySet[key] then if DEBUG then print("\t\tsaving unsaved value: `"..key.."` = nil [ERASING]") end oldData[key] = nil end end -- return the finalized result return oldData end) -- finish the save mSavingCount = mSavingCount - 1 saveData:unlock() if DEBUG then print("}") end end -- Main method for loading in the data of a user -- or grabbing it from the cache if it is still -- "hot" (in the UserIdSaveDataCache but nowhere -- else) local function doLoad(userId) if DEBUG then print("PlayerDataStore::doLoad("..userId..") {") end local saveData; -- First see if it is in the cache saveData = mUserIdSaveDataCache[userId] if saveData then if DEBUG then print("\tRecord was already in cache") end -- touch it and return it this:markAsTouched(saveData) if DEBUG then print("}") end return saveData end -- Not on file, we need to load it in, are -- we already loading it though? if mOnRequestUserIdSet[userId] then if DEBUG then print("\tRecord already requested, wait for it...") end -- wait for the existing request to complete while true do saveData = mRequestCompleted.Event:wait()() if saveData.userId == userId then -- this IS the request we're looking for this:markAsTouched(saveData) if DEBUG then print("\tRecord successfully retrieved by another thread") print("}") end return saveData end end else if DEBUG then print("\tRequest record...") end -- Not on request, we need to do the load mOnRequestUserIdSet[userId] = true -- load the data local data = mDataStore:GetAsync(userIdToKey(userId)) or {} -- deserialize any data that needs to be deserialized for key, value in pairs(data) do if DESERIALIZE[key] then data[key] = DESERIALIZE[key](value) end end -- create te SaveData structure and initialize it saveData = SaveData.new(this, userId) saveData:makeReady(data) this:markAsTouched(saveData) -- unmark as loading mOnRequestUserIdSet[userId] = nil -- Pass to other waiters mRequestCompleted:Fire(function() return saveData end) if DEBUG then print("\tRecord successfully retrieved from data store") print("}") end return saveData end end -- Handle adding and removing strong-references to a player's -- data while they are in the server. local function HandlePlayer(player) if DEBUG then print("PlayerDataStore> Player "..player.userId.." Entered > Load Data") end local saveData = doLoad(player.userId) -- are the still in the game? If they are then -- add the strong-reference to the SaveData if player.Parent then mOnlinePlayerSaveDataMap[player] = saveData end end Game.Players.PlayerAdded:connect(HandlePlayer) for _, player in pairs(Game.Players:GetChildren()) do if player:IsA('Player') then HandlePlayer(player) end end Game.Players.PlayerRemoving:connect(function(player) -- remove the strong-reference when they leave. local oldSaveData = mOnlinePlayerSaveDataMap[player] mOnlinePlayerSaveDataMap[player] = nil -- Do a save too if the flag is on if SAVE_ON_LEAVE and oldSaveData then -- Note: We only need to do a save if the initial -- load for that player actually completed yet. Cached -- versions from before the player entered are not a concern -- here as if there were a cache version the oldSaveData -- would exist, as the doLoad on player entered would -- have completed immediately. if DEBUG then print("PlayerDataStore> Player "..player.userId.." Left with data to save > Save Data") end this:doSave(oldSaveData) end end) -- when the game shuts down, save all data local RunService = game:GetService("RunService") game:BindToClose(function () if DEBUG then print("PlayerDataStore> OnClose Shutdown\n\tFlushing...") end -- First, flush all unsaved changes at the point of shutdown this:FlushAll() if DEBUG then print("\tFlushed, additional wait...",os.time()) end -- Then wait for random saves that might still be running -- for some reason to complete as well if not RunService:IsStudio() then while mSavingCount > 0 do wait() end end if DEBUG then print("\tShutdown completed normally.",os.time()) end end) -- Cleanup of cache entries that have timed out (not been touched -- in any way for more than CACHE_EXPIRY_TIME) local function removeTimedOutCacheEntries() local now = tick() for saveData, _ in pairs(mTouchedSaveDataCacheSet) do if (now - saveData.lastTouched) > CACHE_EXPIRY_TIME then -- does it have unsaved changes still somehow? if mDirtySaveDataSet[saveData] then if DEBUG then print(">> Cache expired for: "..saveData.userId..", has unsaved changes, wait.") end -- Spawn off a save and don't remove it, it needs to save SpawnNow(function() this:doSave(saveData) end) else if DEBUG then print(">> Cache expired for: "..saveData.userId..", removing.") end -- It is not needed, uncache it mTouchedSaveDataCacheSet[saveData] = nil end end end end -- Passive saving task, save entries with unsaved changes that have -- not been saved for more than PASSIVE_SAVE_FREQUENCY. local function passiveSaveUnsavedChanges() local now = tick() for saveData, _ in pairs(mDirtySaveDataSet) do if (now - saveData.lastSaved) > PASSIVE_SAVE_FREQUENCY then if DEBUG then print("PlayerDataStore>> Passive save for: "..saveData.userId) end SpawnNow(function() this:doSave(saveData) end) end end end -- Main save / cache handling daemon Spawn(function() while true do removeTimedOutCacheEntries() passiveSaveUnsavedChanges() wait(PASSIVE_GRANULARITY) end end) --===============================================-- --== Public API ==-- --===============================================-- -- Get the data for a player online in the place function this:GetSaveData(player) if not player or not player:IsA('Player') then error("Bad argument #1 to PlayerDataStore::GetSaveData(), Player expected", 2) end return doLoad(player.userId) end -- Get the data for a player by userId, they may -- or may not be currently online in the place. function this:GetSaveDataById(userId) if type(userId) ~= 'number' then error("Bad argument #1 to PlayerDataStore::GetSaveDataById(), userId expected", 2) end return doLoad(userId) end function this:IsEmulating() return (script:FindFirstChild("DebugUserId") and game.JobId == "") end -- Save out all unsaved changes at the time of -- calling. -- Note: This call yields until all the unsaved -- changes have been saved out. function this:FlushAll() local savesRunning = 0 local complete = Instance.new('BindableEvent') this.FlushingAll = true -- Call save on all of the dirty entries for saveData, _ in pairs(mDirtySaveDataSet) do SpawnNow(function() savesRunning = savesRunning + 1 this:doSave(saveData) savesRunning = savesRunning - 1 if savesRunning <= 0 then complete:Fire() end end) end -- wait for completion if savesRunning > 0 then complete.Event:wait() this.FlushingAll = false end end return this end return { Success = true; DataStore = PlayerDataStore.new(); }