roblox-opencode 1.0.0

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.
Files changed (248) hide show
  1. package/README.md +122 -0
  2. package/commands/setup-game.md +108 -0
  3. package/commands/sync-check.md +53 -0
  4. package/core/roblox-core.md +93 -0
  5. package/dist/server.js +167 -0
  6. package/package.json +35 -0
  7. package/skills/roblox-analytics/SKILL.md +277 -0
  8. package/skills/roblox-analytics/references/event-batcher.luau +75 -0
  9. package/skills/roblox-animation-vfx/SKILL.md +1325 -0
  10. package/skills/roblox-architecture/SKILL.md +863 -0
  11. package/skills/roblox-architecture/references/combat-systems.md +1381 -0
  12. package/skills/roblox-code-review/SKILL.md +687 -0
  13. package/skills/roblox-data/SKILL.md +889 -0
  14. package/skills/roblox-data/references/inventory-systems.md +1729 -0
  15. package/skills/roblox-debug/SKILL.md +99 -0
  16. package/skills/roblox-gui/SKILL.md +1103 -0
  17. package/skills/roblox-gui-fusion/SKILL.md +150 -0
  18. package/skills/roblox-gui-fusion/references/inventory.luau +427 -0
  19. package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -0
  20. package/skills/roblox-gui-fusion/references/shop.luau +411 -0
  21. package/skills/roblox-luau-mastery/SKILL.md +1519 -0
  22. package/skills/roblox-monetization/SKILL.md +1084 -0
  23. package/skills/roblox-monetization/references/process-receipt.luau +131 -0
  24. package/skills/roblox-networking/SKILL.md +669 -0
  25. package/skills/roblox-networking/references/remote-validator.luau +193 -0
  26. package/skills/roblox-publish-checklist/SKILL.md +128 -0
  27. package/skills/roblox-runtime/SKILL.md +753 -0
  28. package/skills/roblox-sharp-edges/SKILL.md +295 -0
  29. package/skills/roblox-sync/SKILL.md +126 -0
  30. package/skills/roblox-testing/SKILL.md +943 -0
  31. package/skills/roblox-tooling/SKILL.md +150 -0
  32. package/vendor/LICENSES/ProfileStore-LICENSE +201 -0
  33. package/vendor/LICENSES/RbxUtil-LICENSE +7 -0
  34. package/vendor/LICENSES/promise-LICENSE +21 -0
  35. package/vendor/LICENSES/t-LICENSE +21 -0
  36. package/vendor/LICENSES/testez-LICENSE +201 -0
  37. package/vendor/README.md +84 -0
  38. package/vendor/fusion/Animation/ExternalTime.luau +84 -0
  39. package/vendor/fusion/Animation/Spring.luau +322 -0
  40. package/vendor/fusion/Animation/Stopwatch.luau +128 -0
  41. package/vendor/fusion/Animation/Tween.luau +187 -0
  42. package/vendor/fusion/Animation/getTweenDuration.luau +27 -0
  43. package/vendor/fusion/Animation/getTweenRatio.luau +47 -0
  44. package/vendor/fusion/Animation/lerpType.luau +164 -0
  45. package/vendor/fusion/Animation/packType.luau +100 -0
  46. package/vendor/fusion/Animation/springCoefficients.luau +80 -0
  47. package/vendor/fusion/Animation/unpackType.luau +103 -0
  48. package/vendor/fusion/Colour/Oklab.luau +70 -0
  49. package/vendor/fusion/Colour/sRGB.luau +55 -0
  50. package/vendor/fusion/External.luau +168 -0
  51. package/vendor/fusion/ExternalDebug.luau +70 -0
  52. package/vendor/fusion/Graph/Observer.luau +114 -0
  53. package/vendor/fusion/Graph/castToGraph.luau +29 -0
  54. package/vendor/fusion/Graph/change.luau +81 -0
  55. package/vendor/fusion/Graph/depend.luau +33 -0
  56. package/vendor/fusion/Graph/evaluate.luau +56 -0
  57. package/vendor/fusion/Instances/Attribute.luau +58 -0
  58. package/vendor/fusion/Instances/AttributeChange.luau +47 -0
  59. package/vendor/fusion/Instances/AttributeOut.luau +63 -0
  60. package/vendor/fusion/Instances/Child.luau +21 -0
  61. package/vendor/fusion/Instances/Children.luau +148 -0
  62. package/vendor/fusion/Instances/Hydrate.luau +33 -0
  63. package/vendor/fusion/Instances/New.luau +53 -0
  64. package/vendor/fusion/Instances/OnChange.luau +50 -0
  65. package/vendor/fusion/Instances/OnEvent.luau +54 -0
  66. package/vendor/fusion/Instances/Out.luau +69 -0
  67. package/vendor/fusion/Instances/applyInstanceProps.luau +149 -0
  68. package/vendor/fusion/Instances/defaultProps.luau +194 -0
  69. package/vendor/fusion/LICENSE +21 -0
  70. package/vendor/fusion/Logging/formatError.luau +49 -0
  71. package/vendor/fusion/Logging/messages.luau +52 -0
  72. package/vendor/fusion/Logging/parseError.luau +25 -0
  73. package/vendor/fusion/Memory/checkLifetime.luau +134 -0
  74. package/vendor/fusion/Memory/deriveScope.luau +24 -0
  75. package/vendor/fusion/Memory/deriveScopeImpl.luau +45 -0
  76. package/vendor/fusion/Memory/doCleanup.luau +79 -0
  77. package/vendor/fusion/Memory/innerScope.luau +34 -0
  78. package/vendor/fusion/Memory/legacyCleanup.luau +18 -0
  79. package/vendor/fusion/Memory/needsDestruction.luau +17 -0
  80. package/vendor/fusion/Memory/poisonScope.luau +34 -0
  81. package/vendor/fusion/Memory/scopePool.luau +55 -0
  82. package/vendor/fusion/Memory/scoped.luau +27 -0
  83. package/vendor/fusion/Memory/whichLivesLonger.luau +75 -0
  84. package/vendor/fusion/RobloxExternal.luau +98 -0
  85. package/vendor/fusion/State/Computed.luau +139 -0
  86. package/vendor/fusion/State/For/Disassembly.luau +211 -0
  87. package/vendor/fusion/State/For/ForTypes.luau +30 -0
  88. package/vendor/fusion/State/For/init.luau +110 -0
  89. package/vendor/fusion/State/ForKeys.luau +94 -0
  90. package/vendor/fusion/State/ForPairs.luau +97 -0
  91. package/vendor/fusion/State/ForValues.luau +94 -0
  92. package/vendor/fusion/State/Value.luau +88 -0
  93. package/vendor/fusion/State/castToState.luau +26 -0
  94. package/vendor/fusion/State/peek.luau +31 -0
  95. package/vendor/fusion/State/updateAll.luau +1 -0
  96. package/vendor/fusion/Types.luau +314 -0
  97. package/vendor/fusion/Utility/Contextual.luau +91 -0
  98. package/vendor/fusion/Utility/Safe.luau +23 -0
  99. package/vendor/fusion/Utility/isSimilar.luau +29 -0
  100. package/vendor/fusion/Utility/merge.luau +35 -0
  101. package/vendor/fusion/Utility/nameOf.luau +35 -0
  102. package/vendor/fusion/Utility/never.luau +14 -0
  103. package/vendor/fusion/Utility/nicknames.luau +11 -0
  104. package/vendor/fusion/Utility/xtypeof.luau +27 -0
  105. package/vendor/fusion/init.luau +82 -0
  106. package/vendor/profilestore/init.luau +2243 -0
  107. package/vendor/promise/init.luau +1982 -0
  108. package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -0
  109. package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -0
  110. package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -0
  111. package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -0
  112. package/vendor/rbxutil/buffer-util/Types.luau +60 -0
  113. package/vendor/rbxutil/buffer-util/index.d.ts +153 -0
  114. package/vendor/rbxutil/buffer-util/init.luau +41 -0
  115. package/vendor/rbxutil/buffer-util/package.json +16 -0
  116. package/vendor/rbxutil/buffer-util/wally.toml +9 -0
  117. package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -0
  118. package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -0
  119. package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -0
  120. package/vendor/rbxutil/comm/Client/init.luau +135 -0
  121. package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -0
  122. package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -0
  123. package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -0
  124. package/vendor/rbxutil/comm/Server/init.luau +140 -0
  125. package/vendor/rbxutil/comm/Types.luau +18 -0
  126. package/vendor/rbxutil/comm/Util.luau +27 -0
  127. package/vendor/rbxutil/comm/init.luau +35 -0
  128. package/vendor/rbxutil/comm/wally.toml +13 -0
  129. package/vendor/rbxutil/component/init.luau +759 -0
  130. package/vendor/rbxutil/component/init.test.luau +311 -0
  131. package/vendor/rbxutil/component/wally.toml +14 -0
  132. package/vendor/rbxutil/concur/init.luau +542 -0
  133. package/vendor/rbxutil/concur/init.test.luau +364 -0
  134. package/vendor/rbxutil/concur/wally.toml +8 -0
  135. package/vendor/rbxutil/enum-list/init.luau +101 -0
  136. package/vendor/rbxutil/enum-list/init.test.luau +91 -0
  137. package/vendor/rbxutil/enum-list/wally.toml +8 -0
  138. package/vendor/rbxutil/find/index.d.ts +20 -0
  139. package/vendor/rbxutil/find/init.luau +44 -0
  140. package/vendor/rbxutil/find/package.json +17 -0
  141. package/vendor/rbxutil/find/wally.toml +8 -0
  142. package/vendor/rbxutil/input/Gamepad.luau +559 -0
  143. package/vendor/rbxutil/input/Keyboard.luau +124 -0
  144. package/vendor/rbxutil/input/Mouse.luau +278 -0
  145. package/vendor/rbxutil/input/PreferredInput.luau +91 -0
  146. package/vendor/rbxutil/input/Touch.luau +120 -0
  147. package/vendor/rbxutil/input/init.luau +33 -0
  148. package/vendor/rbxutil/input/wally.toml +12 -0
  149. package/vendor/rbxutil/loader/index.d.ts +15 -0
  150. package/vendor/rbxutil/loader/init.luau +137 -0
  151. package/vendor/rbxutil/loader/wally.toml +8 -0
  152. package/vendor/rbxutil/log/index.d.ts +38 -0
  153. package/vendor/rbxutil/log/init.luau +746 -0
  154. package/vendor/rbxutil/log/wally.toml +8 -0
  155. package/vendor/rbxutil/net/init.luau +190 -0
  156. package/vendor/rbxutil/net/wally.toml +8 -0
  157. package/vendor/rbxutil/option/index.d.ts +44 -0
  158. package/vendor/rbxutil/option/init.luau +489 -0
  159. package/vendor/rbxutil/option/init.test.luau +342 -0
  160. package/vendor/rbxutil/option/wally.toml +8 -0
  161. package/vendor/rbxutil/pid/index.d.ts +53 -0
  162. package/vendor/rbxutil/pid/init.luau +195 -0
  163. package/vendor/rbxutil/pid/package.json +16 -0
  164. package/vendor/rbxutil/pid/wally.toml +9 -0
  165. package/vendor/rbxutil/quaternion/index.d.ts +117 -0
  166. package/vendor/rbxutil/quaternion/init.luau +570 -0
  167. package/vendor/rbxutil/quaternion/package.json +16 -0
  168. package/vendor/rbxutil/quaternion/wally.toml +9 -0
  169. package/vendor/rbxutil/query/index.d.ts +43 -0
  170. package/vendor/rbxutil/query/init.luau +117 -0
  171. package/vendor/rbxutil/query/package.json +18 -0
  172. package/vendor/rbxutil/query/wally.toml +9 -0
  173. package/vendor/rbxutil/sequent/index.d.ts +28 -0
  174. package/vendor/rbxutil/sequent/init.luau +340 -0
  175. package/vendor/rbxutil/sequent/package.json +16 -0
  176. package/vendor/rbxutil/sequent/wally.toml +9 -0
  177. package/vendor/rbxutil/ser/init.luau +175 -0
  178. package/vendor/rbxutil/ser/init.test.luau +50 -0
  179. package/vendor/rbxutil/ser/wally.toml +11 -0
  180. package/vendor/rbxutil/shake/index.d.ts +36 -0
  181. package/vendor/rbxutil/shake/init.luau +532 -0
  182. package/vendor/rbxutil/shake/init.test.luau +267 -0
  183. package/vendor/rbxutil/shake/package.json +16 -0
  184. package/vendor/rbxutil/shake/wally.toml +9 -0
  185. package/vendor/rbxutil/signal/index.d.ts +100 -0
  186. package/vendor/rbxutil/signal/init.luau +432 -0
  187. package/vendor/rbxutil/signal/init.test.luau +190 -0
  188. package/vendor/rbxutil/signal/package.json +17 -0
  189. package/vendor/rbxutil/signal/wally.toml +9 -0
  190. package/vendor/rbxutil/silo/TableWatcher.luau +65 -0
  191. package/vendor/rbxutil/silo/Util.luau +55 -0
  192. package/vendor/rbxutil/silo/init.luau +338 -0
  193. package/vendor/rbxutil/silo/init.test.luau +215 -0
  194. package/vendor/rbxutil/silo/wally.toml +8 -0
  195. package/vendor/rbxutil/spring/index.d.ts +40 -0
  196. package/vendor/rbxutil/spring/init.luau +97 -0
  197. package/vendor/rbxutil/spring/package.json +17 -0
  198. package/vendor/rbxutil/spring/wally.toml +8 -0
  199. package/vendor/rbxutil/stream/index.d.ts +88 -0
  200. package/vendor/rbxutil/stream/init.luau +597 -0
  201. package/vendor/rbxutil/stream/package.json +18 -0
  202. package/vendor/rbxutil/stream/wally.toml +9 -0
  203. package/vendor/rbxutil/streamable/Streamable.luau +202 -0
  204. package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -0
  205. package/vendor/rbxutil/streamable/init.luau +8 -0
  206. package/vendor/rbxutil/streamable/wally.toml +12 -0
  207. package/vendor/rbxutil/symbol/init.luau +56 -0
  208. package/vendor/rbxutil/symbol/init.test.luau +37 -0
  209. package/vendor/rbxutil/symbol/wally.toml +8 -0
  210. package/vendor/rbxutil/table-util/init.luau +938 -0
  211. package/vendor/rbxutil/table-util/init.test.luau +439 -0
  212. package/vendor/rbxutil/table-util/wally.toml +8 -0
  213. package/vendor/rbxutil/task-queue/index.d.ts +27 -0
  214. package/vendor/rbxutil/task-queue/init.luau +97 -0
  215. package/vendor/rbxutil/task-queue/wally.toml +8 -0
  216. package/vendor/rbxutil/timer/index.d.ts +81 -0
  217. package/vendor/rbxutil/timer/init.luau +249 -0
  218. package/vendor/rbxutil/timer/init.test.luau +73 -0
  219. package/vendor/rbxutil/timer/wally.toml +11 -0
  220. package/vendor/rbxutil/tree/index.d.ts +15 -0
  221. package/vendor/rbxutil/tree/init.luau +137 -0
  222. package/vendor/rbxutil/tree/wally.toml +8 -0
  223. package/vendor/rbxutil/trove/index.d.ts +46 -0
  224. package/vendor/rbxutil/trove/init.luau +787 -0
  225. package/vendor/rbxutil/trove/init.test.luau +203 -0
  226. package/vendor/rbxutil/trove/wally.toml +8 -0
  227. package/vendor/rbxutil/typed-remote/init.luau +196 -0
  228. package/vendor/rbxutil/typed-remote/wally.toml +8 -0
  229. package/vendor/rbxutil/wait-for/index.d.ts +17 -0
  230. package/vendor/rbxutil/wait-for/init.luau +257 -0
  231. package/vendor/rbxutil/wait-for/init.test.luau +182 -0
  232. package/vendor/rbxutil/wait-for/wally.toml +11 -0
  233. package/vendor/t/t.lua +1350 -0
  234. package/vendor/testez/Context.lua +26 -0
  235. package/vendor/testez/Expectation.lua +311 -0
  236. package/vendor/testez/ExpectationContext.lua +38 -0
  237. package/vendor/testez/LifecycleHooks.lua +89 -0
  238. package/vendor/testez/Reporters/TeamCityReporter.lua +102 -0
  239. package/vendor/testez/Reporters/TextReporter.lua +106 -0
  240. package/vendor/testez/Reporters/TextReporterQuiet.lua +97 -0
  241. package/vendor/testez/TestBootstrap.lua +147 -0
  242. package/vendor/testez/TestEnum.lua +28 -0
  243. package/vendor/testez/TestPlan.lua +304 -0
  244. package/vendor/testez/TestPlanner.lua +40 -0
  245. package/vendor/testez/TestResults.lua +112 -0
  246. package/vendor/testez/TestRunner.lua +188 -0
  247. package/vendor/testez/TestSession.lua +243 -0
  248. package/vendor/testez/init.lua +40 -0
@@ -0,0 +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
+
2243
+ return ProfileStore