roblox-opencode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -122
- package/commands/setup-game.md +108 -108
- package/commands/sync-check.md +53 -53
- package/core/roblox-core.md +93 -93
- package/dist/server.js +189 -167
- package/package.json +35 -35
- package/skills/roblox-analytics/SKILL.md +277 -277
- package/skills/roblox-analytics/references/event-batcher.luau +75 -75
- package/skills/roblox-animation-vfx/SKILL.md +1325 -1325
- package/skills/roblox-architecture/SKILL.md +877 -863
- package/skills/roblox-architecture/references/combat-systems.md +1381 -1381
- package/skills/roblox-code-review/SKILL.md +686 -686
- package/skills/roblox-data/SKILL.md +889 -889
- package/skills/roblox-data/references/inventory-systems.md +1729 -1729
- package/skills/roblox-debug/SKILL.md +98 -98
- package/skills/roblox-gui/SKILL.md +1103 -1103
- package/skills/roblox-gui-fusion/SKILL.md +150 -150
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -427
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -579
- package/skills/roblox-gui-fusion/references/shop.luau +411 -411
- package/skills/roblox-luau-mastery/SKILL.md +1618 -1519
- package/skills/roblox-monetization/SKILL.md +1084 -1084
- package/skills/roblox-monetization/references/process-receipt.luau +131 -131
- package/skills/roblox-networking/SKILL.md +669 -669
- package/skills/roblox-networking/references/remote-validator.luau +193 -193
- package/skills/roblox-publish-checklist/SKILL.md +127 -127
- package/skills/roblox-runtime/SKILL.md +753 -753
- package/skills/roblox-sharp-edges/SKILL.md +294 -294
- package/skills/roblox-sync/SKILL.md +126 -126
- package/skills/roblox-testing/SKILL.md +943 -943
- package/skills/roblox-tooling/SKILL.md +149 -149
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -201
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -7
- package/vendor/LICENSES/promise-LICENSE +20 -20
- package/vendor/LICENSES/t-LICENSE +21 -21
- package/vendor/LICENSES/testez-LICENSE +200 -200
- package/vendor/README.md +83 -83
- package/vendor/fusion/Animation/ExternalTime.luau +83 -83
- package/vendor/fusion/Animation/Spring.luau +321 -321
- package/vendor/fusion/Animation/Stopwatch.luau +127 -127
- package/vendor/fusion/Animation/Tween.luau +187 -187
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -27
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -47
- package/vendor/fusion/Animation/lerpType.luau +163 -163
- package/vendor/fusion/Animation/packType.luau +99 -99
- package/vendor/fusion/Animation/springCoefficients.luau +80 -80
- package/vendor/fusion/Animation/unpackType.luau +102 -102
- package/vendor/fusion/Colour/Oklab.luau +70 -70
- package/vendor/fusion/Colour/sRGB.luau +54 -54
- package/vendor/fusion/External.luau +167 -167
- package/vendor/fusion/ExternalDebug.luau +69 -69
- package/vendor/fusion/Graph/Observer.luau +113 -113
- package/vendor/fusion/Graph/castToGraph.luau +28 -28
- package/vendor/fusion/Graph/change.luau +80 -80
- package/vendor/fusion/Graph/depend.luau +32 -32
- package/vendor/fusion/Graph/evaluate.luau +55 -55
- package/vendor/fusion/Instances/Attribute.luau +57 -57
- package/vendor/fusion/Instances/AttributeChange.luau +46 -46
- package/vendor/fusion/Instances/AttributeOut.luau +63 -63
- package/vendor/fusion/Instances/Child.luau +21 -21
- package/vendor/fusion/Instances/Children.luau +147 -147
- package/vendor/fusion/Instances/Hydrate.luau +32 -32
- package/vendor/fusion/Instances/New.luau +52 -52
- package/vendor/fusion/Instances/OnChange.luau +49 -49
- package/vendor/fusion/Instances/OnEvent.luau +53 -53
- package/vendor/fusion/Instances/Out.luau +69 -69
- package/vendor/fusion/Instances/applyInstanceProps.luau +148 -148
- package/vendor/fusion/Instances/defaultProps.luau +194 -194
- package/vendor/fusion/LICENSE +21 -21
- package/vendor/fusion/Logging/formatError.luau +48 -48
- package/vendor/fusion/Logging/messages.luau +51 -51
- package/vendor/fusion/Logging/parseError.luau +24 -24
- package/vendor/fusion/Memory/checkLifetime.luau +133 -133
- package/vendor/fusion/Memory/deriveScope.luau +23 -23
- package/vendor/fusion/Memory/deriveScopeImpl.luau +44 -44
- package/vendor/fusion/Memory/doCleanup.luau +78 -78
- package/vendor/fusion/Memory/innerScope.luau +33 -33
- package/vendor/fusion/Memory/legacyCleanup.luau +17 -17
- package/vendor/fusion/Memory/needsDestruction.luau +16 -16
- package/vendor/fusion/Memory/poisonScope.luau +33 -33
- package/vendor/fusion/Memory/scopePool.luau +54 -54
- package/vendor/fusion/Memory/scoped.luau +26 -26
- package/vendor/fusion/Memory/whichLivesLonger.luau +74 -74
- package/vendor/fusion/RobloxExternal.luau +97 -97
- package/vendor/fusion/State/Computed.luau +138 -138
- package/vendor/fusion/State/For/Disassembly.luau +210 -210
- package/vendor/fusion/State/For/ForTypes.luau +30 -30
- package/vendor/fusion/State/For/init.luau +109 -109
- package/vendor/fusion/State/ForKeys.luau +93 -93
- package/vendor/fusion/State/ForPairs.luau +96 -96
- package/vendor/fusion/State/ForValues.luau +93 -93
- package/vendor/fusion/State/Value.luau +87 -87
- package/vendor/fusion/State/castToState.luau +25 -25
- package/vendor/fusion/State/peek.luau +30 -30
- package/vendor/fusion/Types.luau +314 -314
- package/vendor/fusion/Utility/Contextual.luau +90 -90
- package/vendor/fusion/Utility/Safe.luau +22 -22
- package/vendor/fusion/Utility/isSimilar.luau +29 -29
- package/vendor/fusion/Utility/merge.luau +35 -35
- package/vendor/fusion/Utility/nameOf.luau +34 -34
- package/vendor/fusion/Utility/never.luau +13 -13
- package/vendor/fusion/Utility/nicknames.luau +10 -10
- package/vendor/fusion/Utility/xtypeof.luau +26 -26
- package/vendor/fusion/init.luau +82 -82
- package/vendor/profilestore/init.luau +2242 -2242
- package/vendor/promise/init.luau +1982 -1982
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -25
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -228
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -269
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -223
- package/vendor/rbxutil/buffer-util/Types.luau +60 -60
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -153
- package/vendor/rbxutil/buffer-util/init.luau +41 -41
- package/vendor/rbxutil/buffer-util/package.json +16 -16
- package/vendor/rbxutil/buffer-util/wally.toml +9 -9
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -232
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -156
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -109
- package/vendor/rbxutil/comm/Client/init.luau +135 -135
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -295
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -211
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -211
- package/vendor/rbxutil/comm/Server/init.luau +140 -140
- package/vendor/rbxutil/comm/Types.luau +18 -18
- package/vendor/rbxutil/comm/Util.luau +27 -27
- package/vendor/rbxutil/comm/init.luau +35 -35
- package/vendor/rbxutil/comm/wally.toml +13 -13
- package/vendor/rbxutil/component/init.luau +759 -759
- package/vendor/rbxutil/component/init.test.luau +311 -311
- package/vendor/rbxutil/component/wally.toml +14 -14
- package/vendor/rbxutil/concur/init.luau +542 -542
- package/vendor/rbxutil/concur/init.test.luau +364 -364
- package/vendor/rbxutil/concur/wally.toml +8 -8
- package/vendor/rbxutil/enum-list/init.luau +101 -101
- package/vendor/rbxutil/enum-list/init.test.luau +91 -91
- package/vendor/rbxutil/enum-list/wally.toml +8 -8
- package/vendor/rbxutil/find/index.d.ts +20 -20
- package/vendor/rbxutil/find/init.luau +44 -44
- package/vendor/rbxutil/find/package.json +17 -17
- package/vendor/rbxutil/find/wally.toml +8 -8
- package/vendor/rbxutil/input/Gamepad.luau +559 -559
- package/vendor/rbxutil/input/Keyboard.luau +124 -124
- package/vendor/rbxutil/input/Mouse.luau +278 -278
- package/vendor/rbxutil/input/PreferredInput.luau +91 -91
- package/vendor/rbxutil/input/Touch.luau +120 -120
- package/vendor/rbxutil/input/init.luau +33 -33
- package/vendor/rbxutil/input/wally.toml +12 -12
- package/vendor/rbxutil/loader/index.d.ts +15 -15
- package/vendor/rbxutil/loader/init.luau +137 -137
- package/vendor/rbxutil/loader/wally.toml +8 -8
- package/vendor/rbxutil/log/index.d.ts +38 -38
- package/vendor/rbxutil/log/init.luau +746 -746
- package/vendor/rbxutil/log/wally.toml +8 -8
- package/vendor/rbxutil/net/init.luau +190 -190
- package/vendor/rbxutil/net/wally.toml +8 -8
- package/vendor/rbxutil/option/index.d.ts +44 -44
- package/vendor/rbxutil/option/init.luau +489 -489
- package/vendor/rbxutil/option/init.test.luau +342 -342
- package/vendor/rbxutil/option/wally.toml +8 -8
- package/vendor/rbxutil/pid/index.d.ts +53 -53
- package/vendor/rbxutil/pid/init.luau +195 -195
- package/vendor/rbxutil/pid/package.json +16 -16
- package/vendor/rbxutil/pid/wally.toml +9 -9
- package/vendor/rbxutil/quaternion/index.d.ts +117 -117
- package/vendor/rbxutil/quaternion/init.luau +570 -570
- package/vendor/rbxutil/quaternion/package.json +16 -16
- package/vendor/rbxutil/quaternion/wally.toml +9 -9
- package/vendor/rbxutil/query/index.d.ts +43 -43
- package/vendor/rbxutil/query/init.luau +117 -117
- package/vendor/rbxutil/query/package.json +18 -18
- package/vendor/rbxutil/query/wally.toml +9 -9
- package/vendor/rbxutil/sequent/index.d.ts +28 -28
- package/vendor/rbxutil/sequent/init.luau +340 -340
- package/vendor/rbxutil/sequent/package.json +16 -16
- package/vendor/rbxutil/sequent/wally.toml +9 -9
- package/vendor/rbxutil/ser/init.luau +175 -175
- package/vendor/rbxutil/ser/init.test.luau +50 -50
- package/vendor/rbxutil/ser/wally.toml +11 -11
- package/vendor/rbxutil/shake/index.d.ts +36 -36
- package/vendor/rbxutil/shake/init.luau +532 -532
- package/vendor/rbxutil/shake/init.test.luau +267 -267
- package/vendor/rbxutil/shake/package.json +16 -16
- package/vendor/rbxutil/shake/wally.toml +9 -9
- package/vendor/rbxutil/signal/index.d.ts +100 -100
- package/vendor/rbxutil/signal/init.luau +432 -432
- package/vendor/rbxutil/signal/init.test.luau +190 -190
- package/vendor/rbxutil/signal/package.json +17 -17
- package/vendor/rbxutil/signal/wally.toml +9 -9
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -65
- package/vendor/rbxutil/silo/Util.luau +55 -55
- package/vendor/rbxutil/silo/init.luau +338 -338
- package/vendor/rbxutil/silo/init.test.luau +215 -215
- package/vendor/rbxutil/silo/wally.toml +8 -8
- package/vendor/rbxutil/spring/index.d.ts +40 -40
- package/vendor/rbxutil/spring/init.luau +97 -97
- package/vendor/rbxutil/spring/package.json +17 -17
- package/vendor/rbxutil/spring/wally.toml +8 -8
- package/vendor/rbxutil/stream/index.d.ts +88 -88
- package/vendor/rbxutil/stream/init.luau +597 -597
- package/vendor/rbxutil/stream/package.json +18 -18
- package/vendor/rbxutil/stream/wally.toml +9 -9
- package/vendor/rbxutil/streamable/Streamable.luau +202 -202
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -80
- package/vendor/rbxutil/streamable/init.luau +8 -8
- package/vendor/rbxutil/streamable/wally.toml +12 -12
- package/vendor/rbxutil/symbol/init.luau +56 -56
- package/vendor/rbxutil/symbol/init.test.luau +37 -37
- package/vendor/rbxutil/symbol/wally.toml +8 -8
- package/vendor/rbxutil/table-util/init.luau +938 -938
- package/vendor/rbxutil/table-util/init.test.luau +439 -439
- package/vendor/rbxutil/task-queue/index.d.ts +27 -27
- package/vendor/rbxutil/task-queue/init.luau +97 -97
- package/vendor/rbxutil/task-queue/wally.toml +8 -8
- package/vendor/rbxutil/timer/index.d.ts +81 -81
- package/vendor/rbxutil/timer/init.luau +249 -249
- package/vendor/rbxutil/timer/init.test.luau +73 -73
- package/vendor/rbxutil/timer/wally.toml +11 -11
- package/vendor/rbxutil/tree/index.d.ts +15 -15
- package/vendor/rbxutil/tree/init.luau +137 -137
- package/vendor/rbxutil/tree/wally.toml +8 -8
- package/vendor/rbxutil/trove/index.d.ts +46 -46
- package/vendor/rbxutil/trove/init.luau +787 -787
- package/vendor/rbxutil/trove/init.test.luau +203 -203
- package/vendor/rbxutil/trove/wally.toml +8 -8
- package/vendor/rbxutil/typed-remote/init.luau +196 -196
- package/vendor/rbxutil/typed-remote/wally.toml +8 -8
- package/vendor/rbxutil/wait-for/index.d.ts +17 -17
- package/vendor/rbxutil/wait-for/init.luau +257 -257
- package/vendor/rbxutil/wait-for/init.test.luau +182 -182
- package/vendor/rbxutil/wait-for/wally.toml +11 -11
- package/vendor/t/t.lua +1350 -1350
- package/vendor/testez/Context.lua +26 -26
- package/vendor/testez/Expectation.lua +311 -311
- package/vendor/testez/ExpectationContext.lua +38 -38
- package/vendor/testez/LifecycleHooks.lua +89 -89
- package/vendor/testez/Reporters/TeamCityReporter.lua +101 -101
- package/vendor/testez/Reporters/TextReporter.lua +105 -105
- package/vendor/testez/Reporters/TextReporterQuiet.lua +96 -96
- package/vendor/testez/TestBootstrap.lua +146 -146
- package/vendor/testez/TestEnum.lua +27 -27
- package/vendor/testez/TestPlan.lua +304 -304
- package/vendor/testez/TestPlanner.lua +39 -39
- package/vendor/testez/TestResults.lua +111 -111
- package/vendor/testez/TestRunner.lua +188 -188
- package/vendor/testez/TestSession.lua +243 -243
- package/vendor/testez/init.lua +39 -39
|
@@ -1,2243 +1,2243 @@
|
|
|
1
|
-
--[[
|
|
2
|
-
MAD STUDIO (by loleris)
|
|
3
|
-
|
|
4
|
-
-[ProfileStore]---------------------------------------
|
|
5
|
-
|
|
6
|
-
Periodic DataStore saving solution with session locking
|
|
7
|
-
|
|
8
|
-
WARNINGS FOR "Profile.Data" VALUES:
|
|
9
|
-
! Do not create numeric tables with gaps - attempting to store such tables will result in an error.
|
|
10
|
-
! Do not create mixed tables (some values indexed by number and others by a string key)
|
|
11
|
-
- only numerically indexed data will be stored.
|
|
12
|
-
! Do not index tables by anything other than numbers and strings.
|
|
13
|
-
! Do not reference Roblox Instances
|
|
14
|
-
! Do not reference userdata (Vector3, Color3, CFrame...) - Serialize userdata before referencing
|
|
15
|
-
! Do not reference functions
|
|
16
|
-
|
|
17
|
-
Members:
|
|
18
|
-
|
|
19
|
-
ProfileStore.IsClosing [bool]
|
|
20
|
-
-- Set to true after a game:BindToClose() trigger
|
|
21
|
-
|
|
22
|
-
ProfileStore.IsCriticalState [bool]
|
|
23
|
-
-- Set to true when ProfileStore experiences too many consecutive errors
|
|
24
|
-
|
|
25
|
-
ProfileStore.OnError [Signal] (message, store_name, profile_key)
|
|
26
|
-
-- Most ProfileStore errors will be caught and passed to this signal
|
|
27
|
-
|
|
28
|
-
ProfileStore.OnOverwrite [Signal] (store_name, profile_key)
|
|
29
|
-
-- Triggered when a DataStore key was likely used to store data that wasn't
|
|
30
|
-
a ProfileStore profile or the ProfileStore structure was invalidly manually
|
|
31
|
-
altered for that DataStore key
|
|
32
|
-
|
|
33
|
-
ProfileStore.OnCriticalToggle [Signal] (is_critical)
|
|
34
|
-
-- Triggered when ProfileStore experiences too many consecutive errors
|
|
35
|
-
|
|
36
|
-
ProfileStore.DataStoreState [string] ("NotReady", "NoInternet", "NoAccess", "Access")
|
|
37
|
-
-- This value resembles ProfileStore's access to the DataStore; The value starts
|
|
38
|
-
as "NotReady" and will eventually change to one of the other 3 possible values.
|
|
39
|
-
|
|
40
|
-
Functions:
|
|
41
|
-
|
|
42
|
-
ProfileStore.New(store_name, template?) --> [ProfileStore]
|
|
43
|
-
store_name [string] -- DataStore name
|
|
44
|
-
template [table] or nil -- Profiles will default to given table (hard-copy) when no data was saved previously
|
|
45
|
-
|
|
46
|
-
ProfileStore.SetConstant(name, value)
|
|
47
|
-
name [string]
|
|
48
|
-
value [number]
|
|
49
|
-
|
|
50
|
-
Members [ProfileStore]:
|
|
51
|
-
|
|
52
|
-
ProfileStore.Mock [ProfileStore]
|
|
53
|
-
-- Reflection of ProfileStore methods, but the methods will now query a mock
|
|
54
|
-
DataStore with no relation to the real DataStore
|
|
55
|
-
|
|
56
|
-
ProfileStore.Name [string]
|
|
57
|
-
|
|
58
|
-
Methods [ProfileStore]:
|
|
59
|
-
|
|
60
|
-
ProfileStore:StartSessionAsync(profile_key, params?) --> [Profile] or nil
|
|
61
|
-
profile_key [string] -- DataStore key
|
|
62
|
-
params nil or [table]: -- Custom params; E.g. {Steal = true}
|
|
63
|
-
{
|
|
64
|
-
Steal = true, -- Pass this to disregard an existing session lock
|
|
65
|
-
Cancel = fn() -> (boolean), -- Pass this to create a request cancel condition.
|
|
66
|
-
-- If the cancel function returns true, ProfileStore will stop trying to
|
|
67
|
-
-- start the session and return nil
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
ProfileStore:MessageAsync(profile_key, message) --> is_success [bool]
|
|
71
|
-
profile_key [string] -- DataStore key
|
|
72
|
-
message [table] -- Data to be messaged to the profile
|
|
73
|
-
|
|
74
|
-
ProfileStore:GetAsync(profile_key, version?) --> [Profile] or nil
|
|
75
|
-
-- Reads a profile without starting a session - will not autosave
|
|
76
|
-
profile_key [string] -- DataStore key
|
|
77
|
-
version nil or [string] -- DataStore key version
|
|
78
|
-
|
|
79
|
-
ProfileStore:VersionQuery(profile_key, sort_direction?, min_date?, max_date?) --> [VersionQuery]
|
|
80
|
-
profile_key [string]
|
|
81
|
-
sort_direction nil or [Enum.SortDirection]
|
|
82
|
-
min_date nil or [DateTime]
|
|
83
|
-
max_date nil or [DateTime]
|
|
84
|
-
|
|
85
|
-
ProfileStore:RemoveAsync(profile_key) --> is_success [bool]
|
|
86
|
-
-- Completely removes profile data from the DataStore / mock DataStore with no way to recover it.
|
|
87
|
-
|
|
88
|
-
Methods [VersionQuery]:
|
|
89
|
-
|
|
90
|
-
VersionQuery:NextAsync() --> [Profile] or nil -- (Yields)
|
|
91
|
-
-- Returned profile is similar to profiles returned by ProfileStore:GetAsync()
|
|
92
|
-
|
|
93
|
-
Members [Profile]:
|
|
94
|
-
|
|
95
|
-
Profile.Data [table]
|
|
96
|
-
-- When the profile is active changes to this table are guaranteed to be saved
|
|
97
|
-
Profile.LastSavedData [table] (Read-only)
|
|
98
|
-
-- Last snapshot of "Profile.Data" that has been successfully saved to the DataStore;
|
|
99
|
-
Useful for proper developer product purchase receipt handling
|
|
100
|
-
|
|
101
|
-
Profile.FirstSessionTime [number] (Read-only)
|
|
102
|
-
-- os.time() timestamp of the first profile session
|
|
103
|
-
|
|
104
|
-
Profile.SessionLoadCount [number] (Read-only) -- Amount of times a session was started for this profile
|
|
105
|
-
|
|
106
|
-
Profile.Session [table] (Read-only) {PlaceId = number, JobId = string} / nil
|
|
107
|
-
-- Set to a table if this profile is in use by a server; nil if released
|
|
108
|
-
|
|
109
|
-
Profile.RobloxMetaData [table] -- Writable table that gets saved automatically and once the profile is released
|
|
110
|
-
Profile.UserIds [table] -- (Read-only) -- {user_id [number], ...} -- User ids associated with this profile
|
|
111
|
-
|
|
112
|
-
Profile.KeyInfo [DataStoreKeyInfo] -- Changes before OnAfterSave signal
|
|
113
|
-
|
|
114
|
-
Profile.OnSave [Signal] ()
|
|
115
|
-
-- Triggered right before changes to Profile.Data are saved to the DataStore
|
|
116
|
-
|
|
117
|
-
Profile.OnLastSave [Signal] (reason [string]: "Manual", "External", "Shutdown")
|
|
118
|
-
-- Triggered right before changes to Profile.Data are saved to the DataStore
|
|
119
|
-
for the last time; A reason is provided for the last save:
|
|
120
|
-
- "Manual" - Profile:EndSession() was called
|
|
121
|
-
- "Shutdown" - The server that has ownership of this profile is shutting down
|
|
122
|
-
- "External" - Another server has started a session for this profile
|
|
123
|
-
Note that this event will not trigger for when a profile session is ended by
|
|
124
|
-
another server trying to take ownership of the session - this is impossible to
|
|
125
|
-
do without compromising on ProfileStore's speed.
|
|
126
|
-
|
|
127
|
-
Profile.OnSessionEnd [Signal] ()
|
|
128
|
-
-- Triggered when the profile session is terminated on this server
|
|
129
|
-
|
|
130
|
-
Profile.OnAfterSave [Signal] (last_saved_data)
|
|
131
|
-
-- Triggered after a successful save
|
|
132
|
-
last_saved_data [table] -- Profile.LastSavedData
|
|
133
|
-
|
|
134
|
-
Profile.ProfileStore [ProfileStore] -- ProfileStore object this profile belongs to
|
|
135
|
-
Profile.Key [string] -- DataStore key
|
|
136
|
-
|
|
137
|
-
Methods [Profile]:
|
|
138
|
-
|
|
139
|
-
Profile:IsActive() --> [bool] -- If "true" is returned, changes to Profile.Data are guaranteed to save;
|
|
140
|
-
This guarantee is only valid until code yields (e.g. task.wait() is used).
|
|
141
|
-
|
|
142
|
-
Profile:Reconcile() -- Fills in missing (nil) [string_key] = [value] pairs to the Profile.Data structure
|
|
143
|
-
from the "template" argument that was passed to "ProfileStore.New()"
|
|
144
|
-
|
|
145
|
-
Profile:EndSession() -- Call after the server has finished working with this profile
|
|
146
|
-
e.g., after the player leaves (Profile object will become inactive)
|
|
147
|
-
|
|
148
|
-
Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
|
|
149
|
-
user_id [number]
|
|
150
|
-
|
|
151
|
-
Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
|
|
152
|
-
user_id [number]
|
|
153
|
-
|
|
154
|
-
Profile:MessageHandler(fn) -- Sets a message handler for this profile
|
|
155
|
-
fn [function] (message [table], processed [function]())
|
|
156
|
-
-- The handler function receives a message table and a callback function;
|
|
157
|
-
The callback function is to be called when a message has been processed
|
|
158
|
-
- this will discard the message from the profile message cache; If the
|
|
159
|
-
callback function is not called, other message handlers will also be triggered
|
|
160
|
-
with unprocessed message data.
|
|
161
|
-
|
|
162
|
-
Profile:Save() -- If the profile session is still active makes an UpdateAsync call
|
|
163
|
-
to the DataStore to immediately save profile data
|
|
164
|
-
|
|
165
|
-
Profile:SetAsync() -- Forcefully saves changes to the profile; Only for profiles
|
|
166
|
-
loaded with ProfileStore:GetAsync() or ProfileStore:VersionQuery()
|
|
167
|
-
|
|
168
|
-
--]]
|
|
169
|
-
|
|
170
|
-
local AUTO_SAVE_PERIOD = 300 -- (Seconds) Time between when changes to a profile are saved to the DataStore
|
|
171
|
-
local LOAD_REPEAT_PERIOD = 10 -- (Seconds) Time between successive profile reads when handling a session conflict
|
|
172
|
-
local FIRST_LOAD_REPEAT = 5 -- (Seconds) Time between first and second profile read when handling a session conflict
|
|
173
|
-
local SESSION_STEAL = 40 -- (Seconds) Time until a session conflict is resolved with the waiting server stealing the session
|
|
174
|
-
local ASSUME_DEAD = 630 -- (Seconds) If a profile hasn't had updates for this long, quickly assume an active session belongs to a crashed server
|
|
175
|
-
local START_SESSION_TIMEOUT = 120 -- (Seconds) If a session can't be started for a profile for this long, stop repeating calls to the DataStore
|
|
176
|
-
|
|
177
|
-
local CRITICAL_STATE_ERROR_COUNT = 5 -- Assume critical state if this many issues happen in a short amount of time
|
|
178
|
-
local CRITICAL_STATE_ERROR_EXPIRE = 120 -- (Seconds) Individual issue expiration
|
|
179
|
-
local CRITICAL_STATE_EXPIRE = 120 -- (Seconds) Critical state expiration
|
|
180
|
-
|
|
181
|
-
local MAX_MESSAGE_QUEUE = 1000 -- Max messages saved in a profile that were sent using "ProfileStore:MessageAsync()"
|
|
182
|
-
|
|
183
|
-
----- Dependencies -----
|
|
184
|
-
|
|
185
|
-
-- local Util = require(game.ReplicatedStorage.Shared.Util)
|
|
186
|
-
-- local Signal = Util.Signal
|
|
187
|
-
|
|
188
|
-
local Signal do
|
|
189
|
-
|
|
190
|
-
local FreeRunnerThread
|
|
191
|
-
|
|
192
|
-
--[[
|
|
193
|
-
Yield-safe coroutine reusing by stravant;
|
|
194
|
-
Sources:
|
|
195
|
-
https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063
|
|
196
|
-
https://gist.github.com/stravant/b75a322e0919d60dde8a0316d1f09d2f
|
|
197
|
-
--]]
|
|
198
|
-
|
|
199
|
-
local function AcquireRunnerThreadAndCallEventHandler(fn, ...)
|
|
200
|
-
local acquired_runner_thread = FreeRunnerThread
|
|
201
|
-
FreeRunnerThread = nil
|
|
202
|
-
fn(...)
|
|
203
|
-
-- The handler finished running, this runner thread is free again.
|
|
204
|
-
FreeRunnerThread = acquired_runner_thread
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
local function RunEventHandlerInFreeThread(...)
|
|
208
|
-
AcquireRunnerThreadAndCallEventHandler(...)
|
|
209
|
-
while true do
|
|
210
|
-
AcquireRunnerThreadAndCallEventHandler(coroutine.yield())
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
local Connection = {}
|
|
215
|
-
Connection.__index = Connection
|
|
216
|
-
|
|
217
|
-
local SignalClass = {}
|
|
218
|
-
SignalClass.__index = SignalClass
|
|
219
|
-
|
|
220
|
-
function Connection:Disconnect()
|
|
221
|
-
|
|
222
|
-
if self.is_connected == false then
|
|
223
|
-
return
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
local signal = self.signal
|
|
227
|
-
self.is_connected = false
|
|
228
|
-
signal.listener_count -= 1
|
|
229
|
-
|
|
230
|
-
if signal.head == self then
|
|
231
|
-
signal.head = self.next
|
|
232
|
-
else
|
|
233
|
-
local prev = signal.head
|
|
234
|
-
while prev ~= nil and prev.next ~= self do
|
|
235
|
-
prev = prev.next
|
|
236
|
-
end
|
|
237
|
-
if prev ~= nil then
|
|
238
|
-
prev.next = self.next
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
function SignalClass.New()
|
|
245
|
-
|
|
246
|
-
local self = {
|
|
247
|
-
head = nil,
|
|
248
|
-
listener_count = 0,
|
|
249
|
-
}
|
|
250
|
-
setmetatable(self, SignalClass)
|
|
251
|
-
|
|
252
|
-
return self
|
|
253
|
-
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
function SignalClass:Connect(listener: (...any) -> ())
|
|
257
|
-
|
|
258
|
-
if type(listener) ~= "function" then
|
|
259
|
-
error(`[{script.Name}]: \"listener\" must be a function; Received {typeof(listener)}`)
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
local connection = {
|
|
263
|
-
listener = listener,
|
|
264
|
-
signal = self,
|
|
265
|
-
next = self.head,
|
|
266
|
-
is_connected = true,
|
|
267
|
-
}
|
|
268
|
-
setmetatable(connection, Connection)
|
|
269
|
-
|
|
270
|
-
self.head = connection
|
|
271
|
-
self.listener_count += 1
|
|
272
|
-
|
|
273
|
-
return connection
|
|
274
|
-
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
function SignalClass:GetListenerCount(): number
|
|
278
|
-
return self.listener_count
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
function SignalClass:Fire(...)
|
|
282
|
-
local item = self.head
|
|
283
|
-
while item ~= nil do
|
|
284
|
-
if item.is_connected == true then
|
|
285
|
-
if not FreeRunnerThread then
|
|
286
|
-
FreeRunnerThread = coroutine.create(RunEventHandlerInFreeThread)
|
|
287
|
-
end
|
|
288
|
-
task.spawn(FreeRunnerThread, item.listener, ...)
|
|
289
|
-
end
|
|
290
|
-
item = item.next
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
function SignalClass:Wait()
|
|
295
|
-
local co = coroutine.running()
|
|
296
|
-
local connection
|
|
297
|
-
connection = self:Connect(function(...)
|
|
298
|
-
connection:Disconnect()
|
|
299
|
-
task.spawn(co, ...)
|
|
300
|
-
end)
|
|
301
|
-
return coroutine.yield()
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
Signal = table.freeze({
|
|
305
|
-
New = SignalClass.New,
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
----- Private -----
|
|
311
|
-
|
|
312
|
-
local ActiveSessionCheck = {} -- {[session_token] = profile, ...}
|
|
313
|
-
local AutoSaveList = {} -- {profile, ...} -- Loaded profile table which will be circularly auto-saved
|
|
314
|
-
local IssueQueue = {} -- {issue_time, ...}
|
|
315
|
-
|
|
316
|
-
local DataStoreService = game:GetService("DataStoreService")
|
|
317
|
-
local MessagingService = game:GetService("MessagingService")
|
|
318
|
-
local HttpService = game:GetService("HttpService")
|
|
319
|
-
local RunService = game:GetService("RunService")
|
|
320
|
-
|
|
321
|
-
local PlaceId = game.PlaceId
|
|
322
|
-
local JobId = game.JobId
|
|
323
|
-
|
|
324
|
-
local AutoSaveIndex = 1 -- Next profile to auto save
|
|
325
|
-
local LastAutoSave = os.clock()
|
|
326
|
-
|
|
327
|
-
local LoadIndex = 0
|
|
328
|
-
|
|
329
|
-
local ActiveProfileLoadJobs = 0 -- Number of active threads that are loading in profiles
|
|
330
|
-
local ActiveProfileSaveJobs = 0 -- Number of active threads that are saving profiles
|
|
331
|
-
|
|
332
|
-
local CriticalStateStart = 0 -- os.clock()
|
|
333
|
-
|
|
334
|
-
local IsStudio = RunService:IsStudio()
|
|
335
|
-
local DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access" = "NotReady"
|
|
336
|
-
|
|
337
|
-
local MockStore = {}
|
|
338
|
-
local UserMockStore = {}
|
|
339
|
-
local MockFlag = false
|
|
340
|
-
|
|
341
|
-
local OnError = Signal.New() -- (message, store_name, profile_key)
|
|
342
|
-
local OnOverwrite = Signal.New() -- (store_name, profile_key)
|
|
343
|
-
|
|
344
|
-
local UpdateQueue = { -- For stability sake, we won't do UpdateAsync calls for the same key until all previous calls finish
|
|
345
|
-
--[[
|
|
346
|
-
[session_token] = {
|
|
347
|
-
coroutine, ...
|
|
348
|
-
},
|
|
349
|
-
...
|
|
350
|
-
--]]
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
local function WaitInUpdateQueue(session_token) --> next_in_queue()
|
|
354
|
-
|
|
355
|
-
local is_first = false
|
|
356
|
-
|
|
357
|
-
if UpdateQueue[session_token] == nil then
|
|
358
|
-
is_first = true
|
|
359
|
-
UpdateQueue[session_token] = {}
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
local queue = UpdateQueue[session_token]
|
|
363
|
-
|
|
364
|
-
if is_first == false then
|
|
365
|
-
table.insert(queue, coroutine.running())
|
|
366
|
-
coroutine.yield()
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
return function()
|
|
370
|
-
local next_co = table.remove(queue, 1)
|
|
371
|
-
if next_co ~= nil then
|
|
372
|
-
coroutine.resume(next_co)
|
|
373
|
-
else
|
|
374
|
-
UpdateQueue[session_token] = nil
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
local function SessionToken(store_name, profile_key, is_mock)
|
|
381
|
-
|
|
382
|
-
local session_token = "L_" -- Live
|
|
383
|
-
|
|
384
|
-
if is_mock == true then
|
|
385
|
-
session_token = "U_" -- User mock
|
|
386
|
-
elseif DataStoreState ~= "Access" then
|
|
387
|
-
session_token = "M_" -- Mock, cause no DataStore access
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
session_token ..= store_name .. "\0" .. profile_key
|
|
391
|
-
|
|
392
|
-
return session_token
|
|
393
|
-
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
local function DeepCopyTable(t)
|
|
397
|
-
local copy = {}
|
|
398
|
-
for key, value in pairs(t) do
|
|
399
|
-
if type(value) == "table" then
|
|
400
|
-
copy[key] = DeepCopyTable(value)
|
|
401
|
-
else
|
|
402
|
-
copy[key] = value
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
return copy
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
local function ReconcileTable(target, template)
|
|
409
|
-
for k, v in pairs(template) do
|
|
410
|
-
if type(k) == "string" then -- Only string keys will be reconciled
|
|
411
|
-
if target[k] == nil then
|
|
412
|
-
if type(v) == "table" then
|
|
413
|
-
target[k] = DeepCopyTable(v)
|
|
414
|
-
else
|
|
415
|
-
target[k] = v
|
|
416
|
-
end
|
|
417
|
-
elseif type(target[k]) == "table" and type(v) == "table" then
|
|
418
|
-
ReconcileTable(target[k], v)
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
end
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
local function RegisterError(error_message, store_name, profile_key) -- Called when a DataStore API call errors
|
|
425
|
-
warn(`[{script.Name}]: DataStore API error (STORE:{store_name}; KEY:{profile_key}) - {tostring(error_message)}`)
|
|
426
|
-
table.insert(IssueQueue, os.clock()) -- Adding issue time to queue
|
|
427
|
-
OnError:Fire(tostring(error_message), store_name, profile_key)
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
local function RegisterOverwrite(store_name, profile_key) -- Called when a corrupted profile is loaded
|
|
431
|
-
warn(`[{script.Name}]: Invalid profile was overwritten (STORE:{store_name}; KEY:{profile_key})`)
|
|
432
|
-
OnOverwrite:Fire(store_name, profile_key)
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
local function NewMockDataStoreKeyInfo(params)
|
|
436
|
-
|
|
437
|
-
local version_id_string = tostring(params.VersionId or 0)
|
|
438
|
-
local meta_data = params.MetaData or {}
|
|
439
|
-
local user_ids = params.UserIds or {}
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
CreatedTime = params.CreatedTime,
|
|
443
|
-
UpdatedTime = params.UpdatedTime,
|
|
444
|
-
Version = string.rep("0", 16) .. "."
|
|
445
|
-
.. string.rep("0", 10 - string.len(version_id_string)) .. version_id_string
|
|
446
|
-
.. "." .. string.rep("0", 16) .. "." .. "01",
|
|
447
|
-
|
|
448
|
-
GetMetadata = function()
|
|
449
|
-
return DeepCopyTable(meta_data)
|
|
450
|
-
end,
|
|
451
|
-
|
|
452
|
-
GetUserIds = function()
|
|
453
|
-
return DeepCopyTable(user_ids)
|
|
454
|
-
end,
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
local function MockUpdateAsync(mock_data_store, profile_store_name, key, transform_function, is_get_call) --> loaded_data, key_info
|
|
460
|
-
|
|
461
|
-
local profile_store = mock_data_store[profile_store_name]
|
|
462
|
-
|
|
463
|
-
if profile_store == nil then
|
|
464
|
-
profile_store = {}
|
|
465
|
-
mock_data_store[profile_store_name] = profile_store
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
local epoch_time = math.floor(os.time() * 1000)
|
|
469
|
-
local mock_entry = profile_store[key]
|
|
470
|
-
local mock_entry_was_nil = false
|
|
471
|
-
|
|
472
|
-
if mock_entry == nil then
|
|
473
|
-
mock_entry_was_nil = true
|
|
474
|
-
if is_get_call ~= true then
|
|
475
|
-
mock_entry = {
|
|
476
|
-
Data = nil,
|
|
477
|
-
CreatedTime = epoch_time,
|
|
478
|
-
UpdatedTime = epoch_time,
|
|
479
|
-
VersionId = 0,
|
|
480
|
-
UserIds = {},
|
|
481
|
-
MetaData = {},
|
|
482
|
-
}
|
|
483
|
-
profile_store[key] = mock_entry
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
local mock_key_info = mock_entry_was_nil == false and NewMockDataStoreKeyInfo(mock_entry) or nil
|
|
488
|
-
|
|
489
|
-
local transform, user_ids, roblox_meta_data = transform_function(mock_entry and mock_entry.Data, mock_key_info)
|
|
490
|
-
|
|
491
|
-
if transform == nil then
|
|
492
|
-
return nil
|
|
493
|
-
else
|
|
494
|
-
if mock_entry ~= nil and is_get_call ~= true then
|
|
495
|
-
mock_entry.Data = DeepCopyTable(transform)
|
|
496
|
-
mock_entry.UserIds = DeepCopyTable(user_ids or {})
|
|
497
|
-
mock_entry.MetaData = DeepCopyTable(roblox_meta_data or {})
|
|
498
|
-
mock_entry.VersionId += 1
|
|
499
|
-
mock_entry.UpdatedTime = epoch_time
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
return DeepCopyTable(transform), mock_entry ~= nil and NewMockDataStoreKeyInfo(mock_entry) or nil
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
local function UpdateAsync(profile_store, profile_key, transform_params, is_user_mock, is_get_call, version) --> loaded_data, key_info
|
|
508
|
-
--transform_params = {
|
|
509
|
-
-- ExistingProfileHandle = function(latest_data),
|
|
510
|
-
-- MissingProfileHandle = function(latest_data),
|
|
511
|
-
-- EditProfile = function(latest_data),
|
|
512
|
-
--}
|
|
513
|
-
|
|
514
|
-
local loaded_data, key_info
|
|
515
|
-
|
|
516
|
-
local next_in_queue = WaitInUpdateQueue(SessionToken(profile_store.Name, profile_key, is_user_mock))
|
|
517
|
-
|
|
518
|
-
local success = true
|
|
519
|
-
|
|
520
|
-
local success, error_message = pcall(function()
|
|
521
|
-
local transform_function = function(latest_data)
|
|
522
|
-
|
|
523
|
-
local missing_profile = false
|
|
524
|
-
local overwritten = false
|
|
525
|
-
local global_updates = {0, {}}
|
|
526
|
-
|
|
527
|
-
if latest_data == nil then
|
|
528
|
-
|
|
529
|
-
missing_profile = true
|
|
530
|
-
|
|
531
|
-
elseif type(latest_data) ~= "table" then
|
|
532
|
-
|
|
533
|
-
missing_profile = true
|
|
534
|
-
overwritten = true
|
|
535
|
-
|
|
536
|
-
else
|
|
537
|
-
|
|
538
|
-
if type(latest_data.Data) == "table" and type(latest_data.MetaData) == "table" and type(latest_data.GlobalUpdates) == "table" then
|
|
539
|
-
|
|
540
|
-
-- Regular profile structure detected:
|
|
541
|
-
|
|
542
|
-
latest_data.WasOverwritten = false -- Must be set to false if set previously
|
|
543
|
-
global_updates = latest_data.GlobalUpdates
|
|
544
|
-
|
|
545
|
-
if transform_params.ExistingProfileHandle ~= nil then
|
|
546
|
-
transform_params.ExistingProfileHandle(latest_data)
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
elseif latest_data.Data == nil and latest_data.MetaData == nil and type(latest_data.GlobalUpdates) == "table" then
|
|
550
|
-
|
|
551
|
-
-- Regular structure not detected, but GlobalUpdate data exists:
|
|
552
|
-
|
|
553
|
-
latest_data.WasOverwritten = false -- Must be set to false if set previously
|
|
554
|
-
global_updates = latest_data.GlobalUpdates or global_updates
|
|
555
|
-
missing_profile = true
|
|
556
|
-
|
|
557
|
-
else
|
|
558
|
-
|
|
559
|
-
missing_profile = true
|
|
560
|
-
overwritten = true
|
|
561
|
-
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
-- Profile was not created or corrupted and no GlobalUpdate data exists:
|
|
567
|
-
if missing_profile == true then
|
|
568
|
-
latest_data = {
|
|
569
|
-
-- Data = nil,
|
|
570
|
-
-- MetaData = nil,
|
|
571
|
-
GlobalUpdates = global_updates,
|
|
572
|
-
}
|
|
573
|
-
if transform_params.MissingProfileHandle ~= nil then
|
|
574
|
-
transform_params.MissingProfileHandle(latest_data)
|
|
575
|
-
end
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
-- Editing profile:
|
|
579
|
-
if transform_params.EditProfile ~= nil then
|
|
580
|
-
transform_params.EditProfile(latest_data)
|
|
581
|
-
end
|
|
582
|
-
|
|
583
|
-
-- Invalid data handling (Silently override with empty profile)
|
|
584
|
-
if overwritten == true then
|
|
585
|
-
latest_data.WasOverwritten = true -- Temporary tag that will be removed on first save
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
return latest_data, latest_data.UserIds, latest_data.RobloxMetaData
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
if is_user_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
|
|
592
|
-
|
|
593
|
-
loaded_data, key_info = MockUpdateAsync(UserMockStore, profile_store.Name, profile_key, transform_function, is_get_call)
|
|
594
|
-
task.wait() -- Simulate API call yield
|
|
595
|
-
|
|
596
|
-
elseif DataStoreState ~= "Access" then -- Used when API access is disabled
|
|
597
|
-
|
|
598
|
-
loaded_data, key_info = MockUpdateAsync(MockStore, profile_store.Name, profile_key, transform_function, is_get_call)
|
|
599
|
-
task.wait() -- Simulate API call yield
|
|
600
|
-
|
|
601
|
-
else
|
|
602
|
-
|
|
603
|
-
if is_get_call == true then
|
|
604
|
-
|
|
605
|
-
if version ~= nil then
|
|
606
|
-
|
|
607
|
-
local success, error_message = pcall(function()
|
|
608
|
-
loaded_data, key_info = profile_store.data_store:GetVersionAsync(profile_key, version)
|
|
609
|
-
end)
|
|
610
|
-
|
|
611
|
-
if success == false and type(error_message) == "string" and string.find(error_message, "not valid") ~= nil then
|
|
612
|
-
warn(`[{script.Name}]: Passed version argument is not valid; Traceback:\n` .. debug.traceback())
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
else
|
|
616
|
-
|
|
617
|
-
loaded_data, key_info = profile_store.data_store:GetAsync(profile_key)
|
|
618
|
-
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
loaded_data = transform_function(loaded_data)
|
|
622
|
-
|
|
623
|
-
else
|
|
624
|
-
|
|
625
|
-
loaded_data, key_info = profile_store.data_store:UpdateAsync(profile_key, transform_function)
|
|
626
|
-
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
end)
|
|
632
|
-
|
|
633
|
-
next_in_queue()
|
|
634
|
-
|
|
635
|
-
if success == true and type(loaded_data) == "table" then
|
|
636
|
-
-- Invalid data handling:
|
|
637
|
-
if loaded_data.WasOverwritten == true and is_get_call ~= true then
|
|
638
|
-
RegisterOverwrite(
|
|
639
|
-
profile_store.Name,
|
|
640
|
-
profile_key
|
|
641
|
-
)
|
|
642
|
-
end
|
|
643
|
-
-- Return loaded_data:
|
|
644
|
-
return loaded_data, key_info
|
|
645
|
-
else
|
|
646
|
-
-- Error handling:
|
|
647
|
-
RegisterError(
|
|
648
|
-
error_message or "Undefined error",
|
|
649
|
-
profile_store.Name,
|
|
650
|
-
profile_key
|
|
651
|
-
)
|
|
652
|
-
-- Return nothing:
|
|
653
|
-
return nil
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
end
|
|
657
|
-
|
|
658
|
-
local function IsThisSession(session_tag)
|
|
659
|
-
return session_tag[1] == PlaceId and session_tag[2] == JobId
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
local function ReadMockFlag(): boolean
|
|
663
|
-
local is_mock = MockFlag
|
|
664
|
-
MockFlag = false
|
|
665
|
-
return is_mock
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
local function WaitForStoreReady(profile_store)
|
|
669
|
-
while profile_store.is_ready == false do
|
|
670
|
-
task.wait()
|
|
671
|
-
end
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
local function AddProfileToAutoSave(profile)
|
|
675
|
-
|
|
676
|
-
ActiveSessionCheck[profile.session_token] = profile
|
|
677
|
-
|
|
678
|
-
-- Add at AutoSaveIndex and move AutoSaveIndex right:
|
|
679
|
-
|
|
680
|
-
table.insert(AutoSaveList, AutoSaveIndex, profile)
|
|
681
|
-
|
|
682
|
-
if #AutoSaveList > 1 then
|
|
683
|
-
AutoSaveIndex = AutoSaveIndex + 1
|
|
684
|
-
elseif #AutoSaveList == 1 then
|
|
685
|
-
-- First profile created - make sure it doesn't get immediately auto saved:
|
|
686
|
-
LastAutoSave = os.clock()
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
local function RemoveProfileFromAutoSave(profile)
|
|
692
|
-
|
|
693
|
-
ActiveSessionCheck[profile.session_token] = nil
|
|
694
|
-
|
|
695
|
-
local auto_save_index = table.find(AutoSaveList, profile)
|
|
696
|
-
|
|
697
|
-
if auto_save_index ~= nil then
|
|
698
|
-
table.remove(AutoSaveList, auto_save_index)
|
|
699
|
-
if auto_save_index < AutoSaveIndex then
|
|
700
|
-
AutoSaveIndex = AutoSaveIndex - 1 -- Table contents were moved left before AutoSaveIndex so move AutoSaveIndex left as well
|
|
701
|
-
end
|
|
702
|
-
if AutoSaveList[AutoSaveIndex] == nil then -- AutoSaveIndex was at the end of the AutoSaveList - reset to 1
|
|
703
|
-
AutoSaveIndex = 1
|
|
704
|
-
end
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
end
|
|
708
|
-
|
|
709
|
-
local function SaveProfileAsync(profile, is_ending_session, is_overwriting, last_save_reason)
|
|
710
|
-
|
|
711
|
-
if type(profile.Data) ~= "table" then
|
|
712
|
-
error(`[{script.Name}]: Developer code likely set "Profile.Data" to a non-table value! (STORE:{profile.ProfileStore.Name}; KEY:{profile.Key})`)
|
|
713
|
-
end
|
|
714
|
-
|
|
715
|
-
profile.OnSave:Fire()
|
|
716
|
-
if is_ending_session == true then
|
|
717
|
-
profile.OnLastSave:Fire(last_save_reason or "Manual")
|
|
718
|
-
end
|
|
719
|
-
|
|
720
|
-
if is_ending_session == true and is_overwriting ~= true then
|
|
721
|
-
if profile.roblox_message_subscription ~= nil then
|
|
722
|
-
profile.roblox_message_subscription:Disconnect()
|
|
723
|
-
end
|
|
724
|
-
RemoveProfileFromAutoSave(profile)
|
|
725
|
-
profile.OnSessionEnd:Fire()
|
|
726
|
-
end
|
|
727
|
-
|
|
728
|
-
ActiveProfileSaveJobs = ActiveProfileSaveJobs + 1
|
|
729
|
-
|
|
730
|
-
-- Compare "SessionLoadCount" when writing to profile to prevent a rare case of repeat last save when the profile is loaded on the same server again
|
|
731
|
-
|
|
732
|
-
local repeat_save_flag = true -- Released Profile save calls have to repeat until they succeed
|
|
733
|
-
local exp_backoff = 1
|
|
734
|
-
|
|
735
|
-
while repeat_save_flag == true do
|
|
736
|
-
|
|
737
|
-
if is_ending_session ~= true then
|
|
738
|
-
repeat_save_flag = false
|
|
739
|
-
end
|
|
740
|
-
|
|
741
|
-
local loaded_data, key_info = UpdateAsync(
|
|
742
|
-
profile.ProfileStore,
|
|
743
|
-
profile.Key,
|
|
744
|
-
{
|
|
745
|
-
ExistingProfileHandle = nil,
|
|
746
|
-
MissingProfileHandle = nil,
|
|
747
|
-
EditProfile = function(latest_data)
|
|
748
|
-
|
|
749
|
-
-- Check if this session still owns the profile:
|
|
750
|
-
|
|
751
|
-
local session_owns_profile = false
|
|
752
|
-
|
|
753
|
-
if is_overwriting ~= true then
|
|
754
|
-
|
|
755
|
-
local active_session = latest_data.MetaData.ActiveSession
|
|
756
|
-
local session_load_count = latest_data.MetaData.SessionLoadCount
|
|
757
|
-
|
|
758
|
-
if type(active_session) == "table" then
|
|
759
|
-
session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
|
|
760
|
-
end
|
|
761
|
-
|
|
762
|
-
else
|
|
763
|
-
session_owns_profile = true
|
|
764
|
-
end
|
|
765
|
-
|
|
766
|
-
-- We may only edit the profile if this server has ownership of the profile:
|
|
767
|
-
|
|
768
|
-
if session_owns_profile == true then
|
|
769
|
-
|
|
770
|
-
-- Clear processed updates (messages):
|
|
771
|
-
|
|
772
|
-
local locked_updates = profile.locked_global_updates -- [index] = true, ...
|
|
773
|
-
local active_updates = latest_data.GlobalUpdates[2]
|
|
774
|
-
-- ProfileService module format: {{update_id, version_id, update_locked, update_data}, ...}
|
|
775
|
-
-- ProfileStore module format: {{update_id, update_data}, ...}
|
|
776
|
-
|
|
777
|
-
if next(locked_updates) ~= nil then
|
|
778
|
-
local i = 1
|
|
779
|
-
while i <= #active_updates do
|
|
780
|
-
local update = active_updates[i]
|
|
781
|
-
if locked_updates[update[1]] == true then
|
|
782
|
-
table.remove(active_updates, i)
|
|
783
|
-
else
|
|
784
|
-
i += 1
|
|
785
|
-
end
|
|
786
|
-
end
|
|
787
|
-
end
|
|
788
|
-
|
|
789
|
-
-- Save profile data:
|
|
790
|
-
|
|
791
|
-
latest_data.Data = profile.Data
|
|
792
|
-
latest_data.RobloxMetaData = profile.RobloxMetaData
|
|
793
|
-
latest_data.UserIds = profile.UserIds
|
|
794
|
-
|
|
795
|
-
if is_overwriting ~= true then
|
|
796
|
-
|
|
797
|
-
latest_data.MetaData.LastUpdate = os.time()
|
|
798
|
-
|
|
799
|
-
if is_ending_session == true then
|
|
800
|
-
latest_data.MetaData.ActiveSession = nil
|
|
801
|
-
end
|
|
802
|
-
|
|
803
|
-
else
|
|
804
|
-
|
|
805
|
-
latest_data.MetaData.ActiveSession = nil
|
|
806
|
-
latest_data.MetaData.ForceLoadSession = nil
|
|
807
|
-
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
end
|
|
811
|
-
|
|
812
|
-
end,
|
|
813
|
-
},
|
|
814
|
-
profile.is_mock
|
|
815
|
-
)
|
|
816
|
-
|
|
817
|
-
if loaded_data ~= nil and key_info ~= nil then
|
|
818
|
-
|
|
819
|
-
if is_overwriting == true then
|
|
820
|
-
break
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
repeat_save_flag = false
|
|
824
|
-
|
|
825
|
-
local active_session = loaded_data.MetaData.ActiveSession
|
|
826
|
-
local session_load_count = loaded_data.MetaData.SessionLoadCount
|
|
827
|
-
local session_owns_profile = false
|
|
828
|
-
|
|
829
|
-
if type(active_session) == "table" then
|
|
830
|
-
session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
|
|
831
|
-
end
|
|
832
|
-
|
|
833
|
-
local force_load_session = loaded_data.MetaData.ForceLoadSession
|
|
834
|
-
local force_load_pending = false
|
|
835
|
-
if type(force_load_session) == "table" then
|
|
836
|
-
force_load_pending = not IsThisSession(force_load_session)
|
|
837
|
-
end
|
|
838
|
-
|
|
839
|
-
local is_active = profile:IsActive()
|
|
840
|
-
|
|
841
|
-
-- If another server is trying to start a session for this profile - end the session:
|
|
842
|
-
|
|
843
|
-
if force_load_pending == true and session_owns_profile == true then
|
|
844
|
-
if is_active == true then
|
|
845
|
-
SaveProfileAsync(profile, true, false, "External")
|
|
846
|
-
end
|
|
847
|
-
break
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
-- Clearing processed update list / Detecting new updates:
|
|
851
|
-
|
|
852
|
-
local locked_updates = profile.locked_global_updates -- [index] = true, ...
|
|
853
|
-
local received_updates = profile.received_global_updates -- [index] = true, ...
|
|
854
|
-
local active_updates = loaded_data.GlobalUpdates[2]
|
|
855
|
-
|
|
856
|
-
local new_updates = {} -- {}, ...
|
|
857
|
-
local still_pending = {} -- [index] = true, ...
|
|
858
|
-
|
|
859
|
-
for _, update in ipairs(active_updates) do
|
|
860
|
-
if locked_updates[update[1]] == true then
|
|
861
|
-
still_pending[update[1]] = true
|
|
862
|
-
elseif received_updates[update[1]] ~= true then
|
|
863
|
-
received_updates[update[1]] = true
|
|
864
|
-
table.insert(new_updates, update)
|
|
865
|
-
end
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
for index in pairs(locked_updates) do
|
|
869
|
-
if still_pending[index] ~= true then
|
|
870
|
-
locked_updates[index] = nil
|
|
871
|
-
end
|
|
872
|
-
end
|
|
873
|
-
|
|
874
|
-
-- Updating profile values:
|
|
875
|
-
|
|
876
|
-
profile.KeyInfo = key_info
|
|
877
|
-
profile.LastSavedData = loaded_data.Data
|
|
878
|
-
profile.global_updates = loaded_data.GlobalUpdates and loaded_data.GlobalUpdates[2] or {}
|
|
879
|
-
|
|
880
|
-
if session_owns_profile == true then
|
|
881
|
-
if is_active == true and is_ending_session ~= true then
|
|
882
|
-
|
|
883
|
-
-- Processing new global updates (messages):
|
|
884
|
-
|
|
885
|
-
for _, update in ipairs(new_updates) do
|
|
886
|
-
|
|
887
|
-
local index = update[1]
|
|
888
|
-
local update_data = update[#update] -- Backwards compatibility with ProfileService
|
|
889
|
-
|
|
890
|
-
for _, handler in ipairs(profile.message_handlers) do
|
|
891
|
-
|
|
892
|
-
local is_processed = false
|
|
893
|
-
local processed_callback = function()
|
|
894
|
-
is_processed = true
|
|
895
|
-
locked_updates[index] = true
|
|
896
|
-
end
|
|
897
|
-
|
|
898
|
-
local send_update_data = DeepCopyTable(update_data)
|
|
899
|
-
|
|
900
|
-
task.spawn(handler, send_update_data, processed_callback)
|
|
901
|
-
|
|
902
|
-
if is_processed == true then
|
|
903
|
-
break
|
|
904
|
-
end
|
|
905
|
-
|
|
906
|
-
end
|
|
907
|
-
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
end
|
|
911
|
-
else
|
|
912
|
-
|
|
913
|
-
if profile.roblox_message_subscription ~= nil then
|
|
914
|
-
profile.roblox_message_subscription:Disconnect()
|
|
915
|
-
end
|
|
916
|
-
|
|
917
|
-
if is_active == true then
|
|
918
|
-
RemoveProfileFromAutoSave(profile)
|
|
919
|
-
profile.OnSessionEnd:Fire()
|
|
920
|
-
end
|
|
921
|
-
|
|
922
|
-
end
|
|
923
|
-
|
|
924
|
-
profile.OnAfterSave:Fire(profile.LastSavedData)
|
|
925
|
-
|
|
926
|
-
elseif repeat_save_flag == true then
|
|
927
|
-
|
|
928
|
-
-- DataStore call likely resulted in an error; Repeat the DataStore call shortly
|
|
929
|
-
task.wait(exp_backoff)
|
|
930
|
-
exp_backoff = math.min(if last_save_reason == "Shutdown" then 8 else 20, exp_backoff * 2)
|
|
931
|
-
|
|
932
|
-
end
|
|
933
|
-
|
|
934
|
-
end
|
|
935
|
-
|
|
936
|
-
ActiveProfileSaveJobs = ActiveProfileSaveJobs - 1
|
|
937
|
-
|
|
938
|
-
end
|
|
939
|
-
|
|
940
|
-
----- Public -----
|
|
941
|
-
|
|
942
|
-
--[[
|
|
943
|
-
Saved profile structure:
|
|
944
|
-
|
|
945
|
-
{
|
|
946
|
-
Data = {},
|
|
947
|
-
|
|
948
|
-
MetaData = {
|
|
949
|
-
ProfileCreateTime = 0,
|
|
950
|
-
SessionLoadCount = 0,
|
|
951
|
-
ActiveSession = {place_id, game_job_id, unique_session_id} / nil,
|
|
952
|
-
ForceLoadSession = {place_id, game_job_id} / nil,
|
|
953
|
-
LastUpdate = 0, -- os.time()
|
|
954
|
-
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
955
|
-
},
|
|
956
|
-
|
|
957
|
-
RobloxMetaData = {},
|
|
958
|
-
UserIds = {},
|
|
959
|
-
|
|
960
|
-
GlobalUpdates = {
|
|
961
|
-
update_index,
|
|
962
|
-
{
|
|
963
|
-
{update_index, data}, ...
|
|
964
|
-
},
|
|
965
|
-
},
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
--]]
|
|
969
|
-
|
|
970
|
-
export type JSONAcceptable = { JSONAcceptable } | { [string]: JSONAcceptable } | number | string | boolean | buffer
|
|
971
|
-
|
|
972
|
-
export type Profile<T> = {
|
|
973
|
-
Data: T & JSONAcceptable,
|
|
974
|
-
LastSavedData: T & JSONAcceptable,
|
|
975
|
-
FirstSessionTime: number,
|
|
976
|
-
SessionLoadCount: number,
|
|
977
|
-
Session: {PlaceId: number, JobId: string}?,
|
|
978
|
-
RobloxMetaData: JSONAcceptable,
|
|
979
|
-
UserIds: {number},
|
|
980
|
-
KeyInfo: DataStoreKeyInfo,
|
|
981
|
-
OnSave: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
982
|
-
OnLastSave: {Connect: (self: any, listener: (reason: "Manual" | "External" | "Shutdown") -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
983
|
-
OnSessionEnd: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
984
|
-
OnAfterSave: {Connect: (self: any, listener: (last_saved_data: T & JSONAcceptable) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
985
|
-
ProfileStore: JSONAcceptable,
|
|
986
|
-
Key: string,
|
|
987
|
-
|
|
988
|
-
IsActive: (self: any) -> (boolean),
|
|
989
|
-
Reconcile: (self: any) -> (),
|
|
990
|
-
EndSession: (self: any) -> (),
|
|
991
|
-
AddUserId: (self: any, user_id: number) -> (),
|
|
992
|
-
RemoveUserId: (self: any, user_id: number) -> (),
|
|
993
|
-
MessageHandler: (self: any, fn: (message: JSONAcceptable, processed: () -> ()) -> ()) -> (),
|
|
994
|
-
Save: (self: any) -> (),
|
|
995
|
-
SetAsync: (self: any) -> (),
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
export type VersionQuery<T> = {
|
|
999
|
-
NextAsync: (self: any) -> (Profile<T>?),
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
type ProfileStoreStandard<T> = {
|
|
1003
|
-
Name: string,
|
|
1004
|
-
StartSessionAsync: (self: any, profile_key: string, params: {Steal: boolean?}) -> (Profile<T>?),
|
|
1005
|
-
MessageAsync: (self: any, profile_key: string, message: JSONAcceptable) -> (boolean),
|
|
1006
|
-
GetAsync: (self: any, profile_key: string, version: string?) -> (Profile<T>?),
|
|
1007
|
-
VersionQuery: (self: any, profile_key: string, sort_direction: Enum.SortDirection?, min_date: DateTime | number | nil, max_date: DateTime | number | nil) -> (VersionQuery<T>),
|
|
1008
|
-
RemoveAsync: (self: any, profile_key: string) -> (boolean),
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
export type ProfileStore<T> = {
|
|
1012
|
-
Mock: ProfileStoreStandard<T>,
|
|
1013
|
-
} & ProfileStoreStandard<T>
|
|
1014
|
-
|
|
1015
|
-
type ConstantName = "AUTO_SAVE_PERIOD" | "LOAD_REPEAT_PERIOD" | "FIRST_LOAD_REPEAT" | "SESSION_STEAL"
|
|
1016
|
-
| "ASSUME_DEAD" | "START_SESSION_TIMEOUT" | "CRITICAL_STATE_ERROR_COUNT" | "CRITICAL_STATE_ERROR_EXPIRE"
|
|
1017
|
-
| "CRITICAL_STATE_EXPIRE" | "MAX_MESSAGE_QUEUE"
|
|
1018
|
-
|
|
1019
|
-
export type ProfileStoreModule = {
|
|
1020
|
-
IsClosing: boolean,
|
|
1021
|
-
IsCriticalState: boolean,
|
|
1022
|
-
OnError: {Connect: (self: any, listener: (message: string, store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1023
|
-
OnOverwrite: {Connect: (self: any, listener: (store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1024
|
-
OnCriticalToggle: {Connect: (self: any, listener: (is_critical: boolean) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1025
|
-
DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access",
|
|
1026
|
-
New: <T>(store_name: string, template: (T & JSONAcceptable)?) -> (ProfileStore<T>),
|
|
1027
|
-
SetConstant: (name: ConstantName, value: number) -> ()
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
local Profile = {}
|
|
1031
|
-
Profile.__index = Profile
|
|
1032
|
-
|
|
1033
|
-
function Profile.New(raw_data, key_info, profile_store, key, is_mock, session_token)
|
|
1034
|
-
|
|
1035
|
-
local data = raw_data.Data or {}
|
|
1036
|
-
local session = raw_data.MetaData and raw_data.MetaData.ActiveSession or nil
|
|
1037
|
-
|
|
1038
|
-
local global_updates = raw_data.GlobalUpdates and raw_data.GlobalUpdates[2] or {}
|
|
1039
|
-
local received_global_updates = {}
|
|
1040
|
-
|
|
1041
|
-
for _, update in ipairs(global_updates) do
|
|
1042
|
-
received_global_updates[update[1]] = true
|
|
1043
|
-
end
|
|
1044
|
-
|
|
1045
|
-
local self = {
|
|
1046
|
-
|
|
1047
|
-
Data = data,
|
|
1048
|
-
LastSavedData = DeepCopyTable(data),
|
|
1049
|
-
|
|
1050
|
-
FirstSessionTime = raw_data.MetaData and raw_data.MetaData.ProfileCreateTime or 0,
|
|
1051
|
-
SessionLoadCount = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
|
|
1052
|
-
Session = session and {PlaceId = session[1], JobId = session[2]},
|
|
1053
|
-
|
|
1054
|
-
RobloxMetaData = raw_data.RobloxMetaData or {},
|
|
1055
|
-
UserIds = raw_data.UserIds or {},
|
|
1056
|
-
KeyInfo = key_info,
|
|
1057
|
-
|
|
1058
|
-
OnAfterSave = Signal.New(),
|
|
1059
|
-
OnSave = Signal.New(),
|
|
1060
|
-
OnLastSave = Signal.New(),
|
|
1061
|
-
OnSessionEnd = Signal.New(),
|
|
1062
|
-
|
|
1063
|
-
ProfileStore = profile_store,
|
|
1064
|
-
Key = key,
|
|
1065
|
-
|
|
1066
|
-
load_timestamp = os.clock(),
|
|
1067
|
-
is_mock = is_mock,
|
|
1068
|
-
session_token = session_token or "",
|
|
1069
|
-
load_index = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
|
|
1070
|
-
locked_global_updates = {},
|
|
1071
|
-
received_global_updates = received_global_updates,
|
|
1072
|
-
message_handlers = {},
|
|
1073
|
-
global_updates = global_updates,
|
|
1074
|
-
|
|
1075
|
-
}
|
|
1076
|
-
setmetatable(self, Profile)
|
|
1077
|
-
|
|
1078
|
-
return self
|
|
1079
|
-
|
|
1080
|
-
end
|
|
1081
|
-
|
|
1082
|
-
function Profile:IsActive()
|
|
1083
|
-
return ActiveSessionCheck[self.session_token] == self
|
|
1084
|
-
end
|
|
1085
|
-
|
|
1086
|
-
function Profile:Reconcile()
|
|
1087
|
-
ReconcileTable(self.Data, self.ProfileStore.template)
|
|
1088
|
-
end
|
|
1089
|
-
|
|
1090
|
-
function Profile:EndSession()
|
|
1091
|
-
if self:IsActive() == true then
|
|
1092
|
-
task.spawn(SaveProfileAsync, self, true, nil, "Manual") -- Call save function in a new thread with release_from_session = true
|
|
1093
|
-
end
|
|
1094
|
-
end
|
|
1095
|
-
|
|
1096
|
-
function Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
|
|
1097
|
-
|
|
1098
|
-
if type(user_id) ~= "number" or user_id % 1 ~= 0 then
|
|
1099
|
-
warn(`[{script.Name}]: Invalid UserId argument for :AddUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
|
|
1100
|
-
return
|
|
1101
|
-
end
|
|
1102
|
-
|
|
1103
|
-
if user_id < 0 and self.is_mock ~= true and DataStoreState == "Access" then
|
|
1104
|
-
return -- Avoid giving real Roblox APIs negative UserId's
|
|
1105
|
-
end
|
|
1106
|
-
|
|
1107
|
-
if table.find(self.UserIds, user_id) == nil then
|
|
1108
|
-
table.insert(self.UserIds, user_id)
|
|
1109
|
-
end
|
|
1110
|
-
|
|
1111
|
-
end
|
|
1112
|
-
|
|
1113
|
-
function Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
|
|
1114
|
-
|
|
1115
|
-
if type(user_id) ~= "number" or user_id % 1 ~= 0 then
|
|
1116
|
-
warn(`[{script.Name}]: Invalid UserId argument for :RemoveUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
|
|
1117
|
-
return
|
|
1118
|
-
end
|
|
1119
|
-
|
|
1120
|
-
local index = table.find(self.UserIds, user_id)
|
|
1121
|
-
|
|
1122
|
-
if index ~= nil then
|
|
1123
|
-
table.remove(self.UserIds, index)
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
end
|
|
1127
|
-
|
|
1128
|
-
function Profile:SetAsync() -- Saves the profile to the DataStore and removes the session lock
|
|
1129
|
-
|
|
1130
|
-
if self.view_mode ~= true then
|
|
1131
|
-
error(`[{script.Name}]: :SetAsync() can only be used in view mode`)
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
SaveProfileAsync(self, nil, true)
|
|
1135
|
-
|
|
1136
|
-
end
|
|
1137
|
-
|
|
1138
|
-
function Profile:MessageHandler(fn)
|
|
1139
|
-
|
|
1140
|
-
if type(fn) ~= "function" then
|
|
1141
|
-
error(`[{script.Name}]: fn argument is not a function`)
|
|
1142
|
-
end
|
|
1143
|
-
|
|
1144
|
-
if self.view_mode ~= true and self:IsActive() ~= true then
|
|
1145
|
-
return -- Don't process messages if the profile session was ended
|
|
1146
|
-
end
|
|
1147
|
-
|
|
1148
|
-
local locked_updates = self.locked_global_updates
|
|
1149
|
-
table.insert(self.message_handlers, fn)
|
|
1150
|
-
|
|
1151
|
-
for _, update in ipairs(self.global_updates) do
|
|
1152
|
-
|
|
1153
|
-
local index = update[1]
|
|
1154
|
-
local update_data = update[#update] -- Backwards compatibility with ProfileService
|
|
1155
|
-
|
|
1156
|
-
if locked_updates[index] ~= true then
|
|
1157
|
-
|
|
1158
|
-
local processed_callback = function()
|
|
1159
|
-
locked_updates[index] = true
|
|
1160
|
-
end
|
|
1161
|
-
|
|
1162
|
-
local send_update_data = DeepCopyTable(update_data)
|
|
1163
|
-
|
|
1164
|
-
task.spawn(fn, send_update_data, processed_callback)
|
|
1165
|
-
|
|
1166
|
-
end
|
|
1167
|
-
|
|
1168
|
-
end
|
|
1169
|
-
|
|
1170
|
-
end
|
|
1171
|
-
|
|
1172
|
-
function Profile:Save()
|
|
1173
|
-
|
|
1174
|
-
if self.view_mode == true then
|
|
1175
|
-
error(`[{script.Name}]: Can't save profile in view mode; Should you be calling :SetAsync() instead?`)
|
|
1176
|
-
end
|
|
1177
|
-
|
|
1178
|
-
if self:IsActive() == false then
|
|
1179
|
-
warn(`[{script.Name}]: Attempted saving an inactive profile (STORE:{self.ProfileStore.Name}; KEY:{self.Key});`
|
|
1180
|
-
.. ` Traceback:\n` .. debug.traceback())
|
|
1181
|
-
return
|
|
1182
|
-
end
|
|
1183
|
-
|
|
1184
|
-
-- Move the profile right behind the auto save index to delay the next auto save for it:
|
|
1185
|
-
RemoveProfileFromAutoSave(self)
|
|
1186
|
-
AddProfileToAutoSave(self)
|
|
1187
|
-
|
|
1188
|
-
-- Perform save in new thread:
|
|
1189
|
-
task.spawn(SaveProfileAsync, self)
|
|
1190
|
-
|
|
1191
|
-
end
|
|
1192
|
-
|
|
1193
|
-
local ProfileStore: ProfileStoreModule = {
|
|
1194
|
-
|
|
1195
|
-
IsClosing = false,
|
|
1196
|
-
IsCriticalState = false,
|
|
1197
|
-
OnError = OnError, -- (message, store_name, profile_key)
|
|
1198
|
-
OnOverwrite = OnOverwrite, -- (store_name, profile_key)
|
|
1199
|
-
OnCriticalToggle = Signal.New(), -- (is_critical)
|
|
1200
|
-
DataStoreState = "NotReady", -- ("NotReady", "NoInternet", "NoAccess", "Access")
|
|
1201
|
-
|
|
1202
|
-
}
|
|
1203
|
-
ProfileStore.__index = ProfileStore
|
|
1204
|
-
|
|
1205
|
-
function ProfileStore.SetConstant(name, value)
|
|
1206
|
-
|
|
1207
|
-
if type(value) ~= "number" then
|
|
1208
|
-
error(`[{script.Name}]: Invalid value type`)
|
|
1209
|
-
end
|
|
1210
|
-
|
|
1211
|
-
if name == "AUTO_SAVE_PERIOD" then
|
|
1212
|
-
AUTO_SAVE_PERIOD = value
|
|
1213
|
-
elseif name == "LOAD_REPEAT_PERIOD" then
|
|
1214
|
-
LOAD_REPEAT_PERIOD = value
|
|
1215
|
-
elseif name == "FIRST_LOAD_REPEAT" then
|
|
1216
|
-
FIRST_LOAD_REPEAT = value
|
|
1217
|
-
elseif name == "SESSION_STEAL" then
|
|
1218
|
-
SESSION_STEAL = value
|
|
1219
|
-
elseif name == "ASSUME_DEAD" then
|
|
1220
|
-
ASSUME_DEAD = value
|
|
1221
|
-
elseif name == "START_SESSION_TIMEOUT" then
|
|
1222
|
-
START_SESSION_TIMEOUT = value
|
|
1223
|
-
elseif name == "CRITICAL_STATE_ERROR_COUNT" then
|
|
1224
|
-
CRITICAL_STATE_ERROR_COUNT = value
|
|
1225
|
-
elseif name == "CRITICAL_STATE_ERROR_EXPIRE" then
|
|
1226
|
-
CRITICAL_STATE_ERROR_EXPIRE = value
|
|
1227
|
-
elseif name == "CRITICAL_STATE_EXPIRE" then
|
|
1228
|
-
CRITICAL_STATE_EXPIRE = value
|
|
1229
|
-
elseif name == "MAX_MESSAGE_QUEUE" then
|
|
1230
|
-
MAX_MESSAGE_QUEUE = value
|
|
1231
|
-
else
|
|
1232
|
-
error(`[{script.Name}]: Invalid constant name was provided`)
|
|
1233
|
-
end
|
|
1234
|
-
|
|
1235
|
-
end
|
|
1236
|
-
|
|
1237
|
-
function ProfileStore.Test()
|
|
1238
|
-
return {
|
|
1239
|
-
ActiveSessionCheck = ActiveSessionCheck,
|
|
1240
|
-
AutoSaveList = AutoSaveList,
|
|
1241
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs,
|
|
1242
|
-
ActiveProfileSaveJobs = ActiveProfileSaveJobs,
|
|
1243
|
-
MockStore = MockStore,
|
|
1244
|
-
UserMockStore = UserMockStore,
|
|
1245
|
-
UpdateQueue = UpdateQueue,
|
|
1246
|
-
}
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
function ProfileStore.New(store_name, template)
|
|
1250
|
-
|
|
1251
|
-
template = template or {}
|
|
1252
|
-
|
|
1253
|
-
if type(store_name) ~= "string" then
|
|
1254
|
-
error(`[{script.Name}]: Invalid or missing "store_name"`)
|
|
1255
|
-
elseif string.len(store_name) == 0 then
|
|
1256
|
-
error(`[{script.Name}]: store_name cannot be an empty string`)
|
|
1257
|
-
elseif string.len(store_name) > 50 then
|
|
1258
|
-
error(`[{script.Name}]: store_name is too long`)
|
|
1259
|
-
end
|
|
1260
|
-
|
|
1261
|
-
if type(template) ~= "table" then
|
|
1262
|
-
error(`[{script.Name}]: Invalid template argument`)
|
|
1263
|
-
end
|
|
1264
|
-
|
|
1265
|
-
local self
|
|
1266
|
-
self = {
|
|
1267
|
-
|
|
1268
|
-
Mock = {
|
|
1269
|
-
|
|
1270
|
-
Name = store_name,
|
|
1271
|
-
|
|
1272
|
-
StartSessionAsync = function(_, profile_key)
|
|
1273
|
-
MockFlag = true
|
|
1274
|
-
return self:StartSessionAsync(profile_key)
|
|
1275
|
-
end,
|
|
1276
|
-
MessageAsync = function(_, profile_key, message)
|
|
1277
|
-
MockFlag = true
|
|
1278
|
-
return self:MessageAsync(profile_key, message)
|
|
1279
|
-
end,
|
|
1280
|
-
GetAsync = function(_, profile_key, version)
|
|
1281
|
-
MockFlag = true
|
|
1282
|
-
return self:GetAsync(profile_key, version)
|
|
1283
|
-
end,
|
|
1284
|
-
VersionQuery = function(_, profile_key, sort_direction, min_date, max_date)
|
|
1285
|
-
MockFlag = true
|
|
1286
|
-
return self:VersionQuery(profile_key, sort_direction, min_date, max_date)
|
|
1287
|
-
end,
|
|
1288
|
-
RemoveAsync = function(_, profile_key)
|
|
1289
|
-
MockFlag = true
|
|
1290
|
-
return self:RemoveAsync(profile_key)
|
|
1291
|
-
end
|
|
1292
|
-
},
|
|
1293
|
-
|
|
1294
|
-
Name = store_name,
|
|
1295
|
-
|
|
1296
|
-
template = template,
|
|
1297
|
-
data_store = nil,
|
|
1298
|
-
load_jobs = {},
|
|
1299
|
-
mock_load_jobs = {},
|
|
1300
|
-
is_ready = true,
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1303
|
-
setmetatable(self, ProfileStore)
|
|
1304
|
-
|
|
1305
|
-
local options = Instance.new("DataStoreOptions")
|
|
1306
|
-
options:SetExperimentalFeatures({v2 = true})
|
|
1307
|
-
|
|
1308
|
-
if DataStoreState == "NotReady" then
|
|
1309
|
-
|
|
1310
|
-
-- The module is not sure whether DataStores are accessible yet:
|
|
1311
|
-
|
|
1312
|
-
self.is_ready = false
|
|
1313
|
-
|
|
1314
|
-
task.spawn(function()
|
|
1315
|
-
|
|
1316
|
-
repeat task.wait() until DataStoreState ~= "NotReady"
|
|
1317
|
-
|
|
1318
|
-
if DataStoreState == "Access" then
|
|
1319
|
-
self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
|
|
1320
|
-
end
|
|
1321
|
-
|
|
1322
|
-
self.is_ready = true
|
|
1323
|
-
|
|
1324
|
-
end)
|
|
1325
|
-
|
|
1326
|
-
elseif DataStoreState == "Access" then
|
|
1327
|
-
|
|
1328
|
-
self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
|
|
1329
|
-
|
|
1330
|
-
end
|
|
1331
|
-
|
|
1332
|
-
return self
|
|
1333
|
-
|
|
1334
|
-
end
|
|
1335
|
-
|
|
1336
|
-
local function RobloxMessageSubscription(profile, unique_session_id)
|
|
1337
|
-
|
|
1338
|
-
local last_roblox_message = 0
|
|
1339
|
-
|
|
1340
|
-
local roblox_message_subscription = MessagingService:SubscribeAsync("PS_" .. unique_session_id, function(message)
|
|
1341
|
-
if type(message.Data) == "table" and message.Data.LoadCount == profile.SessionLoadCount then
|
|
1342
|
-
-- High reaction rate, based on numPlayers × 10 DataStore budget as of writing
|
|
1343
|
-
if os.clock() - last_roblox_message > 6 then
|
|
1344
|
-
last_roblox_message = os.clock()
|
|
1345
|
-
if profile:IsActive() == true then
|
|
1346
|
-
if message.Data.EndSession == true then
|
|
1347
|
-
SaveProfileAsync(profile, true, false, "External")
|
|
1348
|
-
else
|
|
1349
|
-
profile:Save()
|
|
1350
|
-
end
|
|
1351
|
-
end
|
|
1352
|
-
end
|
|
1353
|
-
end
|
|
1354
|
-
end)
|
|
1355
|
-
|
|
1356
|
-
if profile:IsActive() == true then
|
|
1357
|
-
profile.roblox_message_subscription = roblox_message_subscription
|
|
1358
|
-
else
|
|
1359
|
-
roblox_message_subscription:Disconnect()
|
|
1360
|
-
end
|
|
1361
|
-
|
|
1362
|
-
end
|
|
1363
|
-
|
|
1364
|
-
function ProfileStore:StartSessionAsync(profile_key, params)
|
|
1365
|
-
|
|
1366
|
-
local is_mock = ReadMockFlag()
|
|
1367
|
-
|
|
1368
|
-
if type(profile_key) ~= "string" then
|
|
1369
|
-
error(`[{script.Name}]: profile_key must be a string`)
|
|
1370
|
-
elseif string.len(profile_key) == 0 then
|
|
1371
|
-
error(`[{script.Name}]: Invalid profile_key`)
|
|
1372
|
-
elseif string.len(profile_key) > 50 then
|
|
1373
|
-
error(`[{script.Name}]: profile_key is too long`)
|
|
1374
|
-
end
|
|
1375
|
-
|
|
1376
|
-
if params ~= nil and type(params) ~= "table" then
|
|
1377
|
-
error(`[{script.Name}]: Invalid params`)
|
|
1378
|
-
end
|
|
1379
|
-
|
|
1380
|
-
params = params or {}
|
|
1381
|
-
|
|
1382
|
-
if ProfileStore.IsClosing == true then
|
|
1383
|
-
return nil
|
|
1384
|
-
end
|
|
1385
|
-
|
|
1386
|
-
WaitForStoreReady(self)
|
|
1387
|
-
|
|
1388
|
-
local session_token = SessionToken(self.Name, profile_key, is_mock)
|
|
1389
|
-
|
|
1390
|
-
if ActiveSessionCheck[session_token] ~= nil then
|
|
1391
|
-
error(`[{script.Name}]: Profile (STORE:{self.Name}; KEY:{profile_key}) is already loaded in this session`)
|
|
1392
|
-
end
|
|
1393
|
-
|
|
1394
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs + 1
|
|
1395
|
-
|
|
1396
|
-
local is_user_cancel = false
|
|
1397
|
-
|
|
1398
|
-
local function cancel_condition()
|
|
1399
|
-
if is_user_cancel == false then
|
|
1400
|
-
if params.Cancel ~= nil then
|
|
1401
|
-
is_user_cancel = params.Cancel() == true
|
|
1402
|
-
end
|
|
1403
|
-
return is_user_cancel
|
|
1404
|
-
end
|
|
1405
|
-
return true
|
|
1406
|
-
end
|
|
1407
|
-
|
|
1408
|
-
local user_steal = params.Steal == true
|
|
1409
|
-
|
|
1410
|
-
local force_load_steps = 0 -- Session conflict handling values
|
|
1411
|
-
local request_force_load = true
|
|
1412
|
-
local steal_session = false
|
|
1413
|
-
|
|
1414
|
-
local start = os.clock()
|
|
1415
|
-
local exp_backoff = 1
|
|
1416
|
-
|
|
1417
|
-
while ProfileStore.IsClosing == false and cancel_condition() == false do
|
|
1418
|
-
|
|
1419
|
-
-- Load profile:
|
|
1420
|
-
|
|
1421
|
-
-- SPECIAL CASE - If StartSessionAsync is called for the same key again before another StartSessionAsync finishes,
|
|
1422
|
-
-- grab the DataStore return for the new call. The early call will return nil. This is supposed to retain
|
|
1423
|
-
-- expected and efficient behavior in cases where a player would quickly rejoin the same server.
|
|
1424
|
-
|
|
1425
|
-
LoadIndex += 1
|
|
1426
|
-
local load_id = LoadIndex
|
|
1427
|
-
local profile_load_jobs = is_mock == true and self.mock_load_jobs or self.load_jobs
|
|
1428
|
-
local profile_load_job = profile_load_jobs[profile_key] -- {load_id, {loaded_data, key_info} or nil}
|
|
1429
|
-
|
|
1430
|
-
local loaded_data, key_info
|
|
1431
|
-
local unique_session_id = HttpService:GenerateGUID(false)
|
|
1432
|
-
|
|
1433
|
-
if profile_load_job ~= nil then
|
|
1434
|
-
|
|
1435
|
-
profile_load_job[1] = load_id -- Steal load job
|
|
1436
|
-
while profile_load_job[2] == nil do -- Wait for job to finish
|
|
1437
|
-
task.wait()
|
|
1438
|
-
end
|
|
1439
|
-
if profile_load_job[1] == load_id then -- Load job hasn't been double-stolen
|
|
1440
|
-
loaded_data, key_info = table.unpack(profile_load_job[2])
|
|
1441
|
-
profile_load_jobs[profile_key] = nil
|
|
1442
|
-
else
|
|
1443
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1444
|
-
return nil
|
|
1445
|
-
end
|
|
1446
|
-
|
|
1447
|
-
else
|
|
1448
|
-
|
|
1449
|
-
profile_load_job = {load_id, nil}
|
|
1450
|
-
profile_load_jobs[profile_key] = profile_load_job
|
|
1451
|
-
|
|
1452
|
-
profile_load_job[2] = table.pack(UpdateAsync(
|
|
1453
|
-
self,
|
|
1454
|
-
profile_key,
|
|
1455
|
-
{
|
|
1456
|
-
ExistingProfileHandle = function(latest_data)
|
|
1457
|
-
|
|
1458
|
-
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1459
|
-
return
|
|
1460
|
-
end
|
|
1461
|
-
|
|
1462
|
-
local active_session = latest_data.MetaData.ActiveSession
|
|
1463
|
-
local force_load_session = latest_data.MetaData.ForceLoadSession
|
|
1464
|
-
|
|
1465
|
-
if active_session == nil then
|
|
1466
|
-
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1467
|
-
latest_data.MetaData.ForceLoadSession = nil
|
|
1468
|
-
elseif type(active_session) == "table" then
|
|
1469
|
-
if IsThisSession(active_session) == false then
|
|
1470
|
-
local last_update = latest_data.MetaData.LastUpdate
|
|
1471
|
-
if last_update ~= nil then
|
|
1472
|
-
if os.time() - last_update > ASSUME_DEAD then
|
|
1473
|
-
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1474
|
-
latest_data.MetaData.ForceLoadSession = nil
|
|
1475
|
-
return
|
|
1476
|
-
end
|
|
1477
|
-
end
|
|
1478
|
-
if steal_session == true or user_steal == true then
|
|
1479
|
-
local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
|
|
1480
|
-
if force_load_interrupted == false or user_steal == true then
|
|
1481
|
-
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1482
|
-
latest_data.MetaData.ForceLoadSession = nil
|
|
1483
|
-
end
|
|
1484
|
-
elseif request_force_load == true then
|
|
1485
|
-
latest_data.MetaData.ForceLoadSession = {PlaceId, JobId}
|
|
1486
|
-
end
|
|
1487
|
-
else
|
|
1488
|
-
latest_data.MetaData.ForceLoadSession = nil
|
|
1489
|
-
end
|
|
1490
|
-
end
|
|
1491
|
-
|
|
1492
|
-
end,
|
|
1493
|
-
MissingProfileHandle = function(latest_data)
|
|
1494
|
-
|
|
1495
|
-
local is_cancel = ProfileStore.IsClosing == true or cancel_condition() == true
|
|
1496
|
-
|
|
1497
|
-
latest_data.Data = DeepCopyTable(self.template)
|
|
1498
|
-
latest_data.MetaData = {
|
|
1499
|
-
ProfileCreateTime = os.time(),
|
|
1500
|
-
SessionLoadCount = 0,
|
|
1501
|
-
ActiveSession = if is_cancel == false then {PlaceId, JobId, unique_session_id} else nil,
|
|
1502
|
-
ForceLoadSession = nil,
|
|
1503
|
-
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
end,
|
|
1507
|
-
EditProfile = function(latest_data)
|
|
1508
|
-
|
|
1509
|
-
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1510
|
-
return
|
|
1511
|
-
end
|
|
1512
|
-
|
|
1513
|
-
local active_session = latest_data.MetaData.ActiveSession
|
|
1514
|
-
if active_session ~= nil and IsThisSession(active_session) == true then
|
|
1515
|
-
latest_data.MetaData.SessionLoadCount = latest_data.MetaData.SessionLoadCount + 1
|
|
1516
|
-
latest_data.MetaData.LastUpdate = os.time()
|
|
1517
|
-
end
|
|
1518
|
-
|
|
1519
|
-
end,
|
|
1520
|
-
},
|
|
1521
|
-
is_mock
|
|
1522
|
-
))
|
|
1523
|
-
if profile_load_job[1] == load_id then -- Load job hasn't been stolen
|
|
1524
|
-
loaded_data, key_info = table.unpack(profile_load_job[2])
|
|
1525
|
-
profile_load_jobs[profile_key] = nil
|
|
1526
|
-
else
|
|
1527
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1528
|
-
return nil -- Load job stolen
|
|
1529
|
-
end
|
|
1530
|
-
end
|
|
1531
|
-
|
|
1532
|
-
-- Handle load_data:
|
|
1533
|
-
|
|
1534
|
-
if loaded_data ~= nil and key_info ~= nil then
|
|
1535
|
-
local active_session = loaded_data.MetaData.ActiveSession
|
|
1536
|
-
if type(active_session) == "table" then
|
|
1537
|
-
|
|
1538
|
-
if IsThisSession(active_session) == true then
|
|
1539
|
-
|
|
1540
|
-
-- Profile is now taken by this session:
|
|
1541
|
-
|
|
1542
|
-
local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock, session_token)
|
|
1543
|
-
AddProfileToAutoSave(profile)
|
|
1544
|
-
|
|
1545
|
-
if is_mock ~= true and DataStoreState == "Access" then
|
|
1546
|
-
|
|
1547
|
-
-- Use MessagingService to quickly detect session conflicts and resolve them quickly:
|
|
1548
|
-
task.spawn(RobloxMessageSubscription, profile, unique_session_id) -- Blocking prevention
|
|
1549
|
-
|
|
1550
|
-
end
|
|
1551
|
-
|
|
1552
|
-
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1553
|
-
-- The server has initiated a shutdown by the time this profile was loaded
|
|
1554
|
-
SaveProfileAsync(profile, true) -- Release profile and yield until the DataStore call is finished
|
|
1555
|
-
profile = nil -- Don't return the profile object
|
|
1556
|
-
end
|
|
1557
|
-
|
|
1558
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1559
|
-
return profile
|
|
1560
|
-
|
|
1561
|
-
else
|
|
1562
|
-
|
|
1563
|
-
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1564
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1565
|
-
return nil
|
|
1566
|
-
end
|
|
1567
|
-
|
|
1568
|
-
-- Profile is taken by some other session:
|
|
1569
|
-
|
|
1570
|
-
local force_load_session = loaded_data.MetaData.ForceLoadSession
|
|
1571
|
-
local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
|
|
1572
|
-
|
|
1573
|
-
if force_load_interrupted == false then
|
|
1574
|
-
|
|
1575
|
-
if request_force_load == false then
|
|
1576
|
-
force_load_steps = force_load_steps + 1
|
|
1577
|
-
if force_load_steps >= math.ceil(SESSION_STEAL / LOAD_REPEAT_PERIOD) then
|
|
1578
|
-
steal_session = true
|
|
1579
|
-
end
|
|
1580
|
-
end
|
|
1581
|
-
|
|
1582
|
-
-- Request the remote server to end its session:
|
|
1583
|
-
if type(active_session[3]) == "string" then
|
|
1584
|
-
local session_load_count = loaded_data.MetaData.SessionLoadCount or 0
|
|
1585
|
-
task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count, EndSession = true})
|
|
1586
|
-
end
|
|
1587
|
-
|
|
1588
|
-
-- Attempt to load the profile again after a delay
|
|
1589
|
-
local wait_until = os.clock() + if request_force_load == true then FIRST_LOAD_REPEAT else LOAD_REPEAT_PERIOD
|
|
1590
|
-
repeat task.wait() until os.clock() >= wait_until or ProfileStore.IsClosing == true
|
|
1591
|
-
|
|
1592
|
-
else
|
|
1593
|
-
-- Another session tried to load this profile:
|
|
1594
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1595
|
-
return nil
|
|
1596
|
-
end
|
|
1597
|
-
|
|
1598
|
-
request_force_load = false -- Only request a force load once
|
|
1599
|
-
|
|
1600
|
-
end
|
|
1601
|
-
|
|
1602
|
-
else
|
|
1603
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1604
|
-
return nil -- In this scenario it is likely that this server started shutting down
|
|
1605
|
-
end
|
|
1606
|
-
else
|
|
1607
|
-
|
|
1608
|
-
-- A DataStore call has likely ended in an error:
|
|
1609
|
-
|
|
1610
|
-
local default_timeout = false
|
|
1611
|
-
|
|
1612
|
-
if params.Cancel == nil then
|
|
1613
|
-
default_timeout = os.clock() - start >= START_SESSION_TIMEOUT
|
|
1614
|
-
end
|
|
1615
|
-
|
|
1616
|
-
if default_timeout == true or ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1617
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1618
|
-
return nil
|
|
1619
|
-
end
|
|
1620
|
-
|
|
1621
|
-
task.wait(exp_backoff) -- Repeat the call shortly
|
|
1622
|
-
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1623
|
-
|
|
1624
|
-
end
|
|
1625
|
-
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1629
|
-
return nil -- Game started shutting down or the request was cancelled - don't return the profile
|
|
1630
|
-
|
|
1631
|
-
end
|
|
1632
|
-
|
|
1633
|
-
function ProfileStore:MessageAsync(profile_key, message)
|
|
1634
|
-
|
|
1635
|
-
local is_mock = ReadMockFlag()
|
|
1636
|
-
|
|
1637
|
-
if type(profile_key) ~= "string" then
|
|
1638
|
-
error(`[{script.Name}]: profile_key must be a string`)
|
|
1639
|
-
elseif string.len(profile_key) == 0 then
|
|
1640
|
-
error(`[{script.Name}]: Invalid profile_key`)
|
|
1641
|
-
elseif string.len(profile_key) > 50 then
|
|
1642
|
-
error(`[{script.Name}]: profile_key is too long`)
|
|
1643
|
-
end
|
|
1644
|
-
|
|
1645
|
-
if type(message) ~= "table" then
|
|
1646
|
-
error(`[{script.Name}]: message must be a table`)
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
|
-
if ProfileStore.IsClosing == true then
|
|
1650
|
-
return false
|
|
1651
|
-
end
|
|
1652
|
-
|
|
1653
|
-
WaitForStoreReady(self)
|
|
1654
|
-
|
|
1655
|
-
local exp_backoff = 1
|
|
1656
|
-
|
|
1657
|
-
while ProfileStore.IsClosing == false do
|
|
1658
|
-
|
|
1659
|
-
-- Updating profile:
|
|
1660
|
-
|
|
1661
|
-
local loaded_data = UpdateAsync(
|
|
1662
|
-
self,
|
|
1663
|
-
profile_key,
|
|
1664
|
-
{
|
|
1665
|
-
ExistingProfileHandle = nil,
|
|
1666
|
-
MissingProfileHandle = nil,
|
|
1667
|
-
EditProfile = function(latest_data)
|
|
1668
|
-
|
|
1669
|
-
local global_updates = latest_data.GlobalUpdates
|
|
1670
|
-
local update_list = global_updates[2]
|
|
1671
|
-
--{
|
|
1672
|
-
-- update_index,
|
|
1673
|
-
-- {
|
|
1674
|
-
-- {update_index, data}, ...
|
|
1675
|
-
-- },
|
|
1676
|
-
--},
|
|
1677
|
-
|
|
1678
|
-
global_updates[1] += 1
|
|
1679
|
-
table.insert(update_list, {global_updates[1], message})
|
|
1680
|
-
|
|
1681
|
-
-- Clearing queue if above limit:
|
|
1682
|
-
|
|
1683
|
-
while #update_list > MAX_MESSAGE_QUEUE do
|
|
1684
|
-
table.remove(update_list, 1)
|
|
1685
|
-
end
|
|
1686
|
-
|
|
1687
|
-
end,
|
|
1688
|
-
},
|
|
1689
|
-
is_mock
|
|
1690
|
-
)
|
|
1691
|
-
|
|
1692
|
-
if loaded_data ~= nil then
|
|
1693
|
-
|
|
1694
|
-
local session_token = SessionToken(self.Name, profile_key, is_mock)
|
|
1695
|
-
|
|
1696
|
-
local profile = ActiveSessionCheck[session_token]
|
|
1697
|
-
|
|
1698
|
-
if profile ~= nil then
|
|
1699
|
-
|
|
1700
|
-
-- The message was sent to a profile that is active in this server:
|
|
1701
|
-
profile:Save()
|
|
1702
|
-
|
|
1703
|
-
else
|
|
1704
|
-
|
|
1705
|
-
local meta_data = loaded_data.MetaData or {}
|
|
1706
|
-
local active_session = meta_data.ActiveSession
|
|
1707
|
-
local session_load_count = meta_data.SessionLoadCount or 0
|
|
1708
|
-
|
|
1709
|
-
if type(active_session) == "table" and type(active_session[3]) == "string" then
|
|
1710
|
-
-- Request the remote server to auto-save sooner and receive the message:
|
|
1711
|
-
task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count})
|
|
1712
|
-
end
|
|
1713
|
-
|
|
1714
|
-
end
|
|
1715
|
-
|
|
1716
|
-
return true
|
|
1717
|
-
|
|
1718
|
-
else
|
|
1719
|
-
|
|
1720
|
-
task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
|
|
1721
|
-
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1722
|
-
|
|
1723
|
-
end
|
|
1724
|
-
|
|
1725
|
-
end
|
|
1726
|
-
|
|
1727
|
-
return false
|
|
1728
|
-
|
|
1729
|
-
end
|
|
1730
|
-
|
|
1731
|
-
function ProfileStore:GetAsync(profile_key, version)
|
|
1732
|
-
|
|
1733
|
-
local is_mock = ReadMockFlag()
|
|
1734
|
-
|
|
1735
|
-
if type(profile_key) ~= "string" then
|
|
1736
|
-
error(`[{script.Name}]: profile_key must be a string`)
|
|
1737
|
-
elseif string.len(profile_key) == 0 then
|
|
1738
|
-
error(`[{script.Name}]: Invalid profile_key`)
|
|
1739
|
-
elseif string.len(profile_key) > 50 then
|
|
1740
|
-
error(`[{script.Name}]: profile_key is too long`)
|
|
1741
|
-
end
|
|
1742
|
-
|
|
1743
|
-
if ProfileStore.IsClosing == true then
|
|
1744
|
-
return nil
|
|
1745
|
-
end
|
|
1746
|
-
|
|
1747
|
-
WaitForStoreReady(self)
|
|
1748
|
-
|
|
1749
|
-
if version ~= nil and (is_mock or DataStoreState ~= "Access") then
|
|
1750
|
-
return nil -- No version support in mock mode
|
|
1751
|
-
end
|
|
1752
|
-
|
|
1753
|
-
local exp_backoff = 1
|
|
1754
|
-
|
|
1755
|
-
while ProfileStore.IsClosing == false do
|
|
1756
|
-
|
|
1757
|
-
-- Load profile:
|
|
1758
|
-
|
|
1759
|
-
local loaded_data, key_info = UpdateAsync(
|
|
1760
|
-
self,
|
|
1761
|
-
profile_key,
|
|
1762
|
-
{
|
|
1763
|
-
ExistingProfileHandle = nil,
|
|
1764
|
-
MissingProfileHandle = function(latest_data)
|
|
1765
|
-
|
|
1766
|
-
latest_data.Data = DeepCopyTable(self.template)
|
|
1767
|
-
latest_data.MetaData = {
|
|
1768
|
-
ProfileCreateTime = os.time(),
|
|
1769
|
-
SessionLoadCount = 0,
|
|
1770
|
-
ActiveSession = nil,
|
|
1771
|
-
ForceLoadSession = nil,
|
|
1772
|
-
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
end,
|
|
1776
|
-
EditProfile = nil,
|
|
1777
|
-
},
|
|
1778
|
-
is_mock,
|
|
1779
|
-
true, -- Use :GetAsync()
|
|
1780
|
-
version -- DataStore key version
|
|
1781
|
-
)
|
|
1782
|
-
|
|
1783
|
-
-- Handle load_data:
|
|
1784
|
-
|
|
1785
|
-
if loaded_data ~= nil then
|
|
1786
|
-
|
|
1787
|
-
if key_info == nil then
|
|
1788
|
-
return nil -- Load was successful, but the key was empty - return no profile object
|
|
1789
|
-
end
|
|
1790
|
-
|
|
1791
|
-
local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock)
|
|
1792
|
-
profile.view_mode = true
|
|
1793
|
-
|
|
1794
|
-
return profile
|
|
1795
|
-
|
|
1796
|
-
else
|
|
1797
|
-
|
|
1798
|
-
task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
|
|
1799
|
-
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1800
|
-
|
|
1801
|
-
end
|
|
1802
|
-
|
|
1803
|
-
end
|
|
1804
|
-
|
|
1805
|
-
return nil -- Game started shutting down - don't return the profile
|
|
1806
|
-
|
|
1807
|
-
end
|
|
1808
|
-
|
|
1809
|
-
function ProfileStore:RemoveAsync(profile_key)
|
|
1810
|
-
|
|
1811
|
-
local is_mock = ReadMockFlag()
|
|
1812
|
-
|
|
1813
|
-
if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
|
|
1814
|
-
error(`[{script.Name}]: Invalid profile_key`)
|
|
1815
|
-
end
|
|
1816
|
-
|
|
1817
|
-
if ProfileStore.IsClosing == true then
|
|
1818
|
-
return false
|
|
1819
|
-
end
|
|
1820
|
-
|
|
1821
|
-
WaitForStoreReady(self)
|
|
1822
|
-
|
|
1823
|
-
local wipe_status = false
|
|
1824
|
-
|
|
1825
|
-
local next_in_queue = WaitInUpdateQueue(SessionToken(self.Name, profile_key, is_mock))
|
|
1826
|
-
|
|
1827
|
-
if is_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
|
|
1828
|
-
|
|
1829
|
-
local mock_data_store = UserMockStore[self.Name]
|
|
1830
|
-
|
|
1831
|
-
if mock_data_store ~= nil then
|
|
1832
|
-
mock_data_store[profile_key] = nil
|
|
1833
|
-
if next(mock_data_store) == nil then
|
|
1834
|
-
UserMockStore[self.Name] = nil
|
|
1835
|
-
end
|
|
1836
|
-
end
|
|
1837
|
-
|
|
1838
|
-
wipe_status = true
|
|
1839
|
-
task.wait() -- Simulate API call yield
|
|
1840
|
-
|
|
1841
|
-
elseif DataStoreState ~= "Access" then -- Used when API access is disabled
|
|
1842
|
-
|
|
1843
|
-
local mock_data_store = MockStore[self.Name]
|
|
1844
|
-
|
|
1845
|
-
if mock_data_store ~= nil then
|
|
1846
|
-
mock_data_store[profile_key] = nil
|
|
1847
|
-
if next(mock_data_store) == nil then
|
|
1848
|
-
MockStore[self.Name] = nil
|
|
1849
|
-
end
|
|
1850
|
-
end
|
|
1851
|
-
|
|
1852
|
-
wipe_status = true
|
|
1853
|
-
task.wait() -- Simulate API call yield
|
|
1854
|
-
|
|
1855
|
-
else -- Live DataStore
|
|
1856
|
-
|
|
1857
|
-
wipe_status = pcall(function()
|
|
1858
|
-
self.data_store:RemoveAsync(profile_key)
|
|
1859
|
-
end)
|
|
1860
|
-
|
|
1861
|
-
end
|
|
1862
|
-
|
|
1863
|
-
next_in_queue()
|
|
1864
|
-
|
|
1865
|
-
return wipe_status
|
|
1866
|
-
|
|
1867
|
-
end
|
|
1868
|
-
|
|
1869
|
-
local ProfileVersionQuery = {}
|
|
1870
|
-
ProfileVersionQuery.__index = ProfileVersionQuery
|
|
1871
|
-
|
|
1872
|
-
function ProfileVersionQuery.New(profile_store, profile_key, sort_direction, min_date, max_date, is_mock)
|
|
1873
|
-
|
|
1874
|
-
local self = {
|
|
1875
|
-
profile_store = profile_store,
|
|
1876
|
-
profile_key = profile_key,
|
|
1877
|
-
sort_direction = sort_direction,
|
|
1878
|
-
min_date = min_date,
|
|
1879
|
-
max_date = max_date,
|
|
1880
|
-
|
|
1881
|
-
query_pages = nil,
|
|
1882
|
-
query_index = 0,
|
|
1883
|
-
query_failure = false,
|
|
1884
|
-
|
|
1885
|
-
is_query_yielded = false,
|
|
1886
|
-
query_queue = {},
|
|
1887
|
-
|
|
1888
|
-
is_mock = is_mock,
|
|
1889
|
-
}
|
|
1890
|
-
setmetatable(self, ProfileVersionQuery)
|
|
1891
|
-
|
|
1892
|
-
return self
|
|
1893
|
-
|
|
1894
|
-
end
|
|
1895
|
-
|
|
1896
|
-
function MoveVersionQueryQueue(self) -- Hidden ProfileVersionQuery method
|
|
1897
|
-
while #self.query_queue > 0 do
|
|
1898
|
-
|
|
1899
|
-
local queue_entry = table.remove(self.query_queue, 1)
|
|
1900
|
-
|
|
1901
|
-
task.spawn(queue_entry)
|
|
1902
|
-
|
|
1903
|
-
if self.is_query_yielded == true then
|
|
1904
|
-
break
|
|
1905
|
-
end
|
|
1906
|
-
|
|
1907
|
-
end
|
|
1908
|
-
end
|
|
1909
|
-
|
|
1910
|
-
local VersionQueryNextAsyncStackingFlag = false
|
|
1911
|
-
local WarnAboutVersionQueryOnce = false
|
|
1912
|
-
|
|
1913
|
-
function ProfileVersionQuery:NextAsync()
|
|
1914
|
-
|
|
1915
|
-
local is_stacking = VersionQueryNextAsyncStackingFlag == true
|
|
1916
|
-
VersionQueryNextAsyncStackingFlag = false
|
|
1917
|
-
|
|
1918
|
-
WaitForStoreReady(self.profile_store)
|
|
1919
|
-
|
|
1920
|
-
if ProfileStore.IsClosing == true then
|
|
1921
|
-
return nil -- Silently fail :NextAsync() requests
|
|
1922
|
-
end
|
|
1923
|
-
|
|
1924
|
-
if self.is_mock == true or DataStoreState ~= "Access" then
|
|
1925
|
-
if IsStudio == true and WarnAboutVersionQueryOnce == false then
|
|
1926
|
-
WarnAboutVersionQueryOnce = true
|
|
1927
|
-
warn(`[{script.Name}]: :VersionQuery() is not supported in mock mode!`)
|
|
1928
|
-
end
|
|
1929
|
-
return nil -- Silently fail :NextAsync() requests
|
|
1930
|
-
end
|
|
1931
|
-
|
|
1932
|
-
local profile
|
|
1933
|
-
local is_finished = false
|
|
1934
|
-
|
|
1935
|
-
local function query_job()
|
|
1936
|
-
|
|
1937
|
-
if self.query_failure == true then
|
|
1938
|
-
is_finished = true
|
|
1939
|
-
return
|
|
1940
|
-
end
|
|
1941
|
-
|
|
1942
|
-
-- First "next" call loads version pages:
|
|
1943
|
-
|
|
1944
|
-
if self.query_pages == nil then
|
|
1945
|
-
|
|
1946
|
-
self.is_query_yielded = true
|
|
1947
|
-
|
|
1948
|
-
task.spawn(function()
|
|
1949
|
-
VersionQueryNextAsyncStackingFlag = true
|
|
1950
|
-
profile = self:NextAsync()
|
|
1951
|
-
is_finished = true
|
|
1952
|
-
end)
|
|
1953
|
-
|
|
1954
|
-
local list_success, error_message = pcall(function()
|
|
1955
|
-
self.query_pages = self.profile_store.data_store:ListVersionsAsync(
|
|
1956
|
-
self.profile_key,
|
|
1957
|
-
self.sort_direction,
|
|
1958
|
-
self.min_date,
|
|
1959
|
-
self.max_date
|
|
1960
|
-
)
|
|
1961
|
-
self.query_index = 0
|
|
1962
|
-
end)
|
|
1963
|
-
|
|
1964
|
-
if list_success == false or self.query_pages == nil then
|
|
1965
|
-
warn(`[{script.Name}]: Version query fail - {tostring(error_message)}`)
|
|
1966
|
-
self.query_failure = true
|
|
1967
|
-
end
|
|
1968
|
-
|
|
1969
|
-
self.is_query_yielded = false
|
|
1970
|
-
|
|
1971
|
-
MoveVersionQueryQueue(self)
|
|
1972
|
-
|
|
1973
|
-
return
|
|
1974
|
-
|
|
1975
|
-
end
|
|
1976
|
-
|
|
1977
|
-
local current_page = self.query_pages:GetCurrentPage()
|
|
1978
|
-
local next_item = current_page[self.query_index + 1]
|
|
1979
|
-
|
|
1980
|
-
-- No more entries:
|
|
1981
|
-
|
|
1982
|
-
if self.query_pages.IsFinished == true and next_item == nil then
|
|
1983
|
-
is_finished = true
|
|
1984
|
-
return
|
|
1985
|
-
end
|
|
1986
|
-
|
|
1987
|
-
-- Load next page when this page is over:
|
|
1988
|
-
|
|
1989
|
-
if next_item == nil then
|
|
1990
|
-
|
|
1991
|
-
self.is_query_yielded = true
|
|
1992
|
-
task.spawn(function()
|
|
1993
|
-
VersionQueryNextAsyncStackingFlag = true
|
|
1994
|
-
profile = self:NextAsync()
|
|
1995
|
-
is_finished = true
|
|
1996
|
-
end)
|
|
1997
|
-
|
|
1998
|
-
local success, error_message = pcall(function()
|
|
1999
|
-
self.query_pages:AdvanceToNextPageAsync()
|
|
2000
|
-
self.query_index = 0
|
|
2001
|
-
end)
|
|
2002
|
-
|
|
2003
|
-
if success == false or #self.query_pages:GetCurrentPage() == 0 then
|
|
2004
|
-
self.query_failure = true
|
|
2005
|
-
end
|
|
2006
|
-
|
|
2007
|
-
self.is_query_yielded = false
|
|
2008
|
-
MoveVersionQueryQueue(self)
|
|
2009
|
-
|
|
2010
|
-
return
|
|
2011
|
-
|
|
2012
|
-
end
|
|
2013
|
-
|
|
2014
|
-
-- Next page item:
|
|
2015
|
-
|
|
2016
|
-
self.query_index += 1
|
|
2017
|
-
profile = self.profile_store:GetAsync(self.profile_key, next_item.Version)
|
|
2018
|
-
is_finished = true
|
|
2019
|
-
|
|
2020
|
-
end
|
|
2021
|
-
|
|
2022
|
-
if self.is_query_yielded == false then
|
|
2023
|
-
query_job()
|
|
2024
|
-
else
|
|
2025
|
-
if is_stacking == true then
|
|
2026
|
-
table.insert(self.query_queue, 1, query_job)
|
|
2027
|
-
else
|
|
2028
|
-
table.insert(self.query_queue, query_job)
|
|
2029
|
-
end
|
|
2030
|
-
end
|
|
2031
|
-
|
|
2032
|
-
while is_finished == false do
|
|
2033
|
-
task.wait()
|
|
2034
|
-
end
|
|
2035
|
-
|
|
2036
|
-
return profile
|
|
2037
|
-
|
|
2038
|
-
end
|
|
2039
|
-
|
|
2040
|
-
function ProfileStore:VersionQuery(profile_key, sort_direction, min_date, max_date)
|
|
2041
|
-
|
|
2042
|
-
local is_mock = ReadMockFlag()
|
|
2043
|
-
|
|
2044
|
-
if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
|
|
2045
|
-
error(`[{script.Name}]: Invalid profile_key`)
|
|
2046
|
-
end
|
|
2047
|
-
|
|
2048
|
-
-- Type check:
|
|
2049
|
-
|
|
2050
|
-
if sort_direction ~= nil and (typeof(sort_direction) ~= "EnumItem"
|
|
2051
|
-
or sort_direction.EnumType ~= Enum.SortDirection) then
|
|
2052
|
-
error(`[{script.Name}]: Invalid sort_direction ({tostring(sort_direction)})`)
|
|
2053
|
-
end
|
|
2054
|
-
|
|
2055
|
-
if min_date ~= nil and typeof(min_date) ~= "DateTime" and typeof(min_date) ~= "number" then
|
|
2056
|
-
error(`[{script.Name}]: Invalid min_date ({tostring(min_date)})`)
|
|
2057
|
-
end
|
|
2058
|
-
|
|
2059
|
-
if max_date ~= nil and typeof(max_date) ~= "DateTime" and typeof(max_date) ~= "number" then
|
|
2060
|
-
error(`[{script.Name}]: Invalid max_date ({tostring(max_date)})`)
|
|
2061
|
-
end
|
|
2062
|
-
|
|
2063
|
-
min_date = typeof(min_date) == "DateTime" and min_date.UnixTimestampMillis or min_date
|
|
2064
|
-
max_date = typeof(max_date) == "DateTime" and max_date.UnixTimestampMillis or max_date
|
|
2065
|
-
|
|
2066
|
-
return ProfileVersionQuery.New(self, profile_key, sort_direction, min_date, max_date, is_mock)
|
|
2067
|
-
|
|
2068
|
-
end
|
|
2069
|
-
|
|
2070
|
-
-- DataStore API access check:
|
|
2071
|
-
|
|
2072
|
-
if IsStudio == true then
|
|
2073
|
-
|
|
2074
|
-
task.spawn(function()
|
|
2075
|
-
|
|
2076
|
-
local new_state = "NoAccess"
|
|
2077
|
-
|
|
2078
|
-
local status, message = pcall(function()
|
|
2079
|
-
-- This will error if current instance has no Studio API access:
|
|
2080
|
-
DataStoreService:GetDataStore("____PS"):SetAsync("____PS", os.time())
|
|
2081
|
-
end)
|
|
2082
|
-
|
|
2083
|
-
local no_internet_access = status == false and string.find(message, "ConnectFail", 1, true) ~= nil
|
|
2084
|
-
|
|
2085
|
-
if no_internet_access == true then
|
|
2086
|
-
warn(`[{script.Name}]: No internet access - check your network connection`)
|
|
2087
|
-
end
|
|
2088
|
-
|
|
2089
|
-
if status == false and
|
|
2090
|
-
(string.find(message, "403", 1, true) ~= nil or -- Cannot write to DataStore from studio if API access is not enabled
|
|
2091
|
-
string.find(message, "must publish", 1, true) ~= nil or -- Game must be published to access live keys
|
|
2092
|
-
no_internet_access == true) then -- No internet access
|
|
2093
|
-
|
|
2094
|
-
new_state = if no_internet_access == true then "NoInternet" else "NoAccess"
|
|
2095
|
-
print(`[{script.Name}]: Roblox API services unavailable - data will not be saved`)
|
|
2096
|
-
else
|
|
2097
|
-
new_state = "Access"
|
|
2098
|
-
print(`[{script.Name}]: Roblox API services available - data will be saved`)
|
|
2099
|
-
end
|
|
2100
|
-
|
|
2101
|
-
DataStoreState = new_state
|
|
2102
|
-
ProfileStore.DataStoreState = new_state
|
|
2103
|
-
|
|
2104
|
-
end)
|
|
2105
|
-
|
|
2106
|
-
else
|
|
2107
|
-
|
|
2108
|
-
DataStoreState = "Access"
|
|
2109
|
-
ProfileStore.DataStoreState = "Access"
|
|
2110
|
-
|
|
2111
|
-
end
|
|
2112
|
-
|
|
2113
|
-
-- Update loop:
|
|
2114
|
-
|
|
2115
|
-
RunService.Heartbeat:Connect(function()
|
|
2116
|
-
|
|
2117
|
-
-- Auto saving:
|
|
2118
|
-
|
|
2119
|
-
local auto_save_list_length = #AutoSaveList
|
|
2120
|
-
if auto_save_list_length > 0 then
|
|
2121
|
-
local auto_save_index_speed = AUTO_SAVE_PERIOD / auto_save_list_length
|
|
2122
|
-
local os_clock = os.clock()
|
|
2123
|
-
while os_clock - LastAutoSave > auto_save_index_speed do
|
|
2124
|
-
LastAutoSave = LastAutoSave + auto_save_index_speed
|
|
2125
|
-
local profile = AutoSaveList[AutoSaveIndex]
|
|
2126
|
-
if os_clock - profile.load_timestamp < AUTO_SAVE_PERIOD / 2 then
|
|
2127
|
-
-- This profile is freshly loaded - auto saving immediately is not necessary:
|
|
2128
|
-
profile = nil
|
|
2129
|
-
for _ = 1, auto_save_list_length - 1 do
|
|
2130
|
-
-- Move auto save index to the right:
|
|
2131
|
-
AutoSaveIndex = AutoSaveIndex + 1
|
|
2132
|
-
if AutoSaveIndex > auto_save_list_length then
|
|
2133
|
-
AutoSaveIndex = 1
|
|
2134
|
-
end
|
|
2135
|
-
profile = AutoSaveList[AutoSaveIndex]
|
|
2136
|
-
if os_clock - profile.load_timestamp >= AUTO_SAVE_PERIOD / 2 then
|
|
2137
|
-
break
|
|
2138
|
-
else
|
|
2139
|
-
profile = nil
|
|
2140
|
-
end
|
|
2141
|
-
end
|
|
2142
|
-
end
|
|
2143
|
-
-- Move auto save index to the right:
|
|
2144
|
-
AutoSaveIndex = AutoSaveIndex + 1
|
|
2145
|
-
if AutoSaveIndex > auto_save_list_length then
|
|
2146
|
-
AutoSaveIndex = 1
|
|
2147
|
-
end
|
|
2148
|
-
-- Perform save call:
|
|
2149
|
-
if profile ~= nil then
|
|
2150
|
-
task.spawn(SaveProfileAsync, profile) -- Auto save profile in new thread
|
|
2151
|
-
end
|
|
2152
|
-
end
|
|
2153
|
-
end
|
|
2154
|
-
|
|
2155
|
-
-- Critical state handling:
|
|
2156
|
-
|
|
2157
|
-
if ProfileStore.IsCriticalState == false then
|
|
2158
|
-
if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
|
|
2159
|
-
ProfileStore.IsCriticalState = true
|
|
2160
|
-
ProfileStore.OnCriticalToggle:Fire(true)
|
|
2161
|
-
CriticalStateStart = os.clock()
|
|
2162
|
-
warn(`[{script.Name}]: Entered critical state`)
|
|
2163
|
-
end
|
|
2164
|
-
else
|
|
2165
|
-
if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
|
|
2166
|
-
CriticalStateStart = os.clock()
|
|
2167
|
-
elseif os.clock() - CriticalStateStart > CRITICAL_STATE_EXPIRE then
|
|
2168
|
-
ProfileStore.IsCriticalState = false
|
|
2169
|
-
ProfileStore.OnCriticalToggle:Fire(false)
|
|
2170
|
-
warn(`[{script.Name}]: Critical state ended`)
|
|
2171
|
-
end
|
|
2172
|
-
end
|
|
2173
|
-
|
|
2174
|
-
-- Issue queue:
|
|
2175
|
-
|
|
2176
|
-
while true do
|
|
2177
|
-
local issue_time = IssueQueue[1]
|
|
2178
|
-
if issue_time == nil then
|
|
2179
|
-
break
|
|
2180
|
-
elseif os.clock() - issue_time > CRITICAL_STATE_ERROR_EXPIRE then
|
|
2181
|
-
table.remove(IssueQueue, 1)
|
|
2182
|
-
else
|
|
2183
|
-
break
|
|
2184
|
-
end
|
|
2185
|
-
end
|
|
2186
|
-
|
|
2187
|
-
end)
|
|
2188
|
-
|
|
2189
|
-
-- Release all loaded profiles when the server is shutting down:
|
|
2190
|
-
|
|
2191
|
-
task.spawn(function()
|
|
2192
|
-
|
|
2193
|
-
while DataStoreState == "NotReady" do
|
|
2194
|
-
task.wait()
|
|
2195
|
-
end
|
|
2196
|
-
|
|
2197
|
-
if DataStoreState ~= "Access" then
|
|
2198
|
-
|
|
2199
|
-
game:BindToClose(function()
|
|
2200
|
-
ProfileStore.IsClosing = true
|
|
2201
|
-
task.wait() -- Mock shutdown delay
|
|
2202
|
-
end)
|
|
2203
|
-
|
|
2204
|
-
return -- Don't wait for profiles to properly save in mock mode so studio could end the simulation faster
|
|
2205
|
-
|
|
2206
|
-
end
|
|
2207
|
-
|
|
2208
|
-
game:BindToClose(function()
|
|
2209
|
-
|
|
2210
|
-
ProfileStore.IsClosing = true
|
|
2211
|
-
|
|
2212
|
-
-- Release all active profiles:
|
|
2213
|
-
-- (Clone AutoSaveList to a new table because AutoSaveList changes when profiles are released)
|
|
2214
|
-
|
|
2215
|
-
local on_close_save_job_count = 0
|
|
2216
|
-
local active_profiles = {}
|
|
2217
|
-
for index, profile in ipairs(AutoSaveList) do
|
|
2218
|
-
active_profiles[index] = profile
|
|
2219
|
-
end
|
|
2220
|
-
|
|
2221
|
-
-- Release the profiles; Releasing profiles can trigger listeners that release other profiles, so check active state:
|
|
2222
|
-
for _, profile in ipairs(active_profiles) do
|
|
2223
|
-
if profile:IsActive() == true then
|
|
2224
|
-
on_close_save_job_count = on_close_save_job_count + 1
|
|
2225
|
-
task.spawn(function() -- Save profile on new thread
|
|
2226
|
-
SaveProfileAsync(profile, true, nil, "Shutdown")
|
|
2227
|
-
on_close_save_job_count = on_close_save_job_count - 1
|
|
2228
|
-
end)
|
|
2229
|
-
end
|
|
2230
|
-
end
|
|
2231
|
-
|
|
2232
|
-
-- Yield until all active profile jobs are finished:
|
|
2233
|
-
while on_close_save_job_count > 0 or ActiveProfileLoadJobs > 0 or ActiveProfileSaveJobs > 0 do
|
|
2234
|
-
task.wait()
|
|
2235
|
-
end
|
|
2236
|
-
|
|
2237
|
-
return -- We're done!
|
|
2238
|
-
|
|
2239
|
-
end)
|
|
2240
|
-
|
|
2241
|
-
end)
|
|
2242
|
-
|
|
1
|
+
--[[
|
|
2
|
+
MAD STUDIO (by loleris)
|
|
3
|
+
|
|
4
|
+
-[ProfileStore]---------------------------------------
|
|
5
|
+
|
|
6
|
+
Periodic DataStore saving solution with session locking
|
|
7
|
+
|
|
8
|
+
WARNINGS FOR "Profile.Data" VALUES:
|
|
9
|
+
! Do not create numeric tables with gaps - attempting to store such tables will result in an error.
|
|
10
|
+
! Do not create mixed tables (some values indexed by number and others by a string key)
|
|
11
|
+
- only numerically indexed data will be stored.
|
|
12
|
+
! Do not index tables by anything other than numbers and strings.
|
|
13
|
+
! Do not reference Roblox Instances
|
|
14
|
+
! Do not reference userdata (Vector3, Color3, CFrame...) - Serialize userdata before referencing
|
|
15
|
+
! Do not reference functions
|
|
16
|
+
|
|
17
|
+
Members:
|
|
18
|
+
|
|
19
|
+
ProfileStore.IsClosing [bool]
|
|
20
|
+
-- Set to true after a game:BindToClose() trigger
|
|
21
|
+
|
|
22
|
+
ProfileStore.IsCriticalState [bool]
|
|
23
|
+
-- Set to true when ProfileStore experiences too many consecutive errors
|
|
24
|
+
|
|
25
|
+
ProfileStore.OnError [Signal] (message, store_name, profile_key)
|
|
26
|
+
-- Most ProfileStore errors will be caught and passed to this signal
|
|
27
|
+
|
|
28
|
+
ProfileStore.OnOverwrite [Signal] (store_name, profile_key)
|
|
29
|
+
-- Triggered when a DataStore key was likely used to store data that wasn't
|
|
30
|
+
a ProfileStore profile or the ProfileStore structure was invalidly manually
|
|
31
|
+
altered for that DataStore key
|
|
32
|
+
|
|
33
|
+
ProfileStore.OnCriticalToggle [Signal] (is_critical)
|
|
34
|
+
-- Triggered when ProfileStore experiences too many consecutive errors
|
|
35
|
+
|
|
36
|
+
ProfileStore.DataStoreState [string] ("NotReady", "NoInternet", "NoAccess", "Access")
|
|
37
|
+
-- This value resembles ProfileStore's access to the DataStore; The value starts
|
|
38
|
+
as "NotReady" and will eventually change to one of the other 3 possible values.
|
|
39
|
+
|
|
40
|
+
Functions:
|
|
41
|
+
|
|
42
|
+
ProfileStore.New(store_name, template?) --> [ProfileStore]
|
|
43
|
+
store_name [string] -- DataStore name
|
|
44
|
+
template [table] or nil -- Profiles will default to given table (hard-copy) when no data was saved previously
|
|
45
|
+
|
|
46
|
+
ProfileStore.SetConstant(name, value)
|
|
47
|
+
name [string]
|
|
48
|
+
value [number]
|
|
49
|
+
|
|
50
|
+
Members [ProfileStore]:
|
|
51
|
+
|
|
52
|
+
ProfileStore.Mock [ProfileStore]
|
|
53
|
+
-- Reflection of ProfileStore methods, but the methods will now query a mock
|
|
54
|
+
DataStore with no relation to the real DataStore
|
|
55
|
+
|
|
56
|
+
ProfileStore.Name [string]
|
|
57
|
+
|
|
58
|
+
Methods [ProfileStore]:
|
|
59
|
+
|
|
60
|
+
ProfileStore:StartSessionAsync(profile_key, params?) --> [Profile] or nil
|
|
61
|
+
profile_key [string] -- DataStore key
|
|
62
|
+
params nil or [table]: -- Custom params; E.g. {Steal = true}
|
|
63
|
+
{
|
|
64
|
+
Steal = true, -- Pass this to disregard an existing session lock
|
|
65
|
+
Cancel = fn() -> (boolean), -- Pass this to create a request cancel condition.
|
|
66
|
+
-- If the cancel function returns true, ProfileStore will stop trying to
|
|
67
|
+
-- start the session and return nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ProfileStore:MessageAsync(profile_key, message) --> is_success [bool]
|
|
71
|
+
profile_key [string] -- DataStore key
|
|
72
|
+
message [table] -- Data to be messaged to the profile
|
|
73
|
+
|
|
74
|
+
ProfileStore:GetAsync(profile_key, version?) --> [Profile] or nil
|
|
75
|
+
-- Reads a profile without starting a session - will not autosave
|
|
76
|
+
profile_key [string] -- DataStore key
|
|
77
|
+
version nil or [string] -- DataStore key version
|
|
78
|
+
|
|
79
|
+
ProfileStore:VersionQuery(profile_key, sort_direction?, min_date?, max_date?) --> [VersionQuery]
|
|
80
|
+
profile_key [string]
|
|
81
|
+
sort_direction nil or [Enum.SortDirection]
|
|
82
|
+
min_date nil or [DateTime]
|
|
83
|
+
max_date nil or [DateTime]
|
|
84
|
+
|
|
85
|
+
ProfileStore:RemoveAsync(profile_key) --> is_success [bool]
|
|
86
|
+
-- Completely removes profile data from the DataStore / mock DataStore with no way to recover it.
|
|
87
|
+
|
|
88
|
+
Methods [VersionQuery]:
|
|
89
|
+
|
|
90
|
+
VersionQuery:NextAsync() --> [Profile] or nil -- (Yields)
|
|
91
|
+
-- Returned profile is similar to profiles returned by ProfileStore:GetAsync()
|
|
92
|
+
|
|
93
|
+
Members [Profile]:
|
|
94
|
+
|
|
95
|
+
Profile.Data [table]
|
|
96
|
+
-- When the profile is active changes to this table are guaranteed to be saved
|
|
97
|
+
Profile.LastSavedData [table] (Read-only)
|
|
98
|
+
-- Last snapshot of "Profile.Data" that has been successfully saved to the DataStore;
|
|
99
|
+
Useful for proper developer product purchase receipt handling
|
|
100
|
+
|
|
101
|
+
Profile.FirstSessionTime [number] (Read-only)
|
|
102
|
+
-- os.time() timestamp of the first profile session
|
|
103
|
+
|
|
104
|
+
Profile.SessionLoadCount [number] (Read-only) -- Amount of times a session was started for this profile
|
|
105
|
+
|
|
106
|
+
Profile.Session [table] (Read-only) {PlaceId = number, JobId = string} / nil
|
|
107
|
+
-- Set to a table if this profile is in use by a server; nil if released
|
|
108
|
+
|
|
109
|
+
Profile.RobloxMetaData [table] -- Writable table that gets saved automatically and once the profile is released
|
|
110
|
+
Profile.UserIds [table] -- (Read-only) -- {user_id [number], ...} -- User ids associated with this profile
|
|
111
|
+
|
|
112
|
+
Profile.KeyInfo [DataStoreKeyInfo] -- Changes before OnAfterSave signal
|
|
113
|
+
|
|
114
|
+
Profile.OnSave [Signal] ()
|
|
115
|
+
-- Triggered right before changes to Profile.Data are saved to the DataStore
|
|
116
|
+
|
|
117
|
+
Profile.OnLastSave [Signal] (reason [string]: "Manual", "External", "Shutdown")
|
|
118
|
+
-- Triggered right before changes to Profile.Data are saved to the DataStore
|
|
119
|
+
for the last time; A reason is provided for the last save:
|
|
120
|
+
- "Manual" - Profile:EndSession() was called
|
|
121
|
+
- "Shutdown" - The server that has ownership of this profile is shutting down
|
|
122
|
+
- "External" - Another server has started a session for this profile
|
|
123
|
+
Note that this event will not trigger for when a profile session is ended by
|
|
124
|
+
another server trying to take ownership of the session - this is impossible to
|
|
125
|
+
do without compromising on ProfileStore's speed.
|
|
126
|
+
|
|
127
|
+
Profile.OnSessionEnd [Signal] ()
|
|
128
|
+
-- Triggered when the profile session is terminated on this server
|
|
129
|
+
|
|
130
|
+
Profile.OnAfterSave [Signal] (last_saved_data)
|
|
131
|
+
-- Triggered after a successful save
|
|
132
|
+
last_saved_data [table] -- Profile.LastSavedData
|
|
133
|
+
|
|
134
|
+
Profile.ProfileStore [ProfileStore] -- ProfileStore object this profile belongs to
|
|
135
|
+
Profile.Key [string] -- DataStore key
|
|
136
|
+
|
|
137
|
+
Methods [Profile]:
|
|
138
|
+
|
|
139
|
+
Profile:IsActive() --> [bool] -- If "true" is returned, changes to Profile.Data are guaranteed to save;
|
|
140
|
+
This guarantee is only valid until code yields (e.g. task.wait() is used).
|
|
141
|
+
|
|
142
|
+
Profile:Reconcile() -- Fills in missing (nil) [string_key] = [value] pairs to the Profile.Data structure
|
|
143
|
+
from the "template" argument that was passed to "ProfileStore.New()"
|
|
144
|
+
|
|
145
|
+
Profile:EndSession() -- Call after the server has finished working with this profile
|
|
146
|
+
e.g., after the player leaves (Profile object will become inactive)
|
|
147
|
+
|
|
148
|
+
Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
|
|
149
|
+
user_id [number]
|
|
150
|
+
|
|
151
|
+
Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
|
|
152
|
+
user_id [number]
|
|
153
|
+
|
|
154
|
+
Profile:MessageHandler(fn) -- Sets a message handler for this profile
|
|
155
|
+
fn [function] (message [table], processed [function]())
|
|
156
|
+
-- The handler function receives a message table and a callback function;
|
|
157
|
+
The callback function is to be called when a message has been processed
|
|
158
|
+
- this will discard the message from the profile message cache; If the
|
|
159
|
+
callback function is not called, other message handlers will also be triggered
|
|
160
|
+
with unprocessed message data.
|
|
161
|
+
|
|
162
|
+
Profile:Save() -- If the profile session is still active makes an UpdateAsync call
|
|
163
|
+
to the DataStore to immediately save profile data
|
|
164
|
+
|
|
165
|
+
Profile:SetAsync() -- Forcefully saves changes to the profile; Only for profiles
|
|
166
|
+
loaded with ProfileStore:GetAsync() or ProfileStore:VersionQuery()
|
|
167
|
+
|
|
168
|
+
--]]
|
|
169
|
+
|
|
170
|
+
local AUTO_SAVE_PERIOD = 300 -- (Seconds) Time between when changes to a profile are saved to the DataStore
|
|
171
|
+
local LOAD_REPEAT_PERIOD = 10 -- (Seconds) Time between successive profile reads when handling a session conflict
|
|
172
|
+
local FIRST_LOAD_REPEAT = 5 -- (Seconds) Time between first and second profile read when handling a session conflict
|
|
173
|
+
local SESSION_STEAL = 40 -- (Seconds) Time until a session conflict is resolved with the waiting server stealing the session
|
|
174
|
+
local ASSUME_DEAD = 630 -- (Seconds) If a profile hasn't had updates for this long, quickly assume an active session belongs to a crashed server
|
|
175
|
+
local START_SESSION_TIMEOUT = 120 -- (Seconds) If a session can't be started for a profile for this long, stop repeating calls to the DataStore
|
|
176
|
+
|
|
177
|
+
local CRITICAL_STATE_ERROR_COUNT = 5 -- Assume critical state if this many issues happen in a short amount of time
|
|
178
|
+
local CRITICAL_STATE_ERROR_EXPIRE = 120 -- (Seconds) Individual issue expiration
|
|
179
|
+
local CRITICAL_STATE_EXPIRE = 120 -- (Seconds) Critical state expiration
|
|
180
|
+
|
|
181
|
+
local MAX_MESSAGE_QUEUE = 1000 -- Max messages saved in a profile that were sent using "ProfileStore:MessageAsync()"
|
|
182
|
+
|
|
183
|
+
----- Dependencies -----
|
|
184
|
+
|
|
185
|
+
-- local Util = require(game.ReplicatedStorage.Shared.Util)
|
|
186
|
+
-- local Signal = Util.Signal
|
|
187
|
+
|
|
188
|
+
local Signal do
|
|
189
|
+
|
|
190
|
+
local FreeRunnerThread
|
|
191
|
+
|
|
192
|
+
--[[
|
|
193
|
+
Yield-safe coroutine reusing by stravant;
|
|
194
|
+
Sources:
|
|
195
|
+
https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063
|
|
196
|
+
https://gist.github.com/stravant/b75a322e0919d60dde8a0316d1f09d2f
|
|
197
|
+
--]]
|
|
198
|
+
|
|
199
|
+
local function AcquireRunnerThreadAndCallEventHandler(fn, ...)
|
|
200
|
+
local acquired_runner_thread = FreeRunnerThread
|
|
201
|
+
FreeRunnerThread = nil
|
|
202
|
+
fn(...)
|
|
203
|
+
-- The handler finished running, this runner thread is free again.
|
|
204
|
+
FreeRunnerThread = acquired_runner_thread
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
local function RunEventHandlerInFreeThread(...)
|
|
208
|
+
AcquireRunnerThreadAndCallEventHandler(...)
|
|
209
|
+
while true do
|
|
210
|
+
AcquireRunnerThreadAndCallEventHandler(coroutine.yield())
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
local Connection = {}
|
|
215
|
+
Connection.__index = Connection
|
|
216
|
+
|
|
217
|
+
local SignalClass = {}
|
|
218
|
+
SignalClass.__index = SignalClass
|
|
219
|
+
|
|
220
|
+
function Connection:Disconnect()
|
|
221
|
+
|
|
222
|
+
if self.is_connected == false then
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
local signal = self.signal
|
|
227
|
+
self.is_connected = false
|
|
228
|
+
signal.listener_count -= 1
|
|
229
|
+
|
|
230
|
+
if signal.head == self then
|
|
231
|
+
signal.head = self.next
|
|
232
|
+
else
|
|
233
|
+
local prev = signal.head
|
|
234
|
+
while prev ~= nil and prev.next ~= self do
|
|
235
|
+
prev = prev.next
|
|
236
|
+
end
|
|
237
|
+
if prev ~= nil then
|
|
238
|
+
prev.next = self.next
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
function SignalClass.New()
|
|
245
|
+
|
|
246
|
+
local self = {
|
|
247
|
+
head = nil,
|
|
248
|
+
listener_count = 0,
|
|
249
|
+
}
|
|
250
|
+
setmetatable(self, SignalClass)
|
|
251
|
+
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
function SignalClass:Connect(listener: (...any) -> ())
|
|
257
|
+
|
|
258
|
+
if type(listener) ~= "function" then
|
|
259
|
+
error(`[{script.Name}]: \"listener\" must be a function; Received {typeof(listener)}`)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
local connection = {
|
|
263
|
+
listener = listener,
|
|
264
|
+
signal = self,
|
|
265
|
+
next = self.head,
|
|
266
|
+
is_connected = true,
|
|
267
|
+
}
|
|
268
|
+
setmetatable(connection, Connection)
|
|
269
|
+
|
|
270
|
+
self.head = connection
|
|
271
|
+
self.listener_count += 1
|
|
272
|
+
|
|
273
|
+
return connection
|
|
274
|
+
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
function SignalClass:GetListenerCount(): number
|
|
278
|
+
return self.listener_count
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
function SignalClass:Fire(...)
|
|
282
|
+
local item = self.head
|
|
283
|
+
while item ~= nil do
|
|
284
|
+
if item.is_connected == true then
|
|
285
|
+
if not FreeRunnerThread then
|
|
286
|
+
FreeRunnerThread = coroutine.create(RunEventHandlerInFreeThread)
|
|
287
|
+
end
|
|
288
|
+
task.spawn(FreeRunnerThread, item.listener, ...)
|
|
289
|
+
end
|
|
290
|
+
item = item.next
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
function SignalClass:Wait()
|
|
295
|
+
local co = coroutine.running()
|
|
296
|
+
local connection
|
|
297
|
+
connection = self:Connect(function(...)
|
|
298
|
+
connection:Disconnect()
|
|
299
|
+
task.spawn(co, ...)
|
|
300
|
+
end)
|
|
301
|
+
return coroutine.yield()
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
Signal = table.freeze({
|
|
305
|
+
New = SignalClass.New,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
----- Private -----
|
|
311
|
+
|
|
312
|
+
local ActiveSessionCheck = {} -- {[session_token] = profile, ...}
|
|
313
|
+
local AutoSaveList = {} -- {profile, ...} -- Loaded profile table which will be circularly auto-saved
|
|
314
|
+
local IssueQueue = {} -- {issue_time, ...}
|
|
315
|
+
|
|
316
|
+
local DataStoreService = game:GetService("DataStoreService")
|
|
317
|
+
local MessagingService = game:GetService("MessagingService")
|
|
318
|
+
local HttpService = game:GetService("HttpService")
|
|
319
|
+
local RunService = game:GetService("RunService")
|
|
320
|
+
|
|
321
|
+
local PlaceId = game.PlaceId
|
|
322
|
+
local JobId = game.JobId
|
|
323
|
+
|
|
324
|
+
local AutoSaveIndex = 1 -- Next profile to auto save
|
|
325
|
+
local LastAutoSave = os.clock()
|
|
326
|
+
|
|
327
|
+
local LoadIndex = 0
|
|
328
|
+
|
|
329
|
+
local ActiveProfileLoadJobs = 0 -- Number of active threads that are loading in profiles
|
|
330
|
+
local ActiveProfileSaveJobs = 0 -- Number of active threads that are saving profiles
|
|
331
|
+
|
|
332
|
+
local CriticalStateStart = 0 -- os.clock()
|
|
333
|
+
|
|
334
|
+
local IsStudio = RunService:IsStudio()
|
|
335
|
+
local DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access" = "NotReady"
|
|
336
|
+
|
|
337
|
+
local MockStore = {}
|
|
338
|
+
local UserMockStore = {}
|
|
339
|
+
local MockFlag = false
|
|
340
|
+
|
|
341
|
+
local OnError = Signal.New() -- (message, store_name, profile_key)
|
|
342
|
+
local OnOverwrite = Signal.New() -- (store_name, profile_key)
|
|
343
|
+
|
|
344
|
+
local UpdateQueue = { -- For stability sake, we won't do UpdateAsync calls for the same key until all previous calls finish
|
|
345
|
+
--[[
|
|
346
|
+
[session_token] = {
|
|
347
|
+
coroutine, ...
|
|
348
|
+
},
|
|
349
|
+
...
|
|
350
|
+
--]]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
local function WaitInUpdateQueue(session_token) --> next_in_queue()
|
|
354
|
+
|
|
355
|
+
local is_first = false
|
|
356
|
+
|
|
357
|
+
if UpdateQueue[session_token] == nil then
|
|
358
|
+
is_first = true
|
|
359
|
+
UpdateQueue[session_token] = {}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
local queue = UpdateQueue[session_token]
|
|
363
|
+
|
|
364
|
+
if is_first == false then
|
|
365
|
+
table.insert(queue, coroutine.running())
|
|
366
|
+
coroutine.yield()
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
return function()
|
|
370
|
+
local next_co = table.remove(queue, 1)
|
|
371
|
+
if next_co ~= nil then
|
|
372
|
+
coroutine.resume(next_co)
|
|
373
|
+
else
|
|
374
|
+
UpdateQueue[session_token] = nil
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
local function SessionToken(store_name, profile_key, is_mock)
|
|
381
|
+
|
|
382
|
+
local session_token = "L_" -- Live
|
|
383
|
+
|
|
384
|
+
if is_mock == true then
|
|
385
|
+
session_token = "U_" -- User mock
|
|
386
|
+
elseif DataStoreState ~= "Access" then
|
|
387
|
+
session_token = "M_" -- Mock, cause no DataStore access
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
session_token ..= store_name .. "\0" .. profile_key
|
|
391
|
+
|
|
392
|
+
return session_token
|
|
393
|
+
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
local function DeepCopyTable(t)
|
|
397
|
+
local copy = {}
|
|
398
|
+
for key, value in pairs(t) do
|
|
399
|
+
if type(value) == "table" then
|
|
400
|
+
copy[key] = DeepCopyTable(value)
|
|
401
|
+
else
|
|
402
|
+
copy[key] = value
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
return copy
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
local function ReconcileTable(target, template)
|
|
409
|
+
for k, v in pairs(template) do
|
|
410
|
+
if type(k) == "string" then -- Only string keys will be reconciled
|
|
411
|
+
if target[k] == nil then
|
|
412
|
+
if type(v) == "table" then
|
|
413
|
+
target[k] = DeepCopyTable(v)
|
|
414
|
+
else
|
|
415
|
+
target[k] = v
|
|
416
|
+
end
|
|
417
|
+
elseif type(target[k]) == "table" and type(v) == "table" then
|
|
418
|
+
ReconcileTable(target[k], v)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
local function RegisterError(error_message, store_name, profile_key) -- Called when a DataStore API call errors
|
|
425
|
+
warn(`[{script.Name}]: DataStore API error (STORE:{store_name}; KEY:{profile_key}) - {tostring(error_message)}`)
|
|
426
|
+
table.insert(IssueQueue, os.clock()) -- Adding issue time to queue
|
|
427
|
+
OnError:Fire(tostring(error_message), store_name, profile_key)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
local function RegisterOverwrite(store_name, profile_key) -- Called when a corrupted profile is loaded
|
|
431
|
+
warn(`[{script.Name}]: Invalid profile was overwritten (STORE:{store_name}; KEY:{profile_key})`)
|
|
432
|
+
OnOverwrite:Fire(store_name, profile_key)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
local function NewMockDataStoreKeyInfo(params)
|
|
436
|
+
|
|
437
|
+
local version_id_string = tostring(params.VersionId or 0)
|
|
438
|
+
local meta_data = params.MetaData or {}
|
|
439
|
+
local user_ids = params.UserIds or {}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
CreatedTime = params.CreatedTime,
|
|
443
|
+
UpdatedTime = params.UpdatedTime,
|
|
444
|
+
Version = string.rep("0", 16) .. "."
|
|
445
|
+
.. string.rep("0", 10 - string.len(version_id_string)) .. version_id_string
|
|
446
|
+
.. "." .. string.rep("0", 16) .. "." .. "01",
|
|
447
|
+
|
|
448
|
+
GetMetadata = function()
|
|
449
|
+
return DeepCopyTable(meta_data)
|
|
450
|
+
end,
|
|
451
|
+
|
|
452
|
+
GetUserIds = function()
|
|
453
|
+
return DeepCopyTable(user_ids)
|
|
454
|
+
end,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
local function MockUpdateAsync(mock_data_store, profile_store_name, key, transform_function, is_get_call) --> loaded_data, key_info
|
|
460
|
+
|
|
461
|
+
local profile_store = mock_data_store[profile_store_name]
|
|
462
|
+
|
|
463
|
+
if profile_store == nil then
|
|
464
|
+
profile_store = {}
|
|
465
|
+
mock_data_store[profile_store_name] = profile_store
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
local epoch_time = math.floor(os.time() * 1000)
|
|
469
|
+
local mock_entry = profile_store[key]
|
|
470
|
+
local mock_entry_was_nil = false
|
|
471
|
+
|
|
472
|
+
if mock_entry == nil then
|
|
473
|
+
mock_entry_was_nil = true
|
|
474
|
+
if is_get_call ~= true then
|
|
475
|
+
mock_entry = {
|
|
476
|
+
Data = nil,
|
|
477
|
+
CreatedTime = epoch_time,
|
|
478
|
+
UpdatedTime = epoch_time,
|
|
479
|
+
VersionId = 0,
|
|
480
|
+
UserIds = {},
|
|
481
|
+
MetaData = {},
|
|
482
|
+
}
|
|
483
|
+
profile_store[key] = mock_entry
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
local mock_key_info = mock_entry_was_nil == false and NewMockDataStoreKeyInfo(mock_entry) or nil
|
|
488
|
+
|
|
489
|
+
local transform, user_ids, roblox_meta_data = transform_function(mock_entry and mock_entry.Data, mock_key_info)
|
|
490
|
+
|
|
491
|
+
if transform == nil then
|
|
492
|
+
return nil
|
|
493
|
+
else
|
|
494
|
+
if mock_entry ~= nil and is_get_call ~= true then
|
|
495
|
+
mock_entry.Data = DeepCopyTable(transform)
|
|
496
|
+
mock_entry.UserIds = DeepCopyTable(user_ids or {})
|
|
497
|
+
mock_entry.MetaData = DeepCopyTable(roblox_meta_data or {})
|
|
498
|
+
mock_entry.VersionId += 1
|
|
499
|
+
mock_entry.UpdatedTime = epoch_time
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
return DeepCopyTable(transform), mock_entry ~= nil and NewMockDataStoreKeyInfo(mock_entry) or nil
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
local function UpdateAsync(profile_store, profile_key, transform_params, is_user_mock, is_get_call, version) --> loaded_data, key_info
|
|
508
|
+
--transform_params = {
|
|
509
|
+
-- ExistingProfileHandle = function(latest_data),
|
|
510
|
+
-- MissingProfileHandle = function(latest_data),
|
|
511
|
+
-- EditProfile = function(latest_data),
|
|
512
|
+
--}
|
|
513
|
+
|
|
514
|
+
local loaded_data, key_info
|
|
515
|
+
|
|
516
|
+
local next_in_queue = WaitInUpdateQueue(SessionToken(profile_store.Name, profile_key, is_user_mock))
|
|
517
|
+
|
|
518
|
+
local success = true
|
|
519
|
+
|
|
520
|
+
local success, error_message = pcall(function()
|
|
521
|
+
local transform_function = function(latest_data)
|
|
522
|
+
|
|
523
|
+
local missing_profile = false
|
|
524
|
+
local overwritten = false
|
|
525
|
+
local global_updates = {0, {}}
|
|
526
|
+
|
|
527
|
+
if latest_data == nil then
|
|
528
|
+
|
|
529
|
+
missing_profile = true
|
|
530
|
+
|
|
531
|
+
elseif type(latest_data) ~= "table" then
|
|
532
|
+
|
|
533
|
+
missing_profile = true
|
|
534
|
+
overwritten = true
|
|
535
|
+
|
|
536
|
+
else
|
|
537
|
+
|
|
538
|
+
if type(latest_data.Data) == "table" and type(latest_data.MetaData) == "table" and type(latest_data.GlobalUpdates) == "table" then
|
|
539
|
+
|
|
540
|
+
-- Regular profile structure detected:
|
|
541
|
+
|
|
542
|
+
latest_data.WasOverwritten = false -- Must be set to false if set previously
|
|
543
|
+
global_updates = latest_data.GlobalUpdates
|
|
544
|
+
|
|
545
|
+
if transform_params.ExistingProfileHandle ~= nil then
|
|
546
|
+
transform_params.ExistingProfileHandle(latest_data)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
elseif latest_data.Data == nil and latest_data.MetaData == nil and type(latest_data.GlobalUpdates) == "table" then
|
|
550
|
+
|
|
551
|
+
-- Regular structure not detected, but GlobalUpdate data exists:
|
|
552
|
+
|
|
553
|
+
latest_data.WasOverwritten = false -- Must be set to false if set previously
|
|
554
|
+
global_updates = latest_data.GlobalUpdates or global_updates
|
|
555
|
+
missing_profile = true
|
|
556
|
+
|
|
557
|
+
else
|
|
558
|
+
|
|
559
|
+
missing_profile = true
|
|
560
|
+
overwritten = true
|
|
561
|
+
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
-- Profile was not created or corrupted and no GlobalUpdate data exists:
|
|
567
|
+
if missing_profile == true then
|
|
568
|
+
latest_data = {
|
|
569
|
+
-- Data = nil,
|
|
570
|
+
-- MetaData = nil,
|
|
571
|
+
GlobalUpdates = global_updates,
|
|
572
|
+
}
|
|
573
|
+
if transform_params.MissingProfileHandle ~= nil then
|
|
574
|
+
transform_params.MissingProfileHandle(latest_data)
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
-- Editing profile:
|
|
579
|
+
if transform_params.EditProfile ~= nil then
|
|
580
|
+
transform_params.EditProfile(latest_data)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
-- Invalid data handling (Silently override with empty profile)
|
|
584
|
+
if overwritten == true then
|
|
585
|
+
latest_data.WasOverwritten = true -- Temporary tag that will be removed on first save
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
return latest_data, latest_data.UserIds, latest_data.RobloxMetaData
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if is_user_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
|
|
592
|
+
|
|
593
|
+
loaded_data, key_info = MockUpdateAsync(UserMockStore, profile_store.Name, profile_key, transform_function, is_get_call)
|
|
594
|
+
task.wait() -- Simulate API call yield
|
|
595
|
+
|
|
596
|
+
elseif DataStoreState ~= "Access" then -- Used when API access is disabled
|
|
597
|
+
|
|
598
|
+
loaded_data, key_info = MockUpdateAsync(MockStore, profile_store.Name, profile_key, transform_function, is_get_call)
|
|
599
|
+
task.wait() -- Simulate API call yield
|
|
600
|
+
|
|
601
|
+
else
|
|
602
|
+
|
|
603
|
+
if is_get_call == true then
|
|
604
|
+
|
|
605
|
+
if version ~= nil then
|
|
606
|
+
|
|
607
|
+
local success, error_message = pcall(function()
|
|
608
|
+
loaded_data, key_info = profile_store.data_store:GetVersionAsync(profile_key, version)
|
|
609
|
+
end)
|
|
610
|
+
|
|
611
|
+
if success == false and type(error_message) == "string" and string.find(error_message, "not valid") ~= nil then
|
|
612
|
+
warn(`[{script.Name}]: Passed version argument is not valid; Traceback:\n` .. debug.traceback())
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
else
|
|
616
|
+
|
|
617
|
+
loaded_data, key_info = profile_store.data_store:GetAsync(profile_key)
|
|
618
|
+
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
loaded_data = transform_function(loaded_data)
|
|
622
|
+
|
|
623
|
+
else
|
|
624
|
+
|
|
625
|
+
loaded_data, key_info = profile_store.data_store:UpdateAsync(profile_key, transform_function)
|
|
626
|
+
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
end)
|
|
632
|
+
|
|
633
|
+
next_in_queue()
|
|
634
|
+
|
|
635
|
+
if success == true and type(loaded_data) == "table" then
|
|
636
|
+
-- Invalid data handling:
|
|
637
|
+
if loaded_data.WasOverwritten == true and is_get_call ~= true then
|
|
638
|
+
RegisterOverwrite(
|
|
639
|
+
profile_store.Name,
|
|
640
|
+
profile_key
|
|
641
|
+
)
|
|
642
|
+
end
|
|
643
|
+
-- Return loaded_data:
|
|
644
|
+
return loaded_data, key_info
|
|
645
|
+
else
|
|
646
|
+
-- Error handling:
|
|
647
|
+
RegisterError(
|
|
648
|
+
error_message or "Undefined error",
|
|
649
|
+
profile_store.Name,
|
|
650
|
+
profile_key
|
|
651
|
+
)
|
|
652
|
+
-- Return nothing:
|
|
653
|
+
return nil
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
local function IsThisSession(session_tag)
|
|
659
|
+
return session_tag[1] == PlaceId and session_tag[2] == JobId
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
local function ReadMockFlag(): boolean
|
|
663
|
+
local is_mock = MockFlag
|
|
664
|
+
MockFlag = false
|
|
665
|
+
return is_mock
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
local function WaitForStoreReady(profile_store)
|
|
669
|
+
while profile_store.is_ready == false do
|
|
670
|
+
task.wait()
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
local function AddProfileToAutoSave(profile)
|
|
675
|
+
|
|
676
|
+
ActiveSessionCheck[profile.session_token] = profile
|
|
677
|
+
|
|
678
|
+
-- Add at AutoSaveIndex and move AutoSaveIndex right:
|
|
679
|
+
|
|
680
|
+
table.insert(AutoSaveList, AutoSaveIndex, profile)
|
|
681
|
+
|
|
682
|
+
if #AutoSaveList > 1 then
|
|
683
|
+
AutoSaveIndex = AutoSaveIndex + 1
|
|
684
|
+
elseif #AutoSaveList == 1 then
|
|
685
|
+
-- First profile created - make sure it doesn't get immediately auto saved:
|
|
686
|
+
LastAutoSave = os.clock()
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
local function RemoveProfileFromAutoSave(profile)
|
|
692
|
+
|
|
693
|
+
ActiveSessionCheck[profile.session_token] = nil
|
|
694
|
+
|
|
695
|
+
local auto_save_index = table.find(AutoSaveList, profile)
|
|
696
|
+
|
|
697
|
+
if auto_save_index ~= nil then
|
|
698
|
+
table.remove(AutoSaveList, auto_save_index)
|
|
699
|
+
if auto_save_index < AutoSaveIndex then
|
|
700
|
+
AutoSaveIndex = AutoSaveIndex - 1 -- Table contents were moved left before AutoSaveIndex so move AutoSaveIndex left as well
|
|
701
|
+
end
|
|
702
|
+
if AutoSaveList[AutoSaveIndex] == nil then -- AutoSaveIndex was at the end of the AutoSaveList - reset to 1
|
|
703
|
+
AutoSaveIndex = 1
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
local function SaveProfileAsync(profile, is_ending_session, is_overwriting, last_save_reason)
|
|
710
|
+
|
|
711
|
+
if type(profile.Data) ~= "table" then
|
|
712
|
+
error(`[{script.Name}]: Developer code likely set "Profile.Data" to a non-table value! (STORE:{profile.ProfileStore.Name}; KEY:{profile.Key})`)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
profile.OnSave:Fire()
|
|
716
|
+
if is_ending_session == true then
|
|
717
|
+
profile.OnLastSave:Fire(last_save_reason or "Manual")
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
if is_ending_session == true and is_overwriting ~= true then
|
|
721
|
+
if profile.roblox_message_subscription ~= nil then
|
|
722
|
+
profile.roblox_message_subscription:Disconnect()
|
|
723
|
+
end
|
|
724
|
+
RemoveProfileFromAutoSave(profile)
|
|
725
|
+
profile.OnSessionEnd:Fire()
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
ActiveProfileSaveJobs = ActiveProfileSaveJobs + 1
|
|
729
|
+
|
|
730
|
+
-- Compare "SessionLoadCount" when writing to profile to prevent a rare case of repeat last save when the profile is loaded on the same server again
|
|
731
|
+
|
|
732
|
+
local repeat_save_flag = true -- Released Profile save calls have to repeat until they succeed
|
|
733
|
+
local exp_backoff = 1
|
|
734
|
+
|
|
735
|
+
while repeat_save_flag == true do
|
|
736
|
+
|
|
737
|
+
if is_ending_session ~= true then
|
|
738
|
+
repeat_save_flag = false
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
local loaded_data, key_info = UpdateAsync(
|
|
742
|
+
profile.ProfileStore,
|
|
743
|
+
profile.Key,
|
|
744
|
+
{
|
|
745
|
+
ExistingProfileHandle = nil,
|
|
746
|
+
MissingProfileHandle = nil,
|
|
747
|
+
EditProfile = function(latest_data)
|
|
748
|
+
|
|
749
|
+
-- Check if this session still owns the profile:
|
|
750
|
+
|
|
751
|
+
local session_owns_profile = false
|
|
752
|
+
|
|
753
|
+
if is_overwriting ~= true then
|
|
754
|
+
|
|
755
|
+
local active_session = latest_data.MetaData.ActiveSession
|
|
756
|
+
local session_load_count = latest_data.MetaData.SessionLoadCount
|
|
757
|
+
|
|
758
|
+
if type(active_session) == "table" then
|
|
759
|
+
session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
else
|
|
763
|
+
session_owns_profile = true
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
-- We may only edit the profile if this server has ownership of the profile:
|
|
767
|
+
|
|
768
|
+
if session_owns_profile == true then
|
|
769
|
+
|
|
770
|
+
-- Clear processed updates (messages):
|
|
771
|
+
|
|
772
|
+
local locked_updates = profile.locked_global_updates -- [index] = true, ...
|
|
773
|
+
local active_updates = latest_data.GlobalUpdates[2]
|
|
774
|
+
-- ProfileService module format: {{update_id, version_id, update_locked, update_data}, ...}
|
|
775
|
+
-- ProfileStore module format: {{update_id, update_data}, ...}
|
|
776
|
+
|
|
777
|
+
if next(locked_updates) ~= nil then
|
|
778
|
+
local i = 1
|
|
779
|
+
while i <= #active_updates do
|
|
780
|
+
local update = active_updates[i]
|
|
781
|
+
if locked_updates[update[1]] == true then
|
|
782
|
+
table.remove(active_updates, i)
|
|
783
|
+
else
|
|
784
|
+
i += 1
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
-- Save profile data:
|
|
790
|
+
|
|
791
|
+
latest_data.Data = profile.Data
|
|
792
|
+
latest_data.RobloxMetaData = profile.RobloxMetaData
|
|
793
|
+
latest_data.UserIds = profile.UserIds
|
|
794
|
+
|
|
795
|
+
if is_overwriting ~= true then
|
|
796
|
+
|
|
797
|
+
latest_data.MetaData.LastUpdate = os.time()
|
|
798
|
+
|
|
799
|
+
if is_ending_session == true then
|
|
800
|
+
latest_data.MetaData.ActiveSession = nil
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
else
|
|
804
|
+
|
|
805
|
+
latest_data.MetaData.ActiveSession = nil
|
|
806
|
+
latest_data.MetaData.ForceLoadSession = nil
|
|
807
|
+
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
end,
|
|
813
|
+
},
|
|
814
|
+
profile.is_mock
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
if loaded_data ~= nil and key_info ~= nil then
|
|
818
|
+
|
|
819
|
+
if is_overwriting == true then
|
|
820
|
+
break
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
repeat_save_flag = false
|
|
824
|
+
|
|
825
|
+
local active_session = loaded_data.MetaData.ActiveSession
|
|
826
|
+
local session_load_count = loaded_data.MetaData.SessionLoadCount
|
|
827
|
+
local session_owns_profile = false
|
|
828
|
+
|
|
829
|
+
if type(active_session) == "table" then
|
|
830
|
+
session_owns_profile = IsThisSession(active_session) and session_load_count == profile.load_index
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
local force_load_session = loaded_data.MetaData.ForceLoadSession
|
|
834
|
+
local force_load_pending = false
|
|
835
|
+
if type(force_load_session) == "table" then
|
|
836
|
+
force_load_pending = not IsThisSession(force_load_session)
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
local is_active = profile:IsActive()
|
|
840
|
+
|
|
841
|
+
-- If another server is trying to start a session for this profile - end the session:
|
|
842
|
+
|
|
843
|
+
if force_load_pending == true and session_owns_profile == true then
|
|
844
|
+
if is_active == true then
|
|
845
|
+
SaveProfileAsync(profile, true, false, "External")
|
|
846
|
+
end
|
|
847
|
+
break
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
-- Clearing processed update list / Detecting new updates:
|
|
851
|
+
|
|
852
|
+
local locked_updates = profile.locked_global_updates -- [index] = true, ...
|
|
853
|
+
local received_updates = profile.received_global_updates -- [index] = true, ...
|
|
854
|
+
local active_updates = loaded_data.GlobalUpdates[2]
|
|
855
|
+
|
|
856
|
+
local new_updates = {} -- {}, ...
|
|
857
|
+
local still_pending = {} -- [index] = true, ...
|
|
858
|
+
|
|
859
|
+
for _, update in ipairs(active_updates) do
|
|
860
|
+
if locked_updates[update[1]] == true then
|
|
861
|
+
still_pending[update[1]] = true
|
|
862
|
+
elseif received_updates[update[1]] ~= true then
|
|
863
|
+
received_updates[update[1]] = true
|
|
864
|
+
table.insert(new_updates, update)
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
for index in pairs(locked_updates) do
|
|
869
|
+
if still_pending[index] ~= true then
|
|
870
|
+
locked_updates[index] = nil
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
-- Updating profile values:
|
|
875
|
+
|
|
876
|
+
profile.KeyInfo = key_info
|
|
877
|
+
profile.LastSavedData = loaded_data.Data
|
|
878
|
+
profile.global_updates = loaded_data.GlobalUpdates and loaded_data.GlobalUpdates[2] or {}
|
|
879
|
+
|
|
880
|
+
if session_owns_profile == true then
|
|
881
|
+
if is_active == true and is_ending_session ~= true then
|
|
882
|
+
|
|
883
|
+
-- Processing new global updates (messages):
|
|
884
|
+
|
|
885
|
+
for _, update in ipairs(new_updates) do
|
|
886
|
+
|
|
887
|
+
local index = update[1]
|
|
888
|
+
local update_data = update[#update] -- Backwards compatibility with ProfileService
|
|
889
|
+
|
|
890
|
+
for _, handler in ipairs(profile.message_handlers) do
|
|
891
|
+
|
|
892
|
+
local is_processed = false
|
|
893
|
+
local processed_callback = function()
|
|
894
|
+
is_processed = true
|
|
895
|
+
locked_updates[index] = true
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
local send_update_data = DeepCopyTable(update_data)
|
|
899
|
+
|
|
900
|
+
task.spawn(handler, send_update_data, processed_callback)
|
|
901
|
+
|
|
902
|
+
if is_processed == true then
|
|
903
|
+
break
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
end
|
|
911
|
+
else
|
|
912
|
+
|
|
913
|
+
if profile.roblox_message_subscription ~= nil then
|
|
914
|
+
profile.roblox_message_subscription:Disconnect()
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
if is_active == true then
|
|
918
|
+
RemoveProfileFromAutoSave(profile)
|
|
919
|
+
profile.OnSessionEnd:Fire()
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
profile.OnAfterSave:Fire(profile.LastSavedData)
|
|
925
|
+
|
|
926
|
+
elseif repeat_save_flag == true then
|
|
927
|
+
|
|
928
|
+
-- DataStore call likely resulted in an error; Repeat the DataStore call shortly
|
|
929
|
+
task.wait(exp_backoff)
|
|
930
|
+
exp_backoff = math.min(if last_save_reason == "Shutdown" then 8 else 20, exp_backoff * 2)
|
|
931
|
+
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
ActiveProfileSaveJobs = ActiveProfileSaveJobs - 1
|
|
937
|
+
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
----- Public -----
|
|
941
|
+
|
|
942
|
+
--[[
|
|
943
|
+
Saved profile structure:
|
|
944
|
+
|
|
945
|
+
{
|
|
946
|
+
Data = {},
|
|
947
|
+
|
|
948
|
+
MetaData = {
|
|
949
|
+
ProfileCreateTime = 0,
|
|
950
|
+
SessionLoadCount = 0,
|
|
951
|
+
ActiveSession = {place_id, game_job_id, unique_session_id} / nil,
|
|
952
|
+
ForceLoadSession = {place_id, game_job_id} / nil,
|
|
953
|
+
LastUpdate = 0, -- os.time()
|
|
954
|
+
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
955
|
+
},
|
|
956
|
+
|
|
957
|
+
RobloxMetaData = {},
|
|
958
|
+
UserIds = {},
|
|
959
|
+
|
|
960
|
+
GlobalUpdates = {
|
|
961
|
+
update_index,
|
|
962
|
+
{
|
|
963
|
+
{update_index, data}, ...
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
--]]
|
|
969
|
+
|
|
970
|
+
export type JSONAcceptable = { JSONAcceptable } | { [string]: JSONAcceptable } | number | string | boolean | buffer
|
|
971
|
+
|
|
972
|
+
export type Profile<T> = {
|
|
973
|
+
Data: T & JSONAcceptable,
|
|
974
|
+
LastSavedData: T & JSONAcceptable,
|
|
975
|
+
FirstSessionTime: number,
|
|
976
|
+
SessionLoadCount: number,
|
|
977
|
+
Session: {PlaceId: number, JobId: string}?,
|
|
978
|
+
RobloxMetaData: JSONAcceptable,
|
|
979
|
+
UserIds: {number},
|
|
980
|
+
KeyInfo: DataStoreKeyInfo,
|
|
981
|
+
OnSave: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
982
|
+
OnLastSave: {Connect: (self: any, listener: (reason: "Manual" | "External" | "Shutdown") -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
983
|
+
OnSessionEnd: {Connect: (self: any, listener: () -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
984
|
+
OnAfterSave: {Connect: (self: any, listener: (last_saved_data: T & JSONAcceptable) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
985
|
+
ProfileStore: JSONAcceptable,
|
|
986
|
+
Key: string,
|
|
987
|
+
|
|
988
|
+
IsActive: (self: any) -> (boolean),
|
|
989
|
+
Reconcile: (self: any) -> (),
|
|
990
|
+
EndSession: (self: any) -> (),
|
|
991
|
+
AddUserId: (self: any, user_id: number) -> (),
|
|
992
|
+
RemoveUserId: (self: any, user_id: number) -> (),
|
|
993
|
+
MessageHandler: (self: any, fn: (message: JSONAcceptable, processed: () -> ()) -> ()) -> (),
|
|
994
|
+
Save: (self: any) -> (),
|
|
995
|
+
SetAsync: (self: any) -> (),
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export type VersionQuery<T> = {
|
|
999
|
+
NextAsync: (self: any) -> (Profile<T>?),
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
type ProfileStoreStandard<T> = {
|
|
1003
|
+
Name: string,
|
|
1004
|
+
StartSessionAsync: (self: any, profile_key: string, params: {Steal: boolean?}) -> (Profile<T>?),
|
|
1005
|
+
MessageAsync: (self: any, profile_key: string, message: JSONAcceptable) -> (boolean),
|
|
1006
|
+
GetAsync: (self: any, profile_key: string, version: string?) -> (Profile<T>?),
|
|
1007
|
+
VersionQuery: (self: any, profile_key: string, sort_direction: Enum.SortDirection?, min_date: DateTime | number | nil, max_date: DateTime | number | nil) -> (VersionQuery<T>),
|
|
1008
|
+
RemoveAsync: (self: any, profile_key: string) -> (boolean),
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export type ProfileStore<T> = {
|
|
1012
|
+
Mock: ProfileStoreStandard<T>,
|
|
1013
|
+
} & ProfileStoreStandard<T>
|
|
1014
|
+
|
|
1015
|
+
type ConstantName = "AUTO_SAVE_PERIOD" | "LOAD_REPEAT_PERIOD" | "FIRST_LOAD_REPEAT" | "SESSION_STEAL"
|
|
1016
|
+
| "ASSUME_DEAD" | "START_SESSION_TIMEOUT" | "CRITICAL_STATE_ERROR_COUNT" | "CRITICAL_STATE_ERROR_EXPIRE"
|
|
1017
|
+
| "CRITICAL_STATE_EXPIRE" | "MAX_MESSAGE_QUEUE"
|
|
1018
|
+
|
|
1019
|
+
export type ProfileStoreModule = {
|
|
1020
|
+
IsClosing: boolean,
|
|
1021
|
+
IsCriticalState: boolean,
|
|
1022
|
+
OnError: {Connect: (self: any, listener: (message: string, store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1023
|
+
OnOverwrite: {Connect: (self: any, listener: (store_name: string, profile_key: string) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1024
|
+
OnCriticalToggle: {Connect: (self: any, listener: (is_critical: boolean) -> ()) -> ({Disconnect: (self: any) -> ()})},
|
|
1025
|
+
DataStoreState: "NotReady" | "NoInternet" | "NoAccess" | "Access",
|
|
1026
|
+
New: <T>(store_name: string, template: (T & JSONAcceptable)?) -> (ProfileStore<T>),
|
|
1027
|
+
SetConstant: (name: ConstantName, value: number) -> ()
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
local Profile = {}
|
|
1031
|
+
Profile.__index = Profile
|
|
1032
|
+
|
|
1033
|
+
function Profile.New(raw_data, key_info, profile_store, key, is_mock, session_token)
|
|
1034
|
+
|
|
1035
|
+
local data = raw_data.Data or {}
|
|
1036
|
+
local session = raw_data.MetaData and raw_data.MetaData.ActiveSession or nil
|
|
1037
|
+
|
|
1038
|
+
local global_updates = raw_data.GlobalUpdates and raw_data.GlobalUpdates[2] or {}
|
|
1039
|
+
local received_global_updates = {}
|
|
1040
|
+
|
|
1041
|
+
for _, update in ipairs(global_updates) do
|
|
1042
|
+
received_global_updates[update[1]] = true
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
local self = {
|
|
1046
|
+
|
|
1047
|
+
Data = data,
|
|
1048
|
+
LastSavedData = DeepCopyTable(data),
|
|
1049
|
+
|
|
1050
|
+
FirstSessionTime = raw_data.MetaData and raw_data.MetaData.ProfileCreateTime or 0,
|
|
1051
|
+
SessionLoadCount = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
|
|
1052
|
+
Session = session and {PlaceId = session[1], JobId = session[2]},
|
|
1053
|
+
|
|
1054
|
+
RobloxMetaData = raw_data.RobloxMetaData or {},
|
|
1055
|
+
UserIds = raw_data.UserIds or {},
|
|
1056
|
+
KeyInfo = key_info,
|
|
1057
|
+
|
|
1058
|
+
OnAfterSave = Signal.New(),
|
|
1059
|
+
OnSave = Signal.New(),
|
|
1060
|
+
OnLastSave = Signal.New(),
|
|
1061
|
+
OnSessionEnd = Signal.New(),
|
|
1062
|
+
|
|
1063
|
+
ProfileStore = profile_store,
|
|
1064
|
+
Key = key,
|
|
1065
|
+
|
|
1066
|
+
load_timestamp = os.clock(),
|
|
1067
|
+
is_mock = is_mock,
|
|
1068
|
+
session_token = session_token or "",
|
|
1069
|
+
load_index = raw_data.MetaData and raw_data.MetaData.SessionLoadCount or 0,
|
|
1070
|
+
locked_global_updates = {},
|
|
1071
|
+
received_global_updates = received_global_updates,
|
|
1072
|
+
message_handlers = {},
|
|
1073
|
+
global_updates = global_updates,
|
|
1074
|
+
|
|
1075
|
+
}
|
|
1076
|
+
setmetatable(self, Profile)
|
|
1077
|
+
|
|
1078
|
+
return self
|
|
1079
|
+
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
function Profile:IsActive()
|
|
1083
|
+
return ActiveSessionCheck[self.session_token] == self
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
function Profile:Reconcile()
|
|
1087
|
+
ReconcileTable(self.Data, self.ProfileStore.template)
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
function Profile:EndSession()
|
|
1091
|
+
if self:IsActive() == true then
|
|
1092
|
+
task.spawn(SaveProfileAsync, self, true, nil, "Manual") -- Call save function in a new thread with release_from_session = true
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
function Profile:AddUserId(user_id) -- Associates user_id with profile (GDPR compliance)
|
|
1097
|
+
|
|
1098
|
+
if type(user_id) ~= "number" or user_id % 1 ~= 0 then
|
|
1099
|
+
warn(`[{script.Name}]: Invalid UserId argument for :AddUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
|
|
1100
|
+
return
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
if user_id < 0 and self.is_mock ~= true and DataStoreState == "Access" then
|
|
1104
|
+
return -- Avoid giving real Roblox APIs negative UserId's
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
if table.find(self.UserIds, user_id) == nil then
|
|
1108
|
+
table.insert(self.UserIds, user_id)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
function Profile:RemoveUserId(user_id) -- Removes user_id association with profile (safe function)
|
|
1114
|
+
|
|
1115
|
+
if type(user_id) ~= "number" or user_id % 1 ~= 0 then
|
|
1116
|
+
warn(`[{script.Name}]: Invalid UserId argument for :RemoveUserId() ({tostring(user_id)}); Traceback:\n` .. debug.traceback())
|
|
1117
|
+
return
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
local index = table.find(self.UserIds, user_id)
|
|
1121
|
+
|
|
1122
|
+
if index ~= nil then
|
|
1123
|
+
table.remove(self.UserIds, index)
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
function Profile:SetAsync() -- Saves the profile to the DataStore and removes the session lock
|
|
1129
|
+
|
|
1130
|
+
if self.view_mode ~= true then
|
|
1131
|
+
error(`[{script.Name}]: :SetAsync() can only be used in view mode`)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
SaveProfileAsync(self, nil, true)
|
|
1135
|
+
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
function Profile:MessageHandler(fn)
|
|
1139
|
+
|
|
1140
|
+
if type(fn) ~= "function" then
|
|
1141
|
+
error(`[{script.Name}]: fn argument is not a function`)
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
if self.view_mode ~= true and self:IsActive() ~= true then
|
|
1145
|
+
return -- Don't process messages if the profile session was ended
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
local locked_updates = self.locked_global_updates
|
|
1149
|
+
table.insert(self.message_handlers, fn)
|
|
1150
|
+
|
|
1151
|
+
for _, update in ipairs(self.global_updates) do
|
|
1152
|
+
|
|
1153
|
+
local index = update[1]
|
|
1154
|
+
local update_data = update[#update] -- Backwards compatibility with ProfileService
|
|
1155
|
+
|
|
1156
|
+
if locked_updates[index] ~= true then
|
|
1157
|
+
|
|
1158
|
+
local processed_callback = function()
|
|
1159
|
+
locked_updates[index] = true
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
local send_update_data = DeepCopyTable(update_data)
|
|
1163
|
+
|
|
1164
|
+
task.spawn(fn, send_update_data, processed_callback)
|
|
1165
|
+
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
function Profile:Save()
|
|
1173
|
+
|
|
1174
|
+
if self.view_mode == true then
|
|
1175
|
+
error(`[{script.Name}]: Can't save profile in view mode; Should you be calling :SetAsync() instead?`)
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
if self:IsActive() == false then
|
|
1179
|
+
warn(`[{script.Name}]: Attempted saving an inactive profile (STORE:{self.ProfileStore.Name}; KEY:{self.Key});`
|
|
1180
|
+
.. ` Traceback:\n` .. debug.traceback())
|
|
1181
|
+
return
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
-- Move the profile right behind the auto save index to delay the next auto save for it:
|
|
1185
|
+
RemoveProfileFromAutoSave(self)
|
|
1186
|
+
AddProfileToAutoSave(self)
|
|
1187
|
+
|
|
1188
|
+
-- Perform save in new thread:
|
|
1189
|
+
task.spawn(SaveProfileAsync, self)
|
|
1190
|
+
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
local ProfileStore: ProfileStoreModule = {
|
|
1194
|
+
|
|
1195
|
+
IsClosing = false,
|
|
1196
|
+
IsCriticalState = false,
|
|
1197
|
+
OnError = OnError, -- (message, store_name, profile_key)
|
|
1198
|
+
OnOverwrite = OnOverwrite, -- (store_name, profile_key)
|
|
1199
|
+
OnCriticalToggle = Signal.New(), -- (is_critical)
|
|
1200
|
+
DataStoreState = "NotReady", -- ("NotReady", "NoInternet", "NoAccess", "Access")
|
|
1201
|
+
|
|
1202
|
+
}
|
|
1203
|
+
ProfileStore.__index = ProfileStore
|
|
1204
|
+
|
|
1205
|
+
function ProfileStore.SetConstant(name, value)
|
|
1206
|
+
|
|
1207
|
+
if type(value) ~= "number" then
|
|
1208
|
+
error(`[{script.Name}]: Invalid value type`)
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
if name == "AUTO_SAVE_PERIOD" then
|
|
1212
|
+
AUTO_SAVE_PERIOD = value
|
|
1213
|
+
elseif name == "LOAD_REPEAT_PERIOD" then
|
|
1214
|
+
LOAD_REPEAT_PERIOD = value
|
|
1215
|
+
elseif name == "FIRST_LOAD_REPEAT" then
|
|
1216
|
+
FIRST_LOAD_REPEAT = value
|
|
1217
|
+
elseif name == "SESSION_STEAL" then
|
|
1218
|
+
SESSION_STEAL = value
|
|
1219
|
+
elseif name == "ASSUME_DEAD" then
|
|
1220
|
+
ASSUME_DEAD = value
|
|
1221
|
+
elseif name == "START_SESSION_TIMEOUT" then
|
|
1222
|
+
START_SESSION_TIMEOUT = value
|
|
1223
|
+
elseif name == "CRITICAL_STATE_ERROR_COUNT" then
|
|
1224
|
+
CRITICAL_STATE_ERROR_COUNT = value
|
|
1225
|
+
elseif name == "CRITICAL_STATE_ERROR_EXPIRE" then
|
|
1226
|
+
CRITICAL_STATE_ERROR_EXPIRE = value
|
|
1227
|
+
elseif name == "CRITICAL_STATE_EXPIRE" then
|
|
1228
|
+
CRITICAL_STATE_EXPIRE = value
|
|
1229
|
+
elseif name == "MAX_MESSAGE_QUEUE" then
|
|
1230
|
+
MAX_MESSAGE_QUEUE = value
|
|
1231
|
+
else
|
|
1232
|
+
error(`[{script.Name}]: Invalid constant name was provided`)
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
function ProfileStore.Test()
|
|
1238
|
+
return {
|
|
1239
|
+
ActiveSessionCheck = ActiveSessionCheck,
|
|
1240
|
+
AutoSaveList = AutoSaveList,
|
|
1241
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs,
|
|
1242
|
+
ActiveProfileSaveJobs = ActiveProfileSaveJobs,
|
|
1243
|
+
MockStore = MockStore,
|
|
1244
|
+
UserMockStore = UserMockStore,
|
|
1245
|
+
UpdateQueue = UpdateQueue,
|
|
1246
|
+
}
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
function ProfileStore.New(store_name, template)
|
|
1250
|
+
|
|
1251
|
+
template = template or {}
|
|
1252
|
+
|
|
1253
|
+
if type(store_name) ~= "string" then
|
|
1254
|
+
error(`[{script.Name}]: Invalid or missing "store_name"`)
|
|
1255
|
+
elseif string.len(store_name) == 0 then
|
|
1256
|
+
error(`[{script.Name}]: store_name cannot be an empty string`)
|
|
1257
|
+
elseif string.len(store_name) > 50 then
|
|
1258
|
+
error(`[{script.Name}]: store_name is too long`)
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
if type(template) ~= "table" then
|
|
1262
|
+
error(`[{script.Name}]: Invalid template argument`)
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
local self
|
|
1266
|
+
self = {
|
|
1267
|
+
|
|
1268
|
+
Mock = {
|
|
1269
|
+
|
|
1270
|
+
Name = store_name,
|
|
1271
|
+
|
|
1272
|
+
StartSessionAsync = function(_, profile_key)
|
|
1273
|
+
MockFlag = true
|
|
1274
|
+
return self:StartSessionAsync(profile_key)
|
|
1275
|
+
end,
|
|
1276
|
+
MessageAsync = function(_, profile_key, message)
|
|
1277
|
+
MockFlag = true
|
|
1278
|
+
return self:MessageAsync(profile_key, message)
|
|
1279
|
+
end,
|
|
1280
|
+
GetAsync = function(_, profile_key, version)
|
|
1281
|
+
MockFlag = true
|
|
1282
|
+
return self:GetAsync(profile_key, version)
|
|
1283
|
+
end,
|
|
1284
|
+
VersionQuery = function(_, profile_key, sort_direction, min_date, max_date)
|
|
1285
|
+
MockFlag = true
|
|
1286
|
+
return self:VersionQuery(profile_key, sort_direction, min_date, max_date)
|
|
1287
|
+
end,
|
|
1288
|
+
RemoveAsync = function(_, profile_key)
|
|
1289
|
+
MockFlag = true
|
|
1290
|
+
return self:RemoveAsync(profile_key)
|
|
1291
|
+
end
|
|
1292
|
+
},
|
|
1293
|
+
|
|
1294
|
+
Name = store_name,
|
|
1295
|
+
|
|
1296
|
+
template = template,
|
|
1297
|
+
data_store = nil,
|
|
1298
|
+
load_jobs = {},
|
|
1299
|
+
mock_load_jobs = {},
|
|
1300
|
+
is_ready = true,
|
|
1301
|
+
|
|
1302
|
+
}
|
|
1303
|
+
setmetatable(self, ProfileStore)
|
|
1304
|
+
|
|
1305
|
+
local options = Instance.new("DataStoreOptions")
|
|
1306
|
+
options:SetExperimentalFeatures({v2 = true})
|
|
1307
|
+
|
|
1308
|
+
if DataStoreState == "NotReady" then
|
|
1309
|
+
|
|
1310
|
+
-- The module is not sure whether DataStores are accessible yet:
|
|
1311
|
+
|
|
1312
|
+
self.is_ready = false
|
|
1313
|
+
|
|
1314
|
+
task.spawn(function()
|
|
1315
|
+
|
|
1316
|
+
repeat task.wait() until DataStoreState ~= "NotReady"
|
|
1317
|
+
|
|
1318
|
+
if DataStoreState == "Access" then
|
|
1319
|
+
self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
self.is_ready = true
|
|
1323
|
+
|
|
1324
|
+
end)
|
|
1325
|
+
|
|
1326
|
+
elseif DataStoreState == "Access" then
|
|
1327
|
+
|
|
1328
|
+
self.data_store = DataStoreService:GetDataStore(store_name, nil, options)
|
|
1329
|
+
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
return self
|
|
1333
|
+
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
local function RobloxMessageSubscription(profile, unique_session_id)
|
|
1337
|
+
|
|
1338
|
+
local last_roblox_message = 0
|
|
1339
|
+
|
|
1340
|
+
local roblox_message_subscription = MessagingService:SubscribeAsync("PS_" .. unique_session_id, function(message)
|
|
1341
|
+
if type(message.Data) == "table" and message.Data.LoadCount == profile.SessionLoadCount then
|
|
1342
|
+
-- High reaction rate, based on numPlayers × 10 DataStore budget as of writing
|
|
1343
|
+
if os.clock() - last_roblox_message > 6 then
|
|
1344
|
+
last_roblox_message = os.clock()
|
|
1345
|
+
if profile:IsActive() == true then
|
|
1346
|
+
if message.Data.EndSession == true then
|
|
1347
|
+
SaveProfileAsync(profile, true, false, "External")
|
|
1348
|
+
else
|
|
1349
|
+
profile:Save()
|
|
1350
|
+
end
|
|
1351
|
+
end
|
|
1352
|
+
end
|
|
1353
|
+
end
|
|
1354
|
+
end)
|
|
1355
|
+
|
|
1356
|
+
if profile:IsActive() == true then
|
|
1357
|
+
profile.roblox_message_subscription = roblox_message_subscription
|
|
1358
|
+
else
|
|
1359
|
+
roblox_message_subscription:Disconnect()
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
function ProfileStore:StartSessionAsync(profile_key, params)
|
|
1365
|
+
|
|
1366
|
+
local is_mock = ReadMockFlag()
|
|
1367
|
+
|
|
1368
|
+
if type(profile_key) ~= "string" then
|
|
1369
|
+
error(`[{script.Name}]: profile_key must be a string`)
|
|
1370
|
+
elseif string.len(profile_key) == 0 then
|
|
1371
|
+
error(`[{script.Name}]: Invalid profile_key`)
|
|
1372
|
+
elseif string.len(profile_key) > 50 then
|
|
1373
|
+
error(`[{script.Name}]: profile_key is too long`)
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
if params ~= nil and type(params) ~= "table" then
|
|
1377
|
+
error(`[{script.Name}]: Invalid params`)
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
params = params or {}
|
|
1381
|
+
|
|
1382
|
+
if ProfileStore.IsClosing == true then
|
|
1383
|
+
return nil
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
WaitForStoreReady(self)
|
|
1387
|
+
|
|
1388
|
+
local session_token = SessionToken(self.Name, profile_key, is_mock)
|
|
1389
|
+
|
|
1390
|
+
if ActiveSessionCheck[session_token] ~= nil then
|
|
1391
|
+
error(`[{script.Name}]: Profile (STORE:{self.Name}; KEY:{profile_key}) is already loaded in this session`)
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs + 1
|
|
1395
|
+
|
|
1396
|
+
local is_user_cancel = false
|
|
1397
|
+
|
|
1398
|
+
local function cancel_condition()
|
|
1399
|
+
if is_user_cancel == false then
|
|
1400
|
+
if params.Cancel ~= nil then
|
|
1401
|
+
is_user_cancel = params.Cancel() == true
|
|
1402
|
+
end
|
|
1403
|
+
return is_user_cancel
|
|
1404
|
+
end
|
|
1405
|
+
return true
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
local user_steal = params.Steal == true
|
|
1409
|
+
|
|
1410
|
+
local force_load_steps = 0 -- Session conflict handling values
|
|
1411
|
+
local request_force_load = true
|
|
1412
|
+
local steal_session = false
|
|
1413
|
+
|
|
1414
|
+
local start = os.clock()
|
|
1415
|
+
local exp_backoff = 1
|
|
1416
|
+
|
|
1417
|
+
while ProfileStore.IsClosing == false and cancel_condition() == false do
|
|
1418
|
+
|
|
1419
|
+
-- Load profile:
|
|
1420
|
+
|
|
1421
|
+
-- SPECIAL CASE - If StartSessionAsync is called for the same key again before another StartSessionAsync finishes,
|
|
1422
|
+
-- grab the DataStore return for the new call. The early call will return nil. This is supposed to retain
|
|
1423
|
+
-- expected and efficient behavior in cases where a player would quickly rejoin the same server.
|
|
1424
|
+
|
|
1425
|
+
LoadIndex += 1
|
|
1426
|
+
local load_id = LoadIndex
|
|
1427
|
+
local profile_load_jobs = is_mock == true and self.mock_load_jobs or self.load_jobs
|
|
1428
|
+
local profile_load_job = profile_load_jobs[profile_key] -- {load_id, {loaded_data, key_info} or nil}
|
|
1429
|
+
|
|
1430
|
+
local loaded_data, key_info
|
|
1431
|
+
local unique_session_id = HttpService:GenerateGUID(false)
|
|
1432
|
+
|
|
1433
|
+
if profile_load_job ~= nil then
|
|
1434
|
+
|
|
1435
|
+
profile_load_job[1] = load_id -- Steal load job
|
|
1436
|
+
while profile_load_job[2] == nil do -- Wait for job to finish
|
|
1437
|
+
task.wait()
|
|
1438
|
+
end
|
|
1439
|
+
if profile_load_job[1] == load_id then -- Load job hasn't been double-stolen
|
|
1440
|
+
loaded_data, key_info = table.unpack(profile_load_job[2])
|
|
1441
|
+
profile_load_jobs[profile_key] = nil
|
|
1442
|
+
else
|
|
1443
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1444
|
+
return nil
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
else
|
|
1448
|
+
|
|
1449
|
+
profile_load_job = {load_id, nil}
|
|
1450
|
+
profile_load_jobs[profile_key] = profile_load_job
|
|
1451
|
+
|
|
1452
|
+
profile_load_job[2] = table.pack(UpdateAsync(
|
|
1453
|
+
self,
|
|
1454
|
+
profile_key,
|
|
1455
|
+
{
|
|
1456
|
+
ExistingProfileHandle = function(latest_data)
|
|
1457
|
+
|
|
1458
|
+
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1459
|
+
return
|
|
1460
|
+
end
|
|
1461
|
+
|
|
1462
|
+
local active_session = latest_data.MetaData.ActiveSession
|
|
1463
|
+
local force_load_session = latest_data.MetaData.ForceLoadSession
|
|
1464
|
+
|
|
1465
|
+
if active_session == nil then
|
|
1466
|
+
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1467
|
+
latest_data.MetaData.ForceLoadSession = nil
|
|
1468
|
+
elseif type(active_session) == "table" then
|
|
1469
|
+
if IsThisSession(active_session) == false then
|
|
1470
|
+
local last_update = latest_data.MetaData.LastUpdate
|
|
1471
|
+
if last_update ~= nil then
|
|
1472
|
+
if os.time() - last_update > ASSUME_DEAD then
|
|
1473
|
+
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1474
|
+
latest_data.MetaData.ForceLoadSession = nil
|
|
1475
|
+
return
|
|
1476
|
+
end
|
|
1477
|
+
end
|
|
1478
|
+
if steal_session == true or user_steal == true then
|
|
1479
|
+
local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
|
|
1480
|
+
if force_load_interrupted == false or user_steal == true then
|
|
1481
|
+
latest_data.MetaData.ActiveSession = {PlaceId, JobId, unique_session_id}
|
|
1482
|
+
latest_data.MetaData.ForceLoadSession = nil
|
|
1483
|
+
end
|
|
1484
|
+
elseif request_force_load == true then
|
|
1485
|
+
latest_data.MetaData.ForceLoadSession = {PlaceId, JobId}
|
|
1486
|
+
end
|
|
1487
|
+
else
|
|
1488
|
+
latest_data.MetaData.ForceLoadSession = nil
|
|
1489
|
+
end
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
end,
|
|
1493
|
+
MissingProfileHandle = function(latest_data)
|
|
1494
|
+
|
|
1495
|
+
local is_cancel = ProfileStore.IsClosing == true or cancel_condition() == true
|
|
1496
|
+
|
|
1497
|
+
latest_data.Data = DeepCopyTable(self.template)
|
|
1498
|
+
latest_data.MetaData = {
|
|
1499
|
+
ProfileCreateTime = os.time(),
|
|
1500
|
+
SessionLoadCount = 0,
|
|
1501
|
+
ActiveSession = if is_cancel == false then {PlaceId, JobId, unique_session_id} else nil,
|
|
1502
|
+
ForceLoadSession = nil,
|
|
1503
|
+
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
end,
|
|
1507
|
+
EditProfile = function(latest_data)
|
|
1508
|
+
|
|
1509
|
+
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1510
|
+
return
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
local active_session = latest_data.MetaData.ActiveSession
|
|
1514
|
+
if active_session ~= nil and IsThisSession(active_session) == true then
|
|
1515
|
+
latest_data.MetaData.SessionLoadCount = latest_data.MetaData.SessionLoadCount + 1
|
|
1516
|
+
latest_data.MetaData.LastUpdate = os.time()
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
end,
|
|
1520
|
+
},
|
|
1521
|
+
is_mock
|
|
1522
|
+
))
|
|
1523
|
+
if profile_load_job[1] == load_id then -- Load job hasn't been stolen
|
|
1524
|
+
loaded_data, key_info = table.unpack(profile_load_job[2])
|
|
1525
|
+
profile_load_jobs[profile_key] = nil
|
|
1526
|
+
else
|
|
1527
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1528
|
+
return nil -- Load job stolen
|
|
1529
|
+
end
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
-- Handle load_data:
|
|
1533
|
+
|
|
1534
|
+
if loaded_data ~= nil and key_info ~= nil then
|
|
1535
|
+
local active_session = loaded_data.MetaData.ActiveSession
|
|
1536
|
+
if type(active_session) == "table" then
|
|
1537
|
+
|
|
1538
|
+
if IsThisSession(active_session) == true then
|
|
1539
|
+
|
|
1540
|
+
-- Profile is now taken by this session:
|
|
1541
|
+
|
|
1542
|
+
local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock, session_token)
|
|
1543
|
+
AddProfileToAutoSave(profile)
|
|
1544
|
+
|
|
1545
|
+
if is_mock ~= true and DataStoreState == "Access" then
|
|
1546
|
+
|
|
1547
|
+
-- Use MessagingService to quickly detect session conflicts and resolve them quickly:
|
|
1548
|
+
task.spawn(RobloxMessageSubscription, profile, unique_session_id) -- Blocking prevention
|
|
1549
|
+
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1553
|
+
-- The server has initiated a shutdown by the time this profile was loaded
|
|
1554
|
+
SaveProfileAsync(profile, true) -- Release profile and yield until the DataStore call is finished
|
|
1555
|
+
profile = nil -- Don't return the profile object
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1559
|
+
return profile
|
|
1560
|
+
|
|
1561
|
+
else
|
|
1562
|
+
|
|
1563
|
+
if ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1564
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1565
|
+
return nil
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
-- Profile is taken by some other session:
|
|
1569
|
+
|
|
1570
|
+
local force_load_session = loaded_data.MetaData.ForceLoadSession
|
|
1571
|
+
local force_load_interrupted = if force_load_session ~= nil then not IsThisSession(force_load_session) else true
|
|
1572
|
+
|
|
1573
|
+
if force_load_interrupted == false then
|
|
1574
|
+
|
|
1575
|
+
if request_force_load == false then
|
|
1576
|
+
force_load_steps = force_load_steps + 1
|
|
1577
|
+
if force_load_steps >= math.ceil(SESSION_STEAL / LOAD_REPEAT_PERIOD) then
|
|
1578
|
+
steal_session = true
|
|
1579
|
+
end
|
|
1580
|
+
end
|
|
1581
|
+
|
|
1582
|
+
-- Request the remote server to end its session:
|
|
1583
|
+
if type(active_session[3]) == "string" then
|
|
1584
|
+
local session_load_count = loaded_data.MetaData.SessionLoadCount or 0
|
|
1585
|
+
task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count, EndSession = true})
|
|
1586
|
+
end
|
|
1587
|
+
|
|
1588
|
+
-- Attempt to load the profile again after a delay
|
|
1589
|
+
local wait_until = os.clock() + if request_force_load == true then FIRST_LOAD_REPEAT else LOAD_REPEAT_PERIOD
|
|
1590
|
+
repeat task.wait() until os.clock() >= wait_until or ProfileStore.IsClosing == true
|
|
1591
|
+
|
|
1592
|
+
else
|
|
1593
|
+
-- Another session tried to load this profile:
|
|
1594
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1595
|
+
return nil
|
|
1596
|
+
end
|
|
1597
|
+
|
|
1598
|
+
request_force_load = false -- Only request a force load once
|
|
1599
|
+
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
else
|
|
1603
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1604
|
+
return nil -- In this scenario it is likely that this server started shutting down
|
|
1605
|
+
end
|
|
1606
|
+
else
|
|
1607
|
+
|
|
1608
|
+
-- A DataStore call has likely ended in an error:
|
|
1609
|
+
|
|
1610
|
+
local default_timeout = false
|
|
1611
|
+
|
|
1612
|
+
if params.Cancel == nil then
|
|
1613
|
+
default_timeout = os.clock() - start >= START_SESSION_TIMEOUT
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
if default_timeout == true or ProfileStore.IsClosing == true or cancel_condition() == true then
|
|
1617
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1618
|
+
return nil
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
task.wait(exp_backoff) -- Repeat the call shortly
|
|
1622
|
+
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1623
|
+
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
end
|
|
1627
|
+
|
|
1628
|
+
ActiveProfileLoadJobs = ActiveProfileLoadJobs - 1
|
|
1629
|
+
return nil -- Game started shutting down or the request was cancelled - don't return the profile
|
|
1630
|
+
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
function ProfileStore:MessageAsync(profile_key, message)
|
|
1634
|
+
|
|
1635
|
+
local is_mock = ReadMockFlag()
|
|
1636
|
+
|
|
1637
|
+
if type(profile_key) ~= "string" then
|
|
1638
|
+
error(`[{script.Name}]: profile_key must be a string`)
|
|
1639
|
+
elseif string.len(profile_key) == 0 then
|
|
1640
|
+
error(`[{script.Name}]: Invalid profile_key`)
|
|
1641
|
+
elseif string.len(profile_key) > 50 then
|
|
1642
|
+
error(`[{script.Name}]: profile_key is too long`)
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
if type(message) ~= "table" then
|
|
1646
|
+
error(`[{script.Name}]: message must be a table`)
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
if ProfileStore.IsClosing == true then
|
|
1650
|
+
return false
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
WaitForStoreReady(self)
|
|
1654
|
+
|
|
1655
|
+
local exp_backoff = 1
|
|
1656
|
+
|
|
1657
|
+
while ProfileStore.IsClosing == false do
|
|
1658
|
+
|
|
1659
|
+
-- Updating profile:
|
|
1660
|
+
|
|
1661
|
+
local loaded_data = UpdateAsync(
|
|
1662
|
+
self,
|
|
1663
|
+
profile_key,
|
|
1664
|
+
{
|
|
1665
|
+
ExistingProfileHandle = nil,
|
|
1666
|
+
MissingProfileHandle = nil,
|
|
1667
|
+
EditProfile = function(latest_data)
|
|
1668
|
+
|
|
1669
|
+
local global_updates = latest_data.GlobalUpdates
|
|
1670
|
+
local update_list = global_updates[2]
|
|
1671
|
+
--{
|
|
1672
|
+
-- update_index,
|
|
1673
|
+
-- {
|
|
1674
|
+
-- {update_index, data}, ...
|
|
1675
|
+
-- },
|
|
1676
|
+
--},
|
|
1677
|
+
|
|
1678
|
+
global_updates[1] += 1
|
|
1679
|
+
table.insert(update_list, {global_updates[1], message})
|
|
1680
|
+
|
|
1681
|
+
-- Clearing queue if above limit:
|
|
1682
|
+
|
|
1683
|
+
while #update_list > MAX_MESSAGE_QUEUE do
|
|
1684
|
+
table.remove(update_list, 1)
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
end,
|
|
1688
|
+
},
|
|
1689
|
+
is_mock
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
if loaded_data ~= nil then
|
|
1693
|
+
|
|
1694
|
+
local session_token = SessionToken(self.Name, profile_key, is_mock)
|
|
1695
|
+
|
|
1696
|
+
local profile = ActiveSessionCheck[session_token]
|
|
1697
|
+
|
|
1698
|
+
if profile ~= nil then
|
|
1699
|
+
|
|
1700
|
+
-- The message was sent to a profile that is active in this server:
|
|
1701
|
+
profile:Save()
|
|
1702
|
+
|
|
1703
|
+
else
|
|
1704
|
+
|
|
1705
|
+
local meta_data = loaded_data.MetaData or {}
|
|
1706
|
+
local active_session = meta_data.ActiveSession
|
|
1707
|
+
local session_load_count = meta_data.SessionLoadCount or 0
|
|
1708
|
+
|
|
1709
|
+
if type(active_session) == "table" and type(active_session[3]) == "string" then
|
|
1710
|
+
-- Request the remote server to auto-save sooner and receive the message:
|
|
1711
|
+
task.spawn(MessagingService.PublishAsync, MessagingService, "PS_" .. active_session[3], {LoadCount = session_load_count})
|
|
1712
|
+
end
|
|
1713
|
+
|
|
1714
|
+
end
|
|
1715
|
+
|
|
1716
|
+
return true
|
|
1717
|
+
|
|
1718
|
+
else
|
|
1719
|
+
|
|
1720
|
+
task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
|
|
1721
|
+
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1722
|
+
|
|
1723
|
+
end
|
|
1724
|
+
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
return false
|
|
1728
|
+
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
function ProfileStore:GetAsync(profile_key, version)
|
|
1732
|
+
|
|
1733
|
+
local is_mock = ReadMockFlag()
|
|
1734
|
+
|
|
1735
|
+
if type(profile_key) ~= "string" then
|
|
1736
|
+
error(`[{script.Name}]: profile_key must be a string`)
|
|
1737
|
+
elseif string.len(profile_key) == 0 then
|
|
1738
|
+
error(`[{script.Name}]: Invalid profile_key`)
|
|
1739
|
+
elseif string.len(profile_key) > 50 then
|
|
1740
|
+
error(`[{script.Name}]: profile_key is too long`)
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1743
|
+
if ProfileStore.IsClosing == true then
|
|
1744
|
+
return nil
|
|
1745
|
+
end
|
|
1746
|
+
|
|
1747
|
+
WaitForStoreReady(self)
|
|
1748
|
+
|
|
1749
|
+
if version ~= nil and (is_mock or DataStoreState ~= "Access") then
|
|
1750
|
+
return nil -- No version support in mock mode
|
|
1751
|
+
end
|
|
1752
|
+
|
|
1753
|
+
local exp_backoff = 1
|
|
1754
|
+
|
|
1755
|
+
while ProfileStore.IsClosing == false do
|
|
1756
|
+
|
|
1757
|
+
-- Load profile:
|
|
1758
|
+
|
|
1759
|
+
local loaded_data, key_info = UpdateAsync(
|
|
1760
|
+
self,
|
|
1761
|
+
profile_key,
|
|
1762
|
+
{
|
|
1763
|
+
ExistingProfileHandle = nil,
|
|
1764
|
+
MissingProfileHandle = function(latest_data)
|
|
1765
|
+
|
|
1766
|
+
latest_data.Data = DeepCopyTable(self.template)
|
|
1767
|
+
latest_data.MetaData = {
|
|
1768
|
+
ProfileCreateTime = os.time(),
|
|
1769
|
+
SessionLoadCount = 0,
|
|
1770
|
+
ActiveSession = nil,
|
|
1771
|
+
ForceLoadSession = nil,
|
|
1772
|
+
MetaTags = {}, -- Backwards compatibility with ProfileService
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
end,
|
|
1776
|
+
EditProfile = nil,
|
|
1777
|
+
},
|
|
1778
|
+
is_mock,
|
|
1779
|
+
true, -- Use :GetAsync()
|
|
1780
|
+
version -- DataStore key version
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
-- Handle load_data:
|
|
1784
|
+
|
|
1785
|
+
if loaded_data ~= nil then
|
|
1786
|
+
|
|
1787
|
+
if key_info == nil then
|
|
1788
|
+
return nil -- Load was successful, but the key was empty - return no profile object
|
|
1789
|
+
end
|
|
1790
|
+
|
|
1791
|
+
local profile = Profile.New(loaded_data, key_info, self, profile_key, is_mock)
|
|
1792
|
+
profile.view_mode = true
|
|
1793
|
+
|
|
1794
|
+
return profile
|
|
1795
|
+
|
|
1796
|
+
else
|
|
1797
|
+
|
|
1798
|
+
task.wait(exp_backoff) -- A DataStore call has likely ended in an error - repeat the call shortly
|
|
1799
|
+
exp_backoff = math.min(20, exp_backoff * 2)
|
|
1800
|
+
|
|
1801
|
+
end
|
|
1802
|
+
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
return nil -- Game started shutting down - don't return the profile
|
|
1806
|
+
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1809
|
+
function ProfileStore:RemoveAsync(profile_key)
|
|
1810
|
+
|
|
1811
|
+
local is_mock = ReadMockFlag()
|
|
1812
|
+
|
|
1813
|
+
if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
|
|
1814
|
+
error(`[{script.Name}]: Invalid profile_key`)
|
|
1815
|
+
end
|
|
1816
|
+
|
|
1817
|
+
if ProfileStore.IsClosing == true then
|
|
1818
|
+
return false
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1821
|
+
WaitForStoreReady(self)
|
|
1822
|
+
|
|
1823
|
+
local wipe_status = false
|
|
1824
|
+
|
|
1825
|
+
local next_in_queue = WaitInUpdateQueue(SessionToken(self.Name, profile_key, is_mock))
|
|
1826
|
+
|
|
1827
|
+
if is_mock == true then -- Used when the profile is accessed through ProfileStore.Mock
|
|
1828
|
+
|
|
1829
|
+
local mock_data_store = UserMockStore[self.Name]
|
|
1830
|
+
|
|
1831
|
+
if mock_data_store ~= nil then
|
|
1832
|
+
mock_data_store[profile_key] = nil
|
|
1833
|
+
if next(mock_data_store) == nil then
|
|
1834
|
+
UserMockStore[self.Name] = nil
|
|
1835
|
+
end
|
|
1836
|
+
end
|
|
1837
|
+
|
|
1838
|
+
wipe_status = true
|
|
1839
|
+
task.wait() -- Simulate API call yield
|
|
1840
|
+
|
|
1841
|
+
elseif DataStoreState ~= "Access" then -- Used when API access is disabled
|
|
1842
|
+
|
|
1843
|
+
local mock_data_store = MockStore[self.Name]
|
|
1844
|
+
|
|
1845
|
+
if mock_data_store ~= nil then
|
|
1846
|
+
mock_data_store[profile_key] = nil
|
|
1847
|
+
if next(mock_data_store) == nil then
|
|
1848
|
+
MockStore[self.Name] = nil
|
|
1849
|
+
end
|
|
1850
|
+
end
|
|
1851
|
+
|
|
1852
|
+
wipe_status = true
|
|
1853
|
+
task.wait() -- Simulate API call yield
|
|
1854
|
+
|
|
1855
|
+
else -- Live DataStore
|
|
1856
|
+
|
|
1857
|
+
wipe_status = pcall(function()
|
|
1858
|
+
self.data_store:RemoveAsync(profile_key)
|
|
1859
|
+
end)
|
|
1860
|
+
|
|
1861
|
+
end
|
|
1862
|
+
|
|
1863
|
+
next_in_queue()
|
|
1864
|
+
|
|
1865
|
+
return wipe_status
|
|
1866
|
+
|
|
1867
|
+
end
|
|
1868
|
+
|
|
1869
|
+
local ProfileVersionQuery = {}
|
|
1870
|
+
ProfileVersionQuery.__index = ProfileVersionQuery
|
|
1871
|
+
|
|
1872
|
+
function ProfileVersionQuery.New(profile_store, profile_key, sort_direction, min_date, max_date, is_mock)
|
|
1873
|
+
|
|
1874
|
+
local self = {
|
|
1875
|
+
profile_store = profile_store,
|
|
1876
|
+
profile_key = profile_key,
|
|
1877
|
+
sort_direction = sort_direction,
|
|
1878
|
+
min_date = min_date,
|
|
1879
|
+
max_date = max_date,
|
|
1880
|
+
|
|
1881
|
+
query_pages = nil,
|
|
1882
|
+
query_index = 0,
|
|
1883
|
+
query_failure = false,
|
|
1884
|
+
|
|
1885
|
+
is_query_yielded = false,
|
|
1886
|
+
query_queue = {},
|
|
1887
|
+
|
|
1888
|
+
is_mock = is_mock,
|
|
1889
|
+
}
|
|
1890
|
+
setmetatable(self, ProfileVersionQuery)
|
|
1891
|
+
|
|
1892
|
+
return self
|
|
1893
|
+
|
|
1894
|
+
end
|
|
1895
|
+
|
|
1896
|
+
function MoveVersionQueryQueue(self) -- Hidden ProfileVersionQuery method
|
|
1897
|
+
while #self.query_queue > 0 do
|
|
1898
|
+
|
|
1899
|
+
local queue_entry = table.remove(self.query_queue, 1)
|
|
1900
|
+
|
|
1901
|
+
task.spawn(queue_entry)
|
|
1902
|
+
|
|
1903
|
+
if self.is_query_yielded == true then
|
|
1904
|
+
break
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
end
|
|
1908
|
+
end
|
|
1909
|
+
|
|
1910
|
+
local VersionQueryNextAsyncStackingFlag = false
|
|
1911
|
+
local WarnAboutVersionQueryOnce = false
|
|
1912
|
+
|
|
1913
|
+
function ProfileVersionQuery:NextAsync()
|
|
1914
|
+
|
|
1915
|
+
local is_stacking = VersionQueryNextAsyncStackingFlag == true
|
|
1916
|
+
VersionQueryNextAsyncStackingFlag = false
|
|
1917
|
+
|
|
1918
|
+
WaitForStoreReady(self.profile_store)
|
|
1919
|
+
|
|
1920
|
+
if ProfileStore.IsClosing == true then
|
|
1921
|
+
return nil -- Silently fail :NextAsync() requests
|
|
1922
|
+
end
|
|
1923
|
+
|
|
1924
|
+
if self.is_mock == true or DataStoreState ~= "Access" then
|
|
1925
|
+
if IsStudio == true and WarnAboutVersionQueryOnce == false then
|
|
1926
|
+
WarnAboutVersionQueryOnce = true
|
|
1927
|
+
warn(`[{script.Name}]: :VersionQuery() is not supported in mock mode!`)
|
|
1928
|
+
end
|
|
1929
|
+
return nil -- Silently fail :NextAsync() requests
|
|
1930
|
+
end
|
|
1931
|
+
|
|
1932
|
+
local profile
|
|
1933
|
+
local is_finished = false
|
|
1934
|
+
|
|
1935
|
+
local function query_job()
|
|
1936
|
+
|
|
1937
|
+
if self.query_failure == true then
|
|
1938
|
+
is_finished = true
|
|
1939
|
+
return
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
-- First "next" call loads version pages:
|
|
1943
|
+
|
|
1944
|
+
if self.query_pages == nil then
|
|
1945
|
+
|
|
1946
|
+
self.is_query_yielded = true
|
|
1947
|
+
|
|
1948
|
+
task.spawn(function()
|
|
1949
|
+
VersionQueryNextAsyncStackingFlag = true
|
|
1950
|
+
profile = self:NextAsync()
|
|
1951
|
+
is_finished = true
|
|
1952
|
+
end)
|
|
1953
|
+
|
|
1954
|
+
local list_success, error_message = pcall(function()
|
|
1955
|
+
self.query_pages = self.profile_store.data_store:ListVersionsAsync(
|
|
1956
|
+
self.profile_key,
|
|
1957
|
+
self.sort_direction,
|
|
1958
|
+
self.min_date,
|
|
1959
|
+
self.max_date
|
|
1960
|
+
)
|
|
1961
|
+
self.query_index = 0
|
|
1962
|
+
end)
|
|
1963
|
+
|
|
1964
|
+
if list_success == false or self.query_pages == nil then
|
|
1965
|
+
warn(`[{script.Name}]: Version query fail - {tostring(error_message)}`)
|
|
1966
|
+
self.query_failure = true
|
|
1967
|
+
end
|
|
1968
|
+
|
|
1969
|
+
self.is_query_yielded = false
|
|
1970
|
+
|
|
1971
|
+
MoveVersionQueryQueue(self)
|
|
1972
|
+
|
|
1973
|
+
return
|
|
1974
|
+
|
|
1975
|
+
end
|
|
1976
|
+
|
|
1977
|
+
local current_page = self.query_pages:GetCurrentPage()
|
|
1978
|
+
local next_item = current_page[self.query_index + 1]
|
|
1979
|
+
|
|
1980
|
+
-- No more entries:
|
|
1981
|
+
|
|
1982
|
+
if self.query_pages.IsFinished == true and next_item == nil then
|
|
1983
|
+
is_finished = true
|
|
1984
|
+
return
|
|
1985
|
+
end
|
|
1986
|
+
|
|
1987
|
+
-- Load next page when this page is over:
|
|
1988
|
+
|
|
1989
|
+
if next_item == nil then
|
|
1990
|
+
|
|
1991
|
+
self.is_query_yielded = true
|
|
1992
|
+
task.spawn(function()
|
|
1993
|
+
VersionQueryNextAsyncStackingFlag = true
|
|
1994
|
+
profile = self:NextAsync()
|
|
1995
|
+
is_finished = true
|
|
1996
|
+
end)
|
|
1997
|
+
|
|
1998
|
+
local success, error_message = pcall(function()
|
|
1999
|
+
self.query_pages:AdvanceToNextPageAsync()
|
|
2000
|
+
self.query_index = 0
|
|
2001
|
+
end)
|
|
2002
|
+
|
|
2003
|
+
if success == false or #self.query_pages:GetCurrentPage() == 0 then
|
|
2004
|
+
self.query_failure = true
|
|
2005
|
+
end
|
|
2006
|
+
|
|
2007
|
+
self.is_query_yielded = false
|
|
2008
|
+
MoveVersionQueryQueue(self)
|
|
2009
|
+
|
|
2010
|
+
return
|
|
2011
|
+
|
|
2012
|
+
end
|
|
2013
|
+
|
|
2014
|
+
-- Next page item:
|
|
2015
|
+
|
|
2016
|
+
self.query_index += 1
|
|
2017
|
+
profile = self.profile_store:GetAsync(self.profile_key, next_item.Version)
|
|
2018
|
+
is_finished = true
|
|
2019
|
+
|
|
2020
|
+
end
|
|
2021
|
+
|
|
2022
|
+
if self.is_query_yielded == false then
|
|
2023
|
+
query_job()
|
|
2024
|
+
else
|
|
2025
|
+
if is_stacking == true then
|
|
2026
|
+
table.insert(self.query_queue, 1, query_job)
|
|
2027
|
+
else
|
|
2028
|
+
table.insert(self.query_queue, query_job)
|
|
2029
|
+
end
|
|
2030
|
+
end
|
|
2031
|
+
|
|
2032
|
+
while is_finished == false do
|
|
2033
|
+
task.wait()
|
|
2034
|
+
end
|
|
2035
|
+
|
|
2036
|
+
return profile
|
|
2037
|
+
|
|
2038
|
+
end
|
|
2039
|
+
|
|
2040
|
+
function ProfileStore:VersionQuery(profile_key, sort_direction, min_date, max_date)
|
|
2041
|
+
|
|
2042
|
+
local is_mock = ReadMockFlag()
|
|
2043
|
+
|
|
2044
|
+
if type(profile_key) ~= "string" or string.len(profile_key) == 0 then
|
|
2045
|
+
error(`[{script.Name}]: Invalid profile_key`)
|
|
2046
|
+
end
|
|
2047
|
+
|
|
2048
|
+
-- Type check:
|
|
2049
|
+
|
|
2050
|
+
if sort_direction ~= nil and (typeof(sort_direction) ~= "EnumItem"
|
|
2051
|
+
or sort_direction.EnumType ~= Enum.SortDirection) then
|
|
2052
|
+
error(`[{script.Name}]: Invalid sort_direction ({tostring(sort_direction)})`)
|
|
2053
|
+
end
|
|
2054
|
+
|
|
2055
|
+
if min_date ~= nil and typeof(min_date) ~= "DateTime" and typeof(min_date) ~= "number" then
|
|
2056
|
+
error(`[{script.Name}]: Invalid min_date ({tostring(min_date)})`)
|
|
2057
|
+
end
|
|
2058
|
+
|
|
2059
|
+
if max_date ~= nil and typeof(max_date) ~= "DateTime" and typeof(max_date) ~= "number" then
|
|
2060
|
+
error(`[{script.Name}]: Invalid max_date ({tostring(max_date)})`)
|
|
2061
|
+
end
|
|
2062
|
+
|
|
2063
|
+
min_date = typeof(min_date) == "DateTime" and min_date.UnixTimestampMillis or min_date
|
|
2064
|
+
max_date = typeof(max_date) == "DateTime" and max_date.UnixTimestampMillis or max_date
|
|
2065
|
+
|
|
2066
|
+
return ProfileVersionQuery.New(self, profile_key, sort_direction, min_date, max_date, is_mock)
|
|
2067
|
+
|
|
2068
|
+
end
|
|
2069
|
+
|
|
2070
|
+
-- DataStore API access check:
|
|
2071
|
+
|
|
2072
|
+
if IsStudio == true then
|
|
2073
|
+
|
|
2074
|
+
task.spawn(function()
|
|
2075
|
+
|
|
2076
|
+
local new_state = "NoAccess"
|
|
2077
|
+
|
|
2078
|
+
local status, message = pcall(function()
|
|
2079
|
+
-- This will error if current instance has no Studio API access:
|
|
2080
|
+
DataStoreService:GetDataStore("____PS"):SetAsync("____PS", os.time())
|
|
2081
|
+
end)
|
|
2082
|
+
|
|
2083
|
+
local no_internet_access = status == false and string.find(message, "ConnectFail", 1, true) ~= nil
|
|
2084
|
+
|
|
2085
|
+
if no_internet_access == true then
|
|
2086
|
+
warn(`[{script.Name}]: No internet access - check your network connection`)
|
|
2087
|
+
end
|
|
2088
|
+
|
|
2089
|
+
if status == false and
|
|
2090
|
+
(string.find(message, "403", 1, true) ~= nil or -- Cannot write to DataStore from studio if API access is not enabled
|
|
2091
|
+
string.find(message, "must publish", 1, true) ~= nil or -- Game must be published to access live keys
|
|
2092
|
+
no_internet_access == true) then -- No internet access
|
|
2093
|
+
|
|
2094
|
+
new_state = if no_internet_access == true then "NoInternet" else "NoAccess"
|
|
2095
|
+
print(`[{script.Name}]: Roblox API services unavailable - data will not be saved`)
|
|
2096
|
+
else
|
|
2097
|
+
new_state = "Access"
|
|
2098
|
+
print(`[{script.Name}]: Roblox API services available - data will be saved`)
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
DataStoreState = new_state
|
|
2102
|
+
ProfileStore.DataStoreState = new_state
|
|
2103
|
+
|
|
2104
|
+
end)
|
|
2105
|
+
|
|
2106
|
+
else
|
|
2107
|
+
|
|
2108
|
+
DataStoreState = "Access"
|
|
2109
|
+
ProfileStore.DataStoreState = "Access"
|
|
2110
|
+
|
|
2111
|
+
end
|
|
2112
|
+
|
|
2113
|
+
-- Update loop:
|
|
2114
|
+
|
|
2115
|
+
RunService.Heartbeat:Connect(function()
|
|
2116
|
+
|
|
2117
|
+
-- Auto saving:
|
|
2118
|
+
|
|
2119
|
+
local auto_save_list_length = #AutoSaveList
|
|
2120
|
+
if auto_save_list_length > 0 then
|
|
2121
|
+
local auto_save_index_speed = AUTO_SAVE_PERIOD / auto_save_list_length
|
|
2122
|
+
local os_clock = os.clock()
|
|
2123
|
+
while os_clock - LastAutoSave > auto_save_index_speed do
|
|
2124
|
+
LastAutoSave = LastAutoSave + auto_save_index_speed
|
|
2125
|
+
local profile = AutoSaveList[AutoSaveIndex]
|
|
2126
|
+
if os_clock - profile.load_timestamp < AUTO_SAVE_PERIOD / 2 then
|
|
2127
|
+
-- This profile is freshly loaded - auto saving immediately is not necessary:
|
|
2128
|
+
profile = nil
|
|
2129
|
+
for _ = 1, auto_save_list_length - 1 do
|
|
2130
|
+
-- Move auto save index to the right:
|
|
2131
|
+
AutoSaveIndex = AutoSaveIndex + 1
|
|
2132
|
+
if AutoSaveIndex > auto_save_list_length then
|
|
2133
|
+
AutoSaveIndex = 1
|
|
2134
|
+
end
|
|
2135
|
+
profile = AutoSaveList[AutoSaveIndex]
|
|
2136
|
+
if os_clock - profile.load_timestamp >= AUTO_SAVE_PERIOD / 2 then
|
|
2137
|
+
break
|
|
2138
|
+
else
|
|
2139
|
+
profile = nil
|
|
2140
|
+
end
|
|
2141
|
+
end
|
|
2142
|
+
end
|
|
2143
|
+
-- Move auto save index to the right:
|
|
2144
|
+
AutoSaveIndex = AutoSaveIndex + 1
|
|
2145
|
+
if AutoSaveIndex > auto_save_list_length then
|
|
2146
|
+
AutoSaveIndex = 1
|
|
2147
|
+
end
|
|
2148
|
+
-- Perform save call:
|
|
2149
|
+
if profile ~= nil then
|
|
2150
|
+
task.spawn(SaveProfileAsync, profile) -- Auto save profile in new thread
|
|
2151
|
+
end
|
|
2152
|
+
end
|
|
2153
|
+
end
|
|
2154
|
+
|
|
2155
|
+
-- Critical state handling:
|
|
2156
|
+
|
|
2157
|
+
if ProfileStore.IsCriticalState == false then
|
|
2158
|
+
if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
|
|
2159
|
+
ProfileStore.IsCriticalState = true
|
|
2160
|
+
ProfileStore.OnCriticalToggle:Fire(true)
|
|
2161
|
+
CriticalStateStart = os.clock()
|
|
2162
|
+
warn(`[{script.Name}]: Entered critical state`)
|
|
2163
|
+
end
|
|
2164
|
+
else
|
|
2165
|
+
if #IssueQueue >= CRITICAL_STATE_ERROR_COUNT then
|
|
2166
|
+
CriticalStateStart = os.clock()
|
|
2167
|
+
elseif os.clock() - CriticalStateStart > CRITICAL_STATE_EXPIRE then
|
|
2168
|
+
ProfileStore.IsCriticalState = false
|
|
2169
|
+
ProfileStore.OnCriticalToggle:Fire(false)
|
|
2170
|
+
warn(`[{script.Name}]: Critical state ended`)
|
|
2171
|
+
end
|
|
2172
|
+
end
|
|
2173
|
+
|
|
2174
|
+
-- Issue queue:
|
|
2175
|
+
|
|
2176
|
+
while true do
|
|
2177
|
+
local issue_time = IssueQueue[1]
|
|
2178
|
+
if issue_time == nil then
|
|
2179
|
+
break
|
|
2180
|
+
elseif os.clock() - issue_time > CRITICAL_STATE_ERROR_EXPIRE then
|
|
2181
|
+
table.remove(IssueQueue, 1)
|
|
2182
|
+
else
|
|
2183
|
+
break
|
|
2184
|
+
end
|
|
2185
|
+
end
|
|
2186
|
+
|
|
2187
|
+
end)
|
|
2188
|
+
|
|
2189
|
+
-- Release all loaded profiles when the server is shutting down:
|
|
2190
|
+
|
|
2191
|
+
task.spawn(function()
|
|
2192
|
+
|
|
2193
|
+
while DataStoreState == "NotReady" do
|
|
2194
|
+
task.wait()
|
|
2195
|
+
end
|
|
2196
|
+
|
|
2197
|
+
if DataStoreState ~= "Access" then
|
|
2198
|
+
|
|
2199
|
+
game:BindToClose(function()
|
|
2200
|
+
ProfileStore.IsClosing = true
|
|
2201
|
+
task.wait() -- Mock shutdown delay
|
|
2202
|
+
end)
|
|
2203
|
+
|
|
2204
|
+
return -- Don't wait for profiles to properly save in mock mode so studio could end the simulation faster
|
|
2205
|
+
|
|
2206
|
+
end
|
|
2207
|
+
|
|
2208
|
+
game:BindToClose(function()
|
|
2209
|
+
|
|
2210
|
+
ProfileStore.IsClosing = true
|
|
2211
|
+
|
|
2212
|
+
-- Release all active profiles:
|
|
2213
|
+
-- (Clone AutoSaveList to a new table because AutoSaveList changes when profiles are released)
|
|
2214
|
+
|
|
2215
|
+
local on_close_save_job_count = 0
|
|
2216
|
+
local active_profiles = {}
|
|
2217
|
+
for index, profile in ipairs(AutoSaveList) do
|
|
2218
|
+
active_profiles[index] = profile
|
|
2219
|
+
end
|
|
2220
|
+
|
|
2221
|
+
-- Release the profiles; Releasing profiles can trigger listeners that release other profiles, so check active state:
|
|
2222
|
+
for _, profile in ipairs(active_profiles) do
|
|
2223
|
+
if profile:IsActive() == true then
|
|
2224
|
+
on_close_save_job_count = on_close_save_job_count + 1
|
|
2225
|
+
task.spawn(function() -- Save profile on new thread
|
|
2226
|
+
SaveProfileAsync(profile, true, nil, "Shutdown")
|
|
2227
|
+
on_close_save_job_count = on_close_save_job_count - 1
|
|
2228
|
+
end)
|
|
2229
|
+
end
|
|
2230
|
+
end
|
|
2231
|
+
|
|
2232
|
+
-- Yield until all active profile jobs are finished:
|
|
2233
|
+
while on_close_save_job_count > 0 or ActiveProfileLoadJobs > 0 or ActiveProfileSaveJobs > 0 do
|
|
2234
|
+
task.wait()
|
|
2235
|
+
end
|
|
2236
|
+
|
|
2237
|
+
return -- We're done!
|
|
2238
|
+
|
|
2239
|
+
end)
|
|
2240
|
+
|
|
2241
|
+
end)
|
|
2242
|
+
|
|
2243
2243
|
return ProfileStore
|