@quenty/settings 11.66.2 → 11.66.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/src/Client/Player/PlayerSettingsClient.lua +22 -0
- package/src/Shared/Setting/SettingDefinition.lua +26 -2
- package/src/Shared/Setting/SettingDefinition.spec.lua +145 -0
- package/src/Shared/Setting/SettingProperty.lua +13 -1
- package/src/Shared/SettingsDataService.lua +49 -6
- package/src/Shared/SettingsDataService.spec.lua +181 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [11.66.3](https://github.com/Quenty/NevermoreEngine/compare/@quenty/settings@11.66.2...@quenty/settings@11.66.3) (2026-07-27)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **settings:** stop settings hydration recursing until the C stack dies ([#757](https://github.com/Quenty/NevermoreEngine/issues/757)) ([d6ff0e3](https://github.com/Quenty/NevermoreEngine/commit/d6ff0e313c05ff618ca794dd4bdc70fb3e645f0a))
|
|
11
|
+
|
|
6
12
|
## [11.66.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/settings@11.66.1...@quenty/settings@11.66.2) (2026-07-27)
|
|
7
13
|
|
|
8
14
|
**Note:** Version bump only for package @quenty/settings
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/settings",
|
|
3
|
-
"version": "11.66.
|
|
3
|
+
"version": "11.66.3",
|
|
4
4
|
"description": "Centralized player settings service",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"publishConfig": {
|
|
67
67
|
"access": "public"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "bc810790774857f24f8c69095c521be4fd4f397f"
|
|
70
70
|
}
|
|
@@ -224,6 +224,13 @@ function PlayerSettingsClient.SetValue<T>(self: PlayerSettingsClient, settingNam
|
|
|
224
224
|
end
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
+
-- A reader already sees this value -- queued, in transit, or replicated. Observers dedupe on value
|
|
228
|
+
-- themselves; returning rather than just skipping the notification is what also skips the
|
|
229
|
+
-- redundant round-trip to the server.
|
|
230
|
+
if self:_getCurrentValue(settingName) == value then
|
|
231
|
+
return
|
|
232
|
+
end
|
|
233
|
+
|
|
227
234
|
local queueReplication = false
|
|
228
235
|
local toReplicate
|
|
229
236
|
if self._toReplicate then
|
|
@@ -256,6 +263,21 @@ function PlayerSettingsClient.SetValue<T>(self: PlayerSettingsClient, settingNam
|
|
|
256
263
|
end
|
|
257
264
|
end
|
|
258
265
|
|
|
266
|
+
-- No default to fall back on, so "unset" stays distinguishable from "set to the default".
|
|
267
|
+
function PlayerSettingsClient._getCurrentValue(self: PlayerSettingsClient, settingName: string): any
|
|
268
|
+
if self._toReplicate and self._toReplicate[settingName] ~= nil then
|
|
269
|
+
return PlayerSettingsUtils.decodeForNetwork(self._toReplicate[settingName])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
local pending = self._pendingReplicationDataInTransit.Value
|
|
273
|
+
if pending and pending[settingName] ~= nil then
|
|
274
|
+
return PlayerSettingsUtils.decodeForNetwork(pending[settingName])
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
local attributeName = PlayerSettingsUtils.getAttributeName(settingName)
|
|
278
|
+
return PlayerSettingsUtils.decodeForAttribute(self._obj:GetAttribute(attributeName))
|
|
279
|
+
end
|
|
280
|
+
|
|
259
281
|
function PlayerSettingsClient._sendSettings(self: PlayerSettingsClient)
|
|
260
282
|
if not self._toReplicate then
|
|
261
283
|
warn("Nothing to save, should not have called this method")
|
|
@@ -31,6 +31,11 @@ local Promise = require("Promise")
|
|
|
31
31
|
local ServiceBag = require("ServiceBag")
|
|
32
32
|
local SettingProperty = require("SettingProperty")
|
|
33
33
|
local SettingsDataService = require("SettingsDataService")
|
|
34
|
+
local Symbol = require("Symbol")
|
|
35
|
+
|
|
36
|
+
-- Cache key for the local player before the DataModel can name them, when there is no Player to
|
|
37
|
+
-- key on (see [SettingDefinition.GetSettingProperty]).
|
|
38
|
+
local UNRESOLVED_LOCAL_PLAYER = Symbol.named("unresolvedLocalPlayer")
|
|
34
39
|
|
|
35
40
|
local SettingDefinition = {}
|
|
36
41
|
SettingDefinition.ClassName = "SettingDefinition"
|
|
@@ -43,6 +48,7 @@ export type SettingDefinition<T> = typeof(setmetatable(
|
|
|
43
48
|
_defaultValue: T,
|
|
44
49
|
_maid: Maid.Maid,
|
|
45
50
|
_serviceBag: ServiceBag.ServiceBag,
|
|
51
|
+
_settingPropertyCache: { [any]: { [any]: any } },
|
|
46
52
|
ServiceName: string,
|
|
47
53
|
},
|
|
48
54
|
{} :: typeof({ __index = SettingDefinition })
|
|
@@ -63,6 +69,7 @@ function SettingDefinition.new<T>(settingName: string, defaultValue: T): Setting
|
|
|
63
69
|
|
|
64
70
|
self._settingName = settingName
|
|
65
71
|
self._defaultValue = defaultValue
|
|
72
|
+
self._settingPropertyCache = setmetatable({}, { __mode = "k" }) :: any
|
|
66
73
|
|
|
67
74
|
self.ServiceName = self._settingName .. "SettingDefinition"
|
|
68
75
|
|
|
@@ -164,7 +171,9 @@ function SettingDefinition.isSettingDefinition(value: any): boolean
|
|
|
164
171
|
end
|
|
165
172
|
|
|
166
173
|
--[=[
|
|
167
|
-
Gets
|
|
174
|
+
Gets the setting property for this definition, reused per (serviceBag, player) -- callers commonly
|
|
175
|
+
ask per use, and constructing one costs an EnsureInitialized round-trip and its own observable
|
|
176
|
+
chain.
|
|
168
177
|
|
|
169
178
|
@param serviceBag ServiceBag
|
|
170
179
|
@param player Player
|
|
@@ -184,7 +193,22 @@ function SettingDefinition.GetSettingProperty<T>(
|
|
|
184
193
|
-- May still be nil in a non-running DataModel; SettingProperty resolves the local player lazily.
|
|
185
194
|
player = player or Players.LocalPlayer or PlayerMock.getMockedLocalPlayer()
|
|
186
195
|
|
|
187
|
-
|
|
196
|
+
-- Weak throughout: a cached property must not keep its bag or player alive. The lazily-resolved
|
|
197
|
+
-- local player has no key of its own, hence the sentinel.
|
|
198
|
+
local byPlayer = self._settingPropertyCache[serviceBag]
|
|
199
|
+
if not byPlayer then
|
|
200
|
+
byPlayer = setmetatable({}, { __mode = "kv" }) :: any
|
|
201
|
+
self._settingPropertyCache[serviceBag] = byPlayer
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
local playerKey: any = player or UNRESOLVED_LOCAL_PLAYER
|
|
205
|
+
local property = byPlayer[playerKey]
|
|
206
|
+
if not property then
|
|
207
|
+
property = SettingProperty.new(serviceBag, player, self)
|
|
208
|
+
byPlayer[playerKey] = property
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
return property
|
|
188
212
|
end
|
|
189
213
|
|
|
190
214
|
--[=[
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for the setting property a definition hands out: reused per (serviceBag, player), and its
|
|
4
|
+
observable neither rebuilds nor re-emits when the tie behind it churns. Callers routinely ask for a
|
|
5
|
+
property inline, and a tie mints a new interface per subscription -- so without these, reading a
|
|
6
|
+
setting from inside another setting's observer fans out work on every emission.
|
|
7
|
+
|
|
8
|
+
A stub implementer stands in for PlayerSettingsClient, returning the default for everything: the
|
|
9
|
+
plumbing between definition, property, and cache is what is under test, not stored values.
|
|
10
|
+
|
|
11
|
+
@class SettingDefinition.spec.lua
|
|
12
|
+
]]
|
|
13
|
+
|
|
14
|
+
local require = require(script.Parent.loader).load(script)
|
|
15
|
+
|
|
16
|
+
local Workspace = game:GetService("Workspace")
|
|
17
|
+
|
|
18
|
+
local Jest = require("Jest")
|
|
19
|
+
local Maid = require("Maid")
|
|
20
|
+
local PlayerMock = require("PlayerMock")
|
|
21
|
+
local PlayerSettingsInterface = require("PlayerSettingsInterface")
|
|
22
|
+
local PlayerSettingsUtils = require("PlayerSettingsUtils")
|
|
23
|
+
local Rx = require("Rx")
|
|
24
|
+
local ServiceBag = require("ServiceBag")
|
|
25
|
+
local SettingDefinition = require("SettingDefinition")
|
|
26
|
+
local SettingsDataService = require("SettingsDataService")
|
|
27
|
+
local TieRealmUtils = require("TieRealmUtils")
|
|
28
|
+
|
|
29
|
+
local describe = Jest.Globals.describe
|
|
30
|
+
local expect = Jest.Globals.expect
|
|
31
|
+
local it = Jest.Globals.it
|
|
32
|
+
|
|
33
|
+
local SETTING_NAME = "TestSetting"
|
|
34
|
+
local DEFAULT_VALUE = false
|
|
35
|
+
|
|
36
|
+
local function setup()
|
|
37
|
+
local maid = Maid.new()
|
|
38
|
+
|
|
39
|
+
local serviceBag = maid:Add(ServiceBag.new())
|
|
40
|
+
-- Requested up front: SettingProperty only resolves it lazily, which a started bag rejects.
|
|
41
|
+
serviceBag:GetService(SettingsDataService)
|
|
42
|
+
serviceBag:Init()
|
|
43
|
+
serviceBag:Start()
|
|
44
|
+
|
|
45
|
+
-- Settings are not hydrated for a player outside the DataModel.
|
|
46
|
+
local player = maid:Add(PlayerMock.new())
|
|
47
|
+
player.Parent = Workspace
|
|
48
|
+
|
|
49
|
+
local folder = maid:Add(PlayerSettingsUtils.create())
|
|
50
|
+
folder.Parent = player
|
|
51
|
+
|
|
52
|
+
local implementer = {
|
|
53
|
+
GetPlayer = function()
|
|
54
|
+
return player
|
|
55
|
+
end,
|
|
56
|
+
GetValue = function(_self, _settingName, defaultValue)
|
|
57
|
+
return defaultValue
|
|
58
|
+
end,
|
|
59
|
+
ObserveValue = function(_self, _settingName, defaultValue)
|
|
60
|
+
return Rx.of(defaultValue)
|
|
61
|
+
end,
|
|
62
|
+
EnsureInitialized = function() end,
|
|
63
|
+
GetSettingProperty = function() end,
|
|
64
|
+
SetValue = function() end,
|
|
65
|
+
RestoreDefault = function() end,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
local implementationMaid = maid:Add(Maid.new())
|
|
69
|
+
local function implement()
|
|
70
|
+
implementationMaid._current =
|
|
71
|
+
PlayerSettingsInterface:Implement(folder, implementer, TieRealmUtils.inferTieRealm())
|
|
72
|
+
end
|
|
73
|
+
implement()
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
serviceBag = serviceBag,
|
|
77
|
+
player = player,
|
|
78
|
+
definition = SettingDefinition.new(SETTING_NAME, DEFAULT_VALUE),
|
|
79
|
+
-- Swaps in an equivalent implementation -- the container churn that mints the new interface
|
|
80
|
+
-- identity downstream sees.
|
|
81
|
+
reimplement = implement,
|
|
82
|
+
destroy = function(_self)
|
|
83
|
+
maid:DoCleaning()
|
|
84
|
+
end,
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe("SettingDefinition.GetSettingProperty", function()
|
|
89
|
+
it("reuses the property for the same serviceBag and player", function()
|
|
90
|
+
local controller = setup()
|
|
91
|
+
|
|
92
|
+
local first = controller.definition:GetSettingProperty(controller.serviceBag, controller.player)
|
|
93
|
+
local second = controller.definition:GetSettingProperty(controller.serviceBag, controller.player)
|
|
94
|
+
|
|
95
|
+
expect(second).toBe(first)
|
|
96
|
+
|
|
97
|
+
controller:destroy()
|
|
98
|
+
end)
|
|
99
|
+
|
|
100
|
+
it("keeps a separate property per player", function()
|
|
101
|
+
local controller = setup()
|
|
102
|
+
local otherPlayer = PlayerMock.new()
|
|
103
|
+
otherPlayer.Parent = Workspace
|
|
104
|
+
|
|
105
|
+
local property = controller.definition:GetSettingProperty(controller.serviceBag, controller.player)
|
|
106
|
+
local otherProperty = controller.definition:GetSettingProperty(controller.serviceBag, otherPlayer)
|
|
107
|
+
|
|
108
|
+
expect(otherProperty).never.toBe(property)
|
|
109
|
+
|
|
110
|
+
otherPlayer:Destroy()
|
|
111
|
+
controller:destroy()
|
|
112
|
+
end)
|
|
113
|
+
end)
|
|
114
|
+
|
|
115
|
+
describe("SettingProperty.Observe", function()
|
|
116
|
+
it("reuses the observable across calls", function()
|
|
117
|
+
local controller = setup()
|
|
118
|
+
local property = controller.definition:GetSettingProperty(controller.serviceBag, controller.player)
|
|
119
|
+
|
|
120
|
+
expect(property:Observe()).toBe(property:Observe())
|
|
121
|
+
|
|
122
|
+
controller:destroy()
|
|
123
|
+
end)
|
|
124
|
+
|
|
125
|
+
it("does not re-emit when the tie churns but the value does not", function()
|
|
126
|
+
local controller = setup()
|
|
127
|
+
local property = controller.definition:GetSettingProperty(controller.serviceBag, controller.player)
|
|
128
|
+
|
|
129
|
+
local emissions = 0
|
|
130
|
+
local maid = Maid.new()
|
|
131
|
+
maid:GiveTask(property:Observe():Subscribe(function()
|
|
132
|
+
emissions += 1
|
|
133
|
+
end))
|
|
134
|
+
|
|
135
|
+
expect(emissions).toBe(1)
|
|
136
|
+
|
|
137
|
+
controller.reimplement()
|
|
138
|
+
controller.reimplement()
|
|
139
|
+
|
|
140
|
+
expect(emissions).toBe(1)
|
|
141
|
+
|
|
142
|
+
maid:DoCleaning()
|
|
143
|
+
controller:destroy()
|
|
144
|
+
end)
|
|
145
|
+
end)
|
|
@@ -72,7 +72,12 @@ end
|
|
|
72
72
|
@return Observable<T>
|
|
73
73
|
]=]
|
|
74
74
|
function SettingProperty.Observe<T>(self: SettingProperty<T>): Observable.Observable<T>
|
|
75
|
-
|
|
75
|
+
local found = rawget(self :: any, "_observeCache")
|
|
76
|
+
if found then
|
|
77
|
+
return found
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
local observe = self:_observePlayerSettings():Pipe({
|
|
76
81
|
Rx.where(function(settings)
|
|
77
82
|
return settings ~= nil
|
|
78
83
|
end),
|
|
@@ -92,7 +97,14 @@ function SettingProperty.Observe<T>(self: SettingProperty<T>): Observable.Observ
|
|
|
92
97
|
)
|
|
93
98
|
end
|
|
94
99
|
end) :: any,
|
|
100
|
+
-- The tie mints a new interface per subscription, so the switchMap above re-emits on churn that
|
|
101
|
+
-- never changed the value -- and observers that read other settings while handling one turn
|
|
102
|
+
-- each re-emit into a loop.
|
|
103
|
+
Rx.distinct() :: any,
|
|
104
|
+
Rx.cache() :: any,
|
|
95
105
|
}) :: any
|
|
106
|
+
rawset(self :: any, "_observeCache", observe)
|
|
107
|
+
return observe
|
|
96
108
|
end
|
|
97
109
|
|
|
98
110
|
(SettingProperty :: any).__index = function(self, index): any
|
|
@@ -33,6 +33,7 @@ export type SettingsDataService = typeof(setmetatable(
|
|
|
33
33
|
_settingDefinitions: ObservableSet.ObservableSet<any>,
|
|
34
34
|
_playerSettingsCacheMap: ObservableMap.ObservableMap<Player, any>,
|
|
35
35
|
_hydratedPlayersMaid: Maid.Maid,
|
|
36
|
+
_hydratingPlayers: { [Player]: boolean },
|
|
36
37
|
},
|
|
37
38
|
{} :: typeof({ __index = SettingsDataService })
|
|
38
39
|
))
|
|
@@ -64,33 +65,71 @@ function SettingsDataService._getPlayerSettingsCacheMap(
|
|
|
64
65
|
|
|
65
66
|
self._playerSettingsCacheMap = self._maid:Add(ObservableMap.new() :: ObservableMap.ObservableMap<Player, any>)
|
|
66
67
|
self._hydratedPlayersMaid = self._maid:Add(Maid.new())
|
|
68
|
+
self._hydratingPlayers = {}
|
|
67
69
|
|
|
68
70
|
self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player: Player)
|
|
69
|
-
self
|
|
71
|
+
self:_onPlayerRemoving(player)
|
|
70
72
|
end))
|
|
71
73
|
|
|
72
74
|
return (self._playerSettingsCacheMap :: any) :: ObservableMap.ObservableMap<Player, any>
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
-- Split out from the Players.PlayerRemoving connection above, which no test can make the engine fire.
|
|
78
|
+
function SettingsDataService._onPlayerRemoving(self: SettingsDataService, player: Player): ()
|
|
79
|
+
-- Dropping the hydration emits into live observers, and a settings read from one of those handlers
|
|
80
|
+
-- lands back in [_getPlayerSettingsMapForPlayer], rebuilding the tie for a player on their way out.
|
|
81
|
+
-- So a tombstone claims the slot across the teardown instead of clearing it, and drops itself once
|
|
82
|
+
-- the player is really out of the DataModel and nothing can reach these settings again.
|
|
83
|
+
local tombstone = Maid.new()
|
|
84
|
+
tombstone:GiveTask(player.AncestryChanged:Connect(function()
|
|
85
|
+
if not player:IsDescendantOf(game) then
|
|
86
|
+
self._hydratedPlayersMaid[player] = nil
|
|
87
|
+
end
|
|
88
|
+
end))
|
|
89
|
+
|
|
90
|
+
self._hydratedPlayersMaid[player] = tombstone
|
|
91
|
+
end
|
|
92
|
+
|
|
75
93
|
function SettingsDataService._getPlayerSettingsMapForPlayer(
|
|
76
94
|
self: SettingsDataService,
|
|
77
95
|
player: Player
|
|
78
96
|
): ObservableMap.ObservableMap<Player, any>
|
|
79
97
|
local playerSettingsCacheMap = self:_getPlayerSettingsCacheMap()
|
|
80
98
|
|
|
99
|
+
-- Claimed already: either hydrated, or tombstoned by a departure (see [_onPlayerRemoving]).
|
|
81
100
|
if self._hydratedPlayersMaid[player] then
|
|
82
101
|
return playerSettingsCacheMap
|
|
83
102
|
end
|
|
84
103
|
|
|
104
|
+
-- Gone for good, and the tombstone has already dropped itself: their settings folder went with
|
|
105
|
+
-- them, so claiming a slot would only hold onto a departed player.
|
|
106
|
+
if not player:IsDescendantOf(game) then
|
|
107
|
+
return playerSettingsCacheMap
|
|
108
|
+
end
|
|
109
|
+
|
|
85
110
|
-- Note we only do this as requested to save memory. On the client, we're unlikely
|
|
86
111
|
-- to even query other player's settings.
|
|
87
|
-
|
|
112
|
+
--
|
|
113
|
+
-- Claim the slot before hydrating, not after: hydration emits synchronously, and a settings read
|
|
114
|
+
-- from that emission lands back here. Memoizing afterwards left the slot empty for the whole
|
|
115
|
+
-- emission, so each such read hydrated again -- minting a fresh tie interface the cache map cannot
|
|
116
|
+
-- dedupe by identity, so it re-emitted and recursed until the C stack died.
|
|
117
|
+
local playerMaid = Maid.new()
|
|
118
|
+
self._hydratedPlayersMaid[player] = playerMaid
|
|
119
|
+
self:_hydrateCacheForPlayer(player, playerMaid)
|
|
88
120
|
|
|
89
121
|
return playerSettingsCacheMap
|
|
90
122
|
end
|
|
91
123
|
|
|
92
|
-
function SettingsDataService._hydrateCacheForPlayer(
|
|
93
|
-
|
|
124
|
+
function SettingsDataService._hydrateCacheForPlayer(
|
|
125
|
+
self: SettingsDataService,
|
|
126
|
+
player: Player,
|
|
127
|
+
playerMaid: Maid.Maid
|
|
128
|
+
): ()
|
|
129
|
+
-- Tripwire for the crash: hydrating while a hydration is still on the stack is what used to
|
|
130
|
+
-- recurse. Unreachable now that the slot is claimed first, so firing means the claim was cleared.
|
|
131
|
+
assert(not self._hydratingPlayers[player], "[SettingsDataService] - Re-entrant hydration for player")
|
|
132
|
+
self._hydratingPlayers[player] = true
|
|
94
133
|
|
|
95
134
|
playerMaid:GiveTask((RxInstanceUtils.observeChildrenBrio(player, function(value): any
|
|
96
135
|
-- We really only care about this, and we can assume we have the tag immediately
|
|
@@ -107,10 +146,14 @@ function SettingsDataService._hydrateCacheForPlayer(self: SettingsDataService, p
|
|
|
107
146
|
end
|
|
108
147
|
|
|
109
148
|
local maid, playerSettings = brio:ToMaidAndValue()
|
|
110
|
-
|
|
149
|
+
|
|
150
|
+
-- Keyed off the player we hydrated for, not `playerSettings:GetPlayer()`: we only observe
|
|
151
|
+
-- this player's children, so the tie call can only return `player` again -- at the cost of
|
|
152
|
+
-- a bindable round-trip per emission, and a nil key if the folder reparents.
|
|
153
|
+
maid:GiveTask(self._playerSettingsCacheMap:Set(player, playerSettings))
|
|
111
154
|
end))
|
|
112
155
|
|
|
113
|
-
|
|
156
|
+
self._hydratingPlayers[player] = nil
|
|
114
157
|
end
|
|
115
158
|
|
|
116
159
|
--[=[
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for settings cache hydration, which used to recurse until the C stack died: the per-player
|
|
4
|
+
memo was only filled after hydration returned, so a read arriving during hydration's own
|
|
5
|
+
synchronous emission hydrated again -- and each subscription mints a fresh tie interface, so the
|
|
6
|
+
cache map re-emitted and fed the next read.
|
|
7
|
+
|
|
8
|
+
A stub implementer stands in for PlayerSettingsClient: the tie plumbing is what is under test, not
|
|
9
|
+
the settings themselves.
|
|
10
|
+
|
|
11
|
+
@class SettingsDataService.spec.lua
|
|
12
|
+
]]
|
|
13
|
+
|
|
14
|
+
local require = require(script.Parent.loader).load(script)
|
|
15
|
+
|
|
16
|
+
local Workspace = game:GetService("Workspace")
|
|
17
|
+
|
|
18
|
+
local Jest = require("Jest")
|
|
19
|
+
local Maid = require("Maid")
|
|
20
|
+
local PlayerMock = require("PlayerMock")
|
|
21
|
+
local PlayerSettingsInterface = require("PlayerSettingsInterface")
|
|
22
|
+
local PlayerSettingsUtils = require("PlayerSettingsUtils")
|
|
23
|
+
local ServiceBag = require("ServiceBag")
|
|
24
|
+
local SettingsDataService = require("SettingsDataService")
|
|
25
|
+
local TieRealmUtils = require("TieRealmUtils")
|
|
26
|
+
|
|
27
|
+
local describe = Jest.Globals.describe
|
|
28
|
+
local expect = Jest.Globals.expect
|
|
29
|
+
local it = Jest.Globals.it
|
|
30
|
+
|
|
31
|
+
local function setup()
|
|
32
|
+
local maid = Maid.new()
|
|
33
|
+
|
|
34
|
+
local serviceBag = maid:Add(ServiceBag.new())
|
|
35
|
+
local settingsDataService: SettingsDataService.SettingsDataService =
|
|
36
|
+
serviceBag:GetService(SettingsDataService) :: any
|
|
37
|
+
serviceBag:Init()
|
|
38
|
+
serviceBag:Start()
|
|
39
|
+
|
|
40
|
+
-- Parented so a test can take it back out, the way a departing player leaves Players.
|
|
41
|
+
local player = maid:Add(PlayerMock.new())
|
|
42
|
+
player.Parent = Workspace
|
|
43
|
+
|
|
44
|
+
local getPlayerCalls = 0
|
|
45
|
+
|
|
46
|
+
local folder = maid:Add(PlayerSettingsUtils.create())
|
|
47
|
+
folder.Parent = player
|
|
48
|
+
|
|
49
|
+
maid:Add(PlayerSettingsInterface:Implement(folder, {
|
|
50
|
+
GetPlayer = function()
|
|
51
|
+
getPlayerCalls += 1
|
|
52
|
+
return player
|
|
53
|
+
end,
|
|
54
|
+
-- The rest only has to exist for the implementation to be valid.
|
|
55
|
+
GetSettingProperty = function() end,
|
|
56
|
+
GetValue = function() end,
|
|
57
|
+
SetValue = function() end,
|
|
58
|
+
ObserveValue = function() end,
|
|
59
|
+
RestoreDefault = function() end,
|
|
60
|
+
EnsureInitialized = function() end,
|
|
61
|
+
}, TieRealmUtils.inferTieRealm()))
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
settingsDataService = settingsDataService,
|
|
65
|
+
player = player,
|
|
66
|
+
getPlayerCalls = function()
|
|
67
|
+
return getPlayerCalls
|
|
68
|
+
end,
|
|
69
|
+
maid = maid,
|
|
70
|
+
destroy = function(_self)
|
|
71
|
+
maid:DoCleaning()
|
|
72
|
+
end,
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe("SettingsDataService hydration", function()
|
|
77
|
+
it("resolves the same implementation across repeat reads", function()
|
|
78
|
+
local controller = setup()
|
|
79
|
+
|
|
80
|
+
local first = controller.settingsDataService:GetPlayerSettings(controller.player)
|
|
81
|
+
local second = controller.settingsDataService:GetPlayerSettings(controller.player)
|
|
82
|
+
|
|
83
|
+
expect(first).never.toBeNil()
|
|
84
|
+
expect(second).toBe(first)
|
|
85
|
+
|
|
86
|
+
controller:destroy()
|
|
87
|
+
end)
|
|
88
|
+
|
|
89
|
+
it("hydrates without invoking the implementation's GetPlayer", function()
|
|
90
|
+
local controller = setup()
|
|
91
|
+
|
|
92
|
+
controller.settingsDataService:GetPlayerSettings(controller.player)
|
|
93
|
+
|
|
94
|
+
expect(controller.getPlayerCalls()).toBe(0)
|
|
95
|
+
|
|
96
|
+
controller:destroy()
|
|
97
|
+
end)
|
|
98
|
+
|
|
99
|
+
it("does not re-hydrate when an observer reads settings during a re-hydration", function()
|
|
100
|
+
local controller = setup()
|
|
101
|
+
local settingsDataService = controller.settingsDataService
|
|
102
|
+
local player = controller.player
|
|
103
|
+
|
|
104
|
+
local sequence = ""
|
|
105
|
+
controller.maid:GiveTask(settingsDataService:ObservePlayerSettings(player):Subscribe(function(playerSettings)
|
|
106
|
+
sequence ..= if playerSettings ~= nil then "impl," else "nil,"
|
|
107
|
+
|
|
108
|
+
-- The re-entrant read: in production, any observer that reads a setting while handling one.
|
|
109
|
+
settingsDataService:GetPlayerSettings(player)
|
|
110
|
+
end))
|
|
111
|
+
|
|
112
|
+
expect(sequence).toBe("impl,")
|
|
113
|
+
|
|
114
|
+
-- Drops the memo without marking the player gone, so re-hydration is under test here rather than
|
|
115
|
+
-- the departure guard. The observer's own read used to find the memo still empty and hydrate
|
|
116
|
+
-- again, and so on until the C stack died.
|
|
117
|
+
local hydratedPlayersMaid = (settingsDataService :: any)._hydratedPlayersMaid
|
|
118
|
+
hydratedPlayersMaid[player] = nil
|
|
119
|
+
|
|
120
|
+
expect(settingsDataService:GetPlayerSettings(player)).never.toBeNil()
|
|
121
|
+
|
|
122
|
+
-- One re-hydration, not a cascade: nil is the old hydration tearing down, and the observer's own
|
|
123
|
+
-- read rebuilds from inside that emission.
|
|
124
|
+
expect(sequence).toBe("impl,nil,impl,")
|
|
125
|
+
|
|
126
|
+
controller:destroy()
|
|
127
|
+
end)
|
|
128
|
+
|
|
129
|
+
it("does not hydrate a player who has left", function()
|
|
130
|
+
local controller = setup()
|
|
131
|
+
local settingsDataService = controller.settingsDataService
|
|
132
|
+
local player = controller.player
|
|
133
|
+
|
|
134
|
+
local sequence = ""
|
|
135
|
+
controller.maid:GiveTask(settingsDataService:ObservePlayerSettings(player):Subscribe(function(playerSettings)
|
|
136
|
+
sequence ..= if playerSettings ~= nil then "impl," else "nil,"
|
|
137
|
+
|
|
138
|
+
settingsDataService:GetPlayerSettings(player)
|
|
139
|
+
end))
|
|
140
|
+
|
|
141
|
+
expect(sequence).toBe("impl,")
|
|
142
|
+
|
|
143
|
+
-- Stands in for Players.PlayerRemoving, which no test can make the engine fire.
|
|
144
|
+
local internal: any = settingsDataService
|
|
145
|
+
internal:_onPlayerRemoving(player)
|
|
146
|
+
|
|
147
|
+
-- The read from inside the teardown emission finds nothing to rebuild -- the settings folder is
|
|
148
|
+
-- leaving the DataModel too.
|
|
149
|
+
expect(sequence).toBe("impl,nil,")
|
|
150
|
+
expect(settingsDataService:GetPlayerSettings(player)).toBeNil()
|
|
151
|
+
expect(sequence).toBe("impl,nil,")
|
|
152
|
+
|
|
153
|
+
controller:destroy()
|
|
154
|
+
end)
|
|
155
|
+
|
|
156
|
+
it("stops holding a player once they are out of the DataModel", function()
|
|
157
|
+
local controller = setup()
|
|
158
|
+
local settingsDataService = controller.settingsDataService
|
|
159
|
+
local player = controller.player
|
|
160
|
+
|
|
161
|
+
settingsDataService:GetPlayerSettings(player)
|
|
162
|
+
|
|
163
|
+
local internal: any = settingsDataService
|
|
164
|
+
internal:_onPlayerRemoving(player)
|
|
165
|
+
|
|
166
|
+
-- The tombstone holds the slot for as long as the player could still be asked about.
|
|
167
|
+
expect(internal._hydratedPlayersMaid[player]).never.toBeNil()
|
|
168
|
+
|
|
169
|
+
-- What a removed player's instance actually does, and the point after which nothing can reach
|
|
170
|
+
-- these settings again.
|
|
171
|
+
player.Parent = nil
|
|
172
|
+
|
|
173
|
+
expect(internal._hydratedPlayersMaid[player]).toBeNil()
|
|
174
|
+
|
|
175
|
+
-- With the tombstone gone, the DataModel answers: a later read must not claim the slot back.
|
|
176
|
+
expect(settingsDataService:GetPlayerSettings(player)).toBeNil()
|
|
177
|
+
expect(internal._hydratedPlayersMaid[player]).toBeNil()
|
|
178
|
+
|
|
179
|
+
controller:destroy()
|
|
180
|
+
end)
|
|
181
|
+
end)
|