mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 21:01:38 +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 useAtom = require(script.react.useAtom)
|
||||||
local useAtomState = require(script.react.useAtomState)
|
local useAtomState = require(script.react.useAtomState)
|
||||||
local useSetAtom = require(script.react.useSetAtom)
|
local useSetAtom = require(script.react.useSetAtom)
|
||||||
|
local sync = require(script.sync)
|
||||||
|
|
||||||
export type Atom<State> = atom.Atom<State>
|
export type Atom<State> = atom.Atom<State>
|
||||||
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
export type ReadonlyAtom<State> = atom.ReadonlyAtom<State>
|
||||||
|
|
@ -20,6 +21,7 @@ return {
|
||||||
observe = observe,
|
observe = observe,
|
||||||
split = split,
|
split = split,
|
||||||
subscribe = subscribe,
|
subscribe = subscribe,
|
||||||
|
sync = sync,
|
||||||
useAtom = useAtom,
|
useAtom = useAtom,
|
||||||
useAtomState = useAtomState,
|
useAtomState = useAtomState,
|
||||||
useSetAtom = useSetAtom,
|
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