Super-Nostalgia-Zone/Server/Resources/PlayerDataStore.lua

831 lines
26 KiB
Lua

--===============================================--
--== 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 <value>
-- 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();
}