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
92
README.md
92
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
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
"homepage": "https://github.com/littensy/charm",
|
||||
"files": [
|
||||
"src",
|
||||
"default.project.json",
|
||||
"!src/__tests__"
|
||||
],
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -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,7 +32,10 @@ local function client(options: ClientOptions): ClientSyncer
|
|||
local self = {} :: ClientSyncer
|
||||
|
||||
-- Apply the state changes sent by the server.
|
||||
function self:sync(payload)
|
||||
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))
|
||||
|
|
@ -41,6 +44,7 @@ local function client(options: ClientOptions): ClientSyncer
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
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