@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 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
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 130012331343281,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/playerbinder",
3
- "version": "14.39.0",
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.39.0",
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": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
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,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -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