@quenty/characterutils 12.35.0 → 12.36.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,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
|
+
# [12.36.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.35.0...@quenty/characterutils@12.36.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
|
|
11
|
+
|
|
6
12
|
# [12.35.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.34.0...@quenty/characterutils@12.35.0) (2026-07-18)
|
|
7
13
|
|
|
8
14
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/characterutils",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.36.0",
|
|
4
4
|
"description": "CharacterUtils",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -28,18 +28,19 @@
|
|
|
28
28
|
"Quenty"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@quenty/brio": "14.
|
|
31
|
+
"@quenty/brio": "14.33.0",
|
|
32
32
|
"@quenty/deferred": "2.3.2",
|
|
33
|
-
"@quenty/instanceutils": "13.
|
|
33
|
+
"@quenty/instanceutils": "13.33.0",
|
|
34
34
|
"@quenty/loader": "10.11.0",
|
|
35
|
-
"@quenty/maid": "3.
|
|
36
|
-
"@quenty/nevermore-test-runner": "1.
|
|
37
|
-
"@quenty/
|
|
38
|
-
"@quenty/
|
|
35
|
+
"@quenty/maid": "3.11.0",
|
|
36
|
+
"@quenty/nevermore-test-runner": "1.5.0",
|
|
37
|
+
"@quenty/playermock": "1.1.0",
|
|
38
|
+
"@quenty/promise": "10.21.0",
|
|
39
|
+
"@quenty/rx": "13.31.0",
|
|
39
40
|
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
43
44
|
},
|
|
44
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
|
|
45
46
|
}
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
@class CharacterUtils
|
|
5
5
|
]=]
|
|
6
6
|
|
|
7
|
+
local require = require(script.Parent.loader).load(script)
|
|
8
|
+
|
|
7
9
|
local Players = game:GetService("Players")
|
|
8
10
|
|
|
11
|
+
local PlayerMock = require("PlayerMock")
|
|
12
|
+
|
|
9
13
|
local CharacterUtils = {}
|
|
10
14
|
|
|
11
15
|
--[=[
|
|
@@ -14,7 +18,8 @@ local CharacterUtils = {}
|
|
|
14
18
|
@return Humanoid? -- Nil if not found
|
|
15
19
|
]=]
|
|
16
20
|
function CharacterUtils.getPlayerHumanoid(player: Player): Humanoid?
|
|
17
|
-
|
|
21
|
+
-- A mock's backing Folder has no Character property; read its stand-in instead.
|
|
22
|
+
local character = if PlayerMock.isMock(player) then PlayerMock.read(player, "Character") else player.Character
|
|
18
23
|
if not character then
|
|
19
24
|
return nil
|
|
20
25
|
end
|
|
@@ -110,12 +115,13 @@ end
|
|
|
110
115
|
function CharacterUtils.getPlayerFromCharacter(descendant: Instance): Player?
|
|
111
116
|
local character = descendant
|
|
112
117
|
-- TODO: Only use models
|
|
113
|
-
|
|
118
|
+
-- A mock's character is not resolvable through the Players service; check the mock registry too.
|
|
119
|
+
local player = Players:GetPlayerFromCharacter(character :: any) or PlayerMock.getMockFromCharacter(character)
|
|
114
120
|
|
|
115
121
|
while not player do
|
|
116
122
|
if character.Parent then
|
|
117
123
|
character = character.Parent
|
|
118
|
-
player = Players:GetPlayerFromCharacter(character)
|
|
124
|
+
player = Players:GetPlayerFromCharacter(character) or PlayerMock.getMockFromCharacter(character)
|
|
119
125
|
else
|
|
120
126
|
return nil
|
|
121
127
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local require = require(script.Parent.loader).load(script)
|
|
3
|
+
|
|
4
|
+
local Workspace = game:GetService("Workspace")
|
|
5
|
+
|
|
6
|
+
local CharacterUtils = require("CharacterUtils")
|
|
7
|
+
local Jest = require("Jest")
|
|
8
|
+
local PlayerMock = require("PlayerMock")
|
|
9
|
+
|
|
10
|
+
local afterEach = Jest.Globals.afterEach
|
|
11
|
+
local beforeEach = Jest.Globals.beforeEach
|
|
12
|
+
local describe = Jest.Globals.describe
|
|
13
|
+
local expect = Jest.Globals.expect
|
|
14
|
+
local it = Jest.Globals.it
|
|
15
|
+
|
|
16
|
+
local player: Player = nil :: any
|
|
17
|
+
|
|
18
|
+
beforeEach(function()
|
|
19
|
+
player = PlayerMock.new()
|
|
20
|
+
player.Parent = Workspace -- getPlayerFromCharacter's mock resolution is DataModel-scoped
|
|
21
|
+
end)
|
|
22
|
+
|
|
23
|
+
afterEach(function()
|
|
24
|
+
player:Destroy()
|
|
25
|
+
end)
|
|
26
|
+
|
|
27
|
+
local function getHumanoid(character: Model): Humanoid
|
|
28
|
+
return character:FindFirstChildOfClass("Humanoid") :: Humanoid
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe("CharacterUtils.getPlayerHumanoid", function()
|
|
32
|
+
it("returns nil before any character has spawned", function()
|
|
33
|
+
expect(CharacterUtils.getPlayerHumanoid(player)).toBeNil()
|
|
34
|
+
end)
|
|
35
|
+
|
|
36
|
+
it("returns the spawned character's humanoid", function()
|
|
37
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
38
|
+
expect(CharacterUtils.getPlayerHumanoid(player)).toBe(getHumanoid(character))
|
|
39
|
+
end)
|
|
40
|
+
|
|
41
|
+
it("returns nil after the character despawns", function()
|
|
42
|
+
PlayerMock.loadMinimalCharacterAsync(player)
|
|
43
|
+
PlayerMock.removeCharacter(player)
|
|
44
|
+
expect(CharacterUtils.getPlayerHumanoid(player)).toBeNil()
|
|
45
|
+
end)
|
|
46
|
+
end)
|
|
47
|
+
|
|
48
|
+
describe("CharacterUtils.getAlivePlayerHumanoid", function()
|
|
49
|
+
it("returns the humanoid while it has health", function()
|
|
50
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
51
|
+
expect(CharacterUtils.getAlivePlayerHumanoid(player)).toBe(getHumanoid(character))
|
|
52
|
+
end)
|
|
53
|
+
|
|
54
|
+
it("returns nil once the humanoid's health reaches zero", function()
|
|
55
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
56
|
+
getHumanoid(character).Health = 0
|
|
57
|
+
expect(CharacterUtils.getAlivePlayerHumanoid(player)).toBeNil()
|
|
58
|
+
end)
|
|
59
|
+
|
|
60
|
+
it("returns nil with no character", function()
|
|
61
|
+
expect(CharacterUtils.getAlivePlayerHumanoid(player)).toBeNil()
|
|
62
|
+
end)
|
|
63
|
+
end)
|
|
64
|
+
|
|
65
|
+
describe("CharacterUtils.getPlayerRootPart", function()
|
|
66
|
+
it("returns the humanoid's root part", function()
|
|
67
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
68
|
+
expect(CharacterUtils.getPlayerRootPart(player)).toBe(character:FindFirstChild("HumanoidRootPart"))
|
|
69
|
+
end)
|
|
70
|
+
|
|
71
|
+
it("still returns the root part at zero health", function()
|
|
72
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
73
|
+
getHumanoid(character).Health = 0
|
|
74
|
+
expect(CharacterUtils.getPlayerRootPart(player)).toBe(character:FindFirstChild("HumanoidRootPart"))
|
|
75
|
+
end)
|
|
76
|
+
|
|
77
|
+
it("returns nil with no character", function()
|
|
78
|
+
expect(CharacterUtils.getPlayerRootPart(player)).toBeNil()
|
|
79
|
+
end)
|
|
80
|
+
end)
|
|
81
|
+
|
|
82
|
+
describe("CharacterUtils.getAlivePlayerRootPart", function()
|
|
83
|
+
it("returns the root part while the humanoid is alive", function()
|
|
84
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
85
|
+
expect(CharacterUtils.getAlivePlayerRootPart(player)).toBe(character:FindFirstChild("HumanoidRootPart"))
|
|
86
|
+
end)
|
|
87
|
+
|
|
88
|
+
it("returns nil once the humanoid's health reaches zero", function()
|
|
89
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
90
|
+
getHumanoid(character).Health = 0
|
|
91
|
+
expect(CharacterUtils.getAlivePlayerRootPart(player)).toBeNil()
|
|
92
|
+
end)
|
|
93
|
+
|
|
94
|
+
it("returns nil with no character", function()
|
|
95
|
+
expect(CharacterUtils.getAlivePlayerRootPart(player)).toBeNil()
|
|
96
|
+
end)
|
|
97
|
+
end)
|
|
98
|
+
|
|
99
|
+
describe("CharacterUtils.unequipTools", function()
|
|
100
|
+
it("removes an equipped tool from the character", function()
|
|
101
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
102
|
+
|
|
103
|
+
local tool = Instance.new("Tool")
|
|
104
|
+
tool.RequiresHandle = false
|
|
105
|
+
tool.Parent = character
|
|
106
|
+
expect(tool.Parent).toBe(character)
|
|
107
|
+
|
|
108
|
+
CharacterUtils.unequipTools(player)
|
|
109
|
+
|
|
110
|
+
-- A mock has no real Backpack for the engine to move the tool into; it unparents instead
|
|
111
|
+
expect(tool.Parent).never.toBe(character)
|
|
112
|
+
tool:Destroy()
|
|
113
|
+
end)
|
|
114
|
+
|
|
115
|
+
it("is a no-op with no character", function()
|
|
116
|
+
expect(function()
|
|
117
|
+
CharacterUtils.unequipTools(player)
|
|
118
|
+
end).never.toThrow()
|
|
119
|
+
end)
|
|
120
|
+
end)
|
|
121
|
+
|
|
122
|
+
describe("CharacterUtils.getPlayerFromCharacter", function()
|
|
123
|
+
it("resolves the mock from its character model", function()
|
|
124
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
125
|
+
expect(CharacterUtils.getPlayerFromCharacter(character)).toBe(player)
|
|
126
|
+
end)
|
|
127
|
+
|
|
128
|
+
it("resolves the mock from a part of the character", function()
|
|
129
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
130
|
+
local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
131
|
+
expect(CharacterUtils.getPlayerFromCharacter(rootPart)).toBe(player)
|
|
132
|
+
end)
|
|
133
|
+
|
|
134
|
+
it("resolves the mock from a nested descendant", function()
|
|
135
|
+
local character = PlayerMock.loadMinimalCharacterAsync(player)
|
|
136
|
+
local attachment = Instance.new("Attachment")
|
|
137
|
+
attachment.Parent = character:FindFirstChild("HumanoidRootPart") :: BasePart
|
|
138
|
+
expect(CharacterUtils.getPlayerFromCharacter(attachment)).toBe(player)
|
|
139
|
+
end)
|
|
140
|
+
|
|
141
|
+
it("returns nil for an instance outside any character", function()
|
|
142
|
+
local part = Instance.new("Part")
|
|
143
|
+
part.Parent = Workspace
|
|
144
|
+
expect(CharacterUtils.getPlayerFromCharacter(part)).toBeNil()
|
|
145
|
+
part:Destroy()
|
|
146
|
+
end)
|
|
147
|
+
end)
|
|
@@ -12,6 +12,7 @@ local RunService = game:GetService("RunService")
|
|
|
12
12
|
local Brio = require("Brio")
|
|
13
13
|
local Maid = require("Maid")
|
|
14
14
|
local Observable = require("Observable")
|
|
15
|
+
local PlayerMock = require("PlayerMock")
|
|
15
16
|
local Rx = require("Rx")
|
|
16
17
|
local RxBrioUtils = require("RxBrioUtils")
|
|
17
18
|
local RxInstanceUtils = require("RxInstanceUtils")
|
|
@@ -27,9 +28,7 @@ local RxCharacterUtils = {}
|
|
|
27
28
|
function RxCharacterUtils.observeLastCharacterBrio(player: Player): Observable.Observable<Brio.Brio<Model>>
|
|
28
29
|
-- This assumes a player's 'Character' field is set to nil when
|
|
29
30
|
-- their character is destroyed, or when they leave the game.
|
|
30
|
-
return
|
|
31
|
-
return character ~= nil
|
|
32
|
-
end)
|
|
31
|
+
return RxCharacterUtils.observeCharacterBrio(player)
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
--[=[
|
|
@@ -39,6 +38,17 @@ end
|
|
|
39
38
|
@return Observable<Model>
|
|
40
39
|
]=]
|
|
41
40
|
function RxCharacterUtils.observeCharacter(player: Player): Observable.Observable<Model?>
|
|
41
|
+
if PlayerMock.isMock(player) then
|
|
42
|
+
-- A mock's backing Folder has no Character property; observe its stand-in instead.
|
|
43
|
+
return Observable.new(function(sub)
|
|
44
|
+
local connection = PlayerMock.getPropertyChangedSignal(player, "Character"):Connect(function()
|
|
45
|
+
sub:Fire(PlayerMock.read(player, "Character"))
|
|
46
|
+
end)
|
|
47
|
+
sub:Fire(PlayerMock.read(player, "Character"))
|
|
48
|
+
return connection
|
|
49
|
+
end) :: any
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
return RxInstanceUtils.observeProperty(player, "Character") :: any
|
|
43
53
|
end
|
|
44
54
|
|
|
@@ -49,15 +59,20 @@ end
|
|
|
49
59
|
@return Observable<Brio<Model>>
|
|
50
60
|
]=]
|
|
51
61
|
function RxCharacterUtils.observeCharacterBrio(player: Player): Observable.Observable<Brio.Brio<Model>>
|
|
62
|
+
if PlayerMock.isMock(player) then
|
|
63
|
+
-- switchToBrio matches observePropertyBrio's semantics: one brio per character, killed on change
|
|
64
|
+
return RxCharacterUtils.observeCharacter(player):Pipe({
|
|
65
|
+
RxBrioUtils.switchToBrio(function(character)
|
|
66
|
+
return character ~= nil
|
|
67
|
+
end) :: any,
|
|
68
|
+
}) :: any
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
return RxInstanceUtils.observePropertyBrio(player, "Character", function(character)
|
|
53
72
|
return character ~= nil
|
|
54
73
|
end)
|
|
55
74
|
end
|
|
56
75
|
|
|
57
|
-
function RxCharacterUtils._test_injectPlayerService(newPlayers: Players)
|
|
58
|
-
Players = newPlayers or game:GetService("Players")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
76
|
--[=[
|
|
62
77
|
Observes whether the instance is part of the local player's character
|
|
63
78
|
|
|
@@ -67,7 +82,7 @@ end
|
|
|
67
82
|
function RxCharacterUtils.observeIsOfLocalCharacter(instance: Instance): Observable.Observable<boolean>
|
|
68
83
|
assert(typeof(instance) == "Instance", "Bad instance")
|
|
69
84
|
|
|
70
|
-
local localPlayer = Players.LocalPlayer
|
|
85
|
+
local localPlayer = Players.LocalPlayer or PlayerMock.getMockedLocalPlayer()
|
|
71
86
|
if not localPlayer and RunService:IsClient() then
|
|
72
87
|
warn("[RxCharacterUtils] - No localPlayer")
|
|
73
88
|
return Rx.EMPTY :: any
|
|
@@ -109,6 +124,11 @@ end
|
|
|
109
124
|
]=]
|
|
110
125
|
function RxCharacterUtils.observeLocalPlayerCharacter(): Observable.Observable<Model>
|
|
111
126
|
return RxInstanceUtils.observeProperty(Players, "LocalPlayer"):Pipe({
|
|
127
|
+
Rx.map(function(player: Player?): Player?
|
|
128
|
+
-- Headless tests have no Players.LocalPlayer; a test designates a PlayerMock instead.
|
|
129
|
+
-- Resolved when LocalPlayer emits, so designate the mock before subscribing.
|
|
130
|
+
return player or PlayerMock.getMockedLocalPlayer()
|
|
131
|
+
end) :: any,
|
|
112
132
|
Rx.switchMap(function(player: Player?): any
|
|
113
133
|
if player then
|
|
114
134
|
return RxCharacterUtils.observeCharacter(player)
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Unit tests for RxCharacterUtils.lua
|
|
4
|
-
]]
|
|
5
|
-
|
|
6
2
|
local require = require(script.Parent.loader).load(script)
|
|
7
3
|
|
|
4
|
+
local Players = game:GetService("Players")
|
|
5
|
+
|
|
8
6
|
local Brio = require("Brio")
|
|
9
7
|
local Jest = require("Jest")
|
|
10
8
|
local Maid = require("Maid")
|
|
11
|
-
local
|
|
9
|
+
local PlayerMock = require("PlayerMock")
|
|
12
10
|
local RxCharacterUtils = require("RxCharacterUtils")
|
|
13
11
|
|
|
14
12
|
local afterAll = Jest.Globals.afterAll
|
|
@@ -16,89 +14,51 @@ local describe = Jest.Globals.describe
|
|
|
16
14
|
local expect = Jest.Globals.expect
|
|
17
15
|
local it = Jest.Globals.it
|
|
18
16
|
|
|
19
|
-
--
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
local
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
local unrelatedPart = Instance.new("Part")
|
|
28
|
-
unrelatedPart.Name = "UnrelatedPart"
|
|
29
|
-
|
|
30
|
-
-- Save original before any overrides
|
|
31
|
-
local originalObserveLocalPlayerCharacter = RxCharacterUtils.observeLocalPlayerCharacter
|
|
32
|
-
|
|
33
|
-
--[[
|
|
34
|
-
Creates a mock environment that:
|
|
35
|
-
1. Injects a proxy for Players so Players.LocalPlayer is truthy
|
|
36
|
-
2. Overrides observeLocalPlayerCharacter to return a controllable observable
|
|
37
|
-
Returns setCharacter(char) to change the character and cleanup() to restore.
|
|
38
|
-
]]
|
|
39
|
-
local function createMockEnvironment()
|
|
40
|
-
local currentCharacter: Model? = nil
|
|
41
|
-
local subscribers: { any } = {}
|
|
42
|
-
|
|
43
|
-
-- Mock Players: only needs .LocalPlayer to be truthy to pass the nil-check
|
|
44
|
-
-- in observeIsOfLocalCharacter. We never pass this to RxInstanceUtils.
|
|
45
|
-
local mockPlayers = newproxy(true)
|
|
46
|
-
local mt = getmetatable(mockPlayers)
|
|
47
|
-
mt.__index = function(_, key)
|
|
48
|
-
if key == "LocalPlayer" then
|
|
49
|
-
return true
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
error("Bad index " .. tostring(key) .. " on mock Players")
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
RxCharacterUtils._test_injectPlayerService(mockPlayers :: any)
|
|
56
|
-
|
|
57
|
-
-- Override observeLocalPlayerCharacter so it doesn't call
|
|
58
|
-
-- RxInstanceUtils.observeProperty(Players, "LocalPlayer") which requires a real Instance.
|
|
59
|
-
RxCharacterUtils.observeLocalPlayerCharacter = function()
|
|
60
|
-
return Observable.new(function(sub)
|
|
61
|
-
table.insert(subscribers, sub)
|
|
62
|
-
sub:Fire(currentCharacter)
|
|
63
|
-
return function()
|
|
64
|
-
local idx = table.find(subscribers, sub)
|
|
65
|
-
if idx then
|
|
66
|
-
table.remove(subscribers, idx)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end) :: any
|
|
70
|
-
end
|
|
17
|
+
-- Builds a rig with one tracked descendant part. Rigs are per-spawn: despawning destroys the
|
|
18
|
+
-- character model (engine semantics), so a rig never comes back once removed.
|
|
19
|
+
local function makeRig(): (Model, Part)
|
|
20
|
+
local rig = Instance.new("Model")
|
|
21
|
+
local part = Instance.new("Part")
|
|
22
|
+
part.Parent = rig
|
|
23
|
+
return rig, part
|
|
24
|
+
end
|
|
71
25
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
77
|
-
end
|
|
26
|
+
local function setup()
|
|
27
|
+
local player = PlayerMock.new()
|
|
28
|
+
player.Parent = workspace -- setMockedLocalPlayer requires a parented mock
|
|
29
|
+
PlayerMock.setMockedLocalPlayer(player)
|
|
78
30
|
|
|
79
31
|
local function cleanup()
|
|
80
|
-
|
|
81
|
-
|
|
32
|
+
if PlayerMock.getMockedLocalPlayer() == player then
|
|
33
|
+
PlayerMock.setMockedLocalPlayer(nil)
|
|
34
|
+
end
|
|
35
|
+
player:Destroy() -- also removes any loaded character, like a player leaving
|
|
82
36
|
end
|
|
83
37
|
|
|
84
|
-
return
|
|
38
|
+
return player, cleanup
|
|
85
39
|
end
|
|
86
40
|
|
|
87
41
|
describe("RxCharacterUtils.observeIsOfLocalCharacter", function()
|
|
88
42
|
local maid = Maid.new()
|
|
89
|
-
local
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
local
|
|
93
|
-
local
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
43
|
+
local player, cleanupMock = setup()
|
|
44
|
+
|
|
45
|
+
local firstRig, firstChildPart = makeRig()
|
|
46
|
+
local secondRig, secondChildPart = makeRig()
|
|
47
|
+
local unrelatedPart = Instance.new("Part")
|
|
48
|
+
|
|
49
|
+
local firstChildValue = nil
|
|
50
|
+
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(firstChildPart):Subscribe(function(value)
|
|
51
|
+
firstChildValue = value
|
|
52
|
+
end))
|
|
53
|
+
|
|
54
|
+
local firstRigValue = nil
|
|
55
|
+
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(firstRig):Subscribe(function(value)
|
|
56
|
+
firstRigValue = value
|
|
97
57
|
end))
|
|
98
58
|
|
|
99
|
-
local
|
|
100
|
-
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(
|
|
101
|
-
|
|
59
|
+
local secondChildValue = nil
|
|
60
|
+
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(secondChildPart):Subscribe(function(value)
|
|
61
|
+
secondChildValue = value
|
|
102
62
|
end))
|
|
103
63
|
|
|
104
64
|
local unrelatedValue = nil
|
|
@@ -109,59 +69,66 @@ describe("RxCharacterUtils.observeIsOfLocalCharacter", function()
|
|
|
109
69
|
afterAll(function()
|
|
110
70
|
maid:Destroy()
|
|
111
71
|
cleanupMock()
|
|
72
|
+
unrelatedPart:Destroy()
|
|
112
73
|
end)
|
|
113
74
|
|
|
114
|
-
it("should initially emit false for all instances when no character is
|
|
115
|
-
expect(
|
|
116
|
-
expect(
|
|
75
|
+
it("should initially emit false for all instances when no character is spawned", function()
|
|
76
|
+
expect(firstChildValue).toEqual(false)
|
|
77
|
+
expect(firstRigValue).toEqual(false)
|
|
117
78
|
expect(unrelatedValue).toEqual(false)
|
|
118
79
|
end)
|
|
119
80
|
|
|
120
|
-
it("should emit true for
|
|
121
|
-
|
|
122
|
-
expect(
|
|
123
|
-
expect(
|
|
81
|
+
it("should emit true for the character and its descendant on spawn", function()
|
|
82
|
+
PlayerMock.loadCharacterAsync(player, firstRig)
|
|
83
|
+
expect(firstChildValue).toEqual(true)
|
|
84
|
+
expect(firstRigValue).toEqual(true)
|
|
124
85
|
end)
|
|
125
86
|
|
|
126
|
-
it("should still emit false for
|
|
87
|
+
it("should still emit false for instances outside the character", function()
|
|
127
88
|
expect(unrelatedValue).toEqual(false)
|
|
89
|
+
expect(secondChildValue).toEqual(false)
|
|
128
90
|
end)
|
|
129
91
|
|
|
130
|
-
it("should
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
expect(
|
|
92
|
+
it("should flip membership to the new character's parts on respawn", function()
|
|
93
|
+
PlayerMock.loadCharacterAsync(player, secondRig)
|
|
94
|
+
-- The old rig was destroyed with the respawn; its parts are never of the character again
|
|
95
|
+
expect(firstChildValue).toEqual(false)
|
|
96
|
+
expect(firstRigValue).toEqual(false)
|
|
97
|
+
expect(secondChildValue).toEqual(true)
|
|
134
98
|
end)
|
|
135
99
|
|
|
136
|
-
it("should emit
|
|
137
|
-
|
|
138
|
-
expect(
|
|
139
|
-
expect(characterValue).toEqual(true)
|
|
100
|
+
it("should emit false for everything on despawn", function()
|
|
101
|
+
PlayerMock.removeCharacter(player)
|
|
102
|
+
expect(secondChildValue).toEqual(false)
|
|
140
103
|
expect(unrelatedValue).toEqual(false)
|
|
141
104
|
end)
|
|
142
105
|
end)
|
|
143
106
|
|
|
144
107
|
describe("RxCharacterUtils.observeIsOfLocalCharacterBrio", function()
|
|
145
108
|
local maid = Maid.new()
|
|
146
|
-
local
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
local
|
|
150
|
-
local
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
109
|
+
local player, cleanupMock = setup()
|
|
110
|
+
|
|
111
|
+
local firstRig, firstChildPart = makeRig()
|
|
112
|
+
local secondRig, secondChildPart = makeRig()
|
|
113
|
+
local unrelatedPart = Instance.new("Part")
|
|
114
|
+
|
|
115
|
+
local firstChildBrios = {}
|
|
116
|
+
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(firstChildPart):Subscribe(function(brio)
|
|
117
|
+
table.insert(firstChildBrios, brio)
|
|
118
|
+
end))
|
|
119
|
+
|
|
120
|
+
local secondChildBrios = {}
|
|
121
|
+
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(secondChildPart):Subscribe(function(brio)
|
|
122
|
+
table.insert(secondChildBrios, brio)
|
|
154
123
|
end))
|
|
155
124
|
|
|
156
|
-
-- unrelatedPart subscription
|
|
157
125
|
local unrelatedBrioCount = 0
|
|
158
126
|
maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(unrelatedPart):Subscribe(function(_brio)
|
|
159
127
|
unrelatedBrioCount += 1
|
|
160
128
|
end))
|
|
161
129
|
|
|
162
|
-
-- Separate subscription to test that destroying a sub kills its brio
|
|
163
130
|
local cleanupTestBrio = nil
|
|
164
|
-
local cleanupSub = RxCharacterUtils.observeIsOfLocalCharacterBrio(
|
|
131
|
+
local cleanupSub = RxCharacterUtils.observeIsOfLocalCharacterBrio(firstChildPart):Subscribe(function(brio)
|
|
165
132
|
cleanupTestBrio = brio
|
|
166
133
|
end)
|
|
167
134
|
|
|
@@ -169,48 +136,186 @@ describe("RxCharacterUtils.observeIsOfLocalCharacterBrio", function()
|
|
|
169
136
|
maid:Destroy()
|
|
170
137
|
cleanupSub:Destroy()
|
|
171
138
|
cleanupMock()
|
|
139
|
+
unrelatedPart:Destroy()
|
|
172
140
|
end)
|
|
173
141
|
|
|
174
|
-
it("should not emit any brio
|
|
175
|
-
expect(
|
|
142
|
+
it("should not emit any brio before a spawn", function()
|
|
143
|
+
expect(#firstChildBrios).toEqual(0)
|
|
176
144
|
expect(unrelatedBrioCount).toEqual(0)
|
|
177
145
|
end)
|
|
178
146
|
|
|
179
|
-
it("should emit a living brio for descendant
|
|
180
|
-
|
|
181
|
-
expect(
|
|
182
|
-
expect(Brio.isBrio(
|
|
183
|
-
expect(
|
|
184
|
-
expect(
|
|
147
|
+
it("should emit a living brio for a descendant of the spawned character", function()
|
|
148
|
+
PlayerMock.loadCharacterAsync(player, firstRig)
|
|
149
|
+
expect(#firstChildBrios).toEqual(1)
|
|
150
|
+
expect(Brio.isBrio(firstChildBrios[1])).toEqual(true)
|
|
151
|
+
expect(firstChildBrios[1]:IsDead()).toEqual(false)
|
|
152
|
+
expect(firstChildBrios[1]:GetValue()).toEqual(true)
|
|
185
153
|
end)
|
|
186
154
|
|
|
187
|
-
it("should not emit a brio for
|
|
155
|
+
it("should not emit a brio for instances outside the character", function()
|
|
188
156
|
expect(unrelatedBrioCount).toEqual(0)
|
|
157
|
+
expect(#secondChildBrios).toEqual(0)
|
|
189
158
|
end)
|
|
190
159
|
|
|
191
|
-
it("should kill the brio when
|
|
192
|
-
local firstBrio = childPartBrios[1]
|
|
193
|
-
setCharacter(nil)
|
|
194
|
-
expect(firstBrio:IsDead()).toEqual(true)
|
|
195
|
-
end)
|
|
196
|
-
|
|
197
|
-
it("should emit a new living brio when character is restored", function()
|
|
198
|
-
setCharacter(character)
|
|
199
|
-
expect(childPartBrioCount).toEqual(2)
|
|
200
|
-
|
|
201
|
-
local secondBrio = childPartBrios[2]
|
|
202
|
-
expect(Brio.isBrio(secondBrio)).toEqual(true)
|
|
203
|
-
expect(secondBrio:IsDead()).toEqual(false)
|
|
204
|
-
expect(secondBrio:GetValue()).toEqual(true)
|
|
205
|
-
-- Should be a different brio than the first one
|
|
206
|
-
expect(secondBrio).never.toBe(childPartBrios[1])
|
|
207
|
-
end)
|
|
208
|
-
|
|
209
|
-
it("should kill the brio when subscription is destroyed", function()
|
|
160
|
+
it("should kill the brio when its subscription is destroyed", function()
|
|
210
161
|
expect(cleanupTestBrio).never.toBeNil()
|
|
211
162
|
expect(cleanupTestBrio:IsDead()).toEqual(false)
|
|
212
163
|
local brioRef = cleanupTestBrio
|
|
213
164
|
cleanupSub:Destroy()
|
|
214
165
|
expect(brioRef:IsDead()).toEqual(true)
|
|
215
166
|
end)
|
|
167
|
+
|
|
168
|
+
it("should kill the old part's brio and emit for the new part on respawn", function()
|
|
169
|
+
PlayerMock.loadCharacterAsync(player, secondRig)
|
|
170
|
+
expect(firstChildBrios[1]:IsDead()).toEqual(true)
|
|
171
|
+
expect(#firstChildBrios).toEqual(1) -- the destroyed rig's part never revives
|
|
172
|
+
expect(#secondChildBrios).toEqual(1)
|
|
173
|
+
expect(secondChildBrios[1]:IsDead()).toEqual(false)
|
|
174
|
+
expect(secondChildBrios[1]:GetValue()).toEqual(true)
|
|
175
|
+
end)
|
|
176
|
+
|
|
177
|
+
it("should kill the brio on despawn", function()
|
|
178
|
+
PlayerMock.removeCharacter(player)
|
|
179
|
+
expect(secondChildBrios[1]:IsDead()).toEqual(true)
|
|
180
|
+
expect(#secondChildBrios).toEqual(1)
|
|
181
|
+
end)
|
|
182
|
+
end)
|
|
183
|
+
|
|
184
|
+
describe("RxCharacterUtils.observeCharacterBrio through the spawn lifecycle", function()
|
|
185
|
+
local maid = Maid.new()
|
|
186
|
+
local player = PlayerMock.new()
|
|
187
|
+
|
|
188
|
+
local brios = {}
|
|
189
|
+
maid:GiveTask(RxCharacterUtils.observeCharacterBrio(player):Subscribe(function(brio)
|
|
190
|
+
table.insert(brios, brio)
|
|
191
|
+
end))
|
|
192
|
+
|
|
193
|
+
afterAll(function()
|
|
194
|
+
maid:Destroy()
|
|
195
|
+
PlayerMock.removeCharacter(player)
|
|
196
|
+
player:Destroy()
|
|
197
|
+
end)
|
|
198
|
+
|
|
199
|
+
it("emits nothing before the first spawn", function()
|
|
200
|
+
expect(#brios).toBe(0)
|
|
201
|
+
end)
|
|
202
|
+
|
|
203
|
+
it("emits a living brio holding the character on spawn", function()
|
|
204
|
+
local rig = PlayerMock.loadCharacterAsync(player, Instance.new("Model"))
|
|
205
|
+
expect(#brios).toBe(1)
|
|
206
|
+
expect(brios[1]:IsDead()).toBe(false)
|
|
207
|
+
expect(brios[1]:GetValue()).toBe(rig)
|
|
208
|
+
end)
|
|
209
|
+
|
|
210
|
+
it("kills the old brio and emits a new one on respawn", function()
|
|
211
|
+
local rig = PlayerMock.loadCharacterAsync(player, Instance.new("Model"))
|
|
212
|
+
expect(brios[1]:IsDead()).toBe(true)
|
|
213
|
+
expect(#brios).toBe(2)
|
|
214
|
+
expect(brios[2]:IsDead()).toBe(false)
|
|
215
|
+
expect(brios[2]:GetValue()).toBe(rig)
|
|
216
|
+
end)
|
|
217
|
+
|
|
218
|
+
it("kills the brio with no replacement when the character despawns", function()
|
|
219
|
+
PlayerMock.removeCharacter(player)
|
|
220
|
+
expect(brios[2]:IsDead()).toBe(true)
|
|
221
|
+
expect(#brios).toBe(2)
|
|
222
|
+
end)
|
|
223
|
+
end)
|
|
224
|
+
|
|
225
|
+
describe("RxCharacterUtils.observeLastHumanoidBrio across respawns with real rigs", function()
|
|
226
|
+
local maid = Maid.new()
|
|
227
|
+
local player = PlayerMock.new()
|
|
228
|
+
|
|
229
|
+
local humanoidBrios = {}
|
|
230
|
+
maid:GiveTask(RxCharacterUtils.observeLastHumanoidBrio(player):Subscribe(function(brio)
|
|
231
|
+
table.insert(humanoidBrios, brio)
|
|
232
|
+
end))
|
|
233
|
+
|
|
234
|
+
afterAll(function()
|
|
235
|
+
maid:Destroy()
|
|
236
|
+
PlayerMock.removeCharacter(player)
|
|
237
|
+
player:Destroy()
|
|
238
|
+
end)
|
|
239
|
+
|
|
240
|
+
it("emits the humanoid of an engine-built default R15 rig", function()
|
|
241
|
+
local rig = PlayerMock.loadCharacterAsync(player)
|
|
242
|
+
expect(#humanoidBrios).toBe(1)
|
|
243
|
+
expect(humanoidBrios[1]:IsDead()).toBe(false)
|
|
244
|
+
expect(humanoidBrios[1]:GetValue()).toBe(rig:FindFirstChildOfClass("Humanoid"))
|
|
245
|
+
end)
|
|
246
|
+
|
|
247
|
+
it("swaps to the humanoid of a real avatar appearance on respawn", function()
|
|
248
|
+
-- Real appearance fetch by userId; verified to work in Open Cloud test runs
|
|
249
|
+
local rig = Players:CreateHumanoidModelFromUserId(261)
|
|
250
|
+
PlayerMock.loadCharacterAsync(player, rig)
|
|
251
|
+
|
|
252
|
+
expect(humanoidBrios[1]:IsDead()).toBe(true)
|
|
253
|
+
expect(#humanoidBrios).toBe(2)
|
|
254
|
+
expect(humanoidBrios[2]:GetValue()).toBe(rig:FindFirstChildOfClass("Humanoid"))
|
|
255
|
+
end)
|
|
256
|
+
|
|
257
|
+
it("kills the humanoid brio when the character despawns", function()
|
|
258
|
+
PlayerMock.removeCharacter(player)
|
|
259
|
+
expect(humanoidBrios[2]:IsDead()).toBe(true)
|
|
260
|
+
end)
|
|
261
|
+
end)
|
|
262
|
+
|
|
263
|
+
describe("RxCharacterUtils.observeLastAliveHumanoidBrio", function()
|
|
264
|
+
-- Cloud runs never step the humanoid state machine (Humanoid.Died does not fire even at
|
|
265
|
+
-- Health=0), so the engine-fired Died->brio-death transition is only observable in a live
|
|
266
|
+
-- server. These cover the alive path, the dead-at-subscribe path, and death-by-respawn.
|
|
267
|
+
|
|
268
|
+
it("emits a living brio for a spawned humanoid with health", function()
|
|
269
|
+
local player = PlayerMock.new()
|
|
270
|
+
PlayerMock.loadCharacterAsync(player)
|
|
271
|
+
|
|
272
|
+
local brios = {}
|
|
273
|
+
local sub = RxCharacterUtils.observeLastAliveHumanoidBrio(player):Subscribe(function(brio)
|
|
274
|
+
table.insert(brios, brio)
|
|
275
|
+
end)
|
|
276
|
+
|
|
277
|
+
expect(#brios).toBe(1)
|
|
278
|
+
expect(brios[1]:IsDead()).toBe(false)
|
|
279
|
+
|
|
280
|
+
sub:Destroy()
|
|
281
|
+
PlayerMock.removeCharacter(player)
|
|
282
|
+
player:Destroy()
|
|
283
|
+
end)
|
|
284
|
+
|
|
285
|
+
it("emits nothing when the humanoid is already dead at subscribe", function()
|
|
286
|
+
local player = PlayerMock.new()
|
|
287
|
+
local rig = PlayerMock.loadCharacterAsync(player)
|
|
288
|
+
local humanoid = rig:FindFirstChildOfClass("Humanoid") :: Humanoid
|
|
289
|
+
humanoid.Health = 0
|
|
290
|
+
|
|
291
|
+
local count = 0
|
|
292
|
+
local sub = RxCharacterUtils.observeLastAliveHumanoidBrio(player):Subscribe(function()
|
|
293
|
+
count += 1
|
|
294
|
+
end)
|
|
295
|
+
|
|
296
|
+
expect(count).toBe(0)
|
|
297
|
+
|
|
298
|
+
sub:Destroy()
|
|
299
|
+
PlayerMock.removeCharacter(player)
|
|
300
|
+
player:Destroy()
|
|
301
|
+
end)
|
|
302
|
+
|
|
303
|
+
it("kills the alive brio when the player respawns", function()
|
|
304
|
+
local player = PlayerMock.new()
|
|
305
|
+
PlayerMock.loadCharacterAsync(player)
|
|
306
|
+
|
|
307
|
+
local brios = {}
|
|
308
|
+
local sub = RxCharacterUtils.observeLastAliveHumanoidBrio(player):Subscribe(function(brio)
|
|
309
|
+
table.insert(brios, brio)
|
|
310
|
+
end)
|
|
311
|
+
expect(#brios).toBe(1)
|
|
312
|
+
|
|
313
|
+
PlayerMock.loadCharacterAsync(player)
|
|
314
|
+
expect(brios[1]:IsDead()).toBe(true)
|
|
315
|
+
expect(#brios).toBe(2)
|
|
316
|
+
|
|
317
|
+
sub:Destroy()
|
|
318
|
+
PlayerMock.removeCharacter(player)
|
|
319
|
+
player:Destroy()
|
|
320
|
+
end)
|
|
216
321
|
end)
|