@quenty/playerbinder 14.39.0 → 14.41.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 +14 -0
- package/deploy.nevermore.json +10 -0
- package/package.json +8 -4
- package/src/Server/PlayerBinder.lua +29 -0
- package/src/Server/PlayerBinder.spec.lua +242 -0
- package/src/jest.config.lua +3 -0
- package/test/default.project.json +25 -0
- package/test/scripts/Server/ServerMain.server.lua +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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
|
+
# [14.41.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerbinder@14.40.0...@quenty/playerbinder@14.41.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- Cleanup code a bit ([fa8c596](https://github.com/Quenty/NevermoreEngine/commit/fa8c596544e45610e180f5ae3797154f35930ccd))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
|
|
15
|
+
|
|
16
|
+
# [14.40.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerbinder@14.39.0...@quenty/playerbinder@14.40.0) (2026-07-22)
|
|
17
|
+
|
|
18
|
+
**Note:** Version bump only for package @quenty/playerbinder
|
|
19
|
+
|
|
6
20
|
# [14.39.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerbinder@14.38.0...@quenty/playerbinder@14.39.0) (2026-07-18)
|
|
7
21
|
|
|
8
22
|
**Note:** Version bump only for package @quenty/playerbinder
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/playerbinder",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.41.0",
|
|
4
4
|
"description": "Binds the given class to each player in the game",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
"Quenty"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@quenty/binder": "14.
|
|
32
|
-
"@quenty/loader": "10.11.0"
|
|
31
|
+
"@quenty/binder": "14.41.0",
|
|
32
|
+
"@quenty/loader": "10.11.0",
|
|
33
|
+
"@quenty/nevermore-test-runner": "1.5.0",
|
|
34
|
+
"@quenty/playermock": "1.1.0",
|
|
35
|
+
"@quenty/servicebag": "11.20.0",
|
|
36
|
+
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
33
37
|
},
|
|
34
38
|
"publishConfig": {
|
|
35
39
|
"access": "public"
|
|
36
40
|
},
|
|
37
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
|
|
38
42
|
}
|
|
@@ -9,6 +9,7 @@ local require = require(script.Parent.loader).load(script)
|
|
|
9
9
|
local Players = game:GetService("Players")
|
|
10
10
|
|
|
11
11
|
local Binder = require("Binder")
|
|
12
|
+
local PlayerMockService = require("PlayerMockService")
|
|
12
13
|
|
|
13
14
|
local PlayerBinder = setmetatable({}, Binder)
|
|
14
15
|
PlayerBinder.ClassName = "PlayerBinder"
|
|
@@ -29,6 +30,24 @@ function PlayerBinder.new<T>(tag: string, class: Binder.BinderConstructor<T>, ..
|
|
|
29
30
|
return self
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
--[=[
|
|
34
|
+
Initializes the binder. Captures the owning [ServiceBag] so [PlayerBinder.Start] can discover mock
|
|
35
|
+
players from it. Done via a ServiceBag.
|
|
36
|
+
]=]
|
|
37
|
+
function PlayerBinder.Init<T>(self: PlayerBinder<T>, serviceBag: any, ...: any): ...any
|
|
38
|
+
-- The ServiceBag passes itself as the first Init arg (and on to bound-class constructors).
|
|
39
|
+
(self :: any)._serviceBag = serviceBag
|
|
40
|
+
|
|
41
|
+
if serviceBag then
|
|
42
|
+
-- Declare the PlayerMockService dependency during the init phase. A test can then
|
|
43
|
+
-- serviceBag:GetService(PlayerMockService) after the bag has started (a bag refuses to add
|
|
44
|
+
-- new services once started), and every service upstream of a PlayerBinder inherits this.
|
|
45
|
+
serviceBag:GetService(PlayerMockService)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return (getmetatable(PlayerBinder) :: any).Init(self, serviceBag, ...)
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
--[=[
|
|
33
52
|
Starts the binder. See [Binder.Start].
|
|
34
53
|
Should be done via a [ServiceBag].
|
|
@@ -43,6 +62,16 @@ function PlayerBinder.Start<T>(self: PlayerBinder<T>): ...any
|
|
|
43
62
|
self:Tag(item)
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
-- Discover mock players the same way as real joins. Replication is the default and place-wide,
|
|
66
|
+
-- like Players:GetPlayers() -- any parented mock binds here, whichever bag (either realm) made
|
|
67
|
+
-- it; production places just carry no mocks (they are only ever created by tests).
|
|
68
|
+
local serviceBag = (self :: any)._serviceBag
|
|
69
|
+
if serviceBag then
|
|
70
|
+
self._maid:GiveTask(serviceBag:GetService(PlayerMockService):ObservePlayerMocks():Subscribe(function(playerMock)
|
|
71
|
+
self:Tag(playerMock)
|
|
72
|
+
end))
|
|
73
|
+
end
|
|
74
|
+
|
|
46
75
|
return unpack(results)
|
|
47
76
|
end
|
|
48
77
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Coverage for PlayerBinder, the Binder subclass that auto-tags every player. Real players never join
|
|
4
|
+
a headless Open Cloud place, so the tests drive PlayerMocks: PlayerBinder.Start observes the
|
|
5
|
+
PlayerMockService in its own ServiceBag, so a mock created through that service flows through
|
|
6
|
+
discovery -> Tag -> bind exactly like a real join, with no test reaching in to Tag/Bind it by hand.
|
|
7
|
+
Replication is place-wide like Players:GetPlayers() -- a mock created by any bag binds in every
|
|
8
|
+
bag -- so tests destroy their mocks before the next test observes.
|
|
9
|
+
|
|
10
|
+
Binders are booted the way production boots them: registered on a BinderProvider driven through a
|
|
11
|
+
ServiceBag. Tags are global and the test place is shared across a batch run, so each test uses a
|
|
12
|
+
distinct tag, parents its mocks under its own container, and destroys everything it creates so nothing
|
|
13
|
+
leaks into a later test.
|
|
14
|
+
|
|
15
|
+
@class PlayerBinder.spec.lua
|
|
16
|
+
]]
|
|
17
|
+
|
|
18
|
+
local require = require(script.Parent.loader).load(script)
|
|
19
|
+
|
|
20
|
+
local Binder = require("Binder")
|
|
21
|
+
local BinderProvider = require("BinderProvider")
|
|
22
|
+
local Jest = require("Jest")
|
|
23
|
+
local PlayerBinder = require("PlayerBinder")
|
|
24
|
+
local PlayerMock = require("PlayerMock")
|
|
25
|
+
local PlayerMockService = require("PlayerMockService")
|
|
26
|
+
local ServiceBag = require("ServiceBag")
|
|
27
|
+
|
|
28
|
+
local describe = Jest.Globals.describe
|
|
29
|
+
local expect = Jest.Globals.expect
|
|
30
|
+
local it = Jest.Globals.it
|
|
31
|
+
|
|
32
|
+
local specCounter = 0
|
|
33
|
+
|
|
34
|
+
-- Records its instance and whether it was destroyed. It ignores its constructor varargs: the ServiceBag
|
|
35
|
+
-- is injected as a constructor arg, and its Signals' strict __index makes jest's deep-equality traversal
|
|
36
|
+
-- throw, so the class must not retain it for toEqual to compare instances safely.
|
|
37
|
+
local function makeTrackingClass()
|
|
38
|
+
local Class = {}
|
|
39
|
+
Class.__index = Class
|
|
40
|
+
Class.ClassName = "TrackingClass"
|
|
41
|
+
|
|
42
|
+
function Class.new(inst)
|
|
43
|
+
return setmetatable({ instance = inst, destroyed = false }, Class)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
function Class:Destroy()
|
|
47
|
+
self.destroyed = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return Class
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
local function awaitUnbound(binder, inst)
|
|
54
|
+
if binder:Get(inst) ~= nil then
|
|
55
|
+
binder:GetClassRemovedSignal():Wait()
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
local function setup(constructor: any?)
|
|
60
|
+
specCounter += 1
|
|
61
|
+
local suffix = specCounter
|
|
62
|
+
|
|
63
|
+
local serviceBag = ServiceBag.new()
|
|
64
|
+
local container = Instance.new("Folder")
|
|
65
|
+
container.Name = "PlayerBinderSpecContainer"
|
|
66
|
+
container.Parent = workspace
|
|
67
|
+
|
|
68
|
+
local mocks: { Player } = {}
|
|
69
|
+
local initialized = false
|
|
70
|
+
local started = false
|
|
71
|
+
|
|
72
|
+
local tag = string.format("PlayerBinderSpecTag_%d", suffix)
|
|
73
|
+
local binder = PlayerBinder.new(tag, constructor or makeTrackingClass())
|
|
74
|
+
-- Cast: the service's instance fields are assigned in Init, so its methods do not type-check
|
|
75
|
+
-- against the exported module type.
|
|
76
|
+
local playerMockService: any = serviceBag:GetService(PlayerMockService)
|
|
77
|
+
|
|
78
|
+
local function init()
|
|
79
|
+
assert(not initialized, "Already initialized")
|
|
80
|
+
initialized = true
|
|
81
|
+
|
|
82
|
+
local provider = BinderProvider.new(string.format("PlayerBinderSpecProvider_%d", suffix), function(self)
|
|
83
|
+
self:Add(binder)
|
|
84
|
+
end)
|
|
85
|
+
serviceBag:GetService(provider)
|
|
86
|
+
serviceBag:Init()
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
local function start()
|
|
90
|
+
assert(initialized, "Call init() first")
|
|
91
|
+
assert(not started, "Already started")
|
|
92
|
+
started = true
|
|
93
|
+
|
|
94
|
+
serviceBag:Start()
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
local function boot()
|
|
98
|
+
init()
|
|
99
|
+
start()
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
local function newMock(userId: number?): Player
|
|
103
|
+
assert(initialized, "Call init() first -- mocks are created through the bag's PlayerMockService")
|
|
104
|
+
|
|
105
|
+
local mock = playerMockService:CreatePlayer(if userId ~= nil then { UserId = userId } else nil)
|
|
106
|
+
mock.Parent = container
|
|
107
|
+
table.insert(mocks, mock)
|
|
108
|
+
return mock
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
local function destroy()
|
|
112
|
+
if initialized then
|
|
113
|
+
serviceBag:Destroy()
|
|
114
|
+
end
|
|
115
|
+
for _, mock in mocks do
|
|
116
|
+
pcall(function()
|
|
117
|
+
(mock :: Instance):Destroy()
|
|
118
|
+
end)
|
|
119
|
+
end
|
|
120
|
+
container:Destroy()
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
binder = binder,
|
|
125
|
+
tag = tag,
|
|
126
|
+
init = init,
|
|
127
|
+
start = start,
|
|
128
|
+
boot = boot,
|
|
129
|
+
newMock = newMock,
|
|
130
|
+
destroy = destroy,
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe("PlayerBinder.new()", function()
|
|
135
|
+
it("is a Binder that reports its class name and tag", function()
|
|
136
|
+
local binder = PlayerBinder.new("PlayerBinderMetaSpecTag", makeTrackingClass())
|
|
137
|
+
expect(Binder.isBinder(binder)).toEqual(true)
|
|
138
|
+
expect((binder :: any).ClassName).toEqual("PlayerBinder")
|
|
139
|
+
expect(binder:GetTag()).toEqual("PlayerBinderMetaSpecTag")
|
|
140
|
+
end)
|
|
141
|
+
end)
|
|
142
|
+
|
|
143
|
+
describe("PlayerBinder mock discovery", function()
|
|
144
|
+
it("starts cleanly with no players or mocks", function()
|
|
145
|
+
local controller = setup()
|
|
146
|
+
controller.boot()
|
|
147
|
+
|
|
148
|
+
expect(controller.binder:GetAll()).toEqual({})
|
|
149
|
+
|
|
150
|
+
controller.destroy()
|
|
151
|
+
end)
|
|
152
|
+
|
|
153
|
+
it("binds a player mock that exists before start", function()
|
|
154
|
+
local controller = setup()
|
|
155
|
+
|
|
156
|
+
controller.init()
|
|
157
|
+
local mock = controller.newMock(1)
|
|
158
|
+
controller.start()
|
|
159
|
+
|
|
160
|
+
local ok, class = controller.binder:Promise(mock):Yield()
|
|
161
|
+
assert(ok, "Never bound")
|
|
162
|
+
expect(class.instance).toEqual(mock)
|
|
163
|
+
|
|
164
|
+
controller.destroy()
|
|
165
|
+
end)
|
|
166
|
+
|
|
167
|
+
it("binds a player mock created after start", function()
|
|
168
|
+
local controller = setup()
|
|
169
|
+
controller.boot()
|
|
170
|
+
|
|
171
|
+
local mock = controller.newMock(2)
|
|
172
|
+
|
|
173
|
+
local ok, class = controller.binder:Promise(mock):Yield()
|
|
174
|
+
assert(ok, "Never bound")
|
|
175
|
+
expect(class.instance).toEqual(mock)
|
|
176
|
+
|
|
177
|
+
controller.destroy()
|
|
178
|
+
end)
|
|
179
|
+
|
|
180
|
+
it("applies the binder's tag to the discovered mock", function()
|
|
181
|
+
local controller = setup()
|
|
182
|
+
controller.boot()
|
|
183
|
+
|
|
184
|
+
local mock = controller.newMock()
|
|
185
|
+
assert((controller.binder:Promise(mock):Yield()), "Never bound")
|
|
186
|
+
|
|
187
|
+
expect(controller.binder:HasTag(mock)).toEqual(true)
|
|
188
|
+
|
|
189
|
+
controller.destroy()
|
|
190
|
+
end)
|
|
191
|
+
|
|
192
|
+
it("binds each of several mocks to its own class", function()
|
|
193
|
+
local controller = setup()
|
|
194
|
+
controller.boot()
|
|
195
|
+
|
|
196
|
+
local first = controller.newMock(1)
|
|
197
|
+
local second = controller.newMock(2)
|
|
198
|
+
|
|
199
|
+
local okA, classA = controller.binder:Promise(first):Yield()
|
|
200
|
+
local okB, classB = controller.binder:Promise(second):Yield()
|
|
201
|
+
assert(okA and okB, "Never bound")
|
|
202
|
+
|
|
203
|
+
expect(classA.instance).toEqual(first)
|
|
204
|
+
expect(classB.instance).toEqual(second)
|
|
205
|
+
expect(classA).never.toEqual(classB)
|
|
206
|
+
expect(#controller.binder:GetAll()).toEqual(2)
|
|
207
|
+
|
|
208
|
+
controller.destroy()
|
|
209
|
+
end)
|
|
210
|
+
|
|
211
|
+
it("discovers a hand-built mock it did not create", function()
|
|
212
|
+
local controller = setup()
|
|
213
|
+
controller.boot()
|
|
214
|
+
|
|
215
|
+
local foreignMock = PlayerMock.new({ UserId = 99 })
|
|
216
|
+
foreignMock.Parent = workspace
|
|
217
|
+
|
|
218
|
+
local ok, class = controller.binder:Promise(foreignMock):Yield()
|
|
219
|
+
assert(ok, "Never bound")
|
|
220
|
+
expect(class.instance).toEqual(foreignMock)
|
|
221
|
+
|
|
222
|
+
foreignMock:Destroy()
|
|
223
|
+
controller.destroy()
|
|
224
|
+
end)
|
|
225
|
+
|
|
226
|
+
it("unbinds and destroys the class when the mock is destroyed", function()
|
|
227
|
+
local controller = setup()
|
|
228
|
+
controller.boot()
|
|
229
|
+
|
|
230
|
+
local mock = controller.newMock()
|
|
231
|
+
local ok, class = controller.binder:Promise(mock):Yield()
|
|
232
|
+
assert(ok, "Never bound")
|
|
233
|
+
|
|
234
|
+
mock:Destroy()
|
|
235
|
+
awaitUnbound(controller.binder, mock)
|
|
236
|
+
|
|
237
|
+
expect(controller.binder:Get(mock)).toBeNil()
|
|
238
|
+
expect(class.destroyed).toEqual(true)
|
|
239
|
+
|
|
240
|
+
controller.destroy()
|
|
241
|
+
end)
|
|
242
|
+
end)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "PlayerBinderTest",
|
|
3
|
+
"globIgnorePaths": [
|
|
4
|
+
"**/.package-lock.json",
|
|
5
|
+
"**/.pnpm",
|
|
6
|
+
"**/.pnpm-workspace-state-v1.json",
|
|
7
|
+
"**/.modules.yaml",
|
|
8
|
+
"**/.ignored",
|
|
9
|
+
"**/.ignored_*"
|
|
10
|
+
],
|
|
11
|
+
"tree": {
|
|
12
|
+
"$className": "DataModel",
|
|
13
|
+
"ServerScriptService": {
|
|
14
|
+
"$properties": {
|
|
15
|
+
"LoadStringEnabled": true
|
|
16
|
+
},
|
|
17
|
+
"playerbinder": {
|
|
18
|
+
"$path": ".."
|
|
19
|
+
},
|
|
20
|
+
"Script": {
|
|
21
|
+
"$path": "scripts/Server"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
@class ServerMain
|
|
3
|
+
]]
|
|
4
|
+
local ServerScriptService = game:GetService("ServerScriptService")
|
|
5
|
+
|
|
6
|
+
local root = ServerScriptService.playerbinder
|
|
7
|
+
local loader = root:FindFirstChild("LoaderUtils", true).Parent
|
|
8
|
+
local require = require(loader).bootstrapGame(root)
|
|
9
|
+
|
|
10
|
+
local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
|
|
11
|
+
if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
|
|
12
|
+
return
|
|
13
|
+
end
|