@quenty/settings 11.66.2 → 11.67.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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.67.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/settings@11.66.3...@quenty/settings@11.67.0) (2026-07-28)
7
+
8
+ **Note:** Version bump only for package @quenty/settings
9
+
10
+ ## [11.66.3](https://github.com/Quenty/NevermoreEngine/compare/@quenty/settings@11.66.2...@quenty/settings@11.66.3) (2026-07-27)
11
+
12
+ ### Bug Fixes
13
+
14
+ - **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))
15
+
6
16
  ## [11.66.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/settings@11.66.1...@quenty/settings@11.66.2) (2026-07-27)
7
17
 
8
18
  **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.2",
3
+ "version": "11.67.0",
4
4
  "description": "Centralized player settings service",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -28,13 +28,13 @@
28
28
  "Quenty"
29
29
  ],
30
30
  "dependencies": {
31
- "@quenty/attributeutils": "14.35.1",
31
+ "@quenty/attributeutils": "14.36.0",
32
32
  "@quenty/baseobject": "10.15.0",
33
33
  "@quenty/binder": "14.43.1",
34
34
  "@quenty/brio": "14.35.1",
35
35
  "@quenty/canceltoken": "11.24.0",
36
- "@quenty/cmdrservice": "13.55.2",
37
- "@quenty/datastore": "13.50.2",
36
+ "@quenty/cmdrservice": "13.56.0",
37
+ "@quenty/datastore": "13.51.0",
38
38
  "@quenty/ducktype": "5.11.0",
39
39
  "@quenty/enumutils": "3.4.6",
40
40
  "@quenty/instanceutils": "13.35.1",
@@ -42,13 +42,13 @@
42
42
  "@quenty/loader": "10.11.0",
43
43
  "@quenty/maid": "3.11.0",
44
44
  "@quenty/nevermore-test-runner": "1.5.0",
45
- "@quenty/observablecollection": "12.46.2",
46
- "@quenty/playerbinder": "14.45.2",
47
- "@quenty/playermock": "1.5.2",
48
- "@quenty/playerutils": "8.40.2",
45
+ "@quenty/observablecollection": "12.47.0",
46
+ "@quenty/playerbinder": "14.46.0",
47
+ "@quenty/playermock": "1.6.0",
48
+ "@quenty/playerutils": "8.41.0",
49
49
  "@quenty/promise": "10.23.0",
50
50
  "@quenty/remotefunctionutils": "10.23.0",
51
- "@quenty/remoting": "12.40.2",
51
+ "@quenty/remoting": "12.41.0",
52
52
  "@quenty/rx": "13.33.0",
53
53
  "@quenty/rxbinderutils": "14.43.1",
54
54
  "@quenty/rxsignal": "7.33.0",
@@ -59,12 +59,12 @@
59
59
  "@quenty/symbol": "3.5.2",
60
60
  "@quenty/table": "3.9.2",
61
61
  "@quenty/throttle": "10.14.0",
62
- "@quenty/tie": "10.46.1",
62
+ "@quenty/tie": "10.47.0",
63
63
  "@quenty/valueobject": "13.36.1",
64
64
  "@quentystudios/jest-lua": "3.10.0-quenty.2"
65
65
  },
66
66
  "publishConfig": {
67
67
  "access": "public"
68
68
  },
69
- "gitHead": "b2fa2984c2ea6e83136e5a7cbf57b14038d9a265"
69
+ "gitHead": "65232b0907168a4733c7930268142fb86ef75da4"
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 a new setting property for the given definition
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
- return SettingProperty.new(serviceBag, player, self)
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
- return self:_observePlayerSettings():Pipe({
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._hydratedPlayersMaid[player] = nil
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
- self._hydratedPlayersMaid[player] = self:_hydrateCacheForPlayer(player)
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(self: SettingsDataService, player: Player): Maid.Maid
93
- local playerMaid = Maid.new()
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
- maid:GiveTask(self._playerSettingsCacheMap:Set(playerSettings:GetPlayer(), playerSettings))
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
- return playerMaid
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)