feat(sync): add preserveHistory option (#12)

* feat(sync): add `preserveHistory` option

* build: omit project json from npm
This commit is contained in:
richard 2024-07-08 13:47:50 -07:00 • committed by GitHub
parent acab2d3cce
commit b68879cafd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 226 additions and 84 deletions

View file

@ -1,4 +1,5 @@
local atom = require(script.Parent.Parent.Parent.atom)
local subscribe = require(script.Parent.Parent.Parent.subscribe)
local sync = require(script.Parent.Parent.Parent.sync)
return function()
@ -44,4 +45,26 @@ return function()
expect(a()["2" :: any]).to.never.be.ok()
expect(a()[2]).to.equal(2)
end)
it("applies multiple patches", function()
local a = atom(1)
local client = sync.client({ atoms = { a = a } })
local calls = {}
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
})
expect(a()).to.equal(3)
expect(calls[1]).to.equal(2)
expect(calls[2]).to.equal(3)
end)
end

View file

@ -50,4 +50,45 @@ return function()
disconnect()
end)
it("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { types.SyncPayload }
local server = sync.server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
expect(#payloads).to.equal(1)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(2)
expect(payloads[1].data.b).to.equal(2)
a(3)
a(4)
b(3)
server:_sendPatch(target)
print(payloads)
expect(#payloads).to.equal(2)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(3)
expect(payloads[1].data.b).to.never.be.ok()
expect(payloads[2].type).to.equal("patch")
expect(payloads[2].data.a).to.equal(4)
expect(payloads[2].data.b).to.equal(3)
disconnect()
end)
end

22
src/index.d.ts vendored
View file

@ -288,6 +288,16 @@ declare namespace Charm {
* negative value to disable automatic syncing.
*/
interval?: number;
/**
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple times
* per frame, where each individual change is important. Defaults to `false`.
*
* If `true`, the broadcaster will send a list of payloads to the client
* instead of a single payload. The client will apply each payload in order
* to reconstruct the state's changes over time.
*/
preserveHistory?: boolean;
}
interface ClientSyncer<Atoms extends AtomMap> {
@ -295,24 +305,24 @@ declare namespace Charm {
* Applies a patch or initializes the state of the atoms with the given
* payload from the server.
*
* @param payload The patch or hydration payload to apply.
* @param payloads The patches or hydration payloads to apply.
*/
sync(payload: SyncPayload<Atoms>): void;
sync(...payloads: SyncPayload<Atoms>[]): void;
}
interface ServerSyncer<Atoms extends AtomMap> {
/**
* Sets up a subscription to each atom that schedules a patch to be sent to
* the client whenever the state changes. When a change occurs, the `callback`
* is called with the player and the payload to send.
* is called with the player and the payloads to send.
*
* Note that the `payload` object should not be mutated. If you need to
* modify the payload, apply the changes to a copy of the object.
* Note that a `payload` object should not be mutated. If you need to modify
* a payload, apply the changes to a copy of the object.
*
* @param callback The function to call when the state changes.
* @returns A cleanup function that unsubscribes all listeners.
*/
connect(callback: (player: Player, payload: SyncPayload<Atoms>) => void): Cleanup;
connect(callback: (player: Player, ...payloads: SyncPayload<Atoms>[]) => void): Cleanup;
/**
* Hydrates the client's state with the server's state. This should be
* called when a player joins the game and requires the server's state.

View file

@ -1,7 +1,7 @@
local Package = script.Parent.Parent
local Packages = if Package.Parent:FindFirstChild("React")
then Package.Parent
else Package.Parent:FindFirstChild("ReactLua")
else Package.Parent.Parent:FindFirstChild("ReactLua")
type React = typeof(require(Package.Parent.React))

View file

@ -1,7 +1,7 @@
local Package = script.Parent.Parent
local Packages = if Package.Parent:FindFirstChild("ReactRoblox")
then Package.Parent
else Package.Parent:FindFirstChild("ReactLua")
else Package.Parent.Parent:FindFirstChild("ReactLua")
type ReactRoblox = typeof(require(Package.Parent.ReactRoblox))

View file

@ -15,9 +15,9 @@ type ClientSyncer = {
Applies a patch or initializes the state of the atoms with the given
payload from the server.
@param payload The patch or hydration payload to apply.
@param ...payloads The patches or hydration payloads to apply.
]=]
sync: (self: ClientSyncer, payload: SyncPayload) -> (),
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--[=[
@ -32,12 +32,16 @@ local function client(options: ClientOptions): ClientSyncer
local self = {} :: ClientSyncer
-- Apply the state changes sent by the server.
function self:sync(payload)
for key, state in payload.data do
if payload.type == "patch" then
atoms[key](patch.apply(atoms[key](), state))
else
atoms[key](state)
function self:sync(...)
for index = 1, select("#", ...) do
local payload = select(index, ...)
for key, state in payload.data do
if payload.type == "patch" then
atoms[key](patch.apply(atoms[key](), state))
else
atoms[key](state)
end
end
end
end

View file

@ -18,6 +18,12 @@ type ServerOptions = {
negative value to disable automatic syncing.
]=]
interval: number?,
--[=[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple times
per frame, where each individual change is important. Defaults to `false`.
]=]
preserveHistory: boolean?,
}
type ServerSyncer = {
@ -32,7 +38,7 @@ type ServerSyncer = {
@param callback The function to call when the state changes.
@return A cleanup function that unsubscribes all listeners.
]=]
connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (),
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--[=[
Hydrates the client's state with the server's state. This should be
called when a player joins the game and requires the server's state.
@ -54,13 +60,9 @@ type ServerSyncer = {
@return A `ServerSyncer` object.
]=]
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local interval = options.interval or 0
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local state: { [string]: any } = {}
local stateSnapshot = {}
local snapshots: { { [string]: any } } = {}
local changed = false
-- Start the interval to send state patches over the network.
@ -68,34 +70,78 @@ local function server(options: ServerOptions): ServerSyncer
local cleanups = {}
sync = callback
local function getSnapshot()
local snapshot: { [string]: any } = {}
for key, atom in options.atoms do
snapshot[key] = atom()
end
return snapshot
end
local function pushSnapshot(key: string, current: any, previous: any)
if not options.preserveHistory then
if snapshots[2] then
snapshots[2][key] = current
else
snapshots[2] = getSnapshot()
end
return
end
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the initial state and snapshot for each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in atoms do
cleanups[key] = subscribe(atom, function(current)
state[key] = current
for key, atom in options.atoms do
cleanups[key] = subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
state[key] = atom()
stateSnapshot[key] = state[key]
end
snapshots[1] = getSnapshot()
local disconnect = setInterval(function()
if not changed then
return
end
local diffs = patch.diff(stateSnapshot, state)
stateSnapshot = table.clone(state)
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in Players:GetPlayers() do
callback(player, {
type = "patch",
data = diffs,
})
callback(player, unpack(payloads))
end
end, interval)
end, options.interval or 0)
return function()
disconnect()
@ -111,7 +157,7 @@ local function server(options: ServerOptions): ServerSyncer
sync(player, {
type = "init",
data = state,
data = snapshots[#snapshots],
})
end
@ -122,14 +168,23 @@ local function server(options: ServerOptions): ServerSyncer
return
end
local diffs = patch.diff(stateSnapshot, state)
stateSnapshot = table.clone(state)
local payloads: { SyncPayload } = {}
for index, snapshot in snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, {
type = "patch",
data = diffs,
})
sync(player, unpack(payloads))
end
return self