· 5 years ago · Oct 23, 2020, 04:34 PM
1-- ProfileService
2-- Mad Studios
3-- https://github.com/MadStudioRoblox/ProfileService
4-- Modified for use within AeroGameFramework
5
6
7--[[
8{Madwork}
9
10-[ProfileService]---------------------------------------
11 (STANDALONE VERSION)
12 DataStore profiles - universal session-locked savable table API
13
14 Official documentation:
15 https://madstudioroblox.github.io/ProfileService/
16
17 DevForum discussion:
18 https://devforum.roblox.com/t/ProfileService/667805
19
20 WARNINGS FOR "Profile.Data" VALUES:
21 More information on https://madstudioroblox.github.io/ProfileService/troubleshooting/
22 ! Do not store NaN values
23 ! Do not create array tables with non-sequential indexes - attempting to replicate such tables will result in an error;
24 ! Do not create mixed tables (some values indexed by number and others by string key), as only
25 the data indexed by number will be replicated.
26 ! Do not index tables by anything other than numbers and strings.
27 ! Do not reference Roblox Instances
28 ! Do not reference userdata (Vector3, Color3, CFrame...) - Serialize userdata before referencing
29 ! Do not reference functions
30
31 WARNING: Calling ProfileStore:LoadProfileAsync() with a "profile_key" which wasn't released in the SAME SESSION will result
32 in an error! If you want to "ProfileStore:LoadProfileAsync()" instead of using the already loaded profile, :Release()
33 the old Profile object.
34
35 Members:
36
37 ProfileService.ServiceLocked [bool]
38
39 ProfileService.IssueSignal [ScriptSignal](error_message)
40 ProfileService.CorruptionSignal [ScriptSignal](profile_store_name, profile_key)
41 ProfileService.CriticalStateSignal [ScriptSignal](is_critical_state)
42
43 Functions:
44
45 ProfileService.GetProfileStore(profile_store_name, profile_template) --> [ProfileStore]
46
47 * Parameter description for "ProfileService.GetProfileStore()":
48
49 profile_store_name [string] -- DataStore name
50 profile_template []:
51 {} [table] -- Profiles will default to given table (hard-copy) when no data was saved previously
52 nil [nil] -- ProfileStore:LoadProfileAsync() method will be locked
53
54 Members [ProfileStore]:
55
56 ProfileStore.Mock [ProfileStore] -- Reflection of ProfileStore methods, but the methods will use a mock DataStore
57
58 Methods [ProfileStore]:
59
60 ProfileStore:LoadProfileAsync(profile_key, not_released_handler) --> [Profile / nil] not_released_handler(place_id, game_job_id)
61 ProfileStore:GlobalUpdateProfileAsync(profile_key, update_handler) --> [GlobalUpdates / nil] (update_handler(GlobalUpdates))
62 -- Returns GlobalUpdates object if update was successful, otherwise returns nil
63
64 ProfileStore:ViewProfileAsync(profile_key) --> [Profile / nil] -- Notice #1: Profile object methods will not be available;
65 Notice #2: Profile object members will be nil (Profile.Data = nil, Profile.MetaData = nil) if the profile hasn't
66 been created, with the exception of Profile.GlobalUpdates which could be empty or populated by
67 ProfileStore:GlobalUpdateProfileAsync()
68
69 ProfileStore:WipeProfileAsync(profile_key) --> is_wipe_successful [bool] -- Completely wipes out profile data from the
70 DataStore / mock DataStore with no way to recover it.
71
72 * Parameter description for "ProfileStore:LoadProfileAsync()":
73
74 profile_key [string] -- DataStore key
75 not_released_handler = "ForceLoad" -- Force loads profile on first call
76 OR
77 not_released_handler = "Steal" -- Steals the profile ignoring it's session lock
78 OR
79 not_released_handler [function] (place_id, game_job_id) --> [string] ("Repeat" / "Cancel" / "ForceLoad")
80 -- "not_released_handler" will be triggered in cases where the profile is not released by a session. This
81 function may yield for as long as desirable and must return one of three string values:
82 ["Repeat"] - ProfileService will repeat the profile loading proccess and may trigger the release handler again
83 ["Cancel"] - ProfileStore:LoadProfileAsync() will immediately return nil
84 ["ForceLoad"] - ProfileService will repeat the profile loading call, but will return Profile object afterwards
85 and release the profile for another session that has loaded the profile
86 ["Steal"] - The profile will usually be loaded immediately, ignoring an existing remote session lock and applying
87 a session lock for this session.
88
89 * Parameter description for "ProfileStore:GlobalUpdateProfileAsync()":
90
91 profile_key [string] -- DataStore key
92 update_handler [function] (GlobalUpdates) -- This function gains access to GlobalUpdates object methods
93 (update_handler can't yield)
94
95 Members [Profile]:
96
97 Profile.Data [table] -- Writable table that gets saved automatically and once the profile is released
98 Profile.MetaData [table] (Read-only) -- Information about this profile
99
100 Profile.MetaData.ProfileCreateTime [number] (Read-only) -- os.time() timestamp of profile creation
101 Profile.MetaData.SessionLoadCount [number] (Read-only) -- Amount of times the profile was loaded
102 Profile.MetaData.ActiveSession [table] (Read-only) {place_id, game_job_id} / nil -- Set to a session link if a
103 game session is currently having this profile loaded; nil if released
104 Profile.MetaData.MetaTags [table] {["tag_name"] = tag_value, ...} -- Saved and auto-saved just like Profile.Data
105 Profile.MetaData.MetaTagsLatest [table] (Read-only) -- Latest version of MetaData.MetaTags that was definetly saved to DataStore
106 (You can use Profile.MetaData.MetaTagsLatest for product purchase save confirmation, but create a system to clear old tags after
107 they pile up)
108
109 Profile.GlobalUpdates [GlobalUpdates]
110
111 Methods [Profile]:
112
113 -- SAFE METHODS - Will not error after profile expires:
114 Profile:IsActive() --> [bool] -- Returns true while the profile is active and can be written to
115
116 Profile:GetMetaTag(tag_name) --> value
117
118 Profile:ListenToRelease(listener) --> [ScriptConnection] (place_id / nil, game_job_id / nil) -- WARNING: Profiles can be released externally if another session
119 force-loads this profile - use :ListenToRelease() to handle player leaving cleanup.
120
121 Profile:Release() -- Call after the session has finished working with this profile
122 e.g., after the player leaves (Profile object will become expired) (Does not yield)
123
124 -- DANGEROUS METHODS - Will error if the profile is expired:
125 -- MetaTags - Save and read values stored in Profile.MetaData for storing info about the
126 profile itself like "Profile:SetMetaTag("FirstTimeLoad", true)"
127 Profile:SetMetaTag(tag_name, value)
128
129 Profile:Save() -- Call to quickly progress global update state or to speed up save validation processes (Does not yield)
130
131
132 Methods [GlobalUpdates]:
133
134 -- ALWAYS AVAILABLE:
135 GlobalUpdates:GetActiveUpdates() --> [table] {{update_id, update_data}, ...}
136 GlobalUpdates:GetLockedUpdates() --> [table] {{update_id, update_data}, ...}
137
138 -- ONLY ACCESSIBLE THROUGH "Profile.GlobalUpdates":
139 GlobalUpdates:ListenToNewActiveUpdate(listener) --> [ScriptConnection] listener(update_id, update_data)
140 GlobalUpdates:ListenToNewLockedUpdate(listener) --> [ScriptConnection] listener(update_id, update_data)
141 -- WARNING: GlobalUpdates:LockUpdate() and GlobalUpdates:ClearLockedUpdate() will error after profile expires
142 GlobalUpdates:LockActiveUpdate(update_id)
143 GlobalUpdates:ClearLockedUpdate(update_id)
144
145 -- AVAILABLE INSIDE "update_handler" DURING A ProfileStore:GlobalUpdateProfileAsync() CALL
146 GlobalUpdates:AddActiveUpdate(update_data)
147 GlobalUpdates:ChangeActiveUpdate(update_id, update_data)
148 GlobalUpdates:ClearActiveUpdate(update_id)
149
150--]]
151
152local SETTINGS = {
153
154 AutoSaveProfiles = 30, -- Seconds (This value may vary - ProfileService will split the auto save load evenly in the given time)
155 LoadProfileRepeatDelay = 15, -- Seconds between successive DataStore calls for the same key
156 ForceLoadMaxSteps = 4, -- Steps taken before ForceLoad request steals the active session for a profile
157 AssumeDeadSessionLock = 30 * 60, -- (seconds) If a profile hasn't been updated for 30 minutes, assume the session lock is dead
158 -- As of writing, os.time() is not completely reliable, so we can only assume session locks are dead after a significant amount of time.
159
160 IssueCountForCriticalState = 5, -- Issues to collect to announce critical state
161 IssueLast = 120, -- Seconds
162 CriticalStateLast = 120, -- Seconds
163
164}
165
166local Madwork -- Standalone Madwork reference for portable version of ProfileService
167do
168 -- ScriptConnection object:
169 local ScriptConnection = {
170 -- _listener = function -- [function]
171 -- _listener_table = {} -- [table] -- Table from which the function entry will be removed
172 }
173
174 function ScriptConnection:Disconnect()
175 local listener = self._listener
176 if listener ~= nil then
177 local listener_table = self._listener_table
178 for i = 1, #listener_table do
179 if listener == listener_table[i] then
180 table.remove(listener_table, i)
181 break
182 end
183 end
184 self._listener = nil
185 end
186 end
187
188 function ScriptConnection.NewScriptConnection(listener_table, listener) --> [ScriptConnection]
189 return {
190 _listener = listener,
191 _listener_table = listener_table,
192 Disconnect = ScriptConnection.Disconnect
193 }
194 end
195
196 -- ScriptSignal object:
197 local ScriptSignal = {
198 -- _listeners = {}
199 }
200
201 function ScriptSignal:Connect(listener) --> [ScriptConnection]
202 if type(listener) ~= "function" then
203 error("[ScriptSignal]: Only functions can be passed to ScriptSignal:Connect()")
204 end
205 table.insert(self._listeners, listener)
206 return {
207 _listener = listener,
208 _listener_table = self._listeners,
209 Disconnect = ScriptConnection.Disconnect
210 }
211 end
212
213 function ScriptSignal:Fire(...)
214 for _, listener in ipairs(self._listeners) do
215 listener(...)
216 end
217 end
218
219 function ScriptSignal.NewScriptSignal() --> [ScriptSignal]
220 return {
221 _listeners = {},
222 Connect = ScriptSignal.Connect,
223 Fire = ScriptSignal.Fire
224 }
225 end
226
227 local RunService = game:GetService("RunService")
228 local Heartbeat = RunService.Heartbeat
229
230 Madwork = {
231 NewScriptSignal = ScriptSignal.NewScriptSignal,
232 NewScriptConnection = ScriptConnection.NewScriptConnection,
233 HeartbeatWait = function(wait_time) --> time_elapsed
234 if wait_time == nil or wait_time == 0 then
235 return Heartbeat:Wait()
236 else
237 local time_elapsed = 0
238 while time_elapsed <= wait_time do
239 local time_waited = Heartbeat:Wait()
240 time_elapsed = time_elapsed + time_waited
241 end
242 return time_elapsed
243 end
244 end,
245 ConnectToOnClose = function(task, run_in_studio_mode)
246 if game:GetService("RunService"):IsStudio() == false or run_in_studio_mode == true then
247 game:BindToClose(task)
248 end
249 end,
250 }
251end
252
253----- Service Table -----
254
255local ProfileService = {
256
257 ServiceLocked = false, -- Set to true once the server is shutting down
258
259 IssueSignal = Madwork.NewScriptSignal(), -- (error_message) -- Fired when a DataStore API call throws an error
260 CorruptionSignal = Madwork.NewScriptSignal(), -- (profile_store_name, profile_key) -- Fired when DataStore key returns a value that has
261 -- all or some of it's profile components set to invalid data types. E.g., accidentally setting Profile.Data to a noon table value
262
263 CriticalState = false, -- Set to true while DataStore service is throwing too many errors
264 CriticalStateSignal = Madwork.NewScriptSignal(), -- (is_critical_state) -- Fired when CriticalState is set to true
265 -- (You may alert players with this, or set up analytics)
266
267 ServiceIssueCount = 0,
268
269 _active_profile_stores = {
270 --[[
271 {
272 _profile_store_name = "", -- [string] -- DataStore name
273 _profile_template = {} / nil, -- [table / nil]
274 _global_data_store = global_data_store, -- [GlobalDataStore] -- Object returned by DataStoreService:GetDataStore(_profile_store_name)
275
276 _loaded_profiles = {
277 [profile_key] = {
278 Data = {}, -- [table] -- Loaded once after ProfileStore:LoadProfileAsync() finishes
279 MetaData = {}, -- [table] -- Updated with every auto-save
280 GlobalUpdates = {, -- [GlobalUpdates]
281 _updates_latest = {}, -- [table] {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
282 _pending_update_lock = {update_id, ...} / nil, -- [table / nil]
283 _pending_update_clear = {update_id, ...} / nil, -- [table / nil]
284
285 _new_active_update_listeners = {listener, ...} / nil, -- [table / nil]
286 _new_locked_update_listeners = {listener, ...} / nil, -- [table / nil]
287
288 _profile = Profile / nil, -- [Profile / nil]
289
290 _update_handler_mode = true / nil, -- [bool / nil]
291 }
292
293 _profile_store = ProfileStore, -- [ProfileStore]
294 _profile_key = "", -- [string]
295
296 _release_listeners = {listener, ...} / nil, -- [table / nil]
297
298 _view_mode = true / nil, -- [bool / nil]
299
300 _load_timestamp = tick(),
301
302 _is_user_mock = false, -- ProfileStore.Mock
303 },
304 ...
305 },
306 _profile_load_jobs = {[profile_key] = {load_id, loaded_data}, ...},
307
308 _mock_loaded_profiles = {[profile_key] = Profile, ...},
309 _mock_profile_load_jobs = {[profile_key] = {load_id, loaded_data}, ...},
310 },
311 ...
312 --]]
313 },
314
315 _auto_save_list = { -- loaded profile table which will be circularly auto-saved
316 --[[
317 Profile,
318 ...
319 --]]
320 },
321
322 _issue_queue = {}, -- [table] {issue_tick, ...}
323 _critical_state_start = 0, -- [number] 0 = no critical state / tick() = critical state start
324
325 -- Debug:
326 _mock_data_store = {},
327 _user_mock_data_store = {},
328
329 _use_mock_data_store = false,
330
331}
332
333--[[
334 Saved profile structure:
335
336 DataStoreProfile = {
337 Data = {},
338 MetaData = {
339 ProfileCreateTime = 0,
340 SessionLoadCount = 0,
341 ActiveSession = {place_id, game_job_id} / nil,
342 ForceLoadSession = {place_id, game_job_id} / nil,
343 MetaTags = {},
344 LastUpdate = 0, -- os.time()
345 },
346 GlobalUpdates = {
347 update_index,
348 {
349 {update_id, version_id, update_locked, update_data},
350 ...
351 }
352 },
353 }
354
355 OR
356
357 DataStoreProfile = {
358 GlobalUpdates = {
359 update_index,
360 {
361 {update_id, version_id, update_locked, update_data},
362 ...
363 }
364 },
365 }
366--]]
367
368----- Private Variables -----
369
370local ActiveProfileStores = ProfileService._active_profile_stores
371local AutoSaveList = ProfileService._auto_save_list
372local IssueQueue = ProfileService._issue_queue
373
374local DataStoreService = game:GetService("DataStoreService")
375local RunService = game:GetService("RunService")
376
377local PlaceId = game.PlaceId
378local JobId = game.JobId
379
380local AutoSaveIndex = 1 -- Next profile to auto save
381local LastAutoSave = tick()
382
383local LoadIndex = 0
384
385local ActiveProfileLoadJobs = 0 -- Number of active threads that are loading in profiles
386local ActiveProfileSaveJobs = 0 -- Number of active threads that are saving profiles
387
388local CriticalStateStart = 0 -- tick()
389
390local IsStudio = RunService:IsStudio()
391local UseMockDataStore = false
392local MockDataStore = ProfileService._mock_data_store -- Mock data store used when API access is disabled
393
394local UserMockDataStore = ProfileService._user_mock_data_store -- Separate mock data store accessed via ProfileStore.Mock
395local UseMockTag = {}
396
397----- Utils -----
398
399local function DeepCopyTable(t)
400 local copy = {}
401 for key, value in pairs(t) do
402 if type(value) == "table" then
403 copy[key] = DeepCopyTable(value)
404 else
405 copy[key] = value
406 end
407 end
408 return copy
409end
410
411----- Private functions -----
412
413local function RegisterIssue(error_message) -- Called when a DataStore API call errors
414 warn("[ProfileService]: DataStore API error - \"" .. tostring(error_message) .. "\"")
415 table.insert(IssueQueue, tick()) -- Adding issue time to queue
416 ProfileService.IssueSignal:Fire(tostring(error_message))
417end
418
419local function RegisterCorruption(profile_store_name, profile_key) -- Called when a corrupted profile is loaded
420 warn("[ProfileService]: Profile corruption - ProfileStore = \"" .. profile_store_name .. "\", Key = \"" .. profile_key .. "\"")
421 ProfileService.CorruptionSignal:Fire(profile_store_name, profile_key)
422end
423
424local function MockUpdateAsync(mock_data_store, profile_store_name, key, transform_function)
425 local profile_store = mock_data_store[profile_store_name]
426 if profile_store == nil then
427 profile_store = {}
428 mock_data_store[profile_store_name] = profile_store
429 end
430 local transform = transform_function(profile_store[key])
431 if transform == nil then
432 return nil
433 else
434 profile_store[key] = DeepCopyTable(transform)
435 return DeepCopyTable(profile_store[key])
436 end
437end
438
439local function IsThisSession(session_tag)
440 return session_tag[1] == PlaceId and session_tag[2] == JobId
441end
442
443--[[
444update_settings = {
445 ExistingProfileHandle = function(latest_data),
446 MissingProfileHandle = function(latest_data),
447 EditProfile = function(lastest_data),
448
449 WipeProfile = nil / true,
450}
451--]]
452local function StandardProfileUpdateAsyncDataStore(profile_store, profile_key, update_settings, is_user_mock)
453 local loaded_data
454 local wipe_status = false
455 local success, error_message = pcall(function()
456 if update_settings.WipeProfile ~= true then
457 local transform_function = function(latest_data)
458 if latest_data == "PROFILE_WIPED" then
459 latest_data = nil -- Profile was previously wiped - ProfileService will act like it was empty
460 end
461
462 local missing_profile = false
463 local data_corrupted = false
464 local global_updates_data = {0, {}}
465
466 if latest_data == nil then
467 missing_profile = true
468 elseif type(latest_data) ~= "table" then
469 missing_profile = true
470 data_corrupted = true
471 end
472
473 if type(latest_data) == "table" then
474 -- Case #1: Profile was loaded
475 if type(latest_data.Data) == "table" and
476 type(latest_data.MetaData) == "table" and
477 type(latest_data.GlobalUpdates) == "table" then
478
479 latest_data.WasCorrupted = false -- Must be set to false if set previously
480 global_updates_data = latest_data.GlobalUpdates
481 if update_settings.ExistingProfileHandle ~= nil then
482 update_settings.ExistingProfileHandle(latest_data)
483 end
484 -- Case #2: Profile was not loaded but GlobalUpdate data exists
485 elseif latest_data.Data == nil and
486 latest_data.MetaData == nil and
487 type(latest_data.GlobalUpdates) == "table" then
488
489 latest_data.WasCorrupted = false -- Must be set to false if set previously
490 global_updates_data = latest_data.GlobalUpdates
491 missing_profile = true
492 else
493 missing_profile = true
494 data_corrupted = true
495 end
496 end
497
498 -- Case #3: Profile was not created or corrupted and no GlobalUpdate data exists
499 if missing_profile == true then
500 latest_data = {
501 -- Data = nil,
502 -- MetaData = nil,
503 GlobalUpdates = global_updates_data,
504 }
505 if update_settings.MissingProfileHandle ~= nil then
506 update_settings.MissingProfileHandle(latest_data)
507 end
508 end
509
510 -- Editing profile:
511 if update_settings.EditProfile ~= nil then
512 update_settings.EditProfile(latest_data)
513 end
514
515 -- Data corruption handling (Silently override with empty profile) (Also run Case #1)
516 if data_corrupted == true then
517 latest_data.WasCorrupted = true -- Temporary tag that will be removed on first save
518 end
519
520 return latest_data
521 end
522 if is_user_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
523 loaded_data = MockUpdateAsync(UserMockDataStore, profile_store._profile_store_name, profile_key, transform_function)
524 Madwork.HeartbeatWait() -- Simulate API call yield
525 elseif UseMockDataStore == true then -- Used when API access is disabled
526 loaded_data = MockUpdateAsync(MockDataStore, profile_store._profile_store_name, profile_key, transform_function)
527 Madwork.HeartbeatWait() -- Simulate API call yield
528 else
529 loaded_data = profile_store._global_data_store:UpdateAsync(profile_key, transform_function)
530 end
531 else
532 if is_user_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
533 local p_store = UserMockDataStore[profile_store._profile_store_name]
534 if p_store ~= nil then
535 p_store[profile_key] = nil
536 end
537 wipe_status = true
538 Madwork.HeartbeatWait() -- Simulate API call yield
539 elseif UseMockDataStore == true then -- Used when API access is disabled
540 local p_store = MockDataStore[profile_store._profile_store_name]
541 if p_store ~= nil then
542 p_store[profile_key] = nil
543 end
544 wipe_status = true
545 Madwork.HeartbeatWait() -- Simulate API call yield
546 else
547 loaded_data = profile_store._global_data_store:UpdateAsync(profile_key, function()
548 return "PROFILE_WIPED" -- It's impossible to set DataStore keys to nil after they have been set
549 end)
550 if loaded_data == "PROFILE_WIPED" then
551 wipe_status = true
552 end
553 end
554 end
555 end)
556 if update_settings.WipeProfile == true then
557 return wipe_status
558 elseif success == true and type(loaded_data) == "table" then
559 -- Corruption handling:
560 if loaded_data.WasCorrupted == true then
561 RegisterCorruption(profile_store._profile_store_name, profile_key)
562 end
563 -- Return loaded_data:
564 return loaded_data
565 else
566 RegisterIssue((error_message ~= nil) and error_message or "Undefined error")
567 -- Return nothing:
568 return nil
569 end
570end
571
572local function RemoveProfileFromAutoSave(profile)
573 local auto_save_index = table.find(AutoSaveList, profile)
574 if auto_save_index ~= nil then
575 table.remove(AutoSaveList, auto_save_index)
576 if auto_save_index < AutoSaveIndex then
577 AutoSaveIndex = AutoSaveIndex - 1 -- Table contents were moved left before AutoSaveIndex so move AutoSaveIndex left as well
578 end
579 if AutoSaveList[AutoSaveIndex] == nil then -- AutoSaveIndex was at the end of the AutoSaveList - reset to 1
580 AutoSaveIndex = 1
581 end
582 end
583end
584
585local function AddProfileToAutoSave(profile) -- Notice: Makes sure this profile isn't auto-saved too soon
586 -- Add at AutoSaveIndex and move AutoSaveIndex right:
587 table.insert(AutoSaveList, AutoSaveIndex, profile)
588 if #AutoSaveList > 1 then
589 AutoSaveIndex = AutoSaveIndex + 1
590 elseif #AutoSaveList == 1 then
591 -- First profile created - make sure it doesn't get immediately auto saved:
592 LastAutoSave = tick()
593 end
594end
595
596local function ReleaseProfileInternally(profile)
597 -- 1) Remove profile object from ProfileService references: --
598 -- Clear reference in ProfileStore:
599 local profile_store = profile._profile_store
600 local loaded_profiles = profile._is_user_mock == true and profile_store._mock_loaded_profiles or profile_store._loaded_profiles
601 loaded_profiles[profile._profile_key] = nil
602 if next(profile_store._loaded_profiles) == nil and next(profile_store._mock_loaded_profiles) == nil then -- ProfileStore has turned inactive
603 local index = table.find(ActiveProfileStores, profile_store)
604 if index ~= nil then
605 table.remove(ActiveProfileStores, index)
606 end
607 end
608 -- Clear auto update reference:
609 RemoveProfileFromAutoSave(profile)
610 -- 2) Trigger release listeners: --
611 local place_id
612 local game_job_id
613 local active_session = profile.MetaData.ActiveSession
614 if active_session ~= nil then
615 place_id = active_session[1]
616 game_job_id = active_session[2]
617 end
618 for _, listener in ipairs(profile._release_listeners) do
619 listener(place_id, game_job_id)
620 end
621 profile._release_listeners = {}
622end
623
624local function CheckForNewGlobalUpdates(profile, old_global_updates_data, new_global_updates_data)
625 local global_updates_object = profile.GlobalUpdates -- [GlobalUpdates]
626 local pending_update_lock = global_updates_object._pending_update_lock -- {update_id, ...}
627 local pending_update_clear = global_updates_object._pending_update_clear -- {update_id, ...}
628 -- "old_" or "new_" global_updates_data = {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
629 for _, new_global_update in ipairs(new_global_updates_data[2]) do
630 -- Find old global update with the same update_id:
631 local old_global_update
632 for _, global_update in ipairs(old_global_updates_data[2]) do
633 if global_update[1] == new_global_update[1] then
634 old_global_update = global_update
635 break
636 end
637 end
638 -- A global update is new when it didn't exist before or its version_id or update_locked state changed:
639 local is_new = false
640 if old_global_update == nil or new_global_update[2] > old_global_update[2] or new_global_update[3] ~= old_global_update[3] then
641 is_new = true
642 end
643 if is_new == true then
644 -- Active global updates:
645 if new_global_update[3] == false then
646 -- Check if update is not pending to be locked: (Preventing firing new active update listeners more than necessary)
647 local is_pending_lock = false
648 for _, update_id in ipairs(pending_update_lock) do
649 if new_global_update[1] == update_id then
650 is_pending_lock = true
651 break
652 end
653 end
654 if is_pending_lock == false then
655 -- Trigger new active update listeners:
656 for _, listener in ipairs(global_updates_object._new_active_update_listeners) do
657 listener(new_global_update[1], new_global_update[4])
658 end
659 end
660 end
661 -- Locked global updates:
662 if new_global_update[3] == true then
663 -- Check if update is not pending to be cleared: (Preventing firing new locked update listeners after marking a locked update for clearing)
664 local is_pending_clear = false
665 for _, update_id in ipairs(pending_update_clear) do
666 if new_global_update[1] == update_id then
667 is_pending_clear = true
668 break
669 end
670 end
671 if is_pending_clear == false then
672 -- Trigger new locked update listeners:
673 for _, listener in ipairs(global_updates_object._new_locked_update_listeners) do
674 listener(new_global_update[1], new_global_update[4])
675 -- Check if listener marked the update to be cleared:
676 -- Normally there should be only one listener per profile for new locked global updates, but
677 -- in case several listeners are connected we will not trigger more listeners after one listener
678 -- marks the locked global update to be cleared.
679 for _, update_id in ipairs(pending_update_clear) do
680 if new_global_update[1] == update_id then
681 is_pending_clear = true
682 break
683 end
684 end
685 if is_pending_clear == true then
686 break
687 end
688 end
689 end
690 end
691 end
692 end
693end
694
695local function SaveProfileAsync(profile, release_from_session)
696 if type(profile.Data) ~= "table" then
697 RegisterCorruption(profile._profile_store._profile_store_name, profile._profile_key)
698 error("[ProfileService]: PROFILE DATA CORRUPTED DURING RUNTIME! ProfileStore = \"" .. profile._profile_store._profile_store_name .. "\", Key = \"" .. profile._profile_key .. "\"")
699 end
700 if release_from_session == true then
701 ReleaseProfileInternally(profile)
702 end
703 ActiveProfileSaveJobs = ActiveProfileSaveJobs + 1
704 local loaded_data = StandardProfileUpdateAsyncDataStore(
705 profile._profile_store,
706 profile._profile_key,
707 {
708 ExistingProfileHandle = nil,
709 MissingProfileHandle = nil,
710 EditProfile = function(latest_data)
711 -- 1) Check if this session still owns the profile: --
712 local active_session = latest_data.MetaData.ActiveSession
713 local force_load_session = latest_data.MetaData.ForceLoadSession
714 local session_owns_profile = false
715 local force_load_pending = false
716 if type(active_session) == "table" then
717 session_owns_profile = IsThisSession(active_session)
718 end
719 if type(force_load_session) == "table" then
720 force_load_pending = not IsThisSession(force_load_session)
721 end
722
723 if session_owns_profile == true then -- We may only edit the profile if this session has ownership of the profile
724 -- 2) Manage global updates: --
725 local latest_global_updates_data = latest_data.GlobalUpdates -- {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
726 local latest_global_updates_list = latest_global_updates_data[2]
727
728 local global_updates_object = profile.GlobalUpdates -- [GlobalUpdates]
729 local pending_update_lock = global_updates_object._pending_update_lock -- {update_id, ...}
730 local pending_update_clear = global_updates_object._pending_update_clear -- {update_id, ...}
731 -- Active update locking:
732 for i = 1, #latest_global_updates_list do
733 for _, lock_id in ipairs(pending_update_lock) do
734 if latest_global_updates_list[i][1] == lock_id then
735 latest_global_updates_list[i][3] = true
736 break
737 end
738 end
739 end
740 -- Locked update clearing:
741 for _, clear_id in ipairs(pending_update_clear) do
742 for i = 1, #latest_global_updates_list do
743 if latest_global_updates_list[i][1] == clear_id and latest_global_updates_list[i][3] == true then
744 table.remove(latest_global_updates_list, i)
745 break
746 end
747 end
748 end
749 -- 3) Save profile data: --
750 latest_data.Data = profile.Data
751 latest_data.MetaData.MetaTags = profile.MetaData.MetaTags -- MetaData.MetaTags is the only actively savable component of MetaData
752 latest_data.MetaData.LastUpdate = os.time()
753 if release_from_session == true or force_load_pending == true then
754 latest_data.MetaData.ActiveSession = nil
755 end
756 end
757 end,
758 },
759 profile._is_user_mock
760 )
761 if loaded_data ~= nil then
762 -- 4) Set latest data in profile: --
763 -- Setting global updates:
764 local global_updates_object = profile.GlobalUpdates -- [GlobalUpdates]
765 local old_global_updates_data = global_updates_object._updates_latest
766 local new_global_updates_data = loaded_data.GlobalUpdates
767 global_updates_object._updates_latest = new_global_updates_data
768 -- Setting MetaData:
769 local keep_session_meta_tag_reference = profile.MetaData.MetaTags
770 profile.MetaData = loaded_data.MetaData
771 profile.MetaData.MetaTagsLatest = profile.MetaData.MetaTags
772 profile.MetaData.MetaTags = keep_session_meta_tag_reference
773 -- 5) Check if session still owns the profile: --
774 local active_session = loaded_data.MetaData.ActiveSession
775 --local force_load_session = loaded_data.MetaData.ForceLoadSession
776 local session_owns_profile = false
777 if type(active_session) == "table" then
778 session_owns_profile = IsThisSession(active_session)
779 end
780 local is_active = profile:IsActive()
781 if session_owns_profile == true then
782 -- 6) Check for new global updates: --
783 if is_active == true then -- Profile could've been released before the saving thread finished
784 CheckForNewGlobalUpdates(profile, old_global_updates_data, new_global_updates_data)
785 end
786 else
787 -- Session no longer owns the profile:
788 -- 7) Release profile if it hasn't been released yet: --
789 if is_active == true then
790 ReleaseProfileInternally(profile)
791 end
792 end
793 end
794 ActiveProfileSaveJobs = ActiveProfileSaveJobs - 1
795end
796
797----- Public functions -----
798
799-- GlobalUpdates object:
800
801local GlobalUpdates = {
802 --[[
803 _updates_latest = {}, -- [table] {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
804 _pending_update_lock = {update_id, ...} / nil, -- [table / nil]
805 _pending_update_clear = {update_id, ...} / nil, -- [table / nil]
806
807 _new_active_update_listeners = {listener, ...} / nil, -- [table / nil]
808 _new_locked_update_listeners = {listener, ...} / nil, -- [table / nil]
809
810 _profile = Profile / nil, -- [Profile / nil]
811
812 _update_handler_mode = true / nil, -- [bool / nil]
813 --]]
814}
815GlobalUpdates.__index = GlobalUpdates
816
817-- ALWAYS PUBLIC:
818function GlobalUpdates:GetActiveUpdates() --> [table] {{update_id, update_data}, ...}
819 local query_list = {}
820 for _, global_update in ipairs(self._updates_latest[2]) do
821 if global_update[3] == false then
822 local is_pending_lock = false
823 if self._pending_update_lock ~= nil then
824 for _, update_id in ipairs(self._pending_update_lock) do
825 if global_update[1] == update_id then
826 is_pending_lock = true -- Exclude global updates pending to be locked
827 break
828 end
829 end
830 end
831 if is_pending_lock == false then
832 table.insert(query_list, {global_update[1], global_update[4]})
833 end
834 end
835 end
836 return query_list
837end
838
839function GlobalUpdates:GetLockedUpdates() --> [table] {{update_id, update_data}, ...}
840 local query_list = {}
841 for _, global_update in ipairs(self._updates_latest[2]) do
842 if global_update[3] == true then
843 local is_pending_clear = false
844 if self._pending_update_clear ~= nil then
845 for _, update_id in ipairs(self._pending_update_clear) do
846 if global_update[1] == update_id then
847 is_pending_clear = true -- Exclude global updates pending to be cleared
848 break
849 end
850 end
851 end
852 if is_pending_clear == false then
853 table.insert(query_list, {global_update[1], global_update[4]})
854 end
855 end
856 end
857 return query_list
858end
859
860-- ONLY WHEN FROM "Profile.GlobalUpdates":
861function GlobalUpdates:ListenToNewActiveUpdate(listener) --> [ScriptConnection] listener(update_id, update_data)
862 if type(listener) ~= "function" then
863 error("[ProfileService]: Only a function can be set as listener in GlobalUpdates:ListenToNewActiveUpdate()")
864 end
865 local profile = self._profile
866 if self._update_handler_mode == true then
867 error("[ProfileService]: Can't listen to new global updates in ProfileStore:GlobalUpdateProfileAsync()")
868 elseif self._new_active_update_listeners == nil then
869 error("[ProfileService]: Can't listen to new global updates in view mode")
870 elseif profile:IsActive() == false then -- Check if profile is expired
871 return { -- Do not connect listener if the profile is expired
872 Disconnect = function() end,
873 }
874 end
875 -- Connect listener:
876 table.insert(self._new_active_update_listeners, listener)
877 return Madwork.NewScriptConnection(self._new_active_update_listeners, listener)
878end
879
880function GlobalUpdates:ListenToNewLockedUpdate(listener) --> [ScriptConnection] listener(update_id, update_data)
881 if type(listener) ~= "function" then
882 error("[ProfileService]: Only a function can be set as listener in GlobalUpdates:ListenToNewLockedUpdate()")
883 end
884 local profile = self._profile
885 if self._update_handler_mode == true then
886 error("[ProfileService]: Can't listen to new global updates in ProfileStore:GlobalUpdateProfileAsync()")
887 elseif self._new_locked_update_listeners == nil then
888 error("[ProfileService]: Can't listen to new global updates in view mode")
889 elseif profile:IsActive() == false then -- Check if profile is expired
890 return { -- Do not connect listener if the profile is expired
891 Disconnect = function() end,
892 }
893 end
894 -- Connect listener:
895 table.insert(self._new_locked_update_listeners, listener)
896 return Madwork.NewScriptConnection(self._new_locked_update_listeners, listener)
897end
898
899function GlobalUpdates:LockActiveUpdate(update_id)
900 if type(update_id) ~= "number" then
901 error("[ProfileService]: Invalid update_id")
902 end
903 local profile = self._profile
904 if self._update_handler_mode == true then
905 error("[ProfileService]: Can't lock active global updates in ProfileStore:GlobalUpdateProfileAsync()")
906 elseif self._pending_update_lock == nil then
907 error("[ProfileService]: Can't lock active global updates in view mode")
908 elseif profile:IsActive() == false then -- Check if profile is expired
909 error("[ProfileService]: PROFILE EXPIRED - Can't lock active global updates")
910 end
911 -- Check if global update exists with given update_id
912 local global_update_exists = nil
913 for _, global_update in ipairs(self._updates_latest[2]) do
914 if global_update[1] == update_id then
915 global_update_exists = global_update
916 break
917 end
918 end
919 if global_update_exists ~= nil then
920 local is_pending_lock = false
921 for _, lock_update_id in ipairs(self._pending_update_lock) do
922 if update_id == lock_update_id then
923 is_pending_lock = true -- Exclude global updates pending to be locked
924 break
925 end
926 end
927 if is_pending_lock == false and global_update_exists[3] == false then -- Avoid id duplicates in _pending_update_lock
928 table.insert(self._pending_update_lock, update_id)
929 end
930 else
931 error("[ProfileService]: Passed non-existant update_id")
932 end
933end
934
935function GlobalUpdates:ClearLockedUpdate(update_id)
936 if type(update_id) ~= "number" then
937 error("[ProfileService]: Invalid update_id")
938 end
939 local profile = self._profile
940 if self._update_handler_mode == true then
941 error("[ProfileService]: Can't clear locked global updates in ProfileStore:GlobalUpdateProfileAsync()")
942 elseif self._pending_update_clear == nil then
943 error("[ProfileService]: Can't clear locked global updates in view mode")
944 elseif profile:IsActive() == false then -- Check if profile is expired
945 error("[ProfileService]: PROFILE EXPIRED - Can't clear locked global updates")
946 end
947 -- Check if global update exists with given update_id
948 local global_update_exists = nil
949 for _, global_update in ipairs(self._updates_latest[2]) do
950 if global_update[1] == update_id then
951 global_update_exists = global_update
952 break
953 end
954 end
955 if global_update_exists ~= nil then
956 local is_pending_clear = false
957 for _, clear_update_id in ipairs(self._pending_update_clear) do
958 if update_id == clear_update_id then
959 is_pending_clear = true -- Exclude global updates pending to be cleared
960 break
961 end
962 end
963 if is_pending_clear == false and global_update_exists[3] == true then -- Avoid id duplicates in _pending_update_clear
964 table.insert(self._pending_update_clear, update_id)
965 end
966 else
967 error("[ProfileService]: Passed non-existant update_id")
968 end
969end
970
971-- EXPOSED TO "update_handler" DURING ProfileStore:GlobalUpdateProfileAsync() CALL
972function GlobalUpdates:AddActiveUpdate(update_data)
973 if type(update_data) ~= "table" then
974 error("[ProfileService]: Invalid update_data")
975 end
976 if self._new_active_update_listeners ~= nil then
977 error("[ProfileService]: Can't add active global updates in loaded Profile; Use ProfileStore:GlobalUpdateProfileAsync()")
978 elseif self._update_handler_mode ~= true then
979 error("[ProfileService]: Can't add active global updates in view mode; Use ProfileStore:GlobalUpdateProfileAsync()")
980 end
981 -- self._updates_latest = {}, -- [table] {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
982 local updates_latest = self._updates_latest
983 local update_index = updates_latest[1] + 1 -- Incrementing global update index
984 updates_latest[1] = update_index
985 -- Add new active global update:
986 table.insert(updates_latest[2], {update_index, 1, false, update_data})
987end
988
989function GlobalUpdates:ChangeActiveUpdate(update_id, update_data)
990 if type(update_id) ~= "number" then
991 error("[ProfileService]: Invalid update_id")
992 end
993 if type(update_data) ~= "table" then
994 error("[ProfileService]: Invalid update_data")
995 end
996 if self._new_active_update_listeners ~= nil then
997 error("[ProfileService]: Can't change active global updates in loaded Profile; Use ProfileStore:GlobalUpdateProfileAsync()")
998 elseif self._update_handler_mode ~= true then
999 error("[ProfileService]: Can't change active global updates in view mode; Use ProfileStore:GlobalUpdateProfileAsync()")
1000 end
1001 -- self._updates_latest = {}, -- [table] {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
1002 local updates_latest = self._updates_latest
1003 local get_global_update = nil
1004 for _, global_update in ipairs(updates_latest[2]) do
1005 if update_id == global_update[1] then
1006 get_global_update = global_update
1007 break
1008 end
1009 end
1010 if get_global_update ~= nil then
1011 if get_global_update[3] == true then
1012 error("[ProfileService]: Can't change locked global update")
1013 end
1014 get_global_update[2] = get_global_update[2] + 1 -- Increment version id
1015 get_global_update[4] = update_data -- Set new global update data
1016 else
1017 error("[ProfileService]: Passed non-existant update_id")
1018 end
1019end
1020
1021function GlobalUpdates:ClearActiveUpdate(update_id)
1022 if type(update_id) ~= "number" then
1023 error("[ProfileService]: Invalid update_id argument")
1024 end
1025 if self._new_active_update_listeners ~= nil then
1026 error("[ProfileService]: Can't clear active global updates in loaded Profile; Use ProfileStore:GlobalUpdateProfileAsync()")
1027 elseif self._update_handler_mode ~= true then
1028 error("[ProfileService]: Can't clear active global updates in view mode; Use ProfileStore:GlobalUpdateProfileAsync()")
1029 end
1030 -- self._updates_latest = {}, -- [table] {update_index, {{update_id, version_id, update_locked, update_data}, ...}}
1031 local updates_latest = self._updates_latest
1032 local get_global_update_index = nil
1033 local get_global_update = nil
1034 for index, global_update in ipairs(updates_latest[2]) do
1035 if update_id == global_update[1] then
1036 get_global_update_index = index
1037 get_global_update = global_update
1038 break
1039 end
1040 end
1041 if get_global_update ~= nil then
1042 if get_global_update[3] == true then
1043 error("[ProfileService]: Can't clear locked global update")
1044 end
1045 table.remove(updates_latest[2], get_global_update_index) -- Remove active global update
1046 else
1047 error("[ProfileService]: Passed non-existant update_id")
1048 end
1049end
1050
1051-- Profile object:
1052
1053local Profile = {
1054 --[[
1055 Data = {}, -- [table] -- Loaded once after ProfileStore:LoadProfileAsync() finishes
1056 MetaData = {}, -- [table] -- Updated with every auto-save
1057 GlobalUpdates = GlobalUpdates, -- [GlobalUpdates]
1058
1059 _profile_store = ProfileStore, -- [ProfileStore]
1060 _profile_key = "", -- [string]
1061
1062 _release_listeners = {listener, ...} / nil, -- [table / nil]
1063
1064 _view_mode = true / nil, -- [bool / nil]
1065
1066 _load_timestamp = tick(),
1067
1068 _is_user_mock = false, -- ProfileStore.Mock
1069 --]]
1070}
1071Profile.__index = Profile
1072
1073function Profile:IsActive() --> [bool]
1074 local loaded_profiles = self._is_user_mock == true and self._profile_store._mock_loaded_profiles or self._profile_store._loaded_profiles
1075 return loaded_profiles[self._profile_key] == self
1076end
1077
1078function Profile:GetMetaTag(tag_name) --> value
1079 local meta_data = self.MetaData
1080 if meta_data == nil then
1081 return nil
1082 -- error("[ProfileService]: This Profile hasn't been loaded before - MetaData not available")
1083 end
1084 return self.MetaData.MetaTags[tag_name]
1085end
1086
1087function Profile:SetMetaTag(tag_name, value)
1088 if type(tag_name) ~= "string" then
1089 error("[ProfileService]: tag_name must be a string")
1090 elseif string.len(tag_name) == 0 then
1091 error("[ProfileService]: Invalid tag_name")
1092 end
1093 if self._view_mode == true then
1094 error("[ProfileService]: Can't set meta tag in view mode")
1095 end
1096 if self:IsActive() == false then
1097 error("[ProfileService]: PROFILE EXPIRED - Meta tags can't be set")
1098 end
1099 self.MetaData.MetaTags[tag_name] = value
1100end
1101
1102function Profile:ListenToRelease(listener) --> [ScriptConnection] (place_id / nil, game_job_id / nil)
1103 if type(listener) ~= "function" then
1104 error("[ProfileService]: Only a function can be set as listener in Profile:ListenToRelease()")
1105 end
1106 if self._view_mode == true then
1107 error("[ProfileService]: Can't listen to Profile release in view mode")
1108 end
1109 if self:IsActive() == false then
1110 -- Call release listener immediately if profile is expired
1111 local place_id
1112 local game_job_id
1113 local active_session = self.MetaData.ActiveSession
1114 if active_session ~= nil then
1115 place_id = active_session[1]
1116 game_job_id = active_session[2]
1117 end
1118 listener(place_id, game_job_id)
1119 return {
1120 Disconnect = function() end,
1121 }
1122 else
1123 table.insert(self._release_listeners, listener)
1124 return Madwork.NewScriptConnection(self._release_listeners, listener)
1125 end
1126end
1127
1128function Profile:Save()
1129 if self._view_mode == true then
1130 error("[ProfileService]: Can't save Profile in view mode")
1131 end
1132 if self:IsActive() == false then
1133 error("[ProfileService]: PROFILE EXPIRED - Can't save Profile")
1134 end
1135 -- We don't want auto save to trigger too soon after manual saving - this will reset the auto save timer:
1136 RemoveProfileFromAutoSave(self)
1137 AddProfileToAutoSave(self)
1138 -- Call save function in a new thread:
1139 coroutine.wrap(SaveProfileAsync)(self)
1140end
1141
1142function Profile:Release()
1143 if self._view_mode == true then
1144 error("[ProfileService]: Can't release Profile in view mode")
1145 end
1146 if self:IsActive() == true then
1147 coroutine.wrap(SaveProfileAsync)(self, true) -- Call save function in a new thread with release_from_session = true
1148 end
1149end
1150
1151-- ProfileStore object:
1152
1153local ProfileStore = {
1154 --[[
1155 Mock = {},
1156
1157 _profile_store_name = "", -- [string] -- DataStore name
1158 _profile_template = {} / nil, -- [table / nil]
1159 _global_data_store = global_data_store, -- [GlobalDataStore] -- Object returned by DataStoreService:GetDataStore(_profile_store_name)
1160
1161 _loaded_profiles = {[profile_key] = Profile, ...},
1162 _profile_load_jobs = {[profile_key] = {load_id, loaded_data}, ...},
1163
1164 _mock_loaded_profiles = {[profile_key] = Profile, ...},
1165 _mock_profile_load_jobs = {[profile_key] = {load_id, loaded_data}, ...},
1166 --]]
1167}
1168ProfileStore.__index = ProfileStore
1169
1170function ProfileStore:LoadProfileAsync(profile_key, not_released_handler, _use_mock) --> [Profile / nil] not_released_handler(place_id, game_job_id)
1171 if self._profile_template == nil then
1172 error("[ProfileService]: Profile template not set - ProfileStore:LoadProfileAsync() locked for this ProfileStore")
1173 end
1174 if type(profile_key) ~= "string" then
1175 error("[ProfileService]: profile_key must be a string")
1176 elseif string.len(profile_key) == 0 then
1177 error("[ProfileService]: Invalid profile_key")
1178 end
1179 if type(not_released_handler) ~= "function" and not_released_handler ~= "ForceLoad" and not_released_handler ~= "Steal" then
1180 error("[ProfileService]: Invalid not_released_handler")
1181 end
1182
1183 if ProfileService.ServiceLocked == true then
1184 return nil
1185 end
1186
1187 local is_user_mock = _use_mock == UseMockTag
1188
1189 -- Check if profile with profile_key isn't already loaded in this session:
1190 for _, profile_store in ipairs(ActiveProfileStores) do
1191 if profile_store._profile_store_name == self._profile_store_name then
1192 local loaded_profiles = is_user_mock == true and profile_store._mock_loaded_profiles or profile_store._loaded_profiles
1193 if loaded_profiles[profile_key] ~= nil then
1194 error("[ProfileService]: Profile of ProfileStore \"" .. self._profile_store_name .. "\" with key \"" .. profile_key .. "\" is already loaded in this session")
1195 -- Are you using Profile:Release() properly?
1196 end
1197 end
1198 end
1199
1200 ActiveProfileLoadJobs = ActiveProfileLoadJobs + 1
1201 local force_load = not_released_handler == "ForceLoad"
1202 local force_load_steps = 0
1203 local request_force_load = force_load -- First step of ForceLoad
1204 local steal_session = false -- Second step of ForceLoad
1205 local aggressive_steal = not_released_handler == "Steal" -- Developer invoked steal
1206 while ProfileService.ServiceLocked == false do
1207 -- Load profile:
1208 -- SPECIAL CASE - If LoadProfileAsync is called for the same key before another LoadProfileAsync finishes,
1209 -- yoink the DataStore return for the new call. The older call will return nil. This would prevent very rare
1210 -- game breaking errors where a player rejoins the server super fast.
1211 local profile_load_jobs = is_user_mock == true and self._mock_profile_load_jobs or self._profile_load_jobs
1212 local loaded_data
1213 local load_id = LoadIndex + 1
1214 LoadIndex = load_id
1215 local profile_load_job = profile_load_jobs[profile_key] -- {load_id, loaded_data}
1216 if profile_load_job ~= nil then
1217 profile_load_job[1] = load_id -- Yoink load job
1218 while profile_load_job[2] == nil do -- Wait for job to finish
1219 Madwork.HeartbeatWait()
1220 end
1221 if profile_load_job[1] == load_id then -- Load job hasn't been double-yoinked
1222 loaded_data = profile_load_job[2]
1223 profile_load_jobs[profile_key] = nil
1224 else
1225 return nil
1226 end
1227 else
1228 profile_load_job = {load_id, nil}
1229 profile_load_jobs[profile_key] = profile_load_job
1230 profile_load_job[2] = StandardProfileUpdateAsyncDataStore(
1231 self,
1232 profile_key,
1233 {
1234 ExistingProfileHandle = function(latest_data)
1235 if ProfileService.ServiceLocked == false then
1236 local active_session = latest_data.MetaData.ActiveSession
1237 local force_load_session = latest_data.MetaData.ForceLoadSession
1238 -- IsThisSession(active_session)
1239 if active_session == nil then
1240 latest_data.MetaData.ActiveSession = {PlaceId, JobId}
1241 latest_data.MetaData.ForceLoadSession = nil
1242 elseif type(active_session) == "table" then
1243 if IsThisSession(active_session) == false then
1244 local last_update = latest_data.MetaData.LastUpdate
1245 if last_update ~= nil then
1246 if os.time() - last_update > SETTINGS.AssumeDeadSessionLock then
1247 latest_data.MetaData.ActiveSession = {PlaceId, JobId}
1248 latest_data.MetaData.ForceLoadSession = nil
1249 return
1250 end
1251 end
1252 if steal_session == true or aggressive_steal == true then
1253 local force_load_uninterrupted = false
1254 if force_load_session ~= nil then
1255 force_load_uninterrupted = IsThisSession(force_load_session)
1256 end
1257 if force_load_uninterrupted == true or aggressive_steal == true then
1258 latest_data.MetaData.ActiveSession = {PlaceId, JobId}
1259 latest_data.MetaData.ForceLoadSession = nil
1260 end
1261 elseif request_force_load == true then
1262 latest_data.MetaData.ForceLoadSession = {PlaceId, JobId}
1263 end
1264 else
1265 latest_data.MetaData.ForceLoadSession = nil
1266 end
1267 end
1268 end
1269 end,
1270 MissingProfileHandle = function(latest_data)
1271 latest_data.Data = DeepCopyTable(self._profile_template)
1272 latest_data.MetaData = {
1273 ProfileCreateTime = os.time(),
1274 SessionLoadCount = 0,
1275 ActiveSession = {PlaceId, JobId},
1276 ForceLoadSession = nil,
1277 MetaTags = {},
1278
1279 }
1280 end,
1281 EditProfile = function(latest_data)
1282 if ProfileService.ServiceLocked == false then
1283 local active_session = latest_data.MetaData.ActiveSession
1284 if active_session ~= nil and IsThisSession(active_session) == true then
1285 latest_data.MetaData.SessionLoadCount = latest_data.MetaData.SessionLoadCount + 1
1286 latest_data.MetaData.LastUpdate = os.time()
1287 end
1288 end
1289 end,
1290 },
1291 is_user_mock
1292 )
1293 if profile_load_job[1] == load_id then -- Load job hasn't been yoinked
1294 loaded_data = profile_load_job[2]
1295 profile_load_jobs[profile_key] = nil
1296 else
1297 return nil -- Load job yoinked
1298 end
1299 end
1300 -- Handle load_data:
1301 if loaded_data ~= nil then
1302 local active_session = loaded_data.MetaData.ActiveSession
1303 if type(active_session) == "table" then
1304 if IsThisSession(active_session) == true then
1305 -- Special component in MetaTags:
1306 loaded_data.MetaData.MetaTagsLatest = DeepCopyTable(loaded_data.MetaData.MetaTags)
1307 -- Case #1: Profile is now taken by this session:
1308 -- Create Profile object:
1309 local global_updates_object = {
1310 _updates_latest = loaded_data.GlobalUpdates,
1311 _pending_update_lock = {},
1312 _pending_update_clear = {},
1313
1314 _new_active_update_listeners = {},
1315 _new_locked_update_listeners = {},
1316
1317 _profile = nil,
1318 }
1319 setmetatable(global_updates_object, GlobalUpdates)
1320 local profile = {
1321 Data = loaded_data.Data,
1322 MetaData = loaded_data.MetaData,
1323 GlobalUpdates = global_updates_object,
1324
1325 _profile_store = self,
1326 _profile_key = profile_key,
1327
1328 _release_listeners = {},
1329
1330 _load_timestamp = tick(),
1331
1332 _is_user_mock = is_user_mock,
1333 }
1334 setmetatable(profile, Profile)
1335 global_updates_object._profile = profile
1336 -- Referencing Profile object in ProfileStore:
1337 if next(self._loaded_profiles) == nil and next(self._mock_loaded_profiles) == nil then -- ProfileStore object was inactive
1338 table.insert(ActiveProfileStores, self)
1339 end
1340 if is_user_mock == true then
1341 self._mock_loaded_profiles[profile_key] = profile
1342 else
1343 self._loaded_profiles[profile_key] = profile
1344 end
1345 -- Adding profile to AutoSaveList;
1346 AddProfileToAutoSave(profile)
1347 -- Special case - finished loading profile, but session is shutting down:
1348 if ProfileService.ServiceLocked == true then
1349 SaveProfileAsync(profile, true) -- Release profile and yield until the DataStore call is finished
1350 profile = nil -- nil will be returned by this call
1351 end
1352 -- Return Profile object:
1353 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1354 return profile
1355 else
1356 -- Case #2: Profile is taken by some other session:
1357 if force_load == true then
1358 local force_load_session = loaded_data.MetaData.ForceLoadSession
1359 local force_load_uninterrupted = false
1360 if force_load_session ~= nil then
1361 force_load_uninterrupted = IsThisSession(force_load_session)
1362 end
1363 if force_load_uninterrupted == true then
1364 if request_force_load == false then
1365 force_load_steps = force_load_steps + 1
1366 if force_load_steps == SETTINGS.ForceLoadMaxSteps then
1367 steal_session = true
1368 end
1369 end
1370 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1371 else
1372 -- Another session tried to force load this profile:
1373 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1374 return nil
1375 end
1376 request_force_load = false -- Only request a force load once
1377 elseif aggressive_steal == true then
1378 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1379 else
1380 local handler_result = not_released_handler(active_session[1], active_session[2])
1381 if handler_result == "Repeat" then
1382 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1383 elseif handler_result == "Cancel" then
1384 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1385 return nil
1386 elseif handler_result == "ForceLoad" then
1387 force_load = true
1388 request_force_load = true
1389 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1390 elseif handler_result == "Steal" then
1391 aggressive_steal = true
1392 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1393 else
1394 error("[ProfileService]: Invalid return from not_released_handler")
1395 end
1396 end
1397 end
1398 else
1399 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1400 error("[ProfileService]: Invalid ActiveSession value in Profile.MetaData - Fatal corruption") -- It's unlikely this will ever fire
1401 end
1402 else
1403 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1404 end
1405 end
1406 ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
1407 return nil -- If loop breaks return nothing
1408end
1409
1410function ProfileStore:GlobalUpdateProfileAsync(profile_key, update_handler, _use_mock) --> [GlobalUpdates / nil] (update_handler(GlobalUpdates))
1411 if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
1412 error("[ProfileService]: Invalid profile_key")
1413 end
1414 if type(update_handler) ~= "function" then
1415 error("[ProfileService]: Invalid update_handler")
1416 end
1417
1418 if ProfileService.ServiceLocked == true then
1419 return nil
1420 end
1421
1422 while ProfileService.ServiceLocked == false do
1423 -- Updating profile:
1424 local loaded_data = StandardProfileUpdateAsyncDataStore(
1425 self,
1426 profile_key,
1427 {
1428 ExistingProfileHandle = nil,
1429 MissingProfileHandle = nil,
1430 EditProfile = function(latest_data)
1431 -- Running update_handler:
1432 local global_updates_object = {
1433 _updates_latest = latest_data.GlobalUpdates,
1434 _update_handler_mode = true,
1435 }
1436 setmetatable(global_updates_object, GlobalUpdates)
1437 update_handler(global_updates_object)
1438 end,
1439 },
1440 _use_mock == UseMockTag
1441 )
1442 -- Handling loaded_data:
1443 if loaded_data ~= nil then
1444 -- Return GlobalUpdates object (Update successful):
1445 local global_updates_object = {
1446 _updates_latest = loaded_data.GlobalUpdates,
1447 }
1448 setmetatable(global_updates_object, GlobalUpdates)
1449 return global_updates_object
1450 else
1451 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again
1452 end
1453 end
1454 return nil -- Return nothing (Update unsuccessful)
1455end
1456
1457function ProfileStore:ViewProfileAsync(profile_key, _use_mock) --> [Profile / nil]
1458 if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
1459 error("[ProfileService]: Invalid profile_key")
1460 end
1461
1462 if ProfileService.ServiceLocked == true then
1463 return nil
1464 end
1465
1466 while ProfileService.ServiceLocked == false do
1467 -- Load profile:
1468 local loaded_data = StandardProfileUpdateAsyncDataStore(
1469 self,
1470 profile_key,
1471 {
1472 ExistingProfileHandle = nil,
1473 MissingProfileHandle = nil,
1474 EditProfile = nil,
1475 },
1476 _use_mock == UseMockTag
1477 )
1478 -- Handle load_data:
1479 if loaded_data ~= nil then
1480 -- Create Profile object:
1481 local global_updates_object = {
1482 _updates_latest = loaded_data.GlobalUpdates,
1483 _profile = nil,
1484 }
1485 setmetatable(global_updates_object, GlobalUpdates)
1486 local profile = {
1487 Data = loaded_data.Data,
1488 MetaData = loaded_data.MetaData,
1489 GlobalUpdates = global_updates_object,
1490
1491 _profile_store = self,
1492 _profile_key = profile_key,
1493
1494 _view_mode = true,
1495
1496 _load_timestamp = tick(),
1497 }
1498 setmetatable(profile, Profile)
1499 global_updates_object._profile = profile
1500 -- Returning Profile object:
1501 return profile
1502 else
1503 Madwork.HeartbeatWait(SETTINGS.LoadProfileRepeatDelay) -- Let the cycle repeat again after a delay
1504 end
1505 end
1506 return nil -- If loop breaks return nothing
1507end
1508
1509function ProfileStore:WipeProfileAsync(profile_key, _use_mock) --> is_wipe_successful [bool]
1510 if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
1511 error("[ProfileService]: Invalid profile_key")
1512 end
1513
1514 if ProfileService.ServiceLocked == true then
1515 return false
1516 end
1517
1518 return StandardProfileUpdateAsyncDataStore(
1519 self,
1520 profile_key,
1521 {
1522 WipeProfile = true
1523 },
1524 _use_mock == UseMockTag
1525 )
1526end
1527
1528-- New ProfileStore:
1529
1530function ProfileService.GetProfileStore(profile_store_name, profile_template) --> [ProfileStore]
1531 if type(profile_store_name) ~= "string" then
1532 error("[ProfileService]: profile_store_name must be a string")
1533 elseif string.len(profile_store_name) == 0 then
1534 error("[ProfileService]: Invalid profile_store_name")
1535 end
1536
1537 if type(profile_template) ~= "table" then
1538 error("[ProfileService]: Invalid profile_template")
1539 end
1540
1541 local profile_store
1542 profile_store = {
1543 Mock = {
1544 LoadProfileAsync = function(_, profile_key, not_released_handler)
1545 return profile_store:LoadProfileAsync(profile_key, not_released_handler, UseMockTag)
1546 end,
1547 GlobalUpdateProfileAsync = function(_, profile_key, update_handler)
1548 return profile_store:GlobalUpdateProfileAsync(profile_key, update_handler, UseMockTag)
1549 end,
1550 ViewProfileAsync = function(_, profile_key)
1551 return profile_store:ViewProfileAsync(profile_key, UseMockTag)
1552 end,
1553 WipeProfileAsync = function(_, profile_key)
1554 return profile_store:WipeProfileAsync(profile_key, UseMockTag)
1555 end
1556 },
1557
1558 _profile_store_name = profile_store_name,
1559 _profile_template = profile_template,
1560 _global_data_store = nil,
1561 _loaded_profiles = {},
1562 _profile_load_jobs = {},
1563 _mock_loaded_profiles = {},
1564 _mock_profile_load_jobs = {},
1565 }
1566 if UseMockDataStore == false then
1567 profile_store._global_data_store = DataStoreService:GetDataStore(profile_store_name)
1568 end
1569 setmetatable(profile_store, ProfileStore)
1570 return profile_store
1571end
1572
1573----- Initialize -----
1574
1575if IsStudio == true then
1576 local status, message = pcall(function()
1577 -- This will error if current instance has no Studio API access:
1578 DataStoreService:GetDataStore("____PS"):SetAsync("____PS", os.time())
1579 end)
1580 if status == false and (string.find(message, "403", 1, true) ~= nil or string.find(message, "must publish", 1, true) ~= nil) then
1581 UseMockDataStore = true
1582 ProfileService._use_mock_data_store = true
1583 print("[ProfileService]: Roblox API services unavailable - data will not be saved")
1584 else
1585 print("[ProfileService]: Roblox API services available - data will be saved")
1586 end
1587end
1588
1589----- Connections -----
1590
1591-- Auto saving and issue queue managing:
1592RunService.Heartbeat:Connect(function()
1593 -- 1) Auto saving: --
1594 local auto_save_list_length = #AutoSaveList
1595 if auto_save_list_length > 0 then
1596 local auto_save_index_speed = SETTINGS.AutoSaveProfiles / auto_save_list_length
1597 local current_tick = tick()
1598 while current_tick - LastAutoSave > auto_save_index_speed do
1599 LastAutoSave = LastAutoSave + auto_save_index_speed
1600 local profile = AutoSaveList[AutoSaveIndex]
1601 if current_tick - profile._load_timestamp < SETTINGS.AutoSaveProfiles then
1602 -- This profile is freshly loaded - auto-saving immediately after loading will cause a warning in the log:
1603 profile = nil
1604 for _ = 1, auto_save_list_length - 1 do
1605 -- Move auto save index to the right:
1606 AutoSaveIndex = AutoSaveIndex + 1
1607 if AutoSaveIndex > auto_save_list_length then
1608 AutoSaveIndex = 1
1609 end
1610 profile = AutoSaveList[AutoSaveIndex]
1611 if current_tick - profile._load_timestamp >= SETTINGS.AutoSaveProfiles then
1612 break
1613 else
1614 profile = nil
1615 end
1616 end
1617 end
1618 -- Move auto save index to the right:
1619 AutoSaveIndex = AutoSaveIndex + 1
1620 if AutoSaveIndex > auto_save_list_length then
1621 AutoSaveIndex = 1
1622 end
1623 -- Perform save call:
1624 -- print("[ProfileService]: Auto updating profile - profile_store_name = \"" .. profile._profile_store._profile_store_name .. "\"; profile_key = \"" .. profile._profile_key .. "\"")
1625 if profile ~= nil then
1626 coroutine.wrap(SaveProfileAsync)(profile) -- Auto save profile in new thread
1627 end
1628 end
1629 end
1630 -- 2) Issue queue: --
1631 -- Critical state handling:
1632 if ProfileService.CriticalState == false then
1633 if #IssueQueue >= SETTINGS.IssueCountForCriticalState then
1634 ProfileService.CriticalState = true
1635 ProfileService.CriticalStateSignal:Fire(true)
1636 CriticalStateStart = tick()
1637 warn("[ProfileService]: Entered critical state")
1638 end
1639 else
1640 if #IssueQueue >= SETTINGS.IssueCountForCriticalState then
1641 CriticalStateStart = tick()
1642 elseif tick() - CriticalStateStart > SETTINGS.CriticalStateLast then
1643 ProfileService.CriticalState = false
1644 ProfileService.CriticalStateSignal:Fire(false)
1645 warn("[ProfileService]: Critical state ended")
1646 end
1647 end
1648 -- Issue queue:
1649 while true do
1650 local issue_tick = IssueQueue[1]
1651 if issue_tick == nil then
1652 break
1653 elseif tick() - issue_tick > SETTINGS.IssueLast then
1654 table.remove(IssueQueue, 1)
1655 else
1656 break
1657 end
1658 end
1659end)
1660
1661-- Release all loaded profiles when the server is shutting down:
1662Madwork.ConnectToOnClose(
1663 function()
1664 ProfileService.ServiceLocked = true
1665 -- 1) Release all active profiles: --
1666 -- Clone AutoSaveList to a new table because AutoSaveList changes when profiles are released:
1667 local on_close_save_job_count = 0
1668 local active_profiles = {}
1669 for index, profile in ipairs(AutoSaveList) do
1670 active_profiles[index] = profile
1671 end
1672 -- Release the profiles; Releasing profiles can trigger listeners that release other profiles, so check active state:
1673 for _, profile in ipairs(active_profiles) do
1674 if profile:IsActive() == true then
1675 on_close_save_job_count = on_close_save_job_count + 1
1676 coroutine.wrap(function() -- Save profile on new thread
1677 SaveProfileAsync(profile, true)
1678 on_close_save_job_count = on_close_save_job_count - 1
1679 end)()
1680 end
1681 end
1682 -- 2) Yield until all active profile jobs are finished: --
1683 while on_close_save_job_count > 0 or ActiveProfileLoadJobs > 0 or ActiveProfileSaveJobs > 0 do
1684 Madwork.HeartbeatWait()
1685 end
1686 return -- We're done!
1687 end,
1688 UseMockDataStore == false -- Always run this OnClose task if using Roblox API services
1689)
1690
1691return ProfileService