@quenty/playerutils 8.40.2 → 8.41.1
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 +10 -0
- package/deploy.nevermore.json +10 -0
- package/package.json +12 -10
- package/src/Shared/PlayerUtils.lua +22 -7
- package/src/Shared/PlayerUtils.spec.lua +169 -0
- package/src/Shared/RxPlayerUtils.lua +53 -10
- package/src/Shared/RxPlayerUtils.spec.lua +391 -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,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
|
+
## [8.41.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.41.0...@quenty/playerutils@8.41.1) (2026-08-14)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @quenty/playerutils
|
|
9
|
+
|
|
10
|
+
# [8.41.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.40.2...@quenty/playerutils@8.41.0) (2026-07-28)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Mock player utils + Player mock ([4caed96](https://github.com/Quenty/NevermoreEngine/commit/4caed968ad05b1bce254fbafb0998a7a1b207f29))
|
|
15
|
+
|
|
6
16
|
## [8.40.2](https://github.com/Quenty/NevermoreEngine/compare/@quenty/playerutils@8.40.1...@quenty/playerutils@8.40.2) (2026-07-27)
|
|
7
17
|
|
|
8
18
|
**Note:** Version bump only for package @quenty/playerutils
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/playerutils",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.41.1",
|
|
4
4
|
"description": "Player utility functions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -31,14 +31,16 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@quenty/brio": "14.35.
|
|
35
|
-
"@quenty/characterutils": "12.
|
|
36
|
-
"@quenty/instanceutils": "13.35.
|
|
37
|
-
"@quenty/loader": "10.11.
|
|
38
|
-
"@quenty/maid": "3.11.
|
|
39
|
-
"@quenty/
|
|
40
|
-
"@quenty/
|
|
41
|
-
"@quenty/
|
|
34
|
+
"@quenty/brio": "14.35.2",
|
|
35
|
+
"@quenty/characterutils": "12.41.1",
|
|
36
|
+
"@quenty/instanceutils": "13.35.2",
|
|
37
|
+
"@quenty/loader": "10.11.1",
|
|
38
|
+
"@quenty/maid": "3.11.1",
|
|
39
|
+
"@quenty/nevermore-test-runner": "1.5.1",
|
|
40
|
+
"@quenty/playermock": "1.6.1",
|
|
41
|
+
"@quenty/promise": "10.23.1",
|
|
42
|
+
"@quenty/rx": "13.33.1",
|
|
43
|
+
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
42
44
|
},
|
|
43
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "f084360c3729ea424d49d9bc42f8f0d1c128fa82"
|
|
44
46
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
local require = require(script.Parent.loader).load(script)
|
|
7
7
|
|
|
8
|
+
local Players = game:GetService("Players")
|
|
9
|
+
|
|
8
10
|
local PlayerMock = require("PlayerMock")
|
|
9
11
|
local Promise = require("Promise")
|
|
10
12
|
|
|
@@ -23,10 +25,10 @@ local PlayerUtils = {}
|
|
|
23
25
|
@return string -- Formatted name
|
|
24
26
|
]=]
|
|
25
27
|
function PlayerUtils.formatName(player: Player): string
|
|
26
|
-
assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
|
|
28
|
+
assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
|
|
27
29
|
|
|
28
30
|
local name = player.Name
|
|
29
|
-
local displayName = player.DisplayName
|
|
31
|
+
local displayName = if PlayerMock.isMock(player) then PlayerMock.read(player, "DisplayName") else player.DisplayName
|
|
30
32
|
|
|
31
33
|
return PlayerUtils.formatDisplayName(name, displayName)
|
|
32
34
|
end
|
|
@@ -134,18 +136,20 @@ function PlayerUtils.promiseLoadCharacter(player: Player): Promise.Promise<Model
|
|
|
134
136
|
assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
|
|
135
137
|
|
|
136
138
|
return Promise.spawn(function(resolve, reject)
|
|
139
|
+
local character: Model?
|
|
137
140
|
local ok, err = pcall(function()
|
|
138
141
|
if PlayerMock.isMock(player) then
|
|
139
|
-
PlayerMock.loadCharacterAsync(player)
|
|
142
|
+
character = PlayerMock.loadCharacterAsync(player)
|
|
140
143
|
else
|
|
141
144
|
player:LoadCharacterAsync()
|
|
145
|
+
character = player.Character
|
|
142
146
|
end
|
|
143
147
|
end)
|
|
144
148
|
if not ok then
|
|
145
149
|
return reject(err or "Failed to load character")
|
|
146
150
|
end
|
|
147
151
|
|
|
148
|
-
return resolve()
|
|
152
|
+
return resolve(character)
|
|
149
153
|
end)
|
|
150
154
|
end
|
|
151
155
|
|
|
@@ -160,21 +164,32 @@ function PlayerUtils.promiseLoadCharacterWithHumanoidDescription(
|
|
|
160
164
|
player: Player,
|
|
161
165
|
humanoidDescription: HumanoidDescription
|
|
162
166
|
): Promise.Promise<Model>
|
|
163
|
-
assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
|
|
167
|
+
assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
|
|
164
168
|
assert(
|
|
165
169
|
typeof(humanoidDescription) == "Instance" and humanoidDescription:IsA("HumanoidDescription"),
|
|
166
170
|
"Bad humanoidDescription"
|
|
167
171
|
)
|
|
168
172
|
|
|
169
173
|
return Promise.spawn(function(resolve, reject)
|
|
174
|
+
local character: Model?
|
|
170
175
|
local ok, err = pcall(function()
|
|
171
|
-
player
|
|
176
|
+
if PlayerMock.isMock(player) then
|
|
177
|
+
-- The engine builds the rig from the description before spawning it; the mock's spawn
|
|
178
|
+
-- path takes the built rig, which is the same split the engine call does internally.
|
|
179
|
+
character = PlayerMock.loadCharacterAsync(
|
|
180
|
+
player,
|
|
181
|
+
Players:CreateHumanoidModelFromDescription(humanoidDescription, Enum.HumanoidRigType.R15)
|
|
182
|
+
)
|
|
183
|
+
else
|
|
184
|
+
player:LoadCharacterWithHumanoidDescriptionAsync(humanoidDescription)
|
|
185
|
+
character = player.Character
|
|
186
|
+
end
|
|
172
187
|
end)
|
|
173
188
|
if not ok then
|
|
174
189
|
return reject(err or "Failed to load character")
|
|
175
190
|
end
|
|
176
191
|
|
|
177
|
-
return resolve()
|
|
192
|
+
return resolve(character)
|
|
178
193
|
end)
|
|
179
194
|
end
|
|
180
195
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local require = require(script.Parent.loader).load(script)
|
|
3
|
+
|
|
4
|
+
local Players = game:GetService("Players")
|
|
5
|
+
|
|
6
|
+
local Jest = require("Jest")
|
|
7
|
+
local Maid = require("Maid")
|
|
8
|
+
local PlayerMock = require("PlayerMock")
|
|
9
|
+
local PlayerUtils = require("PlayerUtils")
|
|
10
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
11
|
+
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
local function setup(): any
|
|
17
|
+
local maid = Maid.new()
|
|
18
|
+
|
|
19
|
+
local controller = {
|
|
20
|
+
newPlayer = function(overrides: { [string]: any }?): Player
|
|
21
|
+
local player = PlayerMock.new(overrides)
|
|
22
|
+
player.Parent = Players
|
|
23
|
+
maid:GiveTask(player)
|
|
24
|
+
return player
|
|
25
|
+
end,
|
|
26
|
+
destroy = function()
|
|
27
|
+
maid:DoCleaning()
|
|
28
|
+
end,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return controller
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe("PlayerUtils.formatDisplayName", function()
|
|
35
|
+
it("returns the display name when it matches the username", function()
|
|
36
|
+
expect(PlayerUtils.formatDisplayName("oot", "oot")).toBe("oot")
|
|
37
|
+
end)
|
|
38
|
+
|
|
39
|
+
it("ignores case when comparing", function()
|
|
40
|
+
expect(PlayerUtils.formatDisplayName("MARTXN", "martxn")).toBe("martxn")
|
|
41
|
+
end)
|
|
42
|
+
|
|
43
|
+
it("qualifies the display name with the username when they differ", function()
|
|
44
|
+
expect(PlayerUtils.formatDisplayName("martxn", "oot")).toBe("oot (@martxn)")
|
|
45
|
+
end)
|
|
46
|
+
end)
|
|
47
|
+
|
|
48
|
+
describe("PlayerUtils.formatDisplayNameFromUserInfo", function()
|
|
49
|
+
it("formats without a badge", function()
|
|
50
|
+
expect(PlayerUtils.formatDisplayNameFromUserInfo({
|
|
51
|
+
Username = "martxn",
|
|
52
|
+
DisplayName = "oot",
|
|
53
|
+
HasVerifiedBadge = false,
|
|
54
|
+
})).toBe("oot (@martxn)")
|
|
55
|
+
end)
|
|
56
|
+
|
|
57
|
+
it("appends the verified badge", function()
|
|
58
|
+
expect(PlayerUtils.formatDisplayNameFromUserInfo({
|
|
59
|
+
Username = "martxn",
|
|
60
|
+
DisplayName = "oot",
|
|
61
|
+
HasVerifiedBadge = true,
|
|
62
|
+
})).toBe(PlayerUtils.addVerifiedBadgeToName("oot (@martxn)"))
|
|
63
|
+
end)
|
|
64
|
+
|
|
65
|
+
it("rejects a malformed userInfo", function()
|
|
66
|
+
expect(function()
|
|
67
|
+
PlayerUtils.formatDisplayNameFromUserInfo({ Username = "martxn" } :: any)
|
|
68
|
+
end).toThrow()
|
|
69
|
+
end)
|
|
70
|
+
end)
|
|
71
|
+
|
|
72
|
+
describe("PlayerUtils.getDefaultNameColor", function()
|
|
73
|
+
it("is stable for the same name", function()
|
|
74
|
+
expect(PlayerUtils.getDefaultNameColor("oot")).toEqual(PlayerUtils.getDefaultNameColor("oot"))
|
|
75
|
+
end)
|
|
76
|
+
|
|
77
|
+
it("returns a Color3", function()
|
|
78
|
+
expect(typeof(PlayerUtils.getDefaultNameColor("oot"))).toBe("Color3")
|
|
79
|
+
end)
|
|
80
|
+
end)
|
|
81
|
+
|
|
82
|
+
describe("PlayerUtils.formatName", function()
|
|
83
|
+
it("returns the mock's name when its display name stand-in matches", function()
|
|
84
|
+
local controller = setup()
|
|
85
|
+
|
|
86
|
+
local player = controller.newPlayer({ UserId = 8675309 })
|
|
87
|
+
expect(PlayerUtils.formatName(player)).toBe(player.Name)
|
|
88
|
+
|
|
89
|
+
controller.destroy()
|
|
90
|
+
end)
|
|
91
|
+
|
|
92
|
+
it("qualifies the mock's display name stand-in with its name", function()
|
|
93
|
+
local controller = setup()
|
|
94
|
+
|
|
95
|
+
local player = controller.newPlayer({ UserId = 8675309, DisplayName = "oot" })
|
|
96
|
+
expect(PlayerUtils.formatName(player)).toBe(string.format("oot (@%s)", player.Name))
|
|
97
|
+
|
|
98
|
+
controller.destroy()
|
|
99
|
+
end)
|
|
100
|
+
|
|
101
|
+
it("follows a display name written mid-test", function()
|
|
102
|
+
local controller = setup()
|
|
103
|
+
|
|
104
|
+
local player = controller.newPlayer({ UserId = 8675309 })
|
|
105
|
+
PlayerMock.write(player, "DisplayName", "oot")
|
|
106
|
+
expect(PlayerUtils.formatName(player)).toBe(string.format("oot (@%s)", player.Name))
|
|
107
|
+
|
|
108
|
+
controller.destroy()
|
|
109
|
+
end)
|
|
110
|
+
|
|
111
|
+
it("rejects an instance that is neither a Player nor a mock", function()
|
|
112
|
+
expect(function()
|
|
113
|
+
PlayerUtils.formatName(Instance.new("Folder") :: any)
|
|
114
|
+
end).toThrow()
|
|
115
|
+
end)
|
|
116
|
+
end)
|
|
117
|
+
|
|
118
|
+
describe("PlayerUtils.promiseLoadCharacter", function()
|
|
119
|
+
it("spawns the mock's character and resolves it", function()
|
|
120
|
+
local controller = setup()
|
|
121
|
+
|
|
122
|
+
local player = controller.newPlayer({ UserId = 8675309 })
|
|
123
|
+
local outcome, character = PromiseTestUtils.awaitOutcome(PlayerUtils.promiseLoadCharacter(player), 30)
|
|
124
|
+
|
|
125
|
+
expect(outcome).toBe("resolved")
|
|
126
|
+
expect(character).toBe(PlayerMock.read(player, "Character"))
|
|
127
|
+
expect(character.Parent).toBe(workspace)
|
|
128
|
+
|
|
129
|
+
controller.destroy()
|
|
130
|
+
end)
|
|
131
|
+
|
|
132
|
+
it("rejects an instance that is neither a Player nor a mock", function()
|
|
133
|
+
expect(function()
|
|
134
|
+
PlayerUtils.promiseLoadCharacter(Instance.new("Folder") :: any)
|
|
135
|
+
end).toThrow()
|
|
136
|
+
end)
|
|
137
|
+
end)
|
|
138
|
+
|
|
139
|
+
describe("PlayerUtils.promiseLoadCharacterWithHumanoidDescription", function()
|
|
140
|
+
it("spawns a rig built from the description and resolves it", function()
|
|
141
|
+
local controller = setup()
|
|
142
|
+
|
|
143
|
+
local player = controller.newPlayer({ UserId = 8675309 })
|
|
144
|
+
local description = Instance.new("HumanoidDescription")
|
|
145
|
+
|
|
146
|
+
local outcome, character = PromiseTestUtils.awaitOutcome(
|
|
147
|
+
PlayerUtils.promiseLoadCharacterWithHumanoidDescription(player, description),
|
|
148
|
+
30
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(outcome).toBe("resolved")
|
|
152
|
+
expect(character).toBe(PlayerMock.read(player, "Character"))
|
|
153
|
+
expect(character:FindFirstChildOfClass("Humanoid")).never.toBeNil()
|
|
154
|
+
|
|
155
|
+
description:Destroy()
|
|
156
|
+
controller.destroy()
|
|
157
|
+
end)
|
|
158
|
+
|
|
159
|
+
it("rejects a bad humanoidDescription", function()
|
|
160
|
+
local controller = setup()
|
|
161
|
+
|
|
162
|
+
local player = controller.newPlayer({ UserId = 8675309 })
|
|
163
|
+
expect(function()
|
|
164
|
+
PlayerUtils.promiseLoadCharacterWithHumanoidDescription(player, Instance.new("Folder") :: any)
|
|
165
|
+
end).toThrow()
|
|
166
|
+
|
|
167
|
+
controller.destroy()
|
|
168
|
+
end)
|
|
169
|
+
end)
|
|
@@ -11,6 +11,8 @@ local Players = game:GetService("Players")
|
|
|
11
11
|
local Brio = require("Brio")
|
|
12
12
|
local Maid = require("Maid")
|
|
13
13
|
local Observable = require("Observable")
|
|
14
|
+
local PlayerMock = require("PlayerMock")
|
|
15
|
+
local PlayerMockUtils = require("PlayerMockUtils")
|
|
14
16
|
local Rx = require("Rx")
|
|
15
17
|
local RxBrioUtils = require("RxBrioUtils")
|
|
16
18
|
local RxCharacterUtils = require("RxCharacterUtils")
|
|
@@ -38,11 +40,17 @@ function RxPlayerUtils.observePlayersBrio(predicate: Rx.Predicate<Player>?): Obs
|
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
local function handleRemoving(player: Player)
|
|
44
|
+
maid[player] = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
maid:GiveTask(Players.PlayerAdded:Connect(handlePlayer))
|
|
48
|
+
maid:GiveTask(Players.PlayerRemoving:Connect(handleRemoving))
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
-- Mocks are invisible to the Players service, so their tag lifecycle is the counterpart of
|
|
51
|
+
-- the join events, feeding the same handlers.
|
|
52
|
+
maid:GiveTask(PlayerMock.getMockAddedSignal():Connect(handlePlayer))
|
|
53
|
+
maid:GiveTask(PlayerMock.getMockRemovingSignal():Connect(handleRemoving))
|
|
46
54
|
|
|
47
55
|
for _, player in Players:GetPlayers() do
|
|
48
56
|
task.spawn(function()
|
|
@@ -50,6 +58,12 @@ function RxPlayerUtils.observePlayersBrio(predicate: Rx.Predicate<Player>?): Obs
|
|
|
50
58
|
end)
|
|
51
59
|
end
|
|
52
60
|
|
|
61
|
+
for _, player in PlayerMock.getMocks() do
|
|
62
|
+
task.spawn(function()
|
|
63
|
+
handlePlayer(player)
|
|
64
|
+
end)
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
return maid
|
|
54
68
|
end) :: any
|
|
55
69
|
end
|
|
@@ -82,9 +96,20 @@ end
|
|
|
82
96
|
@return Observable<Brio<Player>>
|
|
83
97
|
]=]
|
|
84
98
|
function RxPlayerUtils.observeLocalPlayerBrio(): Observable.Observable<Brio.Brio<Player>>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
-- Headless tests have no Players.LocalPlayer; a test designates a PlayerMock instead. Observed
|
|
100
|
+
-- rather than read once, so a designation made after subscribing still resolves.
|
|
101
|
+
return Rx.combineLatest({
|
|
102
|
+
real = RxInstanceUtils.observeProperty(Players, "LocalPlayer"),
|
|
103
|
+
mocked = PlayerMockUtils.observeMockedLocalPlayer(),
|
|
104
|
+
}):Pipe({
|
|
105
|
+
Rx.map(function(state: any): Player?
|
|
106
|
+
return state.real or state.mocked
|
|
107
|
+
end) :: any,
|
|
108
|
+
Rx.distinct() :: any,
|
|
109
|
+
RxBrioUtils.switchToBrio(function(player)
|
|
110
|
+
return player ~= nil
|
|
111
|
+
end) :: any,
|
|
112
|
+
}) :: any
|
|
88
113
|
end
|
|
89
114
|
|
|
90
115
|
--[=[
|
|
@@ -116,6 +141,7 @@ function RxPlayerUtils.observePlayers(predicate: Rx.Predicate<Player>?): Observa
|
|
|
116
141
|
end
|
|
117
142
|
|
|
118
143
|
maid:GiveTask(Players.PlayerAdded:Connect(handlePlayer))
|
|
144
|
+
maid:GiveTask(PlayerMock.getMockAddedSignal():Connect(handlePlayer))
|
|
119
145
|
|
|
120
146
|
for _, player in Players:GetPlayers() do
|
|
121
147
|
task.spawn(function()
|
|
@@ -123,6 +149,12 @@ function RxPlayerUtils.observePlayers(predicate: Rx.Predicate<Player>?): Observa
|
|
|
123
149
|
end)
|
|
124
150
|
end
|
|
125
151
|
|
|
152
|
+
for _, player in PlayerMock.getMocks() do
|
|
153
|
+
task.spawn(function()
|
|
154
|
+
handlePlayer(player)
|
|
155
|
+
end)
|
|
156
|
+
end
|
|
157
|
+
|
|
126
158
|
return maid
|
|
127
159
|
end) :: any
|
|
128
160
|
end
|
|
@@ -136,8 +168,13 @@ end
|
|
|
136
168
|
function RxPlayerUtils.observeFirstAppearanceLoaded(player: Player): Observable.Observable<()>
|
|
137
169
|
assert(typeof(player) == "Instance", "Bad player")
|
|
138
170
|
|
|
171
|
+
local isMock = PlayerMock.isMock(player)
|
|
172
|
+
|
|
139
173
|
return Observable.new(function(sub)
|
|
140
|
-
if
|
|
174
|
+
local alreadyLoaded = if isMock
|
|
175
|
+
then PlayerMock.read(player, "HasAppearanceLoaded")
|
|
176
|
+
else player:HasAppearanceLoaded()
|
|
177
|
+
if alreadyLoaded then
|
|
141
178
|
sub:Fire()
|
|
142
179
|
sub:Complete()
|
|
143
180
|
return
|
|
@@ -145,18 +182,24 @@ function RxPlayerUtils.observeFirstAppearanceLoaded(player: Player): Observable.
|
|
|
145
182
|
|
|
146
183
|
local maid = Maid.new()
|
|
147
184
|
|
|
185
|
+
local characterAppearanceLoaded: RBXScriptSignal<...any> = if isMock
|
|
186
|
+
then PlayerMock.getSignal(player, "CharacterAppearanceLoaded")
|
|
187
|
+
else player.CharacterAppearanceLoaded
|
|
188
|
+
|
|
148
189
|
-- In case this works
|
|
149
|
-
maid:GiveTask(
|
|
190
|
+
maid:GiveTask(characterAppearanceLoaded:Connect(function()
|
|
150
191
|
sub:Fire()
|
|
151
192
|
sub:Complete()
|
|
152
193
|
end))
|
|
153
194
|
|
|
154
195
|
maid:GiveTask(task.spawn(function()
|
|
155
|
-
|
|
196
|
+
local loaded = false
|
|
197
|
+
while not loaded and player:IsDescendantOf(game) do
|
|
156
198
|
task.wait(0.05)
|
|
199
|
+
loaded = if isMock then PlayerMock.read(player, "HasAppearanceLoaded") else player:HasAppearanceLoaded()
|
|
157
200
|
end
|
|
158
201
|
|
|
159
|
-
if
|
|
202
|
+
if loaded then
|
|
160
203
|
sub:Fire()
|
|
161
204
|
sub:Complete()
|
|
162
205
|
else
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local require = require(script.Parent.loader).load(script)
|
|
3
|
+
|
|
4
|
+
local Players = game:GetService("Players")
|
|
5
|
+
|
|
6
|
+
local Jest = require("Jest")
|
|
7
|
+
local Maid = require("Maid")
|
|
8
|
+
local PlayerMock = require("PlayerMock")
|
|
9
|
+
local PromiseTestUtils = require("PromiseTestUtils")
|
|
10
|
+
local RxPlayerUtils = require("RxPlayerUtils")
|
|
11
|
+
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
local TEST_USER_ID = 8675309
|
|
17
|
+
|
|
18
|
+
local function isTestPlayer(player: Player): boolean
|
|
19
|
+
return PlayerMock.isMock(player) and PlayerMock.read(player, "UserId") == TEST_USER_ID
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local function findLivingBrio(brios: { any }, value: any): any
|
|
23
|
+
for _, brio in brios do
|
|
24
|
+
if not brio:IsDead() and brio:GetValue() == value then
|
|
25
|
+
return brio
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
local function contains(values: { any }, value: any): boolean
|
|
32
|
+
return table.find(values, value) ~= nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
local function setup(): any
|
|
36
|
+
local maid = Maid.new()
|
|
37
|
+
|
|
38
|
+
local controller = {
|
|
39
|
+
newPlayer = function(overrides: { [string]: any }?): Player
|
|
40
|
+
local player = PlayerMock.new(overrides or { UserId = TEST_USER_ID })
|
|
41
|
+
player.Parent = Players
|
|
42
|
+
maid:GiveTask(player)
|
|
43
|
+
return player
|
|
44
|
+
end,
|
|
45
|
+
designateLocalPlayer = function(player: Player?)
|
|
46
|
+
PlayerMock.setMockedLocalPlayer(player)
|
|
47
|
+
maid:GiveTask(function()
|
|
48
|
+
if player ~= nil and PlayerMock.getMockedLocalPlayer() == player then
|
|
49
|
+
PlayerMock.setMockedLocalPlayer(nil)
|
|
50
|
+
end
|
|
51
|
+
end)
|
|
52
|
+
end,
|
|
53
|
+
record = function(observable: any): { any }
|
|
54
|
+
local values = {}
|
|
55
|
+
maid:GiveTask(observable:Subscribe(function(value)
|
|
56
|
+
table.insert(values, value)
|
|
57
|
+
end))
|
|
58
|
+
return values
|
|
59
|
+
end,
|
|
60
|
+
awaitCount = function(values: { any }, count: number): boolean
|
|
61
|
+
return PromiseTestUtils.awaitValue(function()
|
|
62
|
+
return #values >= count
|
|
63
|
+
end)
|
|
64
|
+
end,
|
|
65
|
+
destroy = function()
|
|
66
|
+
maid:DoCleaning()
|
|
67
|
+
end,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return controller
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe("RxPlayerUtils.observePlayers", function()
|
|
74
|
+
it("emits a mock that is already in the game", function()
|
|
75
|
+
local controller = setup()
|
|
76
|
+
|
|
77
|
+
local player = controller.newPlayer()
|
|
78
|
+
local players = controller.record(RxPlayerUtils.observePlayers())
|
|
79
|
+
|
|
80
|
+
expect(controller.awaitCount(players, 1)).toBe(true)
|
|
81
|
+
expect(contains(players, player)).toBe(true)
|
|
82
|
+
|
|
83
|
+
controller.destroy()
|
|
84
|
+
end)
|
|
85
|
+
|
|
86
|
+
it("emits a mock that joins after subscribing", function()
|
|
87
|
+
local controller = setup()
|
|
88
|
+
|
|
89
|
+
local players = controller.record(RxPlayerUtils.observePlayers())
|
|
90
|
+
local player = controller.newPlayer()
|
|
91
|
+
|
|
92
|
+
expect(controller.awaitCount(players, 1)).toBe(true)
|
|
93
|
+
expect(contains(players, player)).toBe(true)
|
|
94
|
+
|
|
95
|
+
controller.destroy()
|
|
96
|
+
end)
|
|
97
|
+
|
|
98
|
+
it("skips mocks the predicate rejects", function()
|
|
99
|
+
local controller = setup()
|
|
100
|
+
|
|
101
|
+
local rejected = controller.newPlayer({ UserId = TEST_USER_ID + 1 })
|
|
102
|
+
local accepted = controller.newPlayer()
|
|
103
|
+
local players = controller.record(RxPlayerUtils.observePlayers(isTestPlayer))
|
|
104
|
+
|
|
105
|
+
expect(controller.awaitCount(players, 1)).toBe(true)
|
|
106
|
+
expect(contains(players, accepted)).toBe(true)
|
|
107
|
+
expect(contains(players, rejected)).toBe(false)
|
|
108
|
+
|
|
109
|
+
controller.destroy()
|
|
110
|
+
end)
|
|
111
|
+
end)
|
|
112
|
+
|
|
113
|
+
describe("RxPlayerUtils.observePlayersBrio", function()
|
|
114
|
+
it("emits a living brio for a mock already in the game", function()
|
|
115
|
+
local controller = setup()
|
|
116
|
+
|
|
117
|
+
local player = controller.newPlayer()
|
|
118
|
+
local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
|
|
119
|
+
|
|
120
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
121
|
+
expect(findLivingBrio(brios, player)).never.toBeNil()
|
|
122
|
+
|
|
123
|
+
controller.destroy()
|
|
124
|
+
end)
|
|
125
|
+
|
|
126
|
+
it("emits a living brio for a mock that joins after subscribing", function()
|
|
127
|
+
local controller = setup()
|
|
128
|
+
|
|
129
|
+
local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
|
|
130
|
+
local player = controller.newPlayer()
|
|
131
|
+
|
|
132
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
133
|
+
expect(findLivingBrio(brios, player)).never.toBeNil()
|
|
134
|
+
|
|
135
|
+
controller.destroy()
|
|
136
|
+
end)
|
|
137
|
+
|
|
138
|
+
it("kills the brio when the mock is destroyed", function()
|
|
139
|
+
local controller = setup()
|
|
140
|
+
|
|
141
|
+
local player = controller.newPlayer()
|
|
142
|
+
local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
|
|
143
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
144
|
+
|
|
145
|
+
local brio = findLivingBrio(brios, player)
|
|
146
|
+
player:Destroy()
|
|
147
|
+
|
|
148
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
149
|
+
return brio:IsDead()
|
|
150
|
+
end)).toBe(true)
|
|
151
|
+
|
|
152
|
+
controller.destroy()
|
|
153
|
+
end)
|
|
154
|
+
|
|
155
|
+
it("kills the brio when the mock is kicked", function()
|
|
156
|
+
local controller = setup()
|
|
157
|
+
|
|
158
|
+
local player = controller.newPlayer()
|
|
159
|
+
local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
|
|
160
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
161
|
+
|
|
162
|
+
local brio = findLivingBrio(brios, player)
|
|
163
|
+
-- A kick unparents rather than destroys, which is what drops the mock out of the game
|
|
164
|
+
PlayerMock.kick(player, "Kicked by a test")
|
|
165
|
+
|
|
166
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
167
|
+
return brio:IsDead()
|
|
168
|
+
end)).toBe(true)
|
|
169
|
+
|
|
170
|
+
controller.destroy()
|
|
171
|
+
end)
|
|
172
|
+
|
|
173
|
+
it("skips mocks the predicate rejects", function()
|
|
174
|
+
local controller = setup()
|
|
175
|
+
|
|
176
|
+
local rejected = controller.newPlayer({ UserId = TEST_USER_ID + 1 })
|
|
177
|
+
controller.newPlayer()
|
|
178
|
+
local brios = controller.record(RxPlayerUtils.observePlayersBrio(isTestPlayer))
|
|
179
|
+
|
|
180
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
181
|
+
expect(findLivingBrio(brios, rejected)).toBeNil()
|
|
182
|
+
|
|
183
|
+
controller.destroy()
|
|
184
|
+
end)
|
|
185
|
+
end)
|
|
186
|
+
|
|
187
|
+
describe("RxPlayerUtils.observeCharactersBrio", function()
|
|
188
|
+
it("emits a brio holding the mock's spawned character", function()
|
|
189
|
+
local controller = setup()
|
|
190
|
+
|
|
191
|
+
local player = controller.newPlayer()
|
|
192
|
+
local brios = controller.record(RxPlayerUtils.observeCharactersBrio())
|
|
193
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
194
|
+
|
|
195
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
196
|
+
expect(findLivingBrio(brios, character)).never.toBeNil()
|
|
197
|
+
|
|
198
|
+
controller.destroy()
|
|
199
|
+
end)
|
|
200
|
+
|
|
201
|
+
it("kills the brio when the character despawns", function()
|
|
202
|
+
local controller = setup()
|
|
203
|
+
|
|
204
|
+
local player = controller.newPlayer()
|
|
205
|
+
local brios = controller.record(RxPlayerUtils.observeCharactersBrio())
|
|
206
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
207
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
208
|
+
|
|
209
|
+
local brio = findLivingBrio(brios, character)
|
|
210
|
+
PlayerMock.removeCharacter(player)
|
|
211
|
+
|
|
212
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
213
|
+
return brio:IsDead()
|
|
214
|
+
end)).toBe(true)
|
|
215
|
+
|
|
216
|
+
controller.destroy()
|
|
217
|
+
end)
|
|
218
|
+
end)
|
|
219
|
+
|
|
220
|
+
describe("RxPlayerUtils.observeHumanoidsBrio", function()
|
|
221
|
+
it("emits a brio holding the mock character's humanoid", function()
|
|
222
|
+
local controller = setup()
|
|
223
|
+
|
|
224
|
+
local player = controller.newPlayer()
|
|
225
|
+
local brios = controller.record(RxPlayerUtils.observeHumanoidsBrio())
|
|
226
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
227
|
+
|
|
228
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
229
|
+
expect(findLivingBrio(brios, character:FindFirstChildOfClass("Humanoid"))).never.toBeNil()
|
|
230
|
+
|
|
231
|
+
controller.destroy()
|
|
232
|
+
end)
|
|
233
|
+
end)
|
|
234
|
+
|
|
235
|
+
describe("RxPlayerUtils.observeLocalPlayerBrio", function()
|
|
236
|
+
it("emits nothing while no mock is designated", function()
|
|
237
|
+
local controller = setup()
|
|
238
|
+
|
|
239
|
+
controller.newPlayer()
|
|
240
|
+
local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
|
|
241
|
+
|
|
242
|
+
expect(#brios).toBe(0)
|
|
243
|
+
|
|
244
|
+
controller.destroy()
|
|
245
|
+
end)
|
|
246
|
+
|
|
247
|
+
it("emits a living brio for a mock designated before subscribing", function()
|
|
248
|
+
local controller = setup()
|
|
249
|
+
|
|
250
|
+
local player = controller.newPlayer()
|
|
251
|
+
controller.designateLocalPlayer(player)
|
|
252
|
+
local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
|
|
253
|
+
|
|
254
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
255
|
+
expect(brios[1]:GetValue()).toBe(player)
|
|
256
|
+
|
|
257
|
+
controller.destroy()
|
|
258
|
+
end)
|
|
259
|
+
|
|
260
|
+
it("emits a living brio for a mock designated after subscribing", function()
|
|
261
|
+
local controller = setup()
|
|
262
|
+
|
|
263
|
+
local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
|
|
264
|
+
local player = controller.newPlayer()
|
|
265
|
+
controller.designateLocalPlayer(player)
|
|
266
|
+
|
|
267
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
268
|
+
expect(brios[1]:GetValue()).toBe(player)
|
|
269
|
+
|
|
270
|
+
controller.destroy()
|
|
271
|
+
end)
|
|
272
|
+
|
|
273
|
+
it("kills the brio when the designation is cleared", function()
|
|
274
|
+
local controller = setup()
|
|
275
|
+
|
|
276
|
+
local player = controller.newPlayer()
|
|
277
|
+
controller.designateLocalPlayer(player)
|
|
278
|
+
local brios = controller.record(RxPlayerUtils.observeLocalPlayerBrio())
|
|
279
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
280
|
+
|
|
281
|
+
PlayerMock.setMockedLocalPlayer(nil)
|
|
282
|
+
|
|
283
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
284
|
+
return brios[1]:IsDead()
|
|
285
|
+
end)).toBe(true)
|
|
286
|
+
|
|
287
|
+
controller.destroy()
|
|
288
|
+
end)
|
|
289
|
+
end)
|
|
290
|
+
|
|
291
|
+
describe("RxPlayerUtils.observeLocalPlayerHumanoidBrio", function()
|
|
292
|
+
it("emits a brio holding the designated mock's humanoid", function()
|
|
293
|
+
local controller = setup()
|
|
294
|
+
|
|
295
|
+
local player = controller.newPlayer()
|
|
296
|
+
controller.designateLocalPlayer(player)
|
|
297
|
+
local brios = controller.record(RxPlayerUtils.observeLocalPlayerHumanoidBrio())
|
|
298
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
299
|
+
|
|
300
|
+
expect(controller.awaitCount(brios, 1)).toBe(true)
|
|
301
|
+
expect(findLivingBrio(brios, character:FindFirstChildOfClass("Humanoid"))).never.toBeNil()
|
|
302
|
+
|
|
303
|
+
controller.destroy()
|
|
304
|
+
end)
|
|
305
|
+
end)
|
|
306
|
+
|
|
307
|
+
describe("RxPlayerUtils.observeFirstAppearanceLoaded", function()
|
|
308
|
+
it("does not fire before the mock has spawned", function()
|
|
309
|
+
local controller = setup()
|
|
310
|
+
|
|
311
|
+
local player = controller.newPlayer()
|
|
312
|
+
local fires = controller.record(RxPlayerUtils.observeFirstAppearanceLoaded(player))
|
|
313
|
+
|
|
314
|
+
expect(#fires).toBe(0)
|
|
315
|
+
|
|
316
|
+
controller.destroy()
|
|
317
|
+
end)
|
|
318
|
+
|
|
319
|
+
it("fires once the mock spawns", function()
|
|
320
|
+
local controller = setup()
|
|
321
|
+
|
|
322
|
+
local player = controller.newPlayer()
|
|
323
|
+
local fired = 0
|
|
324
|
+
local completed = false
|
|
325
|
+
local maid = Maid.new()
|
|
326
|
+
maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(
|
|
327
|
+
function()
|
|
328
|
+
fired += 1
|
|
329
|
+
end,
|
|
330
|
+
nil,
|
|
331
|
+
function()
|
|
332
|
+
completed = true
|
|
333
|
+
end
|
|
334
|
+
))
|
|
335
|
+
|
|
336
|
+
PlayerMock.loadMinimalCharacterAsync(player)
|
|
337
|
+
|
|
338
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
339
|
+
return fired == 1 and completed
|
|
340
|
+
end)).toBe(true)
|
|
341
|
+
|
|
342
|
+
maid:DoCleaning()
|
|
343
|
+
controller.destroy()
|
|
344
|
+
end)
|
|
345
|
+
|
|
346
|
+
it("fires immediately when the mock has already spawned", function()
|
|
347
|
+
local controller = setup()
|
|
348
|
+
|
|
349
|
+
local player = controller.newPlayer()
|
|
350
|
+
PlayerMock.loadMinimalCharacterAsync(player)
|
|
351
|
+
|
|
352
|
+
local fired = 0
|
|
353
|
+
local completed = false
|
|
354
|
+
local maid = Maid.new()
|
|
355
|
+
maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(
|
|
356
|
+
function()
|
|
357
|
+
fired += 1
|
|
358
|
+
end,
|
|
359
|
+
nil,
|
|
360
|
+
function()
|
|
361
|
+
completed = true
|
|
362
|
+
end
|
|
363
|
+
))
|
|
364
|
+
|
|
365
|
+
expect(fired).toBe(1)
|
|
366
|
+
expect(completed).toBe(true)
|
|
367
|
+
|
|
368
|
+
maid:DoCleaning()
|
|
369
|
+
controller.destroy()
|
|
370
|
+
end)
|
|
371
|
+
|
|
372
|
+
it("fails when the mock leaves before spawning", function()
|
|
373
|
+
local controller = setup()
|
|
374
|
+
|
|
375
|
+
local player = controller.newPlayer()
|
|
376
|
+
local failure: any = nil
|
|
377
|
+
local maid = Maid.new()
|
|
378
|
+
maid:GiveTask(RxPlayerUtils.observeFirstAppearanceLoaded(player):Subscribe(nil, function(err)
|
|
379
|
+
failure = err
|
|
380
|
+
end))
|
|
381
|
+
|
|
382
|
+
PlayerMock.kick(player, "Kicked by a test")
|
|
383
|
+
|
|
384
|
+
expect(PromiseTestUtils.awaitValue(function()
|
|
385
|
+
return failure ~= nil
|
|
386
|
+
end)).toBe(true)
|
|
387
|
+
|
|
388
|
+
maid:DoCleaning()
|
|
389
|
+
controller.destroy()
|
|
390
|
+
end)
|
|
391
|
+
end)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "PlayerUtilsTest",
|
|
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
|
+
"playerutils": {
|
|
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.playerutils
|
|
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
|