@quenty/streamingutils 10.26.0 → 10.27.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,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
+ # [10.27.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.26.0...@quenty/streamingutils@10.27.0) (2026-07-25)
7
+
8
+ ### Bug Fixes
9
+
10
+ - Streaming cinematic allows latest focus to work (for now) ([528cb41](https://github.com/Quenty/NevermoreEngine/commit/528cb41a8c4f233d0903faf66c67dd6e71450cea))
11
+
12
+ ### Features
13
+
14
+ - Use ReplicationFocus instead of overwriting the focus ([daaf82f](https://github.com/Quenty/NevermoreEngine/commit/daaf82f22862570e1eaafd48188c8ed067005ef9))
15
+
6
16
  # [10.26.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/streamingutils@10.25.0...@quenty/streamingutils@10.26.0) (2026-07-24)
7
17
 
8
18
  **Note:** Version bump only for package @quenty/streamingutils
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/streamingutils",
3
- "version": "10.26.0",
3
+ "version": "10.27.0",
4
4
  "description": "Provides utilities for working with Roblox's streaming system",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -29,19 +29,20 @@
29
29
  "Quenty"
30
30
  ],
31
31
  "dependencies": {
32
- "@quenty/instanceutils": "13.34.0",
32
+ "@quenty/instanceutils": "13.35.0",
33
33
  "@quenty/loader": "10.11.0",
34
34
  "@quenty/maid": "3.11.0",
35
35
  "@quenty/nevermore-test-runner": "1.5.0",
36
- "@quenty/playermock": "1.4.0",
37
- "@quenty/promise": "10.22.0",
38
- "@quenty/remoting": "12.39.0",
39
- "@quenty/rx": "13.32.0",
36
+ "@quenty/playermock": "1.5.0",
37
+ "@quenty/promise": "10.23.0",
38
+ "@quenty/remoting": "12.40.0",
39
+ "@quenty/rx": "13.33.0",
40
40
  "@quenty/servicebag": "11.20.0",
41
+ "@quenty/statestack": "14.37.0",
41
42
  "@quentystudios/jest-lua": "3.10.0-quenty.2"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public"
45
46
  },
46
- "gitHead": "04f569a7981517ae20394ec3ca3923c421a93d43"
47
+ "gitHead": "8677b70e89a1ef1585f8492b0638ec4a29aba8a3"
47
48
  }
@@ -20,6 +20,7 @@ local Remoting = require("Remoting")
20
20
  local Rx = require("Rx")
21
21
  local RxInstanceUtils = require("RxInstanceUtils")
22
22
  local ServiceBag = require("ServiceBag")
23
+ local StateStack = require("StateStack")
23
24
 
24
25
  -- A handful of updates per second is plenty for streaming; the pan is slow and the streaming radius
25
26
  -- dwarfs the per-update movement. leading+trailing so the first and final resting positions both send.
@@ -33,6 +34,7 @@ export type StreamingCinematicsServiceClient = typeof(setmetatable(
33
34
  _serviceBag: ServiceBag.ServiceBag,
34
35
  _maid: Maid.Maid,
35
36
  _remoting: any,
37
+ _observableStack: StateStack.StateStack<Observable.Observable<Vector3?>?>,
36
38
  },
37
39
  {} :: typeof({ __index = StreamingCinematicsServiceClient })
38
40
  ))
@@ -46,15 +48,28 @@ function StreamingCinematicsServiceClient.Init(
46
48
  self._maid = Maid.new()
47
49
 
48
50
  self._remoting = self._maid:Add(Remoting.Client.new(ReplicatedStorage, "StreamingCinematics"))
49
- end
50
51
 
51
- --[=[
52
- Sends a single focus position to the server (or nil to clear). Low-level; prefer
53
- [StreamingCinematicsServiceClient:PushCameraFocus].
54
- @param position Vector3?
55
- ]=]
56
- function StreamingCinematicsServiceClient.SetFocus(self: StreamingCinematicsServiceClient, position: Vector3?): ()
57
- self._remoting.SetFocus:FireServer(position)
52
+ -- The server tracks one focus, but anything cinematic can want to drive it, so pushes stack: the
53
+ -- newest wins, and popping hands streaming back to whatever was underneath instead of clearing.
54
+ self._observableStack = self._maid:Add(StateStack.new(nil :: Observable.Observable<Vector3?>?))
55
+
56
+ -- Sending is the service's job rather than each caller's. A caller that outlives its own cleanup
57
+ -- can no longer fire at a torn-down remoting, and the throttle bounds the whole client instead of
58
+ -- being reapplied per push.
59
+ self._maid:GiveTask(self._observableStack
60
+ :Observe()
61
+ :Pipe({
62
+ Rx.switchMap(function(observePosition: Observable.Observable<Vector3?>?): any
63
+ return observePosition or Rx.of(nil)
64
+ end) :: any,
65
+ Rx.throttleTime(SEND_RATE_SECONDS, {
66
+ leading = true,
67
+ trailing = true,
68
+ }) :: any,
69
+ })
70
+ :Subscribe(function(position: Vector3?)
71
+ self._remoting.SetFocus:FireServer(position)
72
+ end))
58
73
  end
59
74
 
60
75
  --[=[
@@ -66,53 +81,32 @@ function StreamingCinematicsServiceClient.ObserveCurrentCameraPosition(
66
81
  ): Observable.Observable<Vector3?>
67
82
  local observeCamera: any = RxInstanceUtils.observeProperty(Workspace, "CurrentCamera")
68
83
 
69
- local switchToCFrame: any = Rx.switchMap(function(camera): any
70
- if not camera then
71
- return Rx.of(nil)
72
- end
73
- return RxInstanceUtils.observeProperty(camera, "CFrame")
74
- end)
75
- local toPosition: any = Rx.map(function(cframe): Vector3?
76
- return if cframe then cframe.Position else nil
77
- end)
78
-
79
- return observeCamera:Pipe({ switchToCFrame, toPosition })
84
+ return observeCamera:Pipe({
85
+ Rx.switchMap(function(camera): any
86
+ if not camera then
87
+ return Rx.of(nil)
88
+ end
89
+ return RxInstanceUtils.observeProperty(camera, "CFrame")
90
+ end),
91
+ Rx.map(function(cframe): Vector3?
92
+ return if cframe then cframe.Position else nil
93
+ end),
94
+ } :: { any })
80
95
  end
81
96
 
82
97
  --[=[
83
98
  Streams world content around a cinematic camera until the returned cleanup runs. Defaults to
84
99
  following the current camera; pass an observable of `Vector3?` to drive the focus explicitly.
85
- Sends are throttled to [SEND_RATE_SECONDS].
100
+ Sends are throttled to [SEND_RATE_SECONDS] across every push.
86
101
 
87
102
  @param observePosition Observable<Vector3?>? -- Optional override
88
- @return function -- Cleanup; clears the focus when called
103
+ @return function -- Cleanup; hands the focus back to the push underneath, or clears it
89
104
  ]=]
90
105
  function StreamingCinematicsServiceClient.PushCameraFocus(
91
106
  self: StreamingCinematicsServiceClient,
92
107
  observePosition: Observable.Observable<Vector3?>?
93
108
  ): () -> ()
94
- local source: any = observePosition or self:ObserveCurrentCameraPosition()
95
-
96
- local maid = Maid.new()
97
-
98
- maid:GiveTask(source
99
- :Pipe({
100
- Rx.throttleTime(SEND_RATE_SECONDS, {
101
- leading = true,
102
- trailing = true,
103
- }),
104
- })
105
- :Subscribe(function(position: Vector3?)
106
- self:SetFocus(position)
107
- end))
108
-
109
- maid:GiveTask(function()
110
- self:SetFocus(nil)
111
- end)
112
-
113
- return function()
114
- maid:DoCleaning()
115
- end
109
+ return self._observableStack:PushState(observePosition or self:ObserveCurrentCameraPosition())
116
110
  end
117
111
 
118
112
  function StreamingCinematicsServiceClient.Destroy(self: StreamingCinematicsServiceClient): ()
@@ -1,9 +1,9 @@
1
1
  --!strict
2
2
  --[=[
3
3
  Server half of the streaming-cinematics system. Receives a focus position from a client
4
- (who has no character, or whose character is far from a cinematic camera) and points that
5
- player's `ReplicationFocus` at it via a [ReplicationFocusTracker], so world content streams
6
- in around the cinematic camera. Sending nil clears it.
4
+ (who has no character, or whose character is far from a cinematic camera) and adds a
5
+ replication focus there via a [ReplicationFocusTracker], so world content streams in around
6
+ the cinematic camera. Sending nil clears it.
7
7
 
8
8
  @server
9
9
  @class StreamingCinematicsService
@@ -1,11 +1,12 @@
1
1
  --!strict
2
2
  --[=[
3
- Keeps a single hidden part positioned at a point and assigned as a [Player]'s
4
- `ReplicationFocus`, so Roblox streams world content around that point. Reuses one part
5
- across position updates and clears the focus (and destroys the part) on cleanup.
3
+ Keeps a single hidden part positioned at a point and added as a [Player] replication focus, so
4
+ Roblox streams world content around that point. Reuses one part across position updates and
5
+ removes the focus (and destroys the part) on cleanup.
6
6
 
7
- The subject is duck-typed at runtime -- anything with a settable `ReplicationFocus` -- so tests
8
- pass a plain table; the production caller always passes a [Player].
7
+ The subject is duck-typed at runtime -- anything with `AddReplicationFocus` /
8
+ `RemoveReplicationFocus` methods -- so tests pass a plain table; the production caller always
9
+ passes a [Player].
9
10
 
10
11
  @class ReplicationFocusTracker
11
12
  ]=]
@@ -68,7 +69,7 @@ function ReplicationFocusTracker.SetPosition(self: ReplicationFocusTracker, posi
68
69
 
69
70
  self._part = part
70
71
  self._maid:GiveTask(part)
71
- self:_writeReplicationFocus(part)
72
+ self:_addReplicationFocus(part)
72
73
  end
73
74
 
74
75
  --[=[
@@ -80,17 +81,29 @@ function ReplicationFocusTracker.IsActive(self: ReplicationFocusTracker): boolea
80
81
  end
81
82
 
82
83
  function ReplicationFocusTracker.Destroy(self: ReplicationFocusTracker): ()
83
- self:_writeReplicationFocus(nil)
84
+ local part = self._part
85
+ if part then
86
+ self:_removeReplicationFocus(part)
87
+ end
88
+
84
89
  self._maid:DoCleaning()
85
90
  self._part = nil
86
91
  end
87
92
 
88
- -- Mock-safe: a PlayerMock's backing Folder has no ReplicationFocus property; write its stand-in.
89
- function ReplicationFocusTracker._writeReplicationFocus(self: ReplicationFocusTracker, part: BasePart?): ()
93
+ -- Mock-safe: a PlayerMock's backing Folder has no AddReplicationFocus method; call its stand-in.
94
+ function ReplicationFocusTracker._addReplicationFocus(self: ReplicationFocusTracker, part: BasePart): ()
95
+ if PlayerMock.isMock(self._subject) then
96
+ PlayerMock.addReplicationFocus(self._subject, part)
97
+ else
98
+ self._subject:AddReplicationFocus(part)
99
+ end
100
+ end
101
+
102
+ function ReplicationFocusTracker._removeReplicationFocus(self: ReplicationFocusTracker, part: BasePart): ()
90
103
  if PlayerMock.isMock(self._subject) then
91
- PlayerMock.write(self._subject, "ReplicationFocus", part)
104
+ PlayerMock.removeReplicationFocus(self._subject, part)
92
105
  else
93
- self._subject.ReplicationFocus = part
106
+ self._subject:RemoveReplicationFocus(part)
94
107
  end
95
108
  end
96
109
 
@@ -6,22 +6,44 @@
6
6
  local require = require(script.Parent.loader).load(script)
7
7
 
8
8
  local Jest = require("Jest")
9
+ local PlayerMock = require("PlayerMock")
9
10
  local ReplicationFocusTracker = require("ReplicationFocusTracker")
10
11
 
11
12
  local describe = Jest.Globals.describe
12
13
  local expect = Jest.Globals.expect
13
14
  local it = Jest.Globals.it
14
15
 
15
- type FakeSubject = { ReplicationFocus: BasePart? }
16
+ type FakeSubject = {
17
+ focuses: { BasePart },
18
+ AddReplicationFocus: (FakeSubject, BasePart) -> (),
19
+ RemoveReplicationFocus: (FakeSubject, BasePart) -> (),
20
+ }
21
+
22
+ local function newFakeSubject(): FakeSubject
23
+ local focuses: { BasePart } = {}
24
+
25
+ return {
26
+ focuses = focuses,
27
+ AddReplicationFocus = function(_self, part)
28
+ table.insert(focuses, part)
29
+ end,
30
+ RemoveReplicationFocus = function(_self, part)
31
+ local index = table.find(focuses, part)
32
+ if index then
33
+ table.remove(focuses, index)
34
+ end
35
+ end,
36
+ }
37
+ end
16
38
 
17
39
  describe("ReplicationFocusTracker", function()
18
- it("creates a hidden part and assigns it as the subject's ReplicationFocus", function()
19
- local subject: FakeSubject = {}
40
+ it("creates a hidden part and adds it as a replication focus", function()
41
+ local subject = newFakeSubject()
20
42
  local tracker = ReplicationFocusTracker.new(subject :: any)
21
43
 
22
44
  tracker:SetPosition(Vector3.new(1, 2, 3))
23
45
 
24
- local part = subject.ReplicationFocus
46
+ local part = subject.focuses[1]
25
47
  assert(part, "expected a focus part")
26
48
  expect(part:IsA("BasePart")).toEqual(true)
27
49
  expect(part.Position).toEqual(Vector3.new(1, 2, 3))
@@ -32,23 +54,24 @@ describe("ReplicationFocusTracker", function()
32
54
  end)
33
55
 
34
56
  it("reuses the same part across position updates", function()
35
- local subject: FakeSubject = {}
57
+ local subject = newFakeSubject()
36
58
  local tracker = ReplicationFocusTracker.new(subject :: any)
37
59
 
38
60
  tracker:SetPosition(Vector3.new(1, 0, 0))
39
- local first = subject.ReplicationFocus
61
+ local first = subject.focuses[1]
40
62
  assert(first, "expected a focus part")
41
63
 
42
64
  tracker:SetPosition(Vector3.new(5, 0, 0))
43
65
 
44
- expect(subject.ReplicationFocus).toBe(first)
66
+ expect(#subject.focuses).toEqual(1)
67
+ expect(subject.focuses[1]).toBe(first)
45
68
  expect(first.Position).toEqual(Vector3.new(5, 0, 0))
46
69
 
47
70
  tracker:Destroy()
48
71
  end)
49
72
 
50
73
  it("reports active state", function()
51
- local subject: FakeSubject = {}
74
+ local subject = newFakeSubject()
52
75
  local tracker = ReplicationFocusTracker.new(subject :: any)
53
76
 
54
77
  expect(tracker:IsActive()).toEqual(false)
@@ -58,27 +81,44 @@ describe("ReplicationFocusTracker", function()
58
81
  tracker:Destroy()
59
82
  end)
60
83
 
61
- it("clears the ReplicationFocus and destroys the part on Destroy", function()
62
- local subject: FakeSubject = {}
84
+ it("removes the replication focus and destroys the part on Destroy", function()
85
+ local subject = newFakeSubject()
63
86
  local tracker = ReplicationFocusTracker.new(subject :: any)
64
87
 
65
88
  tracker:SetPosition(Vector3.new(1, 2, 3))
66
- local part = subject.ReplicationFocus
89
+ local part = subject.focuses[1]
67
90
  assert(part, "expected a focus part")
68
91
 
69
92
  tracker:Destroy()
70
93
 
71
- expect(subject.ReplicationFocus).toEqual(nil)
94
+ expect(#subject.focuses).toEqual(0)
72
95
  -- Destroyed parts are reparented to nil.
73
96
  expect(part.Parent).toEqual(nil)
74
97
  end)
75
98
 
76
99
  it("does nothing to a subject that was never positioned", function()
77
- local subject: FakeSubject = {}
100
+ local subject = newFakeSubject()
78
101
  local tracker = ReplicationFocusTracker.new(subject :: any)
79
102
 
80
103
  tracker:Destroy()
81
104
 
82
- expect(subject.ReplicationFocus).toEqual(nil)
105
+ expect(#subject.focuses).toEqual(0)
106
+ end)
107
+
108
+ it("adds and removes the focus through the PlayerMock seam", function()
109
+ local player = PlayerMock.new()
110
+ local tracker = ReplicationFocusTracker.new(player)
111
+
112
+ tracker:SetPosition(Vector3.new(1, 2, 3))
113
+
114
+ local focuses = PlayerMock.getReplicationFocuses(player)
115
+ expect(#focuses).toEqual(1)
116
+ expect(focuses[1].Position).toEqual(Vector3.new(1, 2, 3))
117
+
118
+ tracker:Destroy()
119
+
120
+ expect(#PlayerMock.getReplicationFocuses(player)).toEqual(0)
121
+
122
+ player:Destroy()
83
123
  end)
84
124
  end)