feat: add state sync

This commit is contained in:
Richard 2024-02-27 22:20:18 -08:00
parent 5fe86ef030
commit 17a8c48e54
9 changed files with 262 additions and 0 deletions

View 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

View 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

View file

@ -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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
export type SyncPayload = {
type: "patch" | "set",
data: { [any]: any },
}
return nil

View 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