@quenty/characterutils 12.34.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,18 @@
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
+
12
+ # [12.35.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.34.0...@quenty/characterutils@12.35.0) (2026-07-18)
13
+
14
+ ### Bug Fixes
15
+
16
+ - pin translation behavior with tests and fix camelCase key generation ([#737](https://github.com/Quenty/NevermoreEngine/issues/737)) ([1b7a536](https://github.com/Quenty/NevermoreEngine/commit/1b7a536dde7e124f8432e57612ec8138dd835d75))
17
+
6
18
  # [12.34.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.33.0...@quenty/characterutils@12.34.0) (2026-07-18)
7
19
 
8
20
  **Note:** Version bump only for package @quenty/characterutils
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/characterutils",
3
- "version": "12.34.0",
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.0",
31
+ "@quenty/brio": "14.33.0",
32
32
  "@quenty/deferred": "2.3.2",
33
- "@quenty/instanceutils": "13.31.0",
33
+ "@quenty/instanceutils": "13.33.0",
34
34
  "@quenty/loader": "10.11.0",
35
- "@quenty/maid": "3.9.0",
36
- "@quenty/nevermore-test-runner": "1.4.0",
37
- "@quenty/promise": "10.19.0",
38
- "@quenty/rx": "13.29.0",
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": "cbbb89635bfdcbf32f26a40422ac736292917cca"
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
- 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,16 +1,12 @@
1
- --!nonstrict
2
- --[[
3
- Unit tests for RxCharacterUtils.lua
4
- ]]
1
+ --!strict
2
+ local require = require(script.Parent.loader).load(script)
5
3
 
6
- local require = (require :: any)(
7
- game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent
8
- ).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script))
4
+ local Players = game:GetService("Players")
9
5
 
10
6
  local Brio = require("Brio")
11
7
  local Jest = require("Jest")
12
8
  local Maid = require("Maid")
13
- local Observable = require("Observable")
9
+ local PlayerMock = require("PlayerMock")
14
10
  local RxCharacterUtils = require("RxCharacterUtils")
15
11
 
16
12
  local afterAll = Jest.Globals.afterAll
@@ -18,89 +14,51 @@ local describe = Jest.Globals.describe
18
14
  local expect = Jest.Globals.expect
19
15
  local it = Jest.Globals.it
20
16
 
21
- -- Test instances (shared across all tests)
22
- local character = Instance.new("Model")
23
- character.Name = "MockCharacter"
24
-
25
- local childPart = Instance.new("Part")
26
- childPart.Name = "ChildPart"
27
- childPart.Parent = character
28
-
29
- local unrelatedPart = Instance.new("Part")
30
- unrelatedPart.Name = "UnrelatedPart"
31
-
32
- -- Save original before any overrides
33
- local originalObserveLocalPlayerCharacter = RxCharacterUtils.observeLocalPlayerCharacter
34
-
35
- --[[
36
- Creates a mock environment that:
37
- 1. Injects a proxy for Players so Players.LocalPlayer is truthy
38
- 2. Overrides observeLocalPlayerCharacter to return a controllable observable
39
- Returns setCharacter(char) to change the character and cleanup() to restore.
40
- ]]
41
- local function createMockEnvironment()
42
- local currentCharacter = nil
43
- local subscribers = {}
44
-
45
- -- Mock Players: only needs .LocalPlayer to be truthy to pass the nil-check
46
- -- in observeIsOfLocalCharacter. We never pass this to RxInstanceUtils.
47
- local mockPlayers = newproxy(true)
48
- local mt = getmetatable(mockPlayers)
49
- mt.__index = function(_, key)
50
- if key == "LocalPlayer" then
51
- return true
52
- end
53
-
54
- error("Bad index " .. tostring(key) .. " on mock Players")
55
- end
56
-
57
- RxCharacterUtils._test_injectPlayerService(mockPlayers :: any)
58
-
59
- -- Override observeLocalPlayerCharacter so it doesn't call
60
- -- RxInstanceUtils.observeProperty(Players, "LocalPlayer") which requires a real Instance.
61
- RxCharacterUtils.observeLocalPlayerCharacter = function()
62
- return Observable.new(function(sub)
63
- table.insert(subscribers, sub)
64
- sub:Fire(currentCharacter)
65
- return function()
66
- local idx = table.find(subscribers, sub)
67
- if idx then
68
- table.remove(subscribers, idx)
69
- end
70
- end
71
- end)
72
- 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
73
25
 
74
- local function setCharacter(char)
75
- currentCharacter = char
76
- for _, sub in subscribers do
77
- sub:Fire(char)
78
- end
79
- end
26
+ local function setup()
27
+ local player = PlayerMock.new()
28
+ player.Parent = workspace -- setMockedLocalPlayer requires a parented mock
29
+ PlayerMock.setMockedLocalPlayer(player)
80
30
 
81
31
  local function cleanup()
82
- RxCharacterUtils.observeLocalPlayerCharacter = originalObserveLocalPlayerCharacter
83
- 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
84
36
  end
85
37
 
86
- return setCharacter, cleanup
38
+ return player, cleanup
87
39
  end
88
40
 
89
41
  describe("RxCharacterUtils.observeIsOfLocalCharacter", function()
90
42
  local maid = Maid.new()
91
- local setCharacter, cleanupMock = createMockEnvironment()
92
-
93
- -- Subscribe to all three instances at once
94
- local childPartValue = nil
95
- local childPartFireCount = 0
96
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(childPart):Subscribe(function(value)
97
- childPartValue = value
98
- 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
99
52
  end))
100
53
 
101
- local characterValue = nil
102
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(character):Subscribe(function(value)
103
- characterValue = value
54
+ local firstRigValue = nil
55
+ maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(firstRig):Subscribe(function(value)
56
+ firstRigValue = value
57
+ end))
58
+
59
+ local secondChildValue = nil
60
+ maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacter(secondChildPart):Subscribe(function(value)
61
+ secondChildValue = value
104
62
  end))
105
63
 
106
64
  local unrelatedValue = nil
@@ -111,59 +69,66 @@ describe("RxCharacterUtils.observeIsOfLocalCharacter", function()
111
69
  afterAll(function()
112
70
  maid:Destroy()
113
71
  cleanupMock()
72
+ unrelatedPart:Destroy()
114
73
  end)
115
74
 
116
- it("should initially emit false for all instances when no character is set", function()
117
- expect(childPartValue).toEqual(false)
118
- 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)
119
78
  expect(unrelatedValue).toEqual(false)
120
79
  end)
121
80
 
122
- it("should emit true for descendant and character itself when character is set", function()
123
- setCharacter(character)
124
- expect(childPartValue).toEqual(true)
125
- 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)
126
85
  end)
127
86
 
128
- 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()
129
88
  expect(unrelatedValue).toEqual(false)
89
+ expect(secondChildValue).toEqual(false)
130
90
  end)
131
91
 
132
- it("should emit false when character is cleared", function()
133
- setCharacter(nil)
134
- expect(childPartValue).toEqual(false)
135
- 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)
136
98
  end)
137
99
 
138
- it("should emit true again when character is restored", function()
139
- setCharacter(character)
140
- expect(childPartValue).toEqual(true)
141
- expect(characterValue).toEqual(true)
100
+ it("should emit false for everything on despawn", function()
101
+ PlayerMock.removeCharacter(player)
102
+ expect(secondChildValue).toEqual(false)
142
103
  expect(unrelatedValue).toEqual(false)
143
104
  end)
144
105
  end)
145
106
 
146
107
  describe("RxCharacterUtils.observeIsOfLocalCharacterBrio", function()
147
108
  local maid = Maid.new()
148
- local setCharacter, cleanupMock = createMockEnvironment()
149
-
150
- -- childPart subscription (descendant of character)
151
- local childPartBrios = {}
152
- local childPartBrioCount = 0
153
- maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(childPart):Subscribe(function(brio)
154
- table.insert(childPartBrios, brio)
155
- 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)
156
123
  end))
157
124
 
158
- -- unrelatedPart subscription
159
125
  local unrelatedBrioCount = 0
160
126
  maid:GiveTask(RxCharacterUtils.observeIsOfLocalCharacterBrio(unrelatedPart):Subscribe(function(_brio)
161
127
  unrelatedBrioCount += 1
162
128
  end))
163
129
 
164
- -- Separate subscription to test that destroying a sub kills its brio
165
130
  local cleanupTestBrio = nil
166
- local cleanupSub = RxCharacterUtils.observeIsOfLocalCharacterBrio(childPart):Subscribe(function(brio)
131
+ local cleanupSub = RxCharacterUtils.observeIsOfLocalCharacterBrio(firstChildPart):Subscribe(function(brio)
167
132
  cleanupTestBrio = brio
168
133
  end)
169
134
 
@@ -171,48 +136,186 @@ describe("RxCharacterUtils.observeIsOfLocalCharacterBrio", function()
171
136
  maid:Destroy()
172
137
  cleanupSub:Destroy()
173
138
  cleanupMock()
139
+ unrelatedPart:Destroy()
174
140
  end)
175
141
 
176
- it("should not emit any brio initially when character is nil", function()
177
- expect(childPartBrioCount).toEqual(0)
142
+ it("should not emit any brio before a spawn", function()
143
+ expect(#firstChildBrios).toEqual(0)
178
144
  expect(unrelatedBrioCount).toEqual(0)
179
145
  end)
180
146
 
181
- it("should emit a living brio for descendant when character is set", function()
182
- setCharacter(character)
183
- expect(childPartBrioCount).toEqual(1)
184
- expect(Brio.isBrio(childPartBrios[1])).toEqual(true)
185
- expect(childPartBrios[1]:IsDead()).toEqual(false)
186
- 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)
187
153
  end)
188
154
 
189
- 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()
190
156
  expect(unrelatedBrioCount).toEqual(0)
157
+ expect(#secondChildBrios).toEqual(0)
191
158
  end)
192
159
 
193
- it("should kill the brio when character is cleared", function()
194
- local firstBrio = childPartBrios[1]
195
- setCharacter(nil)
196
- expect(firstBrio:IsDead()).toEqual(true)
197
- end)
198
-
199
- it("should emit a new living brio when character is restored", function()
200
- setCharacter(character)
201
- expect(childPartBrioCount).toEqual(2)
202
-
203
- local secondBrio = childPartBrios[2]
204
- expect(Brio.isBrio(secondBrio)).toEqual(true)
205
- expect(secondBrio:IsDead()).toEqual(false)
206
- expect(secondBrio:GetValue()).toEqual(true)
207
- -- Should be a different brio than the first one
208
- expect(secondBrio).never.toBe(childPartBrios[1])
209
- end)
210
-
211
- it("should kill the brio when subscription is destroyed", function()
160
+ it("should kill the brio when its subscription is destroyed", function()
212
161
  expect(cleanupTestBrio).never.toBeNil()
213
162
  expect(cleanupTestBrio:IsDead()).toEqual(false)
214
163
  local brioRef = cleanupTestBrio
215
164
  cleanupSub:Destroy()
216
165
  expect(brioRef:IsDead()).toEqual(true)
217
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)
218
321
  end)