@quenty/characterutils 12.35.0 → 12.36.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 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
+ ## [12.36.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.36.0...@quenty/characterutils@12.36.1) (2026-07-23)
7
+
8
+ **Note:** Version bump only for package @quenty/characterutils
9
+
10
+ # [12.36.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.35.0...@quenty/characterutils@12.36.0) (2026-07-23)
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
+
6
16
  # [12.35.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.34.0...@quenty/characterutils@12.35.0) (2026-07-18)
7
17
 
8
18
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/characterutils",
3
- "version": "12.35.0",
3
+ "version": "12.36.1",
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.32.0",
31
+ "@quenty/brio": "14.33.0",
32
32
  "@quenty/deferred": "2.3.2",
33
- "@quenty/instanceutils": "13.32.0",
33
+ "@quenty/instanceutils": "13.33.0",
34
34
  "@quenty/loader": "10.11.0",
35
- "@quenty/maid": "3.10.0",
36
- "@quenty/nevermore-test-runner": "1.4.0",
37
- "@quenty/promise": "10.20.0",
38
- "@quenty/rx": "13.30.0",
35
+ "@quenty/maid": "3.11.0",
36
+ "@quenty/nevermore-test-runner": "1.5.0",
37
+ "@quenty/playermock": "1.1.1",
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": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
45
+ "gitHead": "5ea0583dedebba6492f9ac6d719d32b45c5543fa"
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
- local character = player.Character
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
- local player = Players:GetPlayerFromCharacter(character :: any)
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 RxInstanceUtils.observePropertyBrio(player, "Character", function(character)
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 Observable = require("Observable")
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
- -- Test instances (shared across all tests)
20
- local character = Instance.new("Model")
21
- character.Name = "MockCharacter"
22
-
23
- local childPart = Instance.new("Part")
24
- childPart.Name = "ChildPart"
25
- childPart.Parent = character
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
- local function setCharacter(char: Model?)
73
- currentCharacter = char
74
- for _, sub in subscribers do
75
- sub:Fire(char)
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
- RxCharacterUtils.observeLocalPlayerCharacter = originalObserveLocalPlayerCharacter
81
- RxCharacterUtils._test_injectPlayerService(nil :: any)
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 setCharacter, cleanup
38
+ return player, cleanup
85
39
  end
86
40
 
87
41
  describe("RxCharacterUtils.observeIsOfLocalCharacter", function()
88
42
  local maid = Maid.new()
89
- local setCharacter, cleanupMock = createMockEnvironment()
90
-
91
- -- Subscribe to all three instances at once
92
- local childPartValue = nil
93
- local childPartFireCount = 0
94
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(childPart):Subscribe(function(value)
95
- childPartValue = value
96
- childPartFireCount += 1
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 characterValue = nil
100
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(character):Subscribe(function(value)
101
- characterValue = value
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 set", function()
115
- expect(childPartValue).toEqual(false)
116
- expect(characterValue).toEqual(false)
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 descendant and character itself when character is set", function()
121
- setCharacter(character)
122
- expect(childPartValue).toEqual(true)
123
- expect(characterValue).toEqual(true)
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 unrelated instance when character is set", function()
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 emit false when character is cleared", function()
131
- setCharacter(nil)
132
- expect(childPartValue).toEqual(false)
133
- expect(characterValue).toEqual(false)
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 true again when character is restored", function()
137
- setCharacter(character)
138
- expect(childPartValue).toEqual(true)
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 setCharacter, cleanupMock = createMockEnvironment()
147
-
148
- -- childPart subscription (descendant of character)
149
- local childPartBrios = {}
150
- local childPartBrioCount = 0
151
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(childPart):Subscribe(function(brio)
152
- table.insert(childPartBrios, brio)
153
- childPartBrioCount += 1
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(childPart):Subscribe(function(brio)
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 initially when character is nil", function()
175
- expect(childPartBrioCount).toEqual(0)
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 when character is set", function()
180
- setCharacter(character)
181
- expect(childPartBrioCount).toEqual(1)
182
- expect(Brio.isBrio(childPartBrios[1])).toEqual(true)
183
- expect(childPartBrios[1]:IsDead()).toEqual(false)
184
- expect(childPartBrios[1]:GetValue()).toEqual(true)
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 unrelated instance when character is set", function()
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 character is cleared", function()
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)