@quenty/characterutils 12.39.0 → 12.40.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,17 @@
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.40.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.40.0...@quenty/characterutils@12.40.1) (2026-07-25)
7
+
8
+ **Note:** Version bump only for package @quenty/characterutils
9
+
10
+ # [12.40.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.39.0...@quenty/characterutils@12.40.0) (2026-07-25)
11
+
12
+ ### Features
13
+
14
+ - Add RootPartUtils.getPlayerRootPart(player) ([dc1c687](https://github.com/Quenty/NevermoreEngine/commit/dc1c68760c3e4b699b0a4e59eec464b73a61867b))
15
+ - Add RootPartUtils.promisePlayerHumanoidRootPart(player) ([50a789b](https://github.com/Quenty/NevermoreEngine/commit/50a789b4ae0f126d893fd86405b1f5b2ccdfc5b7))
16
+
6
17
  # [12.39.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/characterutils@12.38.0...@quenty/characterutils@12.39.0) (2026-07-24)
7
18
 
8
19
  **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.39.0",
3
+ "version": "12.40.1",
4
4
  "description": "CharacterUtils",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -28,19 +28,19 @@
28
28
  "Quenty"
29
29
  ],
30
30
  "dependencies": {
31
- "@quenty/brio": "14.34.0",
31
+ "@quenty/brio": "14.35.1",
32
32
  "@quenty/deferred": "2.3.2",
33
- "@quenty/instanceutils": "13.34.0",
33
+ "@quenty/instanceutils": "13.35.1",
34
34
  "@quenty/loader": "10.11.0",
35
35
  "@quenty/maid": "3.11.0",
36
36
  "@quenty/nevermore-test-runner": "1.5.0",
37
- "@quenty/playermock": "1.4.0",
38
- "@quenty/promise": "10.22.0",
39
- "@quenty/rx": "13.32.0",
37
+ "@quenty/playermock": "1.5.1",
38
+ "@quenty/promise": "10.23.0",
39
+ "@quenty/rx": "13.33.0",
40
40
  "@quentystudios/jest-lua": "3.10.0-quenty.2"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "gitHead": "04f569a7981517ae20394ec3ca3923c421a93d43"
45
+ "gitHead": "f276b791b8eb4e88b952b5babaf0f4189c1a11d0"
46
46
  }
@@ -6,7 +6,9 @@
6
6
 
7
7
  local require = require(script.Parent.loader).load(script)
8
8
 
9
+ local CharacterUtils = require("CharacterUtils")
9
10
  local Maid = require("Maid")
11
+ local PlayerMock = require("PlayerMock")
10
12
  local Promise = require("Promise")
11
13
 
12
14
  local RootPartUtils = {}
@@ -70,6 +72,98 @@ function RootPartUtils.promiseRootPart(humanoid: Humanoid): Promise.Promise<Base
70
72
  return promise
71
73
  end
72
74
 
75
+ --[=[
76
+ Given a player creates a promise that will resolve once the player has a character whose
77
+ humanoid has a resolved `Humanoid.RootPart`.
78
+
79
+ Resolves with the root part of whatever character the player has at that moment, so a respawn
80
+ while waiting is followed instead of leaving the promise stuck on a destroyed character. Health
81
+ is not considered -- a dead humanoid still has a root part, matching
82
+ [CharacterUtils.getPlayerRootPart].
83
+
84
+ ```lua
85
+ RootPartUtils.promisePlayerHumanoidRootPart(player):Then(function(rootPart)
86
+ print(rootPart:GetPivot())
87
+ end)
88
+ ```
89
+
90
+ Accepts a [PlayerMock] as well as a real `Player`.
91
+
92
+ @param player Player
93
+ @return Promise<BasePart>
94
+ ]=]
95
+ function RootPartUtils.promisePlayerHumanoidRootPart(player: Player): Promise.Promise<BasePart>
96
+ assert(player:IsA("Player") or PlayerMock.isMock(player), "Bad player")
97
+
98
+ local rootPart = CharacterUtils.getPlayerRootPart(player)
99
+ if rootPart then
100
+ return Promise.resolved(rootPart)
101
+ end
102
+
103
+ local maid = Maid.new()
104
+ local promise = Promise.new()
105
+
106
+ -- Same unobservable-property problem promiseRootPart works around, so the same poll: it reads
107
+ -- through CharacterUtils.getPlayerRootPart, which covers the character spawning, its humanoid
108
+ -- appearing and the root part resolving in one check -- and reads a mock's character through
109
+ -- the mock seam.
110
+ task.spawn(function()
111
+ while promise:IsPending() do
112
+ local currentRootPart = CharacterUtils.getPlayerRootPart(player)
113
+ if currentRootPart then
114
+ promise:Resolve(currentRootPart)
115
+ return
116
+ end
117
+ task.wait(0.05)
118
+ end
119
+ end)
120
+
121
+ task.delay(MAX_YIELD_TIME, function()
122
+ if promise:IsPending() then
123
+ warn(debug.traceback("[RootPartUtils.promisePlayerHumanoidRootPart] - Timed out on root part"))
124
+ promise:Reject("Timed out")
125
+ end
126
+ end)
127
+
128
+ -- A player who left is never getting a character; a mock leaves the same way (PlayerMock.kick
129
+ -- unparents rather than destroys), so this native signal serves both.
130
+ maid:GiveTask(player.AncestryChanged:Connect(function()
131
+ if not player:IsDescendantOf(game) then
132
+ promise:Reject("Player removed from game")
133
+ end
134
+ end))
135
+
136
+ promise:Finally(function()
137
+ maid:DoCleaning()
138
+ end)
139
+
140
+ return promise
141
+ end
142
+
143
+ --[=[
144
+ Gets the root part of the player's current character, if it exists.
145
+
146
+ Synchronous counterpart to [RootPartUtils.promisePlayerHumanoidRootPart]. Health is not
147
+ considered -- a dead humanoid still has a root part.
148
+
149
+ ```lua
150
+ local rootPart = RootPartUtils.getPlayerRootPart(player)
151
+ if rootPart then
152
+ print(rootPart:GetPivot())
153
+ end
154
+ ```
155
+
156
+ Accepts a [PlayerMock] as well as a real `Player`.
157
+
158
+ @param player Player
159
+ @return BasePart? -- Nil if not found
160
+ ]=]
161
+ function RootPartUtils.getPlayerRootPart(player: Player): BasePart?
162
+ assert(player:IsA("Player") or PlayerMock.isMock(player), "Bad player")
163
+
164
+ return CharacterUtils.getPlayerRootPart(player)
165
+ end
166
+
73
167
  --[=[
74
168
  Gets the root part of a character, if it exists
75
169
  @param character Model
@@ -0,0 +1,179 @@
1
+ --!strict
2
+ local require = require(script.Parent.loader).load(script)
3
+
4
+ local Workspace = game:GetService("Workspace")
5
+
6
+ local Jest = require("Jest")
7
+ local Maid = require("Maid")
8
+ local PlayerMock = require("PlayerMock")
9
+ local RootPartUtils = require("RootPartUtils")
10
+
11
+ local afterEach = Jest.Globals.afterEach
12
+ local beforeEach = Jest.Globals.beforeEach
13
+ local describe = Jest.Globals.describe
14
+ local expect = Jest.Globals.expect
15
+ local it = Jest.Globals.it
16
+
17
+ local maid: Maid.Maid = nil :: any
18
+ local player: Player = nil :: any
19
+
20
+ beforeEach(function()
21
+ maid = Maid.new()
22
+ player = PlayerMock.new()
23
+ player.Parent = Workspace -- the leave path needs an ancestry to change
24
+ end)
25
+
26
+ afterEach(function()
27
+ maid:DoCleaning() -- settles any promise left pending, so no poll outlives the test
28
+ player:Destroy()
29
+ end)
30
+
31
+ local function getRootPart(character: Model): BasePart
32
+ return character:FindFirstChild("HumanoidRootPart") :: BasePart
33
+ end
34
+
35
+ describe("RootPartUtils.promisePlayerHumanoidRootPart", function()
36
+ it("resolves already with the root part of a spawned character", function()
37
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
38
+
39
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
40
+ expect(promise:IsFulfilled()).toBe(true)
41
+
42
+ local ok, rootPart = promise:Yield()
43
+ expect(ok).toBe(true)
44
+ expect(rootPart).toBe(getRootPart(character))
45
+ end)
46
+
47
+ it("stays pending until a character spawns", function()
48
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
49
+ expect(promise:IsPending()).toBe(true)
50
+
51
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
52
+
53
+ local ok, rootPart = promise:Yield()
54
+ expect(ok).toBe(true)
55
+ expect(rootPart).toBe(getRootPart(character))
56
+ end)
57
+
58
+ it("resolves with the root part of the character present when it spawns, not a despawned one", function()
59
+ local firstCharacter = PlayerMock.loadMinimalCharacterAsync(player)
60
+ local firstRootPart = getRootPart(firstCharacter)
61
+ PlayerMock.removeCharacter(player)
62
+
63
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
64
+ expect(promise:IsPending()).toBe(true)
65
+
66
+ local secondCharacter = PlayerMock.loadMinimalCharacterAsync(player)
67
+
68
+ local ok, rootPart = promise:Yield()
69
+ expect(ok).toBe(true)
70
+ expect(rootPart).toBe(getRootPart(secondCharacter))
71
+ expect(rootPart).never.toBe(firstRootPart)
72
+ end)
73
+
74
+ it("resolves for a dead humanoid, which still has a root part", function()
75
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
76
+ local humanoid = character:FindFirstChildOfClass("Humanoid") :: Humanoid
77
+ humanoid.Health = 0
78
+
79
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
80
+
81
+ local ok, rootPart = promise:Yield()
82
+ expect(ok).toBe(true)
83
+ expect(rootPart).toBe(getRootPart(character))
84
+ end)
85
+
86
+ it("resolves once a humanoid is added to a character that spawned without one", function()
87
+ local character = Instance.new("Model")
88
+
89
+ local rootPart = Instance.new("Part")
90
+ rootPart.Name = "HumanoidRootPart"
91
+ rootPart.Anchored = true
92
+ rootPart.Parent = character
93
+ character.PrimaryPart = rootPart
94
+
95
+ PlayerMock.loadCharacterAsync(player, character)
96
+
97
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
98
+ expect(promise:IsPending()).toBe(true)
99
+
100
+ local humanoid = Instance.new("Humanoid")
101
+ humanoid.Parent = character
102
+
103
+ local ok, resolved = promise:Yield()
104
+ expect(ok).toBe(true)
105
+ expect(resolved).toBe(rootPart)
106
+ end)
107
+
108
+ it("rejects when the player leaves the game", function()
109
+ local promise = maid:Add(RootPartUtils.promisePlayerHumanoidRootPart(player))
110
+ expect(promise:IsPending()).toBe(true)
111
+
112
+ PlayerMock.kick(player)
113
+
114
+ local ok, err = promise:Yield()
115
+ expect(ok).toBe(false)
116
+ expect(err).toBe("Player removed from game")
117
+ end)
118
+
119
+ it("rejects a value that is neither a Player nor a PlayerMock", function()
120
+ local folder = Instance.new("Folder")
121
+
122
+ expect(function()
123
+ RootPartUtils.promisePlayerHumanoidRootPart(folder :: any)
124
+ end).toThrow()
125
+
126
+ folder:Destroy()
127
+ end)
128
+ end)
129
+
130
+ describe("RootPartUtils.getPlayerRootPart", function()
131
+ it("returns the root part of a spawned character", function()
132
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
133
+
134
+ expect(RootPartUtils.getPlayerRootPart(player)).toBe(getRootPart(character))
135
+ end)
136
+
137
+ it("returns nil when the player has no character", function()
138
+ expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
139
+ end)
140
+
141
+ it("returns nil once the character is removed", function()
142
+ PlayerMock.loadMinimalCharacterAsync(player)
143
+ PlayerMock.removeCharacter(player)
144
+
145
+ expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
146
+ end)
147
+
148
+ it("returns nil for a character without a humanoid", function()
149
+ local character = Instance.new("Model")
150
+
151
+ local rootPart = Instance.new("Part")
152
+ rootPart.Name = "HumanoidRootPart"
153
+ rootPart.Anchored = true
154
+ rootPart.Parent = character
155
+ character.PrimaryPart = rootPart
156
+
157
+ PlayerMock.loadCharacterAsync(player, character)
158
+
159
+ expect(RootPartUtils.getPlayerRootPart(player)).toBe(nil)
160
+ end)
161
+
162
+ it("returns the root part for a dead humanoid", function()
163
+ local character = PlayerMock.loadMinimalCharacterAsync(player)
164
+ local humanoid = character:FindFirstChildOfClass("Humanoid") :: Humanoid
165
+ humanoid.Health = 0
166
+
167
+ expect(RootPartUtils.getPlayerRootPart(player)).toBe(getRootPart(character))
168
+ end)
169
+
170
+ it("rejects a value that is neither a Player nor a PlayerMock", function()
171
+ local folder = Instance.new("Folder")
172
+
173
+ expect(function()
174
+ RootPartUtils.getPlayerRootPart(folder :: any)
175
+ end).toThrow()
176
+
177
+ folder:Destroy()
178
+ end)
179
+ end)