From 17a8c48e54ea3d081e2e494c5f790c1ce66bc040 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 27 Feb 2024 22:20:18 -0800 Subject: [PATCH] feat: add state sync --- src/__tests__/sync/client.spec.luau | 31 ++++++++++++ src/__tests__/sync/server.spec.luau | 40 +++++++++++++++ src/init.luau | 2 + src/sync/client.luau | 31 ++++++++++++ src/sync/init.luau | 10 ++++ src/sync/patch.luau | 42 ++++++++++++++++ src/sync/server.luau | 77 +++++++++++++++++++++++++++++ src/sync/types.luau | 6 +++ src/utils/setInterval.luau | 23 +++++++++ 9 files changed, 262 insertions(+) create mode 100644 src/__tests__/sync/client.spec.luau create mode 100644 src/__tests__/sync/server.spec.luau create mode 100644 src/sync/client.luau create mode 100644 src/sync/init.luau create mode 100644 src/sync/patch.luau create mode 100644 src/sync/server.luau create mode 100644 src/sync/types.luau create mode 100644 src/utils/setInterval.luau diff --git a/src/__tests__/sync/client.spec.luau b/src/__tests__/sync/client.spec.luau new file mode 100644 index 0000000..ea961f8 --- /dev/null +++ b/src/__tests__/sync/client.spec.luau @@ -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 diff --git a/src/__tests__/sync/server.spec.luau b/src/__tests__/sync/server.spec.luau new file mode 100644 index 0000000..ec00984 --- /dev/null +++ b/src/__tests__/sync/server.spec.luau @@ -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 diff --git a/src/init.luau b/src/init.luau index 8bb4341..38fa12d 100644 --- a/src/init.luau +++ b/src/init.luau @@ -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 = atom.Atom export type ReadonlyAtom = atom.ReadonlyAtom @@ -20,6 +21,7 @@ return { observe = observe, split = split, subscribe = subscribe, + sync = sync, useAtom = useAtom, useAtomState = useAtomState, useSetAtom = useSetAtom, diff --git a/src/sync/client.luau b/src/sync/client.luau new file mode 100644 index 0000000..ada134d --- /dev/null +++ b/src/sync/client.luau @@ -0,0 +1,31 @@ +local atom = require(script.Parent.Parent.atom) +type Atom = atom.Atom +local patch = require(script.Parent.patch) +local types = require(script.Parent.types) + +type ClientSyncOptions = { + atoms: { [string]: Atom }, +} + +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 diff --git a/src/sync/init.luau b/src/sync/init.luau new file mode 100644 index 0000000..d1a2d3b --- /dev/null +++ b/src/sync/init.luau @@ -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, +} diff --git a/src/sync/patch.luau b/src/sync/patch.luau new file mode 100644 index 0000000..b912ef3 --- /dev/null +++ b/src/sync/patch.luau @@ -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, +} diff --git a/src/sync/server.luau b/src/sync/server.luau new file mode 100644 index 0000000..34af509 --- /dev/null +++ b/src/sync/server.luau @@ -0,0 +1,77 @@ +local Players = game:GetService("Players") + +local atom = require(script.Parent.Parent.atom) +type Atom = atom.Atom +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 }, + 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 diff --git a/src/sync/types.luau b/src/sync/types.luau new file mode 100644 index 0000000..ca27e02 --- /dev/null +++ b/src/sync/types.luau @@ -0,0 +1,6 @@ +export type SyncPayload = { + type: "patch" | "set", + data: { [any]: any }, +} + +return nil diff --git a/src/utils/setInterval.luau b/src/utils/setInterval.luau new file mode 100644 index 0000000..5c8bdca --- /dev/null +++ b/src/utils/setInterval.luau @@ -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