diff --git a/packages/charm-sync/Charm.luau b/packages/charm-sync/Charm.luau index 6b6fb92..0070134 100644 --- a/packages/charm-sync/Charm.luau +++ b/packages/charm-sync/Charm.luau @@ -1,3 +1,7 @@ +if not script then + return require("../charm/src") +end + local Charm = require(script.Parent.Parent.charm.src) export type Atom = Charm.Atom diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index eaebab2..33f3013 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -1,19 +1,22 @@ local Charm = require("../../Charm") -local CharmSync = require("../") +local client = require("../client") local suite = require("../../../../test/suite") local atom = Charm.atom local subscribe = Charm.subscribe -local client = CharmSync.client local test = suite.test +local afterEach = suite.afterEach + +afterEach(function() + client.clearAtoms() +end) test("should sync state payload", function() local a = atom(1) - local client = client({ atoms = { a = a } }) - client:sync({ - type = "init", - data = { a = 2 }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { type = "init", data = { a = 2 } }, }) assert(a() == 2, "should set a to the initial value") @@ -22,18 +25,16 @@ end) test("should sync state patch", function() local a = atom({ b = 1, c = 2 }) local previous = a() - local client = client({ atoms = { a = a } }) - client:sync({ - type = "init", - data = { a = a() }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { type = "init", data = { a = a() } }, }) assert(a() == previous, "should set a to the initial value") - client:sync({ - type = "patch", - data = { a = { b = 3 } }, + client.applyPayloads({ + { type = "patch", data = { a = { b = 3 } } }, }) assert(a() ~= previous, "should apply patch") @@ -43,19 +44,17 @@ end) test("should fix sparse array patches", function() local a = atom({ 1 }) - local client = client({ atoms = { a = a } }) - client:sync({ - type = "init", - data = { a = a() }, + client.registerAtoms({ a = a }) + client.applyPayloads({ + { type = "init", data = { a = a() } }, }) -- This patch would be the result of a diff between { 1 } and { 1, 2 } that -- was JSON-serialized and deserialized. The client should be able to catch -- this and convert the string key back to a number. - client:sync({ - type = "patch", - data = { a = { ["2"] = 2 } }, + client.applyPayloads({ + { type = "patch", data = { a = { ["2"] = 2 } } }, }) assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") @@ -64,24 +63,21 @@ end) test("should apply multiple patches", function() local a = atom(1) - local client = client({ atoms = { a = a } }) local calls = {} + client.registerAtoms({ a = a }) + subscribe(a, function(value) table.insert(calls, value) end) - client:sync({ - type = "init", - data = { a = a() }, + client.applyPayloads({ + { type = "init", data = { a = a() } }, }) - client:sync({ - type = "patch", - data = { a = 2 }, - }, { - type = "patch", - data = { a = 3 }, + client.applyPayloads({ + { type = "patch", data = { a = 2 } }, + { type = "patch", data = { a = 3 } }, }) assert(a() == 3, "should apply the last patch") @@ -91,9 +87,10 @@ end) test("should batch patches", function() local a = atom(0) local b = atom(0) - local client = client({ atoms = { a = a, b = b } }) local states = {} + client.registerAtoms({ a = a, b = b }) + subscribe(a, function() states.b = b() end) @@ -102,14 +99,12 @@ test("should batch patches", function() states.a = a() end) - client:sync({ - type = "init", - data = { a = a(), b = b() }, + client.applyPayloads({ + { type = "init", data = { a = a(), b = b() } }, }) - client:sync({ - type = "patch", - data = { a = 1, b = 2 }, + client.applyPayloads({ + { type = "patch", data = { a = 1, b = 2 } }, }) assert(a() == 1, "should set a to the new value") diff --git a/packages/charm-sync/src/__tests__/flatten.spec.luau b/packages/charm-sync/src/__tests__/flatten.spec.luau index 2507b17..ff4848d 100644 --- a/packages/charm-sync/src/__tests__/flatten.spec.luau +++ b/packages/charm-sync/src/__tests__/flatten.spec.luau @@ -1,9 +1,8 @@ local Charm = require("../../Charm") -local CharmSync = require("../") +local flatten = require("../flatten") local suite = require("../../../../test/suite") local atom = Charm.atom -local flatten = CharmSync.flatten local test = suite.test test("should flatten atoms", function() diff --git a/packages/charm-sync/src/__tests__/patch.spec.luau b/packages/charm-sync/src/__tests__/patch.spec.luau index 05bfef5..4a68750 100644 --- a/packages/charm-sync/src/__tests__/patch.spec.luau +++ b/packages/charm-sync/src/__tests__/patch.spec.luau @@ -1,15 +1,33 @@ -local HttpService = game:GetService("HttpService") - -local CharmSync = require("../") +local config = require("../config") +local patch = require("../patch") local suite = require("../../../../test/suite") -local patch = CharmSync.patch local test = suite.test +local afterEach = suite.afterEach -local function serdes(value: { [any]: any }) - return HttpService:JSONDecode(HttpService:JSONEncode(value)) +local function mockArraySerdes(object: { [any]: any }): { [any]: any } + if object[1] then + local copy = {} + for index = 1, #object do + copy[index] = object[index] + end + return copy + elseif table.maxn(object) ~= #object then + local copy = {} + for key, value in object do + copy[tostring(key)] = value + end + return copy + else + return object + end end +afterEach(function() + config.autoSerialize = true + config.validatePatches = true +end) + test("returns a patch", function() local prevState = { foo = "bar", baz = "qux" } local nextState = { foo = "bar", baz = "quux" } @@ -60,7 +78,7 @@ test("preserves array density after serdes", function() local prevState = { "a", "b", "c", "d", "e" } local nextState = { "A", "b", "c", "D" } - local patches = serdes(patch.diff(prevState, nextState)) + local patches = mockArraySerdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) for index in next, prevState do @@ -72,27 +90,11 @@ test("preserves array density after serdes", function() end end) -test("preserves large gaps between keys", function() - local prevState = table.create(10000, "foo") - local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" }) - - local start = os.clock() - - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`) - - for index in next, prevState do - assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`) - end -end) - test("cannot preserve sparse arrays", function() local prevState = {} local nextState = { [2] = "foo" } - local patches = serdes(patch.diff(prevState, nextState)) + local patches = mockArraySerdes(patch.diff(prevState, nextState)) local applied = patch.apply(prevState, patches) -- Not enough information is available to determine whether 'patches' @@ -102,24 +104,30 @@ test("cannot preserve sparse arrays", function() end) test("optionally fixes sparse arrays", function() - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do + config.autoSerialize = true + + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "string", `expected {type(index)} {index} to be string`) end - for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do + config.autoSerialize = false + + for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do assert(type(index) == "number", `expected {type(index)} {index} to be number`) end end) test("optionally validates state", function() local success = pcall(function() - patch.diff({}, { [0.1] = true }, true) + config.validatePatches = true + patch.diff({}, { [0.1] = true }) end) assert(not success, "should not allow non-integer keys") success = pcall(function() - patch.diff({}, { [0.1] = true }, false) + config.validatePatches = false + patch.diff({}, { [0.1] = true }) end) assert(success, "should skip validation if not requested") diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau deleted file mode 100644 index d88b36f..0000000 --- a/packages/charm-sync/src/__tests__/server.spec.luau +++ /dev/null @@ -1,99 +0,0 @@ -local Charm = require("../../Charm") -local CharmSync = require("../") -local suite = require("../../../../test/suite") - -type SyncPayload = CharmSync.SyncPayload - -local atom = Charm.atom -local server = CharmSync.server -local patch = CharmSync.patch -local test = suite.test - -local target: Player = {} :: any - -test("sends state to new players", function() - local a = atom(1) - local server = server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: SyncPayload - - local disconnect = server:connect(function(...) - player, payload = ... - end) - - server:hydrate(target) - - assert(player == target, "should send to the target player") - assert(payload.type == "init", "should be an init payload") - assert(payload.data.a == 1, "should send the initial state") - - disconnect() -end) - -test("sends state patches", function() - local a = atom({ b = 1, c = 2 } :: { [string]: number? }) - local server = server({ atoms = { a = a }, interval = -1 }) - local player: Player, payload: SyncPayload - - local disconnect = server:connect(function(...) - player, payload = ... - end) - - a({ b = 1, c = 5 }) - server:_sendPatch(target) - - assert(player == target, "should send to the target player") - assert(payload.type == "patch", "should be a patch payload") - assert(payload.data.a.b == nil, "should not send unchanged values") - assert(payload.data.a.c == 5, "should send changed values") - - a({ b = 1, d = 1 }) - server:_sendPatch(target) - - assert(payload.data.a.b == nil, "should not send unchanged values") - assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values") - assert(payload.data.a.d == 1, "should send new values") - - disconnect() -end) - -test("preserves history", function() - local a = atom(1) - local b = atom(1) - local payloads: { SyncPayload } - - local server = server({ - atoms = { a = a, b = b }, - interval = -1, - preserveHistory = true, - }) - - local disconnect = server:connect(function(_, ...) - payloads = { ... } - end) - - a(2) - b(2) - server:_sendPatch(target) - - assert(#payloads == 1, "should only have one payload") - assert(payloads[1].type == "patch", "should be a patch payload") - assert(payloads[1].data.a == 2, "should send the new value of a") - assert(payloads[1].data.b == 2, "should send the new value of b") - - a(3) - a(4) - b(3) - server:_sendPatch(target) - - assert(#payloads == 2, "should have two payloads") - assert(payloads[1].type == "patch", "1 should be a patch payload") - assert(payloads[1].data.a == 3, "1 should send the new value of a") - assert(payloads[1].data.b == nil, "1 should not send unchanged values") - assert(payloads[2].type == "patch", "2 should be a patch payload") - assert(payloads[2].data.a == 4, "2 should send the new value of a") - assert(payloads[2].data.b == 3, "2 should send the new value of b") - - disconnect() -end) - -return {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 84f7760..2fde10b 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -4,10 +4,6 @@ suite.describe("client", function() require("./client.spec") end) -suite.describe("server", function() - require("./server.spec") -end) - suite.describe("flatten", function() require("./flatten.spec") end) diff --git a/packages/charm-sync/src/__tests__/validate.spec.luau b/packages/charm-sync/src/__tests__/validate.spec.luau index 86d2edb..5d8b99a 100644 --- a/packages/charm-sync/src/__tests__/validate.spec.luau +++ b/packages/charm-sync/src/__tests__/validate.spec.luau @@ -1,9 +1,8 @@ local Charm = require("../../Charm") -local CharmSync = require("../") local suite = require("../../../../test/suite") +local validate = require("../validate") local atom = Charm.atom -local validate = CharmSync.validate local test = suite.test test("validates inputs", function() diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3bb778d..40edc76 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -2,75 +2,72 @@ local Charm = require("../Charm") local patch = require("./patch") local types = require("./types") -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ClientOptions = { - --- The atoms to synchronize with the server. - atoms: AtomMap, - --- Whether to ignore patches sent before the client has been hydrated. - --- Default is `true`. - ignoreUnhydrated: boolean?, -} +local atomRegister: { [string]: Charm.Atom } = {} -type ClientSyncer = { - --- Applies a patch or initializes the state of the atoms with the given - --- payload from the server. - --- - --- @param ...payloads The patches or hydration payloads to apply. - sync: (self: ClientSyncer, ...SyncPayload) -> (), -} +--- @client +local function registerAtoms(atoms: { [string]: Charm.Atom }) + for key, atom in atoms do + if atomRegister[key] and atomRegister[key] ~= atom then + warn("[Charm] Attempted to sync different atoms with the same key:", key) + continue + end ---- Creates a `ClientSyncer` object that receives patches from the server and ---- applies them to the local state. ---- ---- @param options The atoms to synchronize with the server. ---- @return A `ClientSyncer` object. -local function client(options: ClientOptions): ClientSyncer - local atoms = options.atoms - local ignoreUnhydrated = options.ignoreUnhydrated ~= false + atomRegister[key] = atom + end +end - local self = {} :: ClientSyncer - local hydrated = false +--- @client +local function clearAtoms() + table.clear(atomRegister) +end - local function hydrate(state: { [string | number]: any }) - hydrated = true +--- @client +local function unregisterAtoms(...: string) + for index = 1, select("#", ...) do + local key = select(index, ...) + atomRegister[key] = nil + end +end - for key, value in state do - local atom = atoms[key] +local function applyInitPayload(data: { [string]: any }) + for key, value in data do + local atom = atomRegister[key] + if atom then atom(value) end end - - local function apply(data: { [string | number]: any }) - local target = {} - - for key, atom in atoms do - target[key] = atom() - end - - target = patch.apply(target, data) - - for key, atom in atoms do - atom(target[key]) - end - end - - function self:sync(...) - for index = 1, select("#", ...) do - local payload: SyncPayload = select(index, ...) - - Charm.batched(function() - if payload.type == "init" then - hydrate(payload.data) - elseif not ignoreUnhydrated or hydrated then - apply(payload.data) - end - end) - end - end - - return self end -return client +local function applyPatchPayload(data: { [string | number]: any }) + local target = {} + + for key, atom in atomRegister do + target[key] = atom() + end + + target = patch.apply(target, data) + + for key, atom in atomRegister do + atom(target[key]) + end +end + +--- @client +local function applyPayloads(payloads: { SyncPayload }) + for _, payload in payloads do + if payload.type == "init" then + Charm.batched(applyInitPayload, payload.data) + elseif payload.type == "patch" then + Charm.batched(applyPatchPayload, payload.data) + end + end +end + +return { + registerAtoms = registerAtoms, + clearAtoms = clearAtoms, + unregisterAtoms = unregisterAtoms, + applyPayloads = applyPayloads, +} diff --git a/packages/charm-sync/src/config.luau b/packages/charm-sync/src/config.luau new file mode 100644 index 0000000..3023971 --- /dev/null +++ b/packages/charm-sync/src/config.luau @@ -0,0 +1,20 @@ +return { + --- The interval at which to send patches to the client, in seconds. + --- Defaults to `0` (patches are sent up to once per frame). Set to a + --- negative value to disable automatic syncing. + interval = 0, + --- 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 = false, + --- When `true`, Charm will apply validation and serialize unsafe arrays + --- to address remote event argument limitations. Defaults to `true`. + --- This option should be disabled if your network library uses a custom + --- serialization method (i.e. Zap, ByteNet) to prevent interference. + autoSerialize = true, + --- When `true`, patches will be validated before being sent to clients to + --- enforce remote event limitations. Only checked in strict mode and if + --- `autoSerialize` is enabled. Defaults to `true`. + validatePatches = true, +} diff --git a/packages/charm-sync/src/flatten.luau b/packages/charm-sync/src/flatten.luau index 5d4a60d..23a4e5d 100644 --- a/packages/charm-sync/src/flatten.luau +++ b/packages/charm-sync/src/flatten.luau @@ -1,13 +1,9 @@ -local types = require("./types") - -type AtomMap = types.AtomMap - type NestedAtomMap = { - [string]: NestedAtomMap | () -> (), + [string]: NestedAtomMap | () -> any, } -local function flatten(atoms: NestedAtomMap): AtomMap - local result: AtomMap = {} +local function flatten(atoms: NestedAtomMap): { [string]: () -> any } + local result: { [string]: () -> any } = {} local function visit(node: NestedAtomMap, path: string) for key, value in node do diff --git a/packages/charm-sync/src/init.luau b/packages/charm-sync/src/init.luau index 249922f..f9c1a8e 100644 --- a/packages/charm-sync/src/init.luau +++ b/packages/charm-sync/src/init.luau @@ -1,17 +1,18 @@ local client = require("@self/client") +local config = require("@self/config") local flatten = require("@self/flatten") local patch = require("@self/patch") local server = require("@self/server") local types = require("@self/types") -local validate = require("@self/validate") +export type None = types.None export type SyncPayload = types.SyncPayload return { + config = config, client = client, server = server, - flatten = flatten, - validate = validate, patch = patch, isNone = patch.isNone, + flatten = flatten, } diff --git a/packages/charm-sync/src/interval.luau b/packages/charm-sync/src/interval.luau deleted file mode 100644 index 1225ecb..0000000 --- a/packages/charm-sync/src/interval.luau +++ /dev/null @@ -1,26 +0,0 @@ ---- Schedules a function to be called at a fixed interval of `seconds`. ---- ---- @param callback The function to call. ---- @param seconds The interval in seconds. ---- @return A function that cancels the interval. -local function interval(callback: () -> (), seconds: number): () -> () - if seconds < 0 then - return function() end - end - - local clock = 0 - - local connection = game:GetService("RunService").Heartbeat:Connect(function(delta) - clock += delta - if clock >= seconds then - clock = 0 - callback() - end - end) - - return function() - connection:Disconnect() - end -end - -return interval diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index fd449af..859cddc 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -1,16 +1,28 @@ local Charm = require("../Charm") +local config = require("./config") +local types = require("./types") local validate = require("./validate") local flags = Charm.flags +export type None = types.None + --- A special symbol that denotes the absence of a value. Used to represent --- deleted values in patches. -local None = { __none = "__none" } +local None: None = { __none = "__none" } +--- Checks if a value is the special `None` symbol. local function isNone(value: any): boolean return type(value) == "table" and value.__none == "__none" end +--- Converts `nil` values to the special `None` symbol. This is useful when +--- preparing data for serialization, since `nil` can represent both the absence +--- of a value and the removal of a value in patches. +local function nilToNone(value: T?): T | None + return if value == nil then None else value +end + --- JSON serialization can drop the last values of a sparse array. For example, --- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the --- serialization of sparse arrays can be resolved by converting them to @@ -29,7 +41,7 @@ local function stringifySparseArray(object: { any }): { [string | number]: any } local dictionary = {} - for index, value in next, object do + for index, value in object do dictionary[tostring(index)] = value end @@ -41,14 +53,11 @@ end --- --- @param prevState The previous state. --- @param nextState The next state. ---- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`. --- @returns A patch that can be applied to the previous state. -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) - serialize = serialize ~= false - +local function diff(prevState: { [any]: any }, nextState: { [any]: any }) local patches = table.clone(nextState) - for key, previous in next, prevState do + for key, previous in prevState do local current = nextState[key] if previous == current then @@ -56,18 +65,20 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial elseif current == nil then patches[key] = None elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current, serialize) + patches[key] = diff(previous, current) end end - -- Coerce sparse array patches into dictionaries - if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then - patches = stringifySparseArray(patches) - end + if config.autoSerialize then + -- Coerce sparse array patches into dictionaries + if prevState[1] ~= nil or nextState[1] ~= nil then + patches = stringifySparseArray(patches) + end - if serialize and flags.strict then - for key, value in next, patches do - validate(value, key) + if flags.strict and config.validatePatches then + for key, value in patches do + validate(value, key) + end end end @@ -80,7 +91,7 @@ end --- @param patches The patches to apply. --- @returns The next state. local function apply(state: any, patches: any): any - if type(patches) == "table" and patches.__none == "__none" then + if isNone(patches) then return nil elseif type(state) ~= "table" or type(patches) ~= "table" then return patches @@ -89,7 +100,7 @@ local function apply(state: any, patches: any): any local nextState = table.clone(state) local stateIsArray = state[1] ~= nil - for key, patch in next, patches do + for key, patch in patches do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. @@ -105,6 +116,7 @@ end return { isNone = isNone, + nilToNone = nilToNone, diff = diff, apply = apply, } diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 880944a..355ea11 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -1,250 +1,265 @@ local Charm = require("../Charm") -local interval = require("./interval") +local config = require("./config") local patch = require("./patch") local types = require("./types") -type AtomMap = types.AtomMap type SyncPayload = types.SyncPayload -type ServerOptions = { - --- The atoms to synchronize with the client. - atoms: AtomMap, - --- The interval at which to send patches to the client, in seconds. - --- Defaults to `0` (patches are sent up to once per frame). Set to a - --- 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?, - --- When `true`, Charm will apply validation and serialize unsafe arrays - --- to address remote event argument limitations. Defaults to `true`. - --- - --- This option should be disabled if your network library uses a custom - --- serialization method (i.e. Zap, ByteNet) to prevent interference. - autoSerialize: boolean?, +type RegisteredAtom = { + atom: () -> T, + value: T, + valueLastSynced: T, + stop: () -> (), } -type ServerSyncer = { - --- 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. - --- - --- 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. - --- - --- @param callback The function to call when the state changes. - --- @return A cleanup function that unsubscribes all listeners. - 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. - --- - --- @param player The player to hydrate. - hydrate: (self: ServerSyncer, player: Player) -> (), - --- @deprecated For internal use only. - _sendPatch: (self: ServerSyncer, player: Player) -> (), -} +type SyncStatus = number ---- Creates a `ServerSyncer` object that sends patches to the client and ---- hydrates the client's state. ---- ---- @param options The atoms to synchronize with the client. ---- @return A `ServerSyncer` object. -local function server(options: ServerOptions): ServerSyncer - local atoms = options.atoms - local autoSerialize = options.autoSerialize ~= false - local preserveHistory = options.preserveHistory - local syncRate = options.interval or 0 +local PENDING_INITIAL_STATE: SyncStatus = 0 +local LISTENING_FOR_CHANGES: SyncStatus = 1 - local self = {} :: ServerSyncer - local sync: (player: Player, payload: SyncPayload) -> () +local atomRegister: { [string]: RegisteredAtom } = {} +local clientRegister: { [any]: { [string]: SyncStatus } } = {} +local globalUpdates: { { key: string, value: any, previousValue: any } } = {} +local connection: RBXScriptConnection? - local function createSnapshot() - local snapshot = {} - - for key, atom in atoms do - snapshot[key] = atom() +local function registerAtom(key: string, atom: () -> T) + if atomRegister[key] then + if atomRegister[key].atom ~= atom then + warn("[Charm] Attempted to sync different atoms with the same key:", key) end - - return snapshot + return end - -- Send the initial state to a player when they join the server. - function self:hydrate(player) - assert(sync, "connect() must be called before hydrate()") + local registeredAtom: RegisteredAtom = { + atom = atom, + value = atom(), + valueLastSynced = atom(), + stop = function() end, + } - sync(player, { - type = "init", - data = createSnapshot(), - }) - end + registeredAtom.stop = Charm.effect(function() + local value = atom() - -- If the history of state changes should be preserved, keep a list of - -- previous snapshots and diff-check each one from the last. - if preserveHistory then - local snapshots = { createSnapshot() } - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - local function pushSnapshot(key: string | number, current: any, previous: any) - 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 snapshot with the initial state of each atom. - -- Subscribe to each atom and update the state when it changes. - for key, atom in atoms do - subscriptions[key] = Charm.subscribe(atom, function(current, previous) - pushSnapshot(key, current, previous) - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - 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, autoSerialize), - }) - end - - snapshots = { lastSnapshot } - changed = false - - for _, player in game:GetService("Players"):GetPlayers() do - callback(player, unpack(payloads)) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in subscriptions do - unsubscribe() - end - end + if config.preserveHistory and registeredAtom.value ~= value then + table.insert(globalUpdates, { + key = key, + value = value, + previousValue = registeredAtom.value, + }) end - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") + registeredAtom.value = value + end) - if not changed then - return - end - - 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, autoSerialize), - }) - end - - snapshots = { snapshots[#snapshots] } - changed = false - - sync(player, unpack(payloads)) - end - - return self - end - - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - - function self:connect(callback) - local subscriptions = {} - - sync = callback - - -- Subscribe to each atom and update the state when one changes. - for key, atom in atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end - - local disconnect = interval(function() - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in game:GetService("Players"):GetPlayers() do - callback(player, payload) - end - end, syncRate) - - return function() - disconnect() - - for _, unsubscribe in subscriptions do - unsubscribe() - end - end - end - - function self:_sendPatch(player) - assert(sync, "connect() must be called before _sendPatch()") - - if not changed then - return - end - - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - sync(player, payload) - end - - return self + atomRegister[key] = registeredAtom end -return server +local function unregisterAtom(key: string) + local registeredAtom = atomRegister[key] + + if registeredAtom then + registeredAtom.stop() + atomRegister[key] = nil + end +end + +local function unregisterUnusedAtoms() + local used: { [string]: boolean } = {} + + for _, clientAtoms in clientRegister do + for key in clientAtoms do + used[key] = true + end + end + + for key in atomRegister do + if not used[key] then + unregisterAtom(key) + end + end +end + +local function createHistoricalPatches(): { any } + local patchCount = #globalUpdates + local patches: { any } = table.create(patchCount) + + for index, update in globalUpdates do + patches[index] = patch.diff(update.previousValue, update.value) + end + + return patches +end + +local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload } + local syncedAtoms = clientRegister[client] + + if not syncedAtoms then + return {} + end + + local payloads: { SyncPayload } = {} + + for index, update in globalUpdates do + local patchData = patches[index] + + if not patchData then + continue + end + + local key = update.key + local status = syncedAtoms[key] + + if status == LISTENING_FOR_CHANGES then + table.insert(payloads, { + type = "patch", + data = { [key] = patchData }, + }) + elseif status == PENDING_INITIAL_STATE then + table.insert(payloads, { + type = "init", + data = { [key] = update.value }, + }) + + syncedAtoms[key] = LISTENING_FOR_CHANGES + end + end + + return payloads +end + +local function createPatch(): any + local current: { [string]: any } = {} + local previous: { [string]: any } = {} + + for key, registeredAtom in atomRegister do + current[key] = registeredAtom.value + previous[key] = registeredAtom.valueLastSynced + registeredAtom.valueLastSynced = registeredAtom.value + end + + return patch.diff(previous, current) +end + +local function createPayloadsForClient(client: any, patchData: any): { SyncPayload } + local syncedAtoms = clientRegister[client] + local payloads: { SyncPayload } = {} + + if not syncedAtoms then + return payloads + end + + local shouldSendInitialState = false + local initPayload: { [string]: any } = {} + local patchPayload: { [string]: any } = {} + + for key, status in syncedAtoms do + local registeredAtom = atomRegister[key] + + if status == LISTENING_FOR_CHANGES then + patchPayload[key] = patchData[key] + elseif status == PENDING_INITIAL_STATE then + initPayload[key] = registeredAtom.value + syncedAtoms[key] = LISTENING_FOR_CHANGES + shouldSendInitialState = true + end + end + + if shouldSendInitialState then + table.insert(payloads, { type = "init", data = initPayload }) + end + + if next(patchPayload) ~= nil then + table.insert(payloads, { type = "patch", data = patchPayload }) + end + + return payloads +end + +--- @server +local function syncAtomsToClient(client: any, atoms: { [string]: () -> any }) + if not clientRegister[client] then + clientRegister[client] = {} + end + + local syncedAtoms = clientRegister[client] + + for key, atom in atoms do + registerAtom(key, atom) + + if not syncedAtoms[key] then + syncedAtoms[key] = PENDING_INITIAL_STATE + end + end +end + +--- @server +local function unsyncClient(client: any) + clientRegister[client] = nil +end + +--- @server +local function unsyncAtomsFromClient(client: any, ...: string) + local syncedAtoms = clientRegister[client] + + if not syncedAtoms then + return + end + + for index = 1, select("#", ...) do + local key = select(index, ...) + syncedAtoms[key] = nil + end + + if next(syncedAtoms) == nil then + clientRegister[client] = nil + end +end + +--- @server +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) + if connection then + connection:Disconnect() + end + + local elapsedTime = 0 + + local function sync() + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + + if Charm.flags.frozen then + Charm.deepFreeze(patches) + end + + for client in clientRegister do + local payloads: { SyncPayload } = if config.preserveHistory + then createHistoricalPayloadsForClient(client, patches) + else createPayloadsForClient(client, patches) + + if payloads[1] then + task.spawn(onSync, client, payloads) + end + end + + unregisterUnusedAtoms() + table.clear(globalUpdates) + end + + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number) + elapsedTime += deltaTime + + if elapsedTime >= config.interval then + elapsedTime %= config.interval + + debug.profilebegin("CharmSync") + sync() + debug.profileend() + end + end) +end + +return { + syncAtomsToClient = syncAtomsToClient, + unsyncClient = unsyncClient, + unsyncAtomsFromClient = unsyncAtomsFromClient, + startSync = startSync, +} diff --git a/packages/charm-sync/src/types.luau b/packages/charm-sync/src/types.luau index 3ccd746..482f91d 100644 --- a/packages/charm-sync/src/types.luau +++ b/packages/charm-sync/src/types.luau @@ -1,21 +1,17 @@ -local Charm = require("../Charm") - -type Atom = Charm.Atom - --- A payload that can be sent from the server to the client to synchronize ---- state between the two. +--- the states of the client's atoms with the server. export type SyncPayload = | { type: "init", - data: { [string | number]: any }, + data: { [string]: any }, } | { type: "patch", - data: { [string | number]: any }, + data: { [string]: any }, } -export type AtomMap = { - [string | number]: Atom, -} +--- A special type that represents a removed value in a patch. This is used to +--- differentiate between a value that was set to `nil` and an unchanged value. +export type None = { __none: "__none" } return nil diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 769f410..6d747ce 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -2,25 +2,26 @@ local SAFE_KEYS = { string = true, number = true } local UNSAFE_VALUES = { ["function"] = true, thread = true } local function isTableUnsafe(object: { [any]: any }) - local keyType = nil - local objectSize = 0 + local key = next(object) - -- All keys must have the same type - for key in next, object do - local currentType = type(key) - - if not keyType and SAFE_KEYS[currentType] then - keyType = currentType - elseif keyType ~= currentType then - return true - end - - objectSize += 1 + if key == nil then + -- An empty table is always safe + return false end - -- If there are more keys than the length of the array, it's an array with - -- non-sequential keys. - if objectSize > #object and keyType == "number" then + local keyType = type(key) + local keyCount = 0 + + -- Keys must either be all strings or all numbers + for key in object do + if type(key) ~= keyType then + return true + end + keyCount += 1 + end + + -- Array keys must be sequential + if keyType == "number" and keyCount > #object then return true end @@ -33,19 +34,21 @@ end --- @param key The key of the value in the table. --- @error Throws an error if the value cannot be synced. local function validate(value: any, key: any) - local typeOfKey = type(key) - local typeOfValue = type(value) + local typeKey = type(key) + local typeValue = type(value) - if not SAFE_KEYS[typeOfKey] then - error(`Invalid key type '{typeOfKey}' at key '{key}'`) - elseif UNSAFE_VALUES[typeOfValue] then - error(`Invalid value type '{typeOfValue}' at key '{key}'`) - elseif typeOfValue == "table" then + if not SAFE_KEYS[typeKey] then + error(`Invalid key type '{typeKey}' at key '{key}'`) + elseif UNSAFE_VALUES[typeValue] then + error(`Invalid value type '{typeValue}' at key '{key}'`) + end + + if typeValue == "table" then if getmetatable(value) ~= nil then error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) elseif isTableUnsafe(value) then error( - `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` + `Cannot sync tables unsupported by remote events! The table was found at the key '{key}'.\n\n` .. "This can be for the following reasons:\n" .. "1. The object is an array with non-sequential keys\n" .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" @@ -54,7 +57,7 @@ local function validate(value: any, key: any) end end - if typeOfKey == "number" then + if typeKey == "number" then if key == math.huge or key == -math.huge then error("Cannot sync infinity as key") elseif key ~= math.floor(key) then diff --git a/packages/charm/src/init.luau b/packages/charm/src/init.luau index 36236b2..e718af4 100644 --- a/packages/charm/src/init.luau +++ b/packages/charm/src/init.luau @@ -1,5 +1,3 @@ ---!nolint DeprecatedApi - local batched = require("@self/batched") local core = require("@self/core") local flags = require("@self/flags") @@ -9,15 +7,11 @@ local observe = require("@self/observe") local subscribe = require("@self/subscribe") local types = require("@self/types") local untracked = require("@self/untracked") +local utils = require("@self/utils") export type Atom = types.Atom export type Selector = types.Selector ---- @deprecated Function `batch()` is deprecated, use `batched()` instead. -local batch = batched ---- @deprecated Function `peek()` is deprecated, use `untracked()` instead. -local peek = untracked - return { core = core, flags = flags, @@ -32,7 +26,5 @@ return { observe = observe, subscribe = subscribe, untracked = untracked, - -- Temporary aliases for backwards compatibility - batch = batch, - peek = peek, + deepFreeze = utils.deepFreeze, } diff --git a/test/spec.server.luau b/test/spec.server.luau index ab0f072..dfb6d0b 100644 --- a/test/spec.server.luau +++ b/test/spec.server.luau @@ -8,7 +8,7 @@ suite.describe("charm", function() require("../packages/charm/src/__tests__/spec") end) -suite.describe.ingame("charm-sync", function() +suite.describe("charm-sync", function() require("../packages/charm-sync/src/__tests__/spec") end)