-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUserVault.luau
More file actions
1201 lines (1008 loc) · 41.6 KB
/
Copy pathUserVault.luau
File metadata and controls
1201 lines (1008 loc) · 41.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- UserVault
-- Quantum Maniac
-- Feb 19 2024
--[[
____ ___ ____ ____ .__ __
| | \______ __________\ \ / /____ __ __| |_/ |
| | / ___// __ \_ __ \ Y /\__ \ | | \ |\ __\
| | /\___ \\ ___/| | \/\ / / __ \| | / |_| |
|______//____ >\___ >__| \___/ (____ /____/|____/__|
\/ \/ \/
===============================================
https://github.com/rodrick160/UserVault
===============================================
UserVault should be started before any dependent modules (see `UserVault.Start()`).
]]
--\\ Dependencies //--
local RunService = game:GetService("RunService")
local ProfileStore = require(script.Parent.ProfileStore) ---@module ServerPackages/ProfileStore
local Promise = require(script.Parent.Promise) ---@module Packages/Promise
local Signal = require(script.Parent.Signal) ---@module Packages/Signal
local TableUtil = require(script.Parent.TableUtil) ---@module Packages/TableUtil
--\\ Constants //--
local DEFAULT_CONFIG: UserVaultConfig = {
VerboseLevel = 0,
DebugUseMock = true,
WarnNilUpdate = true,
ProfileStoreIndex = "PlayerData",
}
local PROFILE_KEY_FORMAT = "Player_%d"
--\\ Module //--
local UserVault = {}
--\\ Types //--
export type VaultAccessor = {
GetValue: (self: VaultAccessor, key: string) -> any,
SetValue: (self: VaultAccessor, key: string, value: any) -> (),
}
type UserVaultConfig = {
VerboseLevel: number,
DebugUseMock: boolean,
WarnNilUpdate: boolean,
ProfileStoreIndex: string,
PlayerDataTemplate: {Version: number, Shared: table, Server: table},
PlayerDataUpdateFunctions: {[number]: (table) -> ()},
}
type PlayerCache = {
Player: Player,
Profile: Profile,
ValueChangedSignals: {[string]: Signal},
DataChangeQueue: {[string]: DataChange},
ProcessDataQueueSignal: Signal,
ReadyForHop: boolean,
ActiveVault: Vault?,
}
type DataChange = {
Old: any,
New: any,
}
type Vault = {
Accessor: VaultAccessor,
Cache: PlayerCache,
Data: table,
SharedDataChangeQueue: {[string]: DataChange},
ServerDataChangeQueue: {[string]: DataChange},
Promise: Promise,
IsDestroyed: boolean,
DestroyedSignal: Signal,
}
type Profile = ProfileStore.Profile<table>
type ProfileStore = ProfileStore.ProfileStore
type Promise = typeof(Promise.new())
type Signal = Signal.Signal
--\\ Private //--
local currentConfig: UserVaultConfig
local profileStore: ProfileStore
local started = false
local playerCaches: {[Player]: PlayerCache} = {}
local playerLoadedSignals: {[Player]: Signal} = {}
local hopReadySignal = Signal.new()
--[[
Prints if and only if the passed level is not greater than the currently set verbose level
]]
local function debugPrint(level: number, ...: any...)
if level > currentConfig.VerboseLevel then return end
local scriptName, lineNumber = debug.info(coroutine.running(), 2, "sl")
scriptName = scriptName:match("%w+$")
print(`[{scriptName}: {lineNumber}]:\n`, ...)
end
local function checkStarted()
if not started then
error("Must call UserVault.Start() first.", 3)
end
end
--[[
Yields until the given player's profile loads or until they are no longer in the game.
Returns immediately if either of these conditions are already true.
]]
local function waitForPlayerLoaded(player: Player)
debugPrint(4, `Waiting for {player} to load`)
-- Return immediately if the player is not in the game or if their cache already exists
if not player or not player.Parent then
debugPrint(5, `Player not in-game`)
return
end
if playerCaches[player] then
debugPrint(5, `Player data already loaded.`)
return
end
-- Create a player loaded signal and wait for it
local signal = playerLoadedSignals[player]
if not signal then
debugPrint(5, `Creating player loaded signal.`)
signal = Signal.new()
playerLoadedSignals[player] = signal
end
signal:Wait()
end
--[[
Updates the data table to the newest version using the update functions, one version at a time.
]]
local function updateProfileData(data: table)
while currentConfig.PlayerDataTemplate.Version > data.Version do
local updateFunction = currentConfig.PlayerDataUpdateFunctions[data.Version]
if not updateFunction then
error(`Missing update function for player data version {data.Version}`)
end
local oldVersion = data.Version
updateFunction(data)
data.Version = oldVersion + 1
end
end
--[[
Loads the player's profile upon joining the game.
]]
local function loadProfile(player: Player)
debugPrint(1, `Loading profile for {player} ({player.UserId})`)
local profile = profileStore:LoadProfileAsync(PROFILE_KEY_FORMAT:format(player.UserId))
if profile ~= nil then
debugPrint(1, `Profile loaded`)
updateProfileData(profile.Data)
profile:AddUserId(player.UserId) -- GDPR compliance
profile:Reconcile() -- Fill in missing variables from ProfileTemplate
profile:ListenToRelease(function()
debugPrint(1, `Profile released for player {player}`)
local playerCache = playerCaches[player]
if playerCache then
debugPrint(5, `Player cache found`)
if playerCache.ActiveVault then
playerCache.ActiveVault.Promise:cancel()
end
for _, signal in playerCache.ValueChangedSignals do
signal:Destroy()
end
playerCache.ProcessDataQueueSignal:Destroy()
playerCaches[player] = nil
end
-- The profile could've been loaded on another Roblox server:
if player.Parent and not player:GetAttribute("DontKickOnRelease") then
debugPrint(5, `Kicking player {player}`)
player:Kick()
end
end)
profile:ListenToHopReady(function()
debugPrint(1, `Hop ready for player {player}`)
local playerCache = playerCaches[player]
if playerCache then
debugPrint(4, `Player cache found`)
playerCache.ReadyForHop = true
end
hopReadySignal:Fire()
end)
if player.Parent then
debugPrint(5, `Player still in-game`)
-- A profile has been successfully loaded:
local playerCache: PlayerCache = {
Player = player,
Profile = profile,
ValueChangedSignals = {},
DataChangeQueue = {},
ProcessDataQueueSignal = Signal.new(),
ReadyForHop = false
}
playerCache.ProcessDataQueueSignal:Connect(function()
debugPrint(5, `Processing data queue for player {player}`)
local changes = {}
for key, change in playerCache.DataChangeQueue do
changes[key] = change.New
end
playerCache.DataChangeQueue = {}
end)
playerCaches[player] = playerCache
else
debugPrint(5, `Player no longer in-game`)
-- Player left before the profile loaded:
profile:Release()
end
else
debugPrint(1, `Failed to load profile`)
-- The profile couldn't be loaded possibly due to other
-- Roblox servers trying to load this profile at the same time:
player:Kick()
end
-- Release any threads waiting for the player data to load.
if playerLoadedSignals[player] then
debugPrint(5, `Firing player loaded signal`)
playerLoadedSignals[player]:Fire()
task.defer(function()
playerLoadedSignals[player]:Destroy()
playerLoadedSignals[player] = nil
end)
end
end
local function createVault(player: Player): Vault
debugPrint(5, `Waiting for data for player {player}`)
waitForPlayerLoaded(player)
local playerCache = playerCaches[player]
if not playerCache or not playerCache.Profile:IsActive() then
error(`Failed to retrieve profile for player {player}.`)
return
end
local vault: Vault = {
Cache = playerCache,
Data = TableUtil.Copy(playerCache.Profile.Data, true),
SharedDataChangeQueue = {},
ServerDataChangeQueue = {},
IsDestroyed = false,
DestroyedSignal = Signal.new(),
}
local vaultAccessor: VaultAccessor = {}
vault.Accessor = vaultAccessor
function vaultAccessor:GetValue(key: string)
debugPrint(3, `Getting value "{key}" for player {player}`)
if vault.IsDestroyed then
error("Attempt to access destroyed Vault object.", 2)
end
local value
if currentConfig.PlayerDataTemplate.Shared[key] ~= nil then
value = vault.Data.Shared[key]
debugPrint(5, `Shared value: {value}`)
elseif currentConfig.PlayerDataTemplate.Server[key] ~= nil then
value = vault.Data.Server[key]
debugPrint(5, `Server value: {value}`)
else
error(`Attempt to index profile with invalid key '{key}'`)
return
end
debugPrint(4, `Success`)
return value
end
function vaultAccessor:SetValue(key: string, value: any)
debugPrint(3, `Setting value "{key}" to {value} for player {player}`)
if vault.IsDestroyed then
error("Attempt to access destroyed Vault object.", 2)
end
local oldValue, changeQueue
if currentConfig.PlayerDataTemplate.Shared[key] ~= nil then
oldValue = vault.Data.Shared[key]
changeQueue = vault.SharedDataChangeQueue
vault.Data.Shared[key] = value
debugPrint(5, `Shared value: {oldValue}`)
elseif currentConfig.PlayerDataTemplate.Server[key] ~= nil then
oldValue = vault.Data.Server[key]
changeQueue = vault.ServerDataChangeQueue
vault.Data.Server[key] = value
debugPrint(5, `Server value: {oldValue}`)
else
debugPrint(4, `Failed`)
error(`Attempt to index profile with invalid key '{key}'`)
end
local change = changeQueue[key]
if change then
debugPrint(5, `Existing change found`)
if change.Old == value then
debugPrint(5, `Removing existing change`)
changeQueue[key] = nil
else
debugPrint(5, `Modifying exising change`)
change.New = value
end
else
debugPrint(5, `Queueing new change`)
changeQueue[key] = {Old = oldValue, New = value}
end
debugPrint(4, `Success`)
vault.Data[key] = value
end
return vault
end
--\\ Public //--
--[[
# [PerformTransaction](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#performtransaction)
## Description
Performs an atomic operation on one or multiple players' profiles. The callback function is passed a tuple of `Vault` objects, one for each player, which can
be used to access the profile data. The `Vault` object has two functions:
- `GetValue(key: string) -> any`:
Returns the value at the given key.
- `SetValue(key: string, value: any)`:
Assigns the value at the given key, and triggers an update.
Any changes made to the `Vault` objects are not applied to the players' profiles until the entire transaction is complete. If the transaction fails or is canceled
at any point before completion, all changes made to the `Vault` objects are discarded.
Beginning a transaction locks player profiles until the transaction is concluded. Any subsequent attempts to access the player's profile will yield until the
blocking transaction has concluded.
> [!WARNING]
> All access to a player's profile will be blocked until the transaction concludes. Ensure that transaction callbacks will not yield indefinitely.
## Parameters
- `callback: (...Vault) -> ()` - The callback function which performs the transaction.
- `...: Player` - A vararg of Players to include in the transaction. The `Vault` objects passed to the callback be in the same respective order as the Players passed here.
## Return Value
Returns a `Promise` which resolves with any values returned from the callback function, once the transaction is complete.
## Usage Examples
```lua
UserVault.PerformTransaction(function(vault)
local coins = vault:GetValue("Coins")
local inventory = vault:GetValue("Inventory")
if coins < 100 then return end
if inventory.Sword then
inventory.Sword += 1
else
inventory.Sword = 1
end
vault:SetValue("Coins", coins - 100)
vault:SetValue("Inventory", inventory) -- Ensure table values get updated
end, player)
```
]]
function UserVault.PerformTransaction(callback: (...VaultAccessor) -> (), ...: Player): Promise
checkStarted()
assert(typeof(callback) == "function", "operation must be a function.")
local players = {...}
assert(#players > 0, "Must pass at least one Player.")
for _, player in players do
assert(typeof(player) == "Instance" and player:IsA("Player"), "vararg parameters must all be Players.")
end
debugPrint(2, `Performing transaction with players`, ...)
local vaultPromises = {}
for i, player: Player in players do
vaultPromises[i] = Promise.new(function(resolve)
debugPrint(5, `Waiting for data for player {player}`)
waitForPlayerLoaded(player)
local vault = createVault(player)
if vault.Cache.ActiveVault and not vault.Cache.ActiveVault.IsDestroyed then
debugPrint(5, `Waiting for existing vault promise to finish`)
vault.Cache.ActiveVault.DestroyedSignal:Wait()
end
vault.Cache.ActiveVault = vault
resolve(vault)
end)
end
local promise
promise = Promise.all(vaultPromises)
:andThen(function(vaults: {Vault})
debugPrint(5, `VaultInternals gathered, verifying profiles are active`)
local vaultAccessors: {VaultAccessor} = {}
for i, vault in vaults do
if not vault.Cache.Profile:IsActive() then
debugPrint(5, `Profile for player {vault.Cache.Player} was closed before transaction concluded.`)
return Promise.reject(`Profile for player {vault.Cache.Player} was closed before transaction concluded.`)
end
vaultAccessors[i] = vault.Accessor
end
debugPrint(5, `Beginning change operation`)
local result = callback(table.unpack(vaultAccessors))
debugPrint(5, `Success`)
debugPrint(5, `Verifying profiles are still active`)
for _, vault in vaults do
if not vault.Cache.Profile:IsActive() then
debugPrint(5, `Profile for player {vault.Cache.Player} was closed before transaction concluded.`)
return Promise.reject(`Profile for player {vault.Cache.Player} was closed before transaction concluded.`)
end
end
debugPrint(5, `Saving changes to profiles`)
for _, vault in vaults do
vault.Cache.Profile.Data = vault.Data
vault.Cache.DataChangeQueue = vault.SharedDataChangeQueue
vault.Cache.ProcessDataQueueSignal:FireDeferred()
end
debugPrint(5, `Firing changed events for all profiles`)
for _, vault in vaults do
local function fireChanges(changeQueue)
for key, change in changeQueue do
local changedSignal = vault.Cache.ValueChangedSignals[key]
if changedSignal then
changedSignal:Fire(change.New, change.Old)
end
end
end
fireChanges(vault.SharedDataChangeQueue)
fireChanges(vault.ServerDataChangeQueue)
end
return result
end)
Promise.each(vaultPromises, function(vault: Vault)
vault.Promise = promise
end)
promise
:finally(function()
debugPrint(5, `Locking and releasing vaults`)
Promise.each(vaultPromises, function(vault: Vault)
vault.IsDestroyed = true
vault.DestroyedSignal:Fire()
task.defer(function()
vault.DestroyedSignal:Destroy()
end)
end)
for _, player in players do
if not playerCaches[player] then continue end
playerCaches[player].ActiveVault = nil
end
end)
return promise
end
--[[
# [GetValue](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#getvalue)
## Description
Retrieves specified values from the player's profile.
## Parameters
This function supports two parameter formats:
- `GetValue(player: Player, keys: {string})`: Uses an array of keys to retrieve specific player data.
- `player: Player` - The target player.
- `keys: {string}` - The data keys to retrieve.
- `GetValue(player: Player, ...: string)`: Uses a variable number of arguments to specify the data keys.
- `player: Player` - The target player.
- `...: string` - The data keys to retrieve.
## Return Value
Returns a `Promise` that:
- Resolves with the requested player data on success. When `keys` is an array, the promise resolves with a dictionary mapping each key to its value.
When using varargs, the promise resolves with the values directly.
- Rejects if the player profile cannot be loaded.
## Usage Examples
### Array Example
Retrieve values using an array of keys `"Coins"` and `"Level"`.
The promise resolves with a dictionary containing the values for these keys.
```lua
UserVault.GetValue(player, {"Coins", "Level"}):andThen(function(data)
print(`Player {player.DisplayName} has {data.Coins} coins and is level {data.Level}.`)
end, function()
print(`Player {player.DisplayName}'s data failed to load!`)
end)
```
### Vararg Example
Retrieve values using varargs `"Coins"` and `"Level"`.
The promise resolves with the values for these keys in order.
```lua
UserVault.GetValue(player, "Coins", "Level"):andThen(function(coins, level)
print(`Player {player.DisplayName} has {coins} coins and is level {level}.`)
end, function()
print(`Player {player.DisplayName}'s data failed to load!`)
end)
```
> [!TIP]
> GetValues() is a valid alias for GetValue()
]]
function UserVault.GetValue(player: Player, ...: {string} | string): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
local args = {...}
local isTable = typeof(args[1]) == "table"
local keys = if isTable then args[1] else args
assert(keys[1] ~= nil, "must pass at least one key.")
for _, key in keys do
assert(typeof(key) == "string", "keys must be strings.")
end
debugPrint(3, `Getting values for {player}:`, ...)
return UserVault.PerformTransaction(function(vault: VaultAccessor)
local values = {}
for _, key in keys do
local value = vault:GetValue(key)
values[if isTable then key else #values + 1] = value
end
if isTable then
debugPrint(5, `Returning table`)
return values
else
debugPrint(5, `Returning tuple`)
return table.unpack(values)
end
end, player)
end
UserVault.GetValues = UserVault.GetValue
--[[
# [SetValue](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#setvalue)
## Description
Sets a specified value for a key in the player's profile.
## Parameters
- `player: Player` - The player whose profile is being modified.
- `key: string` - The key within the profile to update.
- `value: any` - The new value to assign to the key.
## Return Value
Returns a `Promise` that:
- Resolves when the value is successfully updated in the player's profile.
- Rejects if updating the player profile fails.
## Usage Examples
```lua
UserVault.SetValue(player, "Coins", 500):andThen(function()
print(`Successfully updated {player.DisplayName}'s coins to 500.`)
end, function()
print(`Failed to update {player.DisplayName}'s coins to 500.`)
end)
```
]]
function UserVault.SetValue(player: Player, key: string, value: any): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(typeof(key) == "string", "key must be a string.")
debugPrint(2, `Setting value for {player} ({key} = {value})`)
return UserVault.PerformTransaction(function(vault: VaultAccessor)
vault:SetValue(key, value)
end, player)
end
--[[
# [UpdateValue](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#updatevalue)
## Description
Updates a specified value for a key in the player's profile by applying a callback function.
This function allows for complex transformations of existing data.
## Parameters
- `player: Player` - The player whose profile is being updated.
- `key: string` - The key to be updated within the profile.
- `callback: (value: any) -> any` - A function that receives the current value and returns the updated value.
This callback is used to transform the value.
> [!CAUTION]
> When working with table values, ensure to return the modified table from the callback to avoid unintended `nil` assignments.
> [!CAUTION]
> The callback function cannot yield under any circumstances, as this could create a race condition. If the callback function yields,
> the thread will be killed and the promise will reject.
## Return Value
Returns a `Promise` that:
- Resolves with the newly computed value after successfully updating it in the player's profile.
This ensures that the calling code can immediately use the updated value.
- Rejects if the update process fails.
## Usage Examples
```lua
UserVault.UpdateValue(player, "Coins", function(coins)
return coins + 500
end):andThen(function(newCoins)
print(`Successfully increased {player.DisplayName}'s coins to {newCoins}.`)
end, function()
print(`Failed to update {player.DisplayName}'s coins.`)
end)
```
]]
function UserVault.UpdateValue(player: Player, key: string, callback: (value: any) -> any): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(typeof(key) == "string", "key must be a string.")
assert(typeof(callback) == "function", "callback must be a function.")
debugPrint(2, `Updating value for {player} ({key})`)
return UserVault.PerformTransaction(function(vault: VaultAccessor)
local oldValue = vault:GetValue(key)
local newValue = callback(oldValue)
if currentConfig.WarnNilUpdate and newValue == nil then
warn("UpdateValue callback returned a nil value\n", debug.traceback())
end
vault:SetValue(key, newValue)
if typeof(newValue) == "table" then
newValue = TableUtil.Copy(newValue, true)
end
return newValue
end, player)
end
--[[
# [IncrementValue](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#incrementvalue)
## Description
Increments a specified value for a key in the player's profile by a specific amount.
Sugar for:
```lua
UserVault.UpdateValue(player, key, function(value)
return value + increment
end)
```
## Parameters
- `player: Player` - The player whose profile is being updated.
- `key: string` - The key to be updated within the profile.
- `increment: number` - The amount to increment the value by.
## Return Value
Returns a `Promise` that:
- Resolves with the newly computed value after successfully updating it in the player's profile.
This ensures that the calling code can immediately use the updated value.
- Rejects if the increment process fails.
## Usage Examples
```lua
UserVault.IncrementValue(player, "Coins", 500):andThen(function(newCoins)
print(`Successfully increased {player.DisplayName}'s coins to {newCoins}.`)
end, function()
print(`Failed to update {player.DisplayName}'s coins.`)
end)
```
]]
function UserVault.IncrementValue(player: Player, key: string, increment: number): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(typeof(key) == "string", "key must be a string.")
assert(typeof(increment) == "number", "increment must be a number.")
debugPrint(2, `Incrementing value for {player} ({key} += {increment})`)
return UserVault.UpdateValue(player, key, function(value)
return value + increment
end)
end
--[[
# [GetValueChangedSignal](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#getvaluechangedsignal)
## Description
Creates and returns a `Signal` that is fired when a specified key's value changes in the player's profile.
This operation is dependent on the successful loading of the player's profile.
The signal passes the new and previous values of the observed key.
## Parameters
- `player: Player` - The player whose profile changes are to be monitored.
- `key: string` - The profile key to monitor for changes.
## Return Value
Returns a `Promise` that resolves with a `Signal` object.
The resolved signal can then be connected to functions that will be called with the new and previous values of the key whenever it changes.
The promise is rejected if the player's profile cannot be loaded.
## Usage Examples
```lua
UserVault.GetValueChangedSignal(player, "Coins")
:andThen(function(signal)
signal:Connect(function(newValue, oldValue)
print(`Player {player.DisplayName}'s coins changed from {oldValue} to {newValue}!`)
end)
end)
:catch(function(error)
print(`Player {player.DisplayName}'s data failed to load!`)
end)
```
> [!NOTE]
> The `Signal` only fires after the profile has been successfully loaded.
> It does not fire for the initial load of the profile's data.
> For initial data handling, other methods like directly retrieving the player's data upon profile load should be considered.
]]
function UserVault.GetValueChangedSignal(player: Player, key: string): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(typeof(key) == "string", "key must be a string.")
debugPrint(3, `Getting value changed signal for {player} ({key})`)
return Promise.new(function(resolve, reject)
debugPrint(5, `Waiting for player data`)
waitForPlayerLoaded(player)
local playerCache = playerCaches[player]
if playerCache and playerCache.Profile:IsActive() then
debugPrint(5, `Player data found`)
local signal = playerCache.ValueChangedSignals[key]
if not signal then
debugPrint(5, `Creating new data changed signal`)
signal = Signal.new()
playerCache.ValueChangedSignals[key] = signal
end
resolve(signal)
else
debugPrint(5, `Player data not found`)
reject(`Failed to retrieve profile for player {player}.`)
end
end)
end
--[[
# [BindToValue](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#bindtovalue)
## Description
Invokes a callback function with the current value of a specified key immediately upon binding, and then again each time that key's value
updates in the player's profile.
## Parameters
- `player: Player` - The player whose data is being monitored.
- `key: string` - The key within the player's profile to watch for changes.
- `callback: (newValue: any, oldValue: any?) -> ()` - A callback function that is executed with the new value of the key and,
for updates after the initial call, the previous value. For the initial invocation, `oldValue` will not be provided.
## Return Value
Returns a `Promise` that:
- Resolves once the callback has been successfully registered and invoked with the current value of the key.
- Rejects if the player's profile cannot be loaded or the key does not exist.
## Usage Examples
```lua
-- Bind to monitor and reflect changes in 'Coins' within the player's leaderstats.
UserVault.BindToValue(player, "Coins", function(newValue, oldValue)
if oldValue then
print(`Coins updated from {oldValue} to {newValue}`)
else
print(`Initial coin value: {newValue}`)
end
player.leaderstats.Coins.Value = newValue
end)
```
> [!NOTE]
> The immediate invocation of the callback provides an opportunity to initialize any dependent data or UI elements with the current value of the
> specified key. Subsequent invocations facilitate real-time updates, enabling dynamic content adjustments based on the player's data changes.
]]
function UserVault.BindToValue(player: Player, key: string, callback: (newValue: any, oldValue: any?) -> ()): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(typeof(key) == "string", "key must be a string.")
assert(typeof(callback) == "function", "callback must be a function.")
debugPrint(3, `Binding to {player}'s data ({key})`)
return UserVault.GetValue(player, key)
:andThen(function(value)
UserVault.GetValueChangedSignal(player, key)
:andThen(function(dataChangedSignal)
dataChangedSignal:Connect(callback)
end)
callback(value)
end)
end
--[[
# [OnHopClear](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#onhopclear)
## Description
Prepares a player's profile for teleportation by ensuring it is properly released and ready to be loaded in a new game instance. `OnHopClear` utilizes
`Profile:ListenToHopReady()` from the ProfileService module to monitor and manage the profile's readiness for a hop. This function returns a promise that
resolves once the profile is adequately prepared, optimizing the teleportation process, especially useful when navigating noticeable delays in profile
loading after universe teleports.
## Parameters
- `player: Player` - The player whose profile is to be prepared for a hop.
## Return Value
Returns a `Promise` that:
- Resolves when the player's profile has been successfully released and is ready for loading in a new game instance, facilitating seamless teleportation.
- Rejects if the player leaves the game before the promise resolves. It is recommended to account for this scenario in your implementation to handle
potential errors gracefully.
## Usage Examples
```lua
UserVault.OnHopClear(player)
:andThen(function()
TeleportService:Teleport(placeId, {player})
end, function()
print("Player left before the profile could be cleared for hop.")
end)
```
> [!TIP]
> `OnHopClear` is particularly beneficial for managing profile readiness in scenarios with noticeable delays during teleportation between universe places.
> The promise returned by this function not only signifies that the player's profile is ready for a new game instance but also provides a mechanism to
> handle cases where a player may leave the game before teleportation can occur. Implementing error handling for promise rejection is crucial for
> maintaining a robust teleportation process.
]]
function UserVault.OnHopClear(player: Player): Promise
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
debugPrint(3, `Getting hop clear promise for {player}`)
return Promise.new(function(resolve, reject, onCancel)
if onCancel() then
debugPrint(5, `Canceling hop clear promise`)
return
end
local waitSignal = Signal.new()
local hopReadyConnection = hopReadySignal:Connect(function()
waitSignal:Fire()
end)
local playerRemovingConnection = game.Players.PlayerRemoving:Connect(function(playerWhoLeft)
waitSignal:Fire(playerWhoLeft)
end)
local function doCleanup()
waitSignal:Disconnect()
hopReadyConnection:Disconnect()
playerRemovingConnection:Disconnect()
end
onCancel(function()
debugPrint(5, `Canceling hop clear promise`)
doCleanup()
end)
local playerCache = playerCaches[player]
while not playerCache.ReadyForHop do
local playerWhoLeft = waitSignal:Wait()
if playerWhoLeft == player then
debugPrint(5, `Player left while waiting for hop`)
reject()
doCleanup()
return
end
end
debugPrint(5, `Success`)
doCleanup()
resolve()
end)
end
--[[
# [ReleaseProfile](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#releaseprofile)
## Description
Provides an option to release a player's profile with a parameter that can prevent the player from being automatically kicked from the game.
This is useful for scenarios like teleportation, where the player needs to remain in the game until the teleportation process begins.
## Parameters
- `player: Player` - The player whose profile needs to be released.
- `dontKick: boolean?` (optional) - If true, the player is not automatically kicked from the game when their profile is released.
Useful for managing teleportation without interrupting the player's session.
## Usage Examples
### Basic
```lua
-- Wait for the profile to be ready for a hop
UserVault.OnHopClear(player)
:andThen(function()
TeleportService:TeleportAsync(placeId, {player})
end)
:catch(function(e)
print(`Something went wrong when teleporting`)
end)
-- Release the player's profile without kicking them, in anticipation of teleportation
UserVault.ReleaseProfile(player, true)
```
### Using `Promise:timeout()`
```lua
-- Wait for the profile to be ready for a hop, with a timeout to handle edge cases
UserVault.OnHopClear(player):timeout(5) -- Timeout after 5 seconds
:andThen(function()
-- Proceed with teleportation upon successful readiness confirmation
TeleportService:TeleportAsync(placeId, {player})
end)
:catch(function(e)
-- Handle timeout or other errors
if Promise.Error.isKind(e, Promise.Error.Kind.TimedOut) then
print(`Timeout occurred while waiting for {player.DisplayName}'s profile to be ready for hop.`)
else
print(`An error occurred while preparing {player.DisplayName} for teleportation: {e}`)
end
-- Fallback logic for errors, such as kicking or retrying the teleportation process
if player.Parent then
player:Kick()
end
end)
-- Once the teleportation is set up, release the player's profile without kicking them
UserVault.ReleaseProfile(player, true)
```
> [!TIP]
> Utilizing `dontKick` with `true` is essential for teleportation scenarios, ensuring players aren't forcibly exited from the game after their profile
> release. To handle edge cases, such as players not leaving after a certain period or teleportation failing, it's advisable to use `Promise:timeout()`
> with this process. This approach allows for the implementation of a fallback mechanism, ensuring that if the player does not leave the game within a
> specified timeout period, the game can take appropriate action, such as forcibly removing the player or logging an error for further investigation.
]]
function UserVault.ReleaseProfile(player: Player, dontKick: boolean?)
checkStarted()
assert(typeof(player) == "Instance" and player:IsA("Player"), "player must be a Player.")
assert(dontKick == nil or typeof(dontKick) == "boolean", "dontKick must be nil or boolean.")
debugPrint(3, `Externally releasing profile for {player} (dontKick = {dontKick})`)
local playerCache = playerCaches[player]
if playerCache then
debugPrint(5, `Player cache found`)
if dontKick then
debugPrint(5, `Setting DontKickOnRelease attribute`)
player:SetAttribute("DontKickOnRelease", true)
end
playerCache.Profile:Release()
end
end
--[[
# [ResetProfile](https://github.com/rodrick160/UserVault/blob/main/src/UserVault/DOCUMENTATION.md#resetprofile)
## Description
Deletes all data stored in a player's profile.
## Parameters
- `userId: number` - The user ID of the target player.
- `profileStoreIndex: string` (optional) - If provided, overrides the default profile store index.
Only needed if using a profile store index other than the default.