diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..e2b625c --- /dev/null +++ b/.luaurc @@ -0,0 +1,3 @@ +{ + "languageMode": "strict" +} diff --git a/src/charm-sync/src/client.luau b/src/charm-sync/src/client.luau index a2c2de9..aad3ebb 100644 --- a/src/charm-sync/src/client.luau +++ b/src/charm-sync/src/client.luau @@ -1,5 +1,6 @@ local Charm = require(script.Parent.Parent.Charm) -local patch = require(script.Parent.patch) +local packets = require(script.Parent.packets) +type Packet = packets.Packet local types = require(script.Parent.types) type SyncPayload = types.SyncPayload @@ -7,7 +8,7 @@ type ClientOptions = { --[=[ The atoms to synchronize with the server. ]=] - atoms: { [string]: Charm.Atom }, + atoms: { [string | number]: Charm.Atom }, --[=[ Whether to ignore patches sent before the client has been hydrated. Default is `true`. @@ -33,33 +34,44 @@ type ClientSyncer = { @return A `ClientSyncer` object. ]=] local function client(options: ClientOptions): ClientSyncer + local self = {} :: ClientSyncer + local atoms = options.atoms local ignoreUnhydrated = options.ignoreUnhydrated ~= false - local self = {} :: ClientSyncer local hydrated = false - -- Apply the state changes sent by the server. + local function hydrate(state: { [string | number]: any }) + hydrated = true + + for key, value in next, state do + local atom = atoms[key] + atom(value) + end + end + + local function patch(data: { Packet }) + local target = {} + + for key, atom in next, atoms do + target[key] = atom() + end + + target = packets.apply(target, data) + + for key, atom in next, atoms do + atom(target[key]) + end + end + function self:sync(...) for index = 1, select("#", ...) do local payload: SyncPayload = select(index, ...) - if ignoreUnhydrated and payload.type == "patch" and not hydrated then - -- If the client is not initialized, ignore patches. This is - -- to prevent patching incomplete state. - continue - end - - hydrated = true - Charm.batch(function() - for key, state in next, payload.data do - local atom = atoms[key] - - if payload.type == "patch" then - atom(patch.apply(atom(), state)) - else - atom(state) - end + if payload.type == "init" then + hydrate(payload.data) + elseif not ignoreUnhydrated or hydrated then + patch(payload.data) end end) end diff --git a/src/charm-sync/src/index.d.ts b/src/charm-sync/src/index.d.ts index 842416e..39883f3 100644 --- a/src/charm-sync/src/index.d.ts +++ b/src/charm-sync/src/index.d.ts @@ -8,30 +8,20 @@ type Cleanup = () => void; declare namespace CharmSync { type AtomMap = Record>; + type SelectorMap = Record>; + /** * @deprecated Use `SelectorMap` instead. */ - type MoleculeMap = Record>; - - type SelectorMap = Record>; + type MoleculeMap = SelectorMap; /** * Infers the type of the return values produced by a map of functions. */ type StateOfMap = { - [P in keyof T]: T[P] extends Selector ? State : never; + readonly [P in keyof T]: T[P] extends Selector ? State : never; }; - /** - * A special value that denotes the absence of a value. Used to represent - * removed values in patches. - */ - interface None { - readonly __none: "__none"; - } - - type MaybeNone = undefined extends T ? None : never; - /** * Creates a `ClientSyncer` object that receives patches from the server and * applies them to the local state. @@ -52,52 +42,28 @@ declare namespace CharmSync { */ function server(options: ServerOptions): ServerSyncer; - /** - * Checks whether a value is `None`. If `true`, the value is scheduled to be - * removed from the state when the patch is applied. - * - * @param value The value to check. - * @returns `true` if the value is `None`, otherwise `false`. - */ - function isNone(value: unknown): value is None; - - type DataTypes = { - [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P]; - }; - /** * A type that should not be made partial in patches. */ - type DataType = DataTypes[keyof DataTypes]; + type DataType = CheckableTypes[keyof Omit]; /** - * A partial patch that can be applied to the state to update it. Represents - * the difference between the current state and the next state. - * - * If a value was removed, it is replaced with `None`. This can be checked - * using the `sync.isNone` function. + * A packet representing a modified value in the state. */ - type SyncPatch = - | MaybeNone - | (State extends ReadonlyMap | Map - ? ReadonlyMap | None> - : State extends Set | ReadonlySet - ? ReadonlyMap - : State extends readonly (infer T)[] - ? readonly (SyncPatch | None | undefined)[] - : State extends DataType - ? State - : State extends object - ? { readonly [P in keyof State]?: SyncPatch } - : State); + type SyncPacket = { + readonly path: SyncPacketPath>; + readonly value: unknown; + }; + + type SyncPacketPath = T extends DataType ? readonly [] : readonly [keyof T, ...SyncPacketPath]; /** * A payload that can be sent from the server to the client to synchronize * state between the two. */ type SyncPayload = - | { type: "init"; data: StateOfMap } - | { type: "patch"; data: SyncPatch> }; + | { readonly type: "init"; readonly data: StateOfMap } + | { readonly type: "patch"; readonly data: readonly SyncPacket[] }; interface ClientOptions { /** @@ -132,15 +98,6 @@ declare namespace CharmSync { * to reconstruct the state's changes over time. */ preserveHistory?: boolean; - /** - * When this is enabled, arrays are coerced into dictionaries by adding a - * key of `0`, which prevents Roblox from incorrectly sending sparse arrays - * over a vanilla remote event. Defaults to `true`. - * - * Should be disabled when using network libraries like ByteNet and Zap, - * since they can represent numeric keys. - */ - convertArrays?: boolean; } interface ClientSyncer { diff --git a/src/charm-sync/src/init.luau b/src/charm-sync/src/init.luau index b57c18f..c6947b7 100644 --- a/src/charm-sync/src/init.luau +++ b/src/charm-sync/src/init.luau @@ -1,9 +1,10 @@ local client = require(script.client) -local patch = require(script.patch) +local packets = require(script.packets) local server = require(script.server) local types = require(script.types) export type SyncPayload = types.SyncPayload +export type Packet = packets.Packet --[=[ Synchronizes state between the client and server. The server sends patches @@ -12,5 +13,4 @@ export type SyncPayload = types.SyncPayload return { client = client, server = server, - isNone = patch.isNone, } diff --git a/src/charm-sync/src/packets.luau b/src/charm-sync/src/packets.luau new file mode 100644 index 0000000..df1d218 --- /dev/null +++ b/src/charm-sync/src/packets.luau @@ -0,0 +1,100 @@ +export type Packet = { + path: { string | number }, + data: any, +} + +local packets: { Packet } +local counter: number + +local function push(path: { string | number }, key: string | number) + local result = table.clone(path) + table.insert(result, key) + return result +end + +local function append(path: { string | number }, data: unknown) + packets[counter] = { path = path, data = data } + counter += 1 +end + +local function visit( + previousState: { [string | number]: any }, + currentState: { [string | number]: any }, + path: { string | number } +) + local empty = next(currentState) == nil + + if empty then + -- Overwrite with an empty table instead of deleting keys + return append(path, currentState) + end + + -- Search keys for removals and changes, but not additions + for key, previous in next, previousState do + local current = currentState[key] + + if current == previous then + continue + elseif current == nil then + append(push(path, key), nil) + elseif type(current) == "table" and type(previous) == "table" then + visit(previous, current, push(path, key)) + else + append(push(path, key), current) + end + end + + -- Search for additions not present in the previous state + for key, current in next, currentState do + if previousState[key] == nil then + append(push(path, key), current) + end + end +end + +local function diff(previous: { [string | number]: any }, current: { [string | number]: any }) + packets = {} + counter = 1 + visit(previous, current, {}) + return packets +end + +local function apply(state: { [string | number]: any }, packets: { Packet }) + local result = table.clone(state) + local copied = {} + + local function resolve(path: { string | number }) + local object = result + local length = #path + local id = "" + + for index = 1, length - 1 do + local key = path[index] + + id ..= "\\" .. tostring(key) + + -- Prepare a deep copy of the object if it hasn't been accessed yet + if not copied[id] then + copied[id] = true + object[key] = table.clone(object[key]) + end + + object = object[key] + end + + return object, path[length] + end + + for _, packet in next, packets do + local object, key = resolve(packet.path) + + object[key] = packet.data + end + + return result +end + +return { + diff = diff, + apply = apply, +} diff --git a/src/charm-sync/src/patch.luau b/src/charm-sync/src/patch.luau deleted file mode 100644 index 8bec021..0000000 --- a/src/charm-sync/src/patch.luau +++ /dev/null @@ -1,89 +0,0 @@ -local validate = require(script.Parent.validate) - ---[=[ - A special symbol that denotes the absence of a value. Used to represent - deleted values in patches. -]=] -local None = { __none = "__none" } - -local function isNone(value: any): boolean - return type(value) == "table" and value.__none == "__none" -end - -local function diff(prevState: { [any]: any }, nextState: { [any]: any }, convertArrays: boolean?) - if convertArrays == nil then - convertArrays = true - end - - local patch = table.clone(nextState) - - for key, previous in next, prevState do - local current = nextState[key] - - if previous == current then - patch[key] = nil - elseif current == nil then - patch[key] = None - elseif type(previous) == "table" and type(current) == "table" then - patch[key] = diff(previous, current, convertArrays) - end - 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 coercing remote events - -- to convert these arrays into dictionaries. - if convertArrays and (prevState[1] ~= nil or nextState[1] ~= nil) then - -- Set to an existing value to satisfy serialization - patch[0] = prevState[1] or nextState[1] - end - - if _G.__DEV__ then - for key, value in next, prevState do - validate(value, key) - end - - for key, value in nextState do - if prevState[key] ~= value then - validate(value, key) - end - end - end - - return patch -end - -local function apply(state: any, patch: any): any - if type(patch) == "table" and patch.__none == "__none" then - return nil - elseif type(state) ~= "table" or type(patch) ~= "table" then - return patch - end - - local result = table.clone(state) - local numeric = state[1] ~= nil - - for key, value in next, patch 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. - if numeric then - if key == "0" or key == 0 then - continue - end - - key = tonumber(key) or key - end - - result[key] = apply(result[key], value) - end - - return result -end - -return { - None = None, - isNone = isNone, - diff = diff, - apply = apply, -} diff --git a/src/charm-sync/src/server.luau b/src/charm-sync/src/server.luau index 2b42efa..9f69940 100644 --- a/src/charm-sync/src/server.luau +++ b/src/charm-sync/src/server.luau @@ -2,7 +2,8 @@ local Players = game:GetService("Players") local Charm = require(script.Parent.Parent.Charm) local interval = require(script.Parent.interval) -local patch = require(script.Parent.patch) +local packets = require(script.Parent.packets) +type Packet = packets.Packet local types = require(script.Parent.types) type SyncPayload = types.SyncPayload @@ -10,7 +11,7 @@ type ServerOptions = { --[=[ The atoms to synchronize with the client. ]=] - atoms: { [string]: Charm.Atom }, + atoms: { [string | number]: Charm.Atom }, --[=[ 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 @@ -23,15 +24,6 @@ type ServerOptions = { per frame, where each individual change is important. Defaults to `false`. ]=] preserveHistory: boolean?, - --[=[ - When this is enabled, arrays are coerced into dictionaries by adding a - key of `0`, which prevents Roblox from incorrectly sending sparse arrays - over a vanilla remote event. Defaults to `true`. - - Should be disabled when using network libraries like ByteNet and Zap, - since they can represent numeric keys. - ]=] - convertArrays: boolean?, } type ServerSyncer = { @@ -72,7 +64,7 @@ local function server(options: ServerOptions): ServerSyncer local sync: (player: Player, payload: SyncPayload) -> () local function createSnapshot() - local snapshot: { [string]: any } = {} + local snapshot = {} for key, atom in next, options.atoms do snapshot[key] = atom() @@ -81,10 +73,20 @@ local function server(options: ServerOptions): ServerSyncer return snapshot 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()") + + sync(player, { + type = "init", + data = createSnapshot(), + }) + end + + -- If the history of state changes should be preserved, keep a list of + -- previous snapshots and diff-check each one from the last. if options.preserveHistory then - -- Preserve a history of every state change to allow for more granular - -- syncing with the client. - local snapshots: { { [string]: any } } = {} + local snapshots = { createSnapshot() } local changed = false function self:connect(callback) @@ -92,7 +94,7 @@ local function server(options: ServerOptions): ServerSyncer sync = callback - local function pushSnapshot(key: string, current: any, previous: any) + local function pushSnapshot(key: string | number, current: any, previous: any) local lastSnapshot = snapshots[#snapshots] local previousSnapshot = snapshots[#snapshots - 1] @@ -117,8 +119,6 @@ local function server(options: ServerOptions): ServerSyncer end) end - snapshots[1] = createSnapshot() - local disconnect = interval(function() if not changed then return @@ -136,7 +136,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.convertArrays), + data = packets.diff(snapshots[index - 1], snapshot), }) end @@ -173,7 +173,7 @@ local function server(options: ServerOptions): ServerSyncer table.insert(payloads, { type = "patch", - data = patch.diff(snapshots[index - 1], snapshot, options.convertArrays), + data = packets.diff(snapshots[index - 1], snapshot), }) end @@ -182,79 +182,70 @@ local function server(options: ServerOptions): ServerSyncer sync(player, unpack(payloads)) end - else - -- Only send the most recent state to the client. - local previousSnapshot = createSnapshot() - local currentSnapshot = table.clone(previousSnapshot) - local changed = false - function self:connect(callback) - local subscriptions = {} + return self + end - sync = callback + local previousSnapshot = createSnapshot() + local currentSnapshot = table.clone(previousSnapshot) + local changed = false - -- Subscribe to each atom and update the state when one changes. - for key, atom in next, options.atoms do - subscriptions[key] = Charm.subscribe(atom, function(state) - currentSnapshot[key] = state - changed = true - end) - end + function self:connect(callback) + local subscriptions = {} - local disconnect = interval(function() - if not changed then - return - end + sync = callback - local payload: SyncPayload = { - type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.convertArrays), - } - - previousSnapshot = table.clone(currentSnapshot) - changed = false - - for _, player in next, Players:GetPlayers() do - callback(player, payload) - end - end, options.interval or 0) - - return function() - disconnect() - - for _, unsubscribe in next, subscriptions do - unsubscribe() - end - end + -- Subscribe to each atom and update the state when one changes. + for key, atom in next, options.atoms do + subscriptions[key] = Charm.subscribe(atom, function(state) + currentSnapshot[key] = state + changed = true + end) end - function self:_sendPatch(player: Player) - assert(sync, "connect() must be called before _sendPatch()") - + local disconnect = interval(function() if not changed then return end local payload: SyncPayload = { type = "patch", - data = patch.diff(previousSnapshot, currentSnapshot, options.convertArrays), + data = packets.diff(previousSnapshot, currentSnapshot), } previousSnapshot = table.clone(currentSnapshot) changed = false - sync(player, payload) + for _, player in next, Players:GetPlayers() do + callback(player, payload) + end + end, options.interval or 0) + + return function() + disconnect() + + for _, unsubscribe in next, subscriptions do + unsubscribe() + end end 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()") + function self:_sendPatch(player) + assert(sync, "connect() must be called before _sendPatch()") - sync(player, { - type = "init", - data = createSnapshot(), - }) + if not changed then + return + end + + local payload: SyncPayload = { + type = "patch", + data = packets.diff(previousSnapshot, currentSnapshot), + } + + previousSnapshot = table.clone(currentSnapshot) + changed = false + + sync(player, payload) end return self diff --git a/src/charm-sync/src/types.luau b/src/charm-sync/src/types.luau index de30101..58531b9 100644 --- a/src/charm-sync/src/types.luau +++ b/src/charm-sync/src/types.luau @@ -1,10 +1,16 @@ +local packets = require(script.Parent.packets) +type Packet = packets.Packet + --[=[ A payload that can be sent from the server to the client to synchronize state between the two. ]=] export type SyncPayload = { - type: "patch" | "init", - data: { [any]: any }, + type: "init", + data: { [string | number]: any }, +} | { + type: "patch", + data: { Packet }, } return nil diff --git a/src/charm/src/index.d.ts b/src/charm/src/index.d.ts index 385b7e7..e31bf06 100644 --- a/src/charm/src/index.d.ts +++ b/src/charm/src/index.d.ts @@ -10,24 +10,13 @@ type AnyMap = declare namespace Charm { /** - * A primitive state container that can be read from and written to. When the - * state changes, all subscribers are notified. + * A primitive state container that can be read from or written to. When + * the state changes, all subscribers are notified. * - * @template State The type of the state. * @param state The next state or a function that produces the next state. * @returns The current state, if no arguments are provided. */ - interface Atom extends Selector { - /** - * @deprecated This property is not meant to be accessed directly. - */ - readonly __nominal: unique symbol; - /** - * @param state The next state or a function that produces the next state. - * @returns The current state, if no arguments are provided. - */ - (state?: State | ((prev: State) => State)): State; - } + type Atom = (state?: State | ((prev: State) => State)) => State; /** * A function that depends on one or more atoms and produces a value. Can be diff --git a/tests/charm-sync/client.spec.luau b/tests/charm-sync/client.spec.luau index 2d2de15..85354f3 100644 --- a/tests/charm-sync/client.spec.luau +++ b/tests/charm-sync/client.spec.luau @@ -19,6 +19,30 @@ return function() expect(a()).to.equal(2) end) + it("requires init by default", function() + local a = atom(1) + local client = client({ atoms = { a = a } }) + + client:sync({ + type = "patch", + data = { { path = { "a" }, data = 2 } }, + }) + + expect(a()).to.equal(1) + end) + + it("can ignore init requirement", function() + local a = atom(1) + local client = client({ atoms = { a = a }, ignoreUnhydrated = false }) + + client:sync({ + type = "patch", + data = { { path = { "a" }, data = 2 } }, + }) + + expect(a()).to.equal(2) + end) + it("syncs state patch", function() local a = atom({ b = 1, c = 2 }) local previous = a() @@ -33,7 +57,7 @@ return function() client:sync({ type = "patch", - data = { a = { b = 3 } }, + data = { { path = { "a", "b" }, data = 3 } }, }) expect(a()).to.never.equal(previous) @@ -41,27 +65,6 @@ return function() expect(a().c).to.equal(2) end) - it("fixes sparse array patches", function() - local a = atom({ 1 }) - local client = client({ atoms = { a = a } }) - - client:sync({ - 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 } }, - }) - - expect(a()["2" :: any]).to.never.be.ok() - expect(a()[2]).to.equal(2) - end) - it("applies multiple patches", function() local a = atom(1) local client = client({ atoms = { a = a } }) @@ -78,10 +81,10 @@ return function() client:sync({ type = "patch", - data = { a = 2 }, + data = { { path = { "a" }, data = 2 } }, }, { type = "patch", - data = { a = 3 }, + data = { { path = { "a" }, data = 3 } }, }) expect(a()).to.equal(3) @@ -110,7 +113,10 @@ return function() client:sync({ type = "patch", - data = { a = 1, b = 2 }, + data = { + { path = { "a" }, data = 1 }, + { path = { "b" }, data = 2 }, + }, }) expect(a()).to.equal(1) diff --git a/tests/charm-sync/packets.spec.luau b/tests/charm-sync/packets.spec.luau new file mode 100644 index 0000000..26bdab3 --- /dev/null +++ b/tests/charm-sync/packets.spec.luau @@ -0,0 +1,68 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local packets = require(ReplicatedStorage.DevPackages.CharmSync.packets) + +return function() + it("creates a packet", function() + local prevState = { foo = "bar", baz = "qux" } + local nextState = { foo = "bar", baz = "quux" } + local results = packets.diff(prevState, nextState) + + expect(#results).to.equal(1) + expect(table.concat(results[1].path, "/")).to.equal("baz") + expect(results[1].data).to.equal("quux") + end) + + it("applies a packet", function() + local prevState = { foo = "bar", baz = "qux" } + local packet: packets.Packet = { path = { "baz" }, data = "quux" } + local nextState = packets.apply(prevState, { packet }) + + expect(nextState.baz).to.equal("quux") + expect(nextState.foo).to.equal("bar") + end) + + it("applies nested packets", function() + local prevState = { foo = { bar = "baz" } } + local nextState = { foo = { bar = "qux" } } + + local results = packets.diff(prevState, nextState) + local applied = packets.apply(prevState, results) + + expect(prevState.foo.bar).to.equal("baz") + expect(applied.foo.bar).to.equal("qux") + end) + + it("only copies changed tables", function() + local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } } + local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) } + + local results = packets.diff(prevState, nextState) + local applied = packets.apply(prevState, results) + + expect(applied).to.never.equal(prevState) + expect(applied.foo).to.never.equal(prevState.foo) + expect(applied.foo.bar).to.equal("qux") + expect(applied.baz).to.equal(prevState.baz) + end) + + it("excludes unchanged tables", function() + local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } } + local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) } + local results = packets.diff(prevState, nextState) + + expect(#results).to.equal(1) + expect(table.concat(results[1].path, "/")).to.equal("foo/bar") + expect(results[1].data).to.equal("qux") + end) + + it("overwrites cleared tables", function() + local prevState = { foo = { 1, 2, 3 } } + local nextState = { foo = {} } + local results = packets.diff(prevState, nextState) + + expect(#results).to.equal(1) + expect(table.concat(results[1].path, "/")).to.equal("foo") + expect(#results[1].data).to.equal(0) + end) +end diff --git a/tests/charm-sync/patch.spec.luau b/tests/charm-sync/patch.spec.luau deleted file mode 100644 index a00a19e..0000000 --- a/tests/charm-sync/patch.spec.luau +++ /dev/null @@ -1,102 +0,0 @@ -local HttpService = game:GetService("HttpService") -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) - -return function() - local function serdes(value: { [any]: any }) - return HttpService:JSONDecode(HttpService:JSONEncode(value)) - end - - it("returns a patch", function() - local prevState = { foo = "bar", baz = "qux" } - local nextState = { foo = "bar", baz = "quux" } - local patches = patch.diff(prevState, nextState) - - expect(patches).to.be.ok() - expect(patches.baz).to.equal("quux") - expect(patches.foo).to.never.be.ok() - end) - - it("applies a patch", function() - local prevState = { foo = "bar", baz = "qux" } - local patches = { baz = "quux", qaz = "qux" } - local applied = patch.apply(prevState, patches) - - expect(applied).to.be.ok() - expect(applied.baz).to.equal("quux") - expect(applied.foo).to.equal("bar") - expect(applied.qaz).to.equal("qux") - end) - - it("allows nested patches", function() - local prevState = { foo = { bar = "baz" } } - local nextState = { foo = { bar = "qux" } } - local patches = patch.diff(prevState, nextState) - local applied = patch.apply(prevState, patches) - - expect(applied.foo.bar).to.equal("qux") - end) - - it("preserves array density when applying", function() - local prevState = { "a", "b", "c", "d" } - local nextState = { ["2"] = "B", ["4"] = "D" } - local applied = patch.apply(prevState, nextState) - - expect(applied[1]).to.equal("a") - expect(applied[2]).to.equal("B") - expect(applied[3]).to.equal("c") - expect(applied[4]).to.equal("D") - - expect(applied["2"]).to.never.be.ok() - expect(applied["4"]).to.never.be.ok() - end) - - itFOCUS("preserves array density after serdes", function() - -- This particular change results in a patch that cannot be serialized - -- without losing information. Charm resolves this issue internally. - local prevState = { "a", "b", "c", "d", "e" } - local nextState = { "A", "b", "c", "D" } - - -- FIXME: The [0] index used to fix serialization issues is not preserved - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - for index in next, prevState do - expect(applied[index]).to.equal(nextState[index]) - end - - for index in next, applied do - expect(applied[index]).to.equal(nextState[index]) - end - end) - - it("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 - expect(applied[index]).to.equal(nextState[index]) - end - end) - - it("cannot preserve sparse arrays", function() - local prevState = {} - local nextState = { [2] = "foo" } - - local patches = serdes(patch.diff(prevState, nextState)) - local applied = patch.apply(prevState, patches) - - -- Not enough information is available to determine whether 'patches' - -- should keep the string keys added during remote event serialization. - expect(applied["2"]).to.be.ok() - expect(applied[2]).to.never.be.ok() - end) -end diff --git a/tests/charm-sync/server.spec.luau b/tests/charm-sync/server.spec.luau index b63d91e..db86abe 100644 --- a/tests/charm-sync/server.spec.luau +++ b/tests/charm-sync/server.spec.luau @@ -5,7 +5,6 @@ local atom = Charm.atom local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) type SyncPayload = CharmSync.SyncPayload local server = CharmSync.server -local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch) return function() local target: Player = {} :: any @@ -22,14 +21,14 @@ return function() server:hydrate(target) expect(player).to.equal(target) - expect(payload.type).to.equal("init") + assert(payload.type == "init", "payload.type is not 'init'") expect(payload.data.a).to.equal(1) disconnect() end) it("sends state patches", function() - local a = atom({ b = 1, c = 2 } :: { [string]: number? }) + local a = atom({ 1, 2 }) local server = server({ atoms = { a = a }, interval = -1 }) local player: Player, payload: SyncPayload @@ -37,20 +36,27 @@ return function() player, payload = ... end) - a({ b = 1, c = 5 }) + a({ 1, 5 }) server:_sendPatch(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) + assert(payload.type == "patch", "payload.type is not 'patch'") + expect(#payload.data).to.equal(1) - a({ b = 1, d = 1 }) + expect(table.concat(payload.data[1].path, "/")).to.equal("a/2") + expect(payload.data[1].data).to.equal(5) + + a({ 1, nil :: any, 1 }) server:_sendPatch(target) - expect(payload.data.a.b).to.never.be.ok() - expect(payload.data.a.c).to.equal(patch.None) - expect(payload.data.a.d).to.equal(1) + assert(payload.type == "patch", "payload.type is not 'patch'") + expect(#payload.data).to.equal(2) + + expect(table.concat(payload.data[1].path, "/")).to.equal("a/2") + expect(payload.data[1].data).to.never.be.ok() + + expect(table.concat(payload.data[2].path, "/")).to.equal("a/3") + expect(payload.data[2].data).to.equal(1) disconnect() end) @@ -75,9 +81,16 @@ return function() server:_sendPatch(target) expect(#payloads).to.equal(1) - expect(payloads[1].type).to.equal("patch") - expect(payloads[1].data.a).to.equal(2) - expect(payloads[1].data.b).to.equal(2) + assert(payloads[1].type == "patch", "payload 1 type is not 'patch'") + expect(#payloads[1].data).to.equal(2) + + -- a(2) + expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a") + expect(payloads[1].data[1].data).to.equal(2) + + -- b(2) + expect(table.concat(payloads[1].data[2].path, "/")).to.equal("b") + expect(payloads[1].data[2].data).to.equal(2) a(3) a(4) @@ -85,12 +98,24 @@ return function() server:_sendPatch(target) expect(#payloads).to.equal(2) - expect(payloads[1].type).to.equal("patch") - expect(payloads[1].data.a).to.equal(3) - expect(payloads[1].data.b).to.never.be.ok() - expect(payloads[2].type).to.equal("patch") - expect(payloads[2].data.a).to.equal(4) - expect(payloads[2].data.b).to.equal(3) + assert(payloads[1].type == "patch", "payload 1 type is not 'patch'") + expect(#payloads[1].data).to.equal(1) + + -- a(3) + expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a") + expect(payloads[1].data[1].data).to.equal(3) + + -- second a() call cannot be collapsed, so it is added to a new payload + assert(payloads[2].type == "patch", "payload 2 type is not 'patch'") + expect(#payloads[2].data).to.equal(2) + + -- a(4) + expect(table.concat(payloads[2].data[1].path, "/")).to.equal("a") + expect(payloads[2].data[1].data).to.equal(4) + + -- b(3) + expect(table.concat(payloads[2].data[2].path, "/")).to.equal("b") + expect(payloads[2].data[2].data).to.equal(3) disconnect() end)