mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat(sync): add preserveHistory option (#12)
* feat(sync): add `preserveHistory` option * build: omit project json from npm
This commit is contained in:
parent
acab2d3cce
commit
b68879cafd
9 changed files with 226 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
22
src/index.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue