@quenty/friendutils 12.32.0 → 12.33.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.33.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/friendutils@12.32.0...@quenty/friendutils@12.33.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.32.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/friendutils@12.31.0...@quenty/friendutils@12.32.0) (2026-07-18)
7
13
 
8
14
  **Note:** Version bump only for package @quenty/friendutils
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 117547903586007,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/friendutils",
3
- "version": "12.32.0",
3
+ "version": "12.33.0",
4
4
  "description": "Utlity functions to help find friends of a user. Also contains utility to make testing in studio easier.",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -28,14 +28,18 @@
28
28
  "Quenty"
29
29
  ],
30
30
  "dependencies": {
31
- "@quenty/brio": "14.32.0",
31
+ "@quenty/brio": "14.33.0",
32
32
  "@quenty/loader": "10.11.0",
33
- "@quenty/maid": "3.10.0",
34
- "@quenty/promise": "10.20.0",
35
- "@quenty/rx": "13.30.0"
33
+ "@quenty/maid": "3.11.0",
34
+ "@quenty/nevermore-test-runner": "1.5.0",
35
+ "@quenty/pagesutils": "5.22.0",
36
+ "@quenty/playermock": "1.1.0",
37
+ "@quenty/promise": "10.21.0",
38
+ "@quenty/rx": "13.31.0",
39
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
36
40
  },
37
41
  "publishConfig": {
38
42
  "access": "public"
39
43
  },
40
- "gitHead": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
44
+ "gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
41
45
  }
@@ -8,6 +8,9 @@ local require = require(script.Parent.loader).load(script)
8
8
 
9
9
  local Players = game:GetService("Players")
10
10
 
11
+ local PagesDatabase = require("PagesDatabase")
12
+ local PagesProxy = require("PagesProxy")
13
+ local PlayerMock = require("PlayerMock")
11
14
  local Promise = require("Promise")
12
15
 
13
16
  local FriendUtils = {}
@@ -113,6 +116,12 @@ function FriendUtils.promiseFriendPages(userId: number): Promise.Promise<FriendP
113
116
  assert(type(userId) == "number", "Bad userId")
114
117
 
115
118
  return Promise.spawn(function(resolve, reject)
119
+ local mockPlayer = PlayerMock.getMockByUserId(userId)
120
+ if mockPlayer ~= nil then
121
+ local friends = PlayerMock.readLookup(mockPlayer, "Players.GetFriendsAsync", 0)
122
+ return resolve(PagesProxy.new(PagesDatabase.fromPageData({ friends })) :: any)
123
+ end
124
+
116
125
  local pages
117
126
  local ok, err = pcall(function()
118
127
  pages = Players:GetFriendsAsync(userId)
@@ -0,0 +1,121 @@
1
+ --!strict
2
+ --[[
3
+ @class FriendUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Workspace = game:GetService("Workspace")
9
+
10
+ local FriendUtils = require("FriendUtils")
11
+ local Jest = require("Jest")
12
+ local PagesDatabase = require("PagesDatabase")
13
+ local PagesProxy = require("PagesProxy")
14
+ local PlayerMock = require("PlayerMock")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ local function makeFriendData(userId: number, isOnline: boolean)
22
+ return {
23
+ Id = userId,
24
+ Username = string.format("friend_%d", userId),
25
+ DisplayName = string.format("Friend %d", userId),
26
+ IsOnline = isOnline,
27
+ }
28
+ end
29
+
30
+ describe("FriendUtils.promiseAllFriends against a mock", function()
31
+ it("resolves the injected friends list", function()
32
+ local player = PlayerMock.new({ UserId = 90041001 })
33
+ player.Parent = Workspace
34
+ PlayerMock.writeLookup(player, "Players.GetFriendsAsync", 0, {
35
+ makeFriendData(90041002, true),
36
+ makeFriendData(90041003, false),
37
+ })
38
+
39
+ local outcome, friends = PromiseTestUtils.awaitOutcome(FriendUtils.promiseAllFriends(90041001))
40
+
41
+ expect(outcome).toBe("resolved")
42
+ expect(#friends).toBe(2)
43
+ expect(friends[1].Id).toBe(90041002)
44
+ expect(friends[1].Username).toBe("friend_90041002")
45
+ expect(friends[2].IsOnline).toBe(false)
46
+
47
+ player:Destroy()
48
+ end)
49
+
50
+ it("resolves an empty list for a mock with no injected friends", function()
51
+ local player = PlayerMock.new({ UserId = 90041004 })
52
+ player.Parent = Workspace
53
+
54
+ local outcome, friends = PromiseTestUtils.awaitOutcome(FriendUtils.promiseAllFriends(90041004))
55
+
56
+ expect(outcome).toBe("resolved")
57
+ expect(friends).toEqual({})
58
+
59
+ player:Destroy()
60
+ end)
61
+
62
+ it("stops at limitMaxFriends", function()
63
+ local player = PlayerMock.new({ UserId = 90041005 })
64
+ player.Parent = Workspace
65
+ PlayerMock.writeLookup(player, "Players.GetFriendsAsync", 0, {
66
+ makeFriendData(90041006, true),
67
+ makeFriendData(90041007, true),
68
+ makeFriendData(90041008, true),
69
+ })
70
+
71
+ local outcome, friends = PromiseTestUtils.awaitOutcome(FriendUtils.promiseAllFriends(90041005, 2))
72
+
73
+ expect(outcome).toBe("resolved")
74
+ expect(#friends).toBe(2)
75
+
76
+ player:Destroy()
77
+ end)
78
+ end)
79
+
80
+ describe("FriendUtils.iterateFriendsYielding", function()
81
+ it("iterates across page boundaries", function()
82
+ local pages = PagesProxy.new(PagesDatabase.fromPageData({
83
+ { makeFriendData(90041009, true) },
84
+ { makeFriendData(90041010, false) },
85
+ }))
86
+
87
+ local seen = {}
88
+ for userData in FriendUtils.iterateFriendsYielding(pages :: any) do
89
+ table.insert(seen, userData.Id)
90
+ end
91
+
92
+ expect(seen).toEqual({ 90041009, 90041010 })
93
+ end)
94
+ end)
95
+
96
+ describe("FriendUtils.onlineFriends", function()
97
+ it("filters to online friends only", function()
98
+ local friends = {
99
+ makeFriendData(90041011, true),
100
+ makeFriendData(90041012, false),
101
+ }
102
+
103
+ local online = FriendUtils.onlineFriends(friends)
104
+
105
+ expect(#online).toBe(1)
106
+ expect(online[1].Id).toBe(90041011)
107
+ end)
108
+ end)
109
+
110
+ describe("FriendUtils.friendsNotInGame", function()
111
+ it("filters out friends whose player is in the game", function()
112
+ -- Mocks are not in Players:GetPlayers(), so all injected friends count as not in game
113
+ local friends = {
114
+ makeFriendData(90041013, true),
115
+ }
116
+
117
+ local notInGame = FriendUtils.friendsNotInGame(friends)
118
+
119
+ expect(#notInGame).toBe(1)
120
+ end)
121
+ end)
@@ -7,6 +7,7 @@
7
7
 
8
8
  local require = require(script.Parent.loader).load(script)
9
9
 
10
+ local CollectionService = game:GetService("CollectionService")
10
11
  local Players = game:GetService("Players")
11
12
  local RunService = game:GetService("RunService")
12
13
  local StarterGui = game:GetService("StarterGui")
@@ -14,14 +15,24 @@ local StarterGui = game:GetService("StarterGui")
14
15
  local Brio = require("Brio")
15
16
  local Maid = require("Maid")
16
17
  local Observable = require("Observable")
18
+ local PlayerMock = require("PlayerMock")
17
19
 
18
20
  local RxFriendUtils = {}
19
21
 
22
+ local function getUserId(player: Player): number
23
+ return if PlayerMock.isMock(player) then PlayerMock.read(player, "UserId") else player.UserId
24
+ end
25
+
20
26
  --[=[
21
27
  Observe friends in the current server (not including the LocalPlayer!), useful for social GUIs.
22
28
  The lifetimes exist for the whole duration another player is a friend and in your server.
23
29
  This means if a player is unfriended + friended multiple times per session, they will have emitted multiple friend lifetimes.
24
30
 
31
+ Works against [PlayerMock] players on either side: mocks in the DataModel are enumerated
32
+ alongside real players, friendship resolves from the mock's "Player.IsFriendsWithAsync" lookup
33
+ domain, and a mid-test [PlayerMock.writeLookup] to that domain stands in for the CoreGui
34
+ friended/unfriended events -- the observable re-reads and emits/kills lifetimes accordingly.
35
+
25
36
  @param player Player?
26
37
  @return Observable<Brio<Player>>
27
38
  ]=]
@@ -30,6 +41,8 @@ function RxFriendUtils.observeFriendsInServerAsBrios(player: Player?): Observabl
30
41
 
31
42
  assert(typeof(player) == "Instance", "Bad player")
32
43
 
44
+ local observedPlayer: Player = player :: Player
45
+
33
46
  -- Note that 'PlayerFriendedEvent' and 'PlayerUnfriendedEvent' are currently unreliable.
34
47
  -- See: https://devforum.roblox.com/t/getcores-playerfriendedevent-and-playerunfriendedevent-bindableevents-firing-at-inappropriate-times/570403/4
35
48
  -- Due to players initially starting with an 'unknown' friend value, they fire for all players in the game at launch, and on the first time another player that joins the server.
@@ -38,6 +51,11 @@ function RxFriendUtils.observeFriendsInServerAsBrios(player: Player?): Observabl
38
51
  return Observable.new(function(sub)
39
52
  local maid = Maid.new()
40
53
 
54
+ -- Per-otherPlayer wiring (mock friendship-changed connections). Separate from the brio keyed
55
+ -- under maid[otherPlayer]: an unfriend kills the brio but the wiring must survive so a
56
+ -- re-friend emits a new lifetime. Both die together when the other player leaves.
57
+ local listenMaid = maid:Add(Maid.new())
58
+
41
59
  local function handleFriendState(otherPlayer: Player, isFriendsWith: boolean)
42
60
  if otherPlayer == Players.LocalPlayer then
43
61
  return
@@ -57,45 +75,91 @@ function RxFriendUtils.observeFriendsInServerAsBrios(player: Player?): Observabl
57
75
  end
58
76
 
59
77
  local function handleNewPlayerAsync(otherPlayer: Player)
60
- if otherPlayer ~= player then
61
- local isFriendsWith = false
62
- local ok = pcall(function()
63
- isFriendsWith = player:IsFriendsWithAsync(otherPlayer.UserId)
64
- end)
65
- if not ok then
66
- warn(
67
- string.format(
68
- "[RxFriendUtils.observeFriendsInServerAsBrios] Couldn't get friendship status with %q!",
69
- otherPlayer.Name
70
- )
71
- )
78
+ if otherPlayer == observedPlayer then
79
+ return
80
+ end
72
81
 
73
- -- If the call failed, then 'isFriendsWith' will be nil.
74
- -- We'll assume that this player isn't a friend on failure.
75
- handleFriendState(otherPlayer, false)
76
- return
82
+ -- Friendship is symmetric, so with only one side mocked the injected state lives on
83
+ -- whichever player is the mock; with both mocked, on the observed player.
84
+ local lookupMock: Player? = nil
85
+ local lookupKey: number? = nil
86
+ if PlayerMock.isMock(observedPlayer) then
87
+ lookupMock = observedPlayer
88
+ lookupKey = getUserId(otherPlayer)
89
+ elseif PlayerMock.isMock(otherPlayer) then
90
+ lookupMock = otherPlayer
91
+ lookupKey = getUserId(observedPlayer)
92
+ end
93
+
94
+ if lookupMock ~= nil and lookupKey ~= nil then
95
+ local mock: Player, key: number = lookupMock, lookupKey
96
+
97
+ local function readFriendState()
98
+ handleFriendState(otherPlayer, PlayerMock.readLookup(mock, "Player.IsFriendsWithAsync", key))
77
99
  end
78
100
 
79
- handleFriendState(otherPlayer, isFriendsWith)
101
+ -- The lookup attribute's changed signal stands in for the CoreGui friended /
102
+ -- unfriended events below, which only the engine can fire.
103
+ listenMaid[otherPlayer] = PlayerMock.getLookupChangedSignal(mock, "Player.IsFriendsWithAsync", key)
104
+ :Connect(readFriendState)
105
+
106
+ readFriendState()
107
+ return
80
108
  end
109
+
110
+ local isFriendsWith = false
111
+ local ok = pcall(function()
112
+ isFriendsWith = observedPlayer:IsFriendsWithAsync(getUserId(otherPlayer))
113
+ end)
114
+ if not ok then
115
+ warn(
116
+ string.format(
117
+ "[RxFriendUtils.observeFriendsInServerAsBrios] Couldn't get friendship status with %q!",
118
+ otherPlayer.Name
119
+ )
120
+ )
121
+
122
+ -- If the call failed, then 'isFriendsWith' will be nil.
123
+ -- We'll assume that this player isn't a friend on failure.
124
+ handleFriendState(otherPlayer, false)
125
+ return
126
+ end
127
+
128
+ handleFriendState(otherPlayer, isFriendsWith)
81
129
  end
82
130
 
83
- -- Handle players leaving / joining.
84
- maid:GiveTask(Players.PlayerRemoving:Connect(function(otherPlayer: Player)
131
+ local function handlePlayerRemoving(otherPlayer: Player)
132
+ listenMaid[otherPlayer] = nil
85
133
  maid[otherPlayer] = nil
86
- end))
134
+ end
135
+
136
+ -- Handle players leaving / joining.
137
+ maid:GiveTask(Players.PlayerRemoving:Connect(handlePlayerRemoving))
87
138
  maid:GiveTask(Players.PlayerAdded:Connect(handleNewPlayerAsync))
88
139
 
140
+ -- Mocks never appear in Players:GetPlayers()/PlayerAdded; enumerate them through their tag,
141
+ -- mirroring the DataModel-scoped engine calls (see [PlayerMock.TAG]).
142
+ maid:GiveTask(CollectionService:GetInstanceAddedSignal(PlayerMock.TAG):Connect(function(instance)
143
+ handleNewPlayerAsync((instance :: any) :: Player)
144
+ end))
145
+ maid:GiveTask(CollectionService:GetInstanceRemovedSignal(PlayerMock.TAG):Connect(function(instance)
146
+ handlePlayerRemoving((instance :: any) :: Player)
147
+ end))
148
+
89
149
  for _, otherPlayer in Players:GetPlayers() do
90
150
  task.spawn(handleNewPlayerAsync, otherPlayer)
91
151
  end
92
152
 
153
+ for _, tagged in CollectionService:GetTagged(PlayerMock.TAG) do
154
+ task.spawn(handleNewPlayerAsync, (tagged :: any) :: Player)
155
+ end
156
+
93
157
  -- Handle changes for players already in this server.
94
158
  -- There's a non-zero chance these get removed someday... :(
95
159
  -- https://devforum.roblox.com/t/playerfriendedevent-was-deleted-from-corescripts/696683
96
160
  -- So just incase these connections throw, use a new thread so we don't error out the whole observable.
97
161
  -- Only allow this while the game is running too
98
- if player == Players.LocalPlayer and RunService:IsRunning() then
162
+ if observedPlayer == Players.LocalPlayer and RunService:IsRunning() then
99
163
  task.spawn(function()
100
164
  maid:GiveTask(StarterGui:GetCore("PlayerFriendedEvent").Event:Connect(function(otherPlayer: Player)
101
165
  handleFriendState(otherPlayer, true)
@@ -0,0 +1,133 @@
1
+ --!strict
2
+ --[[
3
+ @class RxFriendUtils.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Workspace = game:GetService("Workspace")
9
+
10
+ local Brio = require("Brio")
11
+ local Jest = require("Jest")
12
+ local Maid = require("Maid")
13
+ local PlayerMock = require("PlayerMock")
14
+ local RxFriendUtils = require("RxFriendUtils")
15
+
16
+ local afterEach = Jest.Globals.afterEach
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ describe("RxFriendUtils.observeFriendsInServerAsBrios", function()
22
+ local maid = Maid.new()
23
+
24
+ afterEach(function()
25
+ maid:DoCleaning()
26
+ end)
27
+
28
+ local function makeMock(userId: number): Player
29
+ local player = PlayerMock.new({ UserId = userId })
30
+ player.Parent = Workspace
31
+ maid:GiveTask(player)
32
+ return player
33
+ end
34
+
35
+ local function trackBrios(observer: Player): { Brio.Brio<Player> }
36
+ local emissions: { Brio.Brio<Player> } = {}
37
+ maid:GiveTask(RxFriendUtils.observeFriendsInServerAsBrios(observer):Subscribe(function(brio)
38
+ table.insert(emissions, brio)
39
+ end))
40
+ return emissions
41
+ end
42
+
43
+ it("emits an initial lifetime for a mock that is already a friend", function()
44
+ local observer = makeMock(90051001)
45
+ local friend = makeMock(90051002)
46
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051002, true)
47
+
48
+ local emissions = trackBrios(observer)
49
+
50
+ expect(#emissions).toBe(1)
51
+ expect(emissions[1]:GetValue()).toBe(friend)
52
+ expect(emissions[1]:IsDead()).toBe(false)
53
+ end)
54
+
55
+ it("emits nothing when no mock is a friend", function()
56
+ local observer = makeMock(90051003)
57
+ makeMock(90051004)
58
+
59
+ local emissions = trackBrios(observer)
60
+
61
+ expect(#emissions).toBe(0)
62
+ end)
63
+
64
+ it("emits when friendship is written mid-test and kills the lifetime on unfriend", function()
65
+ local observer = makeMock(90051005)
66
+ local friend = makeMock(90051006)
67
+
68
+ local emissions = trackBrios(observer)
69
+ expect(#emissions).toBe(0)
70
+
71
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051006, true)
72
+ expect(#emissions).toBe(1)
73
+ expect(emissions[1]:GetValue()).toBe(friend)
74
+
75
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051006, false)
76
+ expect(emissions[1]:IsDead()).toBe(true)
77
+ end)
78
+
79
+ it("emits a fresh lifetime per re-friend", function()
80
+ local observer = makeMock(90051007)
81
+ makeMock(90051008)
82
+
83
+ local emissions = trackBrios(observer)
84
+
85
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051008, true)
86
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051008, false)
87
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051008, true)
88
+
89
+ expect(#emissions).toBe(2)
90
+ expect(emissions[1]:IsDead()).toBe(true)
91
+ expect(emissions[2]:IsDead()).toBe(false)
92
+ end)
93
+
94
+ it("does not emit a duplicate lifetime for a repeated friended signal", function()
95
+ local observer = makeMock(90051009)
96
+ makeMock(90051010)
97
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051010, true)
98
+
99
+ local emissions = trackBrios(observer)
100
+
101
+ -- The CoreGui friended event this wiring stands in for can fire repeatedly; a re-write of
102
+ -- the same state must not open a second lifetime.
103
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051010, true)
104
+
105
+ expect(#emissions).toBe(1)
106
+ end)
107
+
108
+ it("emits for a friend mock that joins after subscription", function()
109
+ local observer = makeMock(90051011)
110
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051012, true)
111
+
112
+ local emissions = trackBrios(observer)
113
+ expect(#emissions).toBe(0)
114
+
115
+ local friend = makeMock(90051012)
116
+
117
+ expect(#emissions).toBe(1)
118
+ expect(emissions[1]:GetValue()).toBe(friend)
119
+ end)
120
+
121
+ it("kills the lifetime when the friend mock leaves", function()
122
+ local observer = makeMock(90051013)
123
+ local friend = makeMock(90051014)
124
+ PlayerMock.writeLookup(observer, "Player.IsFriendsWithAsync", 90051014, true)
125
+
126
+ local emissions = trackBrios(observer)
127
+ expect(#emissions).toBe(1)
128
+
129
+ friend:Destroy()
130
+
131
+ expect(emissions[1]:IsDead()).toBe(true)
132
+ end)
133
+ end)
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "FriendUtilsTest",
3
+ "tree": {
4
+ "$className": "DataModel",
5
+ "ServerScriptService": {
6
+ "$properties": {
7
+ "LoadStringEnabled": true
8
+ },
9
+ "friendutils": {
10
+ "$path": ".."
11
+ },
12
+ "Script": {
13
+ "$path": "scripts/Server"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ --!nonstrict
2
+ local ServerScriptService = game:GetService("ServerScriptService")
3
+
4
+ local root = ServerScriptService.friendutils
5
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
6
+ local require = require(loader).bootstrapGame(root)
7
+
8
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
9
+
10
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
11
+ return
12
+ end