mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat: add state sync
This commit is contained in:
parent
5fe86ef030
commit
17a8c48e54
9 changed files with 262 additions and 0 deletions
31
src/__tests__/sync/client.spec.luau
Normal file
31
src/__tests__/sync/client.spec.luau
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local sync = require(script.Parent.Parent.Parent.sync)
|
||||
|
||||
return function()
|
||||
it("syncs state payload", function()
|
||||
local a = atom(1)
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "set",
|
||||
data = { a = 2 },
|
||||
})
|
||||
|
||||
expect(a()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("syncs state patch", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
local client = sync.client({ atoms = { a = a } })
|
||||
|
||||
client:sync({
|
||||
type = "patch",
|
||||
data = { a = { b = 3 } },
|
||||
})
|
||||
|
||||
expect(a()).to.never.equal(previous)
|
||||
expect(a().b).to.equal(3)
|
||||
expect(a().c).to.equal(2)
|
||||
end)
|
||||
end
|
||||
40
src/__tests__/sync/server.spec.luau
Normal file
40
src/__tests__/sync/server.spec.luau
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
local atom = require(script.Parent.Parent.Parent.atom)
|
||||
local sync = require(script.Parent.Parent.Parent.sync)
|
||||
|
||||
return function()
|
||||
local target: Player = {} :: any
|
||||
|
||||
it("sends state to new players", function()
|
||||
local a = atom(1)
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: sync.SyncPayload
|
||||
|
||||
server:onSync(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
server:hydrate(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("set")
|
||||
expect(payload.data.a).to.equal(1)
|
||||
end)
|
||||
|
||||
it("sends state patches", function()
|
||||
local a = atom({ b = 1, c = 2 })
|
||||
local server = sync.server({ atoms = { a = a }, interval = -1 })
|
||||
local player: Player, payload: sync.SyncPayload
|
||||
|
||||
server:onSync(function(...)
|
||||
player, payload = ...
|
||||
end)
|
||||
|
||||
a({ b = 1, c = 5 })
|
||||
server:flush(target)
|
||||
|
||||
expect(player).to.equal(target)
|
||||
expect(payload.type).to.equal("patch")
|
||||
expect(payload.data.a.b).to.never.be.ok()
|
||||
expect(payload.data.a.c).to.equal(5)
|
||||
end)
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ local subscribe = require(script.subscribe)
|
|||
local useAtom = require(script.react.useAtom)
|
||||
local useAtomState = require(script.react.useAtomState)
|
||||
local useSetAtom = require(script.react.useSetAtom)
|
||||
local sync = require(script.sync)
|
||||
|
||||
export type Atom<State> = atom.Atom<State>
|
||||
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
||||
|
|
@ -20,6 +21,7 @@ return {
|
|||
observe = observe,
|
||||
split = split,
|
||||
subscribe = subscribe,
|
||||
sync = sync,
|
||||
useAtom = useAtom,
|
||||
useAtomState = useAtomState,
|
||||
useSetAtom = useSetAtom,
|
||||
|
|
|
|||
31
src/sync/client.luau
Normal file
31
src/sync/client.luau
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type ClientSyncOptions = {
|
||||
atoms: { [string]: Atom<any> },
|
||||
}
|
||||
|
||||
type ClientSync = {
|
||||
sync: (self: ClientSync, payload: types.SyncPayload) -> (),
|
||||
}
|
||||
|
||||
local function client(options: ClientSyncOptions): ClientSync
|
||||
local atoms = options.atoms
|
||||
local self = {} :: ClientSync
|
||||
|
||||
function self:sync(payload)
|
||||
for key, state in payload.data do
|
||||
if payload.type == "patch" then
|
||||
atoms[key]:set(patch.apply(atoms[key]:get(), state))
|
||||
else
|
||||
atoms[key]:set(state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return client
|
||||
10
src/sync/init.luau
Normal file
10
src/sync/init.luau
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
local client = require(script.client)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
|
||||
export type SyncPayload = types.SyncPayload
|
||||
|
||||
return {
|
||||
client = client,
|
||||
server = server,
|
||||
}
|
||||
42
src/sync/patch.luau
Normal file
42
src/sync/patch.luau
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
local NONE = { __none = "__none" }
|
||||
|
||||
local function diff(previousState: { [any]: any }, nextState: { [any]: any })
|
||||
local patch = table.clone(nextState)
|
||||
|
||||
for key, previous in previousState do
|
||||
local next = nextState[key]
|
||||
|
||||
if previous == next then
|
||||
patch[key] = nil
|
||||
elseif next == nil then
|
||||
patch[key] = NONE
|
||||
elseif type(previous) == "table" and type(next) == "table" then
|
||||
patch[key] = diff(previous, next)
|
||||
end
|
||||
end
|
||||
|
||||
return patch
|
||||
end
|
||||
|
||||
local function apply(state: { [any]: any }, patchState: { [any]: any })
|
||||
local nextState = table.clone(state)
|
||||
|
||||
for key, patch in patchState do
|
||||
local current = nextState[key]
|
||||
|
||||
if type(patch) == "table" and patch.__none == "__none" then
|
||||
nextState[key] = nil
|
||||
elseif type(patch) == "table" and type(current) == "table" then
|
||||
nextState[key] = apply(current, patch)
|
||||
else
|
||||
nextState[key] = patch
|
||||
end
|
||||
end
|
||||
|
||||
return nextState
|
||||
end
|
||||
|
||||
return {
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
}
|
||||
77
src/sync/server.luau
Normal file
77
src/sync/server.luau
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local Players = game:GetService("Players")
|
||||
|
||||
local atom = require(script.Parent.Parent.atom)
|
||||
type Atom<T> = atom.Atom<T>
|
||||
local setInterval = require(script.Parent.Parent.utils.setInterval)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
|
||||
type ServerSyncOptions = {
|
||||
atoms: { [string]: Atom<any> },
|
||||
interval: number?,
|
||||
}
|
||||
|
||||
type ServerSync = {
|
||||
onSync: (self: ServerSync, callback: (player: Player, payload: types.SyncPayload) -> ()) -> () -> (),
|
||||
flush: (self: ServerSync, player: Player?) -> (),
|
||||
hydrate: (self: ServerSync, player: Player) -> (),
|
||||
}
|
||||
|
||||
local function server(options: ServerSyncOptions): ServerSync
|
||||
local atoms = options.atoms
|
||||
local interval = options.interval or 0
|
||||
local self = {} :: ServerSync
|
||||
local sync: (player: Player, payload: types.SyncPayload) -> ()
|
||||
local state: {}
|
||||
|
||||
local function getState()
|
||||
local state = {}
|
||||
for key, atom in atoms do
|
||||
state[key] = atom:get()
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
function self:onSync(callback)
|
||||
sync = callback
|
||||
state = getState()
|
||||
|
||||
return setInterval(function()
|
||||
self:flush()
|
||||
end, interval)
|
||||
end
|
||||
|
||||
function self:flush(player: Player?)
|
||||
local current = getState()
|
||||
local previous = state or current
|
||||
state = current
|
||||
|
||||
if current ~= previous then
|
||||
local payload: types.SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previous, current),
|
||||
}
|
||||
|
||||
if player then
|
||||
return sync(player, payload)
|
||||
end
|
||||
|
||||
for _, player in Players:GetPlayers() do
|
||||
sync(player, payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function self:hydrate(player)
|
||||
assert(sync, "onSync must be called before onJoin")
|
||||
|
||||
sync(player, {
|
||||
type = "set",
|
||||
data = getState(),
|
||||
})
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return server
|
||||
6
src/sync/types.luau
Normal file
6
src/sync/types.luau
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type SyncPayload = {
|
||||
type: "patch" | "set",
|
||||
data: { [any]: any },
|
||||
}
|
||||
|
||||
return nil
|
||||
23
src/utils/setInterval.luau
Normal file
23
src/utils/setInterval.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local RunService = game:GetService("RunService")
|
||||
|
||||
local function setInterval(callback: () -> (), seconds: number): () -> ()
|
||||
if seconds < 0 then
|
||||
return function() end
|
||||
end
|
||||
|
||||
local clock = 0
|
||||
|
||||
local connection = RunService.Heartbeat:Connect(function(delta)
|
||||
clock += delta
|
||||
if clock >= seconds then
|
||||
clock = 0
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
|
||||
return function()
|
||||
connection:Disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
return setInterval
|
||||
Loading…
Add table
Add a link
Reference in a new issue