From b68879cafd2ddcd3450ae25c9e0e17e8aeda0dde Mon Sep 17 00:00:00 2001 From: richard <56808540+littensy@users.noreply.github.com> Date: Mon, 8 Jul 2024 13:47:50 -0700 Subject: [PATCH] feat(sync): add `preserveHistory` option (#12) * feat(sync): add `preserveHistory` option * build: omit project json from npm --- README.md | 92 +++++++++++++----------- package.json | 1 - src/__tests__/sync/client.spec.luau | 23 ++++++ src/__tests__/sync/server.spec.luau | 41 +++++++++++ src/index.d.ts | 22 ++++-- src/modules/React.luau | 2 +- src/modules/ReactRoblox.luau | 2 +- src/sync/client.luau | 20 +++--- src/sync/server.luau | 107 +++++++++++++++++++++------- 9 files changed, 226 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 42c49ba..0ceccf5 100644 --- a/README.md +++ b/README.md @@ -334,40 +334,6 @@ const todos = useAtom(searchTodos(filter), [filter]); ## 📗 Charm Sync -### `sync.client(options)` - -Call `sync.client` to create a client sync object. The object will sync the client's copy of the state with the server's state. - -```ts -import { sync } from "@rbxts/charm"; - -const client = sync.client({ atoms: atomsToSync }); - -remotes.syncState.connect((payload) => { - client.sync(payload); -}); - -remotes.requestState.fire(); -``` - -#### Parameters - -- `options`: An object to configure the client syncer. - - - `atoms`: An object containing the atoms to sync. The keys should match the keys on the server. - -#### Returns - -`sync.client` returns a client sync object. The sync object has the following methods: - -- `client.sync(payload)` applies a state update from the server. - -#### Caveats - -- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above. - ---- - ### `sync.server(options)` Call `sync.server` to create a server sync object. The object handles sending state updates to clients at a specified interval, and hydrating clients with the initial state. @@ -375,10 +341,14 @@ Call `sync.server` to create a server sync object. The object handles sending st ```ts import { sync } from "@rbxts/charm"; -const server = sync.server({ atoms: atomsToSync }); +const server = sync.server({ + atoms: atomsToSync, + interval: 0, + preserveHistory: false, +}); -server.connect((player, payload) => { - remotes.syncState.fire(player, payload); +server.connect((player, ...payloads) => { + remotes.syncState.fire(player, ...payloads); }); remotes.requestState.connect((player) => { @@ -394,6 +364,8 @@ remotes.requestState.connect((player) => { - **optional** `interval`: The interval at which to send state updates to clients. Defaults to `0`, meaning updates are sent on the next frame. + - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance reasons. + #### Returns `sync.server` returns a server sync object. The sync object has the following methods: @@ -404,10 +376,46 @@ remotes.requestState.connect((player) => { #### Caveats +- By default, Charm omits the individual changes made to atoms between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. + - The server sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes sending the initial state, which is implemented via `requestState` in the example above. --- +### `sync.client(options)` + +Call `sync.client` to create a client sync object. The object will sync the client's copy of the state with the server's state. + +```ts +import { sync } from "@rbxts/charm"; + +const client = sync.client({ atoms: atomsToSync }); + +remotes.syncState.connect((...payloads) => { + client.sync(...payloads); +}); + +remotes.requestState.fire(); +``` + +#### Parameters + +- `options`: An object to configure the client syncer. + + - `atoms`: An object containing the atoms to sync. The keys should match the keys on the server. + +#### Returns + +`sync.client` returns a client sync object. The sync object has the following methods: + +- `client.sync(...payloads)` applies a state update from the server. + +#### Caveats + +- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above. + +--- + ### `sync.isNone(value)` State patches represent the _difference_ between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be `nil` in the patch. In these cases, Charm uses the `None` marker to represent a removed value. @@ -491,6 +499,8 @@ export { writerAtom } from "./writer-atom"; Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request. +Note that if `preserveHistory` is `true`, the server will send multiple payloads to the client, so the callback passed to `connect` should accept a `...payloads` parameter. Otherwise, you only need to handle a single `payload` parameter. + ```ts import { sync } from "@rbxts/charm"; import { remotes } from "./remotes"; @@ -499,8 +509,8 @@ import * as atoms from "./atoms"; const server = sync.server({ atoms }); // Broadcast a state update to a specific player -server.connect((player, payload) => { - remotes.syncState.fire(player, payload); +server.connect((player, ...payloads) => { + remotes.syncState.fire(player, ...payloads); }); // Send initial state to a player upon request @@ -519,8 +529,8 @@ import * as atoms from "./atoms"; const client = sync.client({ atoms }); // Listen for incoming state changes from the server -remotes.syncState.connect((payload) => { - client.sync(payload); +remotes.syncState.connect((...payloads) => { + client.sync(...payloads); }); // Request initial state from the server diff --git a/package.json b/package.json index 906e5f9..d33fefa 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "homepage": "https://github.com/littensy/charm", "files": [ "src", - "default.project.json", "!src/__tests__" ], "publishConfig": { diff --git a/src/__tests__/sync/client.spec.luau b/src/__tests__/sync/client.spec.luau index d9a3c65..faae0f7 100644 --- a/src/__tests__/sync/client.spec.luau +++ b/src/__tests__/sync/client.spec.luau @@ -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 diff --git a/src/__tests__/sync/server.spec.luau b/src/__tests__/sync/server.spec.luau index dbffb48..507b4b8 100644 --- a/src/__tests__/sync/server.spec.luau +++ b/src/__tests__/sync/server.spec.luau @@ -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 diff --git a/src/index.d.ts b/src/index.d.ts index 91487df..7b00e15 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -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 { @@ -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): void; + sync(...payloads: SyncPayload[]): void; } interface ServerSyncer { /** * 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) => void): Cleanup; + connect(callback: (player: Player, ...payloads: SyncPayload[]) => 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. diff --git a/src/modules/React.luau b/src/modules/React.luau index 1f24cb1..d91d348 100644 --- a/src/modules/React.luau +++ b/src/modules/React.luau @@ -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)) diff --git a/src/modules/ReactRoblox.luau b/src/modules/ReactRoblox.luau index d5df9b2..7e4560b 100644 --- a/src/modules/ReactRoblox.luau +++ b/src/modules/ReactRoblox.luau @@ -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)) diff --git a/src/sync/client.luau b/src/sync/client.luau index f68a119..da20e0c 100644 --- a/src/sync/client.luau +++ b/src/sync/client.luau @@ -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 diff --git a/src/sync/server.luau b/src/sync/server.luau index 7085b3d..96e6273 100644 --- a/src/sync/server.luau +++ b/src/sync/server.luau @@ -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