perf: sync atomic changes to keys

This commit is contained in:
Richard 2024-10-31 20:57:27 -07:00
parent 13e06dd44c
commit ecf808c706
13 changed files with 368 additions and 402 deletions

3
.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

View file

@ -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<any> },
atoms: { [string | number]: Charm.Atom<any> },
--[=[
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

View file

@ -8,30 +8,20 @@ type Cleanup = () => void;
declare namespace CharmSync {
type AtomMap = Record<string, Atom<any>>;
type SelectorMap = Record<string, Selector<any>>;
/**
* @deprecated Use `SelectorMap` instead.
*/
type MoleculeMap = Record<string, Selector<any>>;
type SelectorMap = Record<string, Selector<any>>;
type MoleculeMap = SelectorMap;
/**
* Infers the type of the return values produced by a map of functions.
*/
type StateOfMap<T> = {
[P in keyof T]: T[P] extends Selector<infer State> ? State : never;
readonly [P in keyof T]: T[P] extends Selector<infer State> ? 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<T> = 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<Selectors extends SelectorMap>(options: ServerOptions<Selectors>): ServerSyncer<Selectors>;
/**
* 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<CheckableTypes, "table" | "userdata" | "nil">];
/**
* 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<State> =
| MaybeNone<State>
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? readonly (SyncPatch<T> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
: State);
type SyncPacket<Selectors extends SelectorMap> = {
readonly path: SyncPacketPath<StateOfMap<Selectors>>;
readonly value: unknown;
};
type SyncPacketPath<T> = T extends DataType ? readonly [] : readonly [keyof T, ...SyncPacketPath<T[keyof T]>];
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
type SyncPayload<Selectors extends SelectorMap> =
| { type: "init"; data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>> };
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { readonly type: "patch"; readonly data: readonly SyncPacket<Selectors>[] };
interface ClientOptions<Atoms extends AtomMap> {
/**
@ -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<Atoms extends AtomMap> {

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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<any> },
atoms: { [string | number]: Charm.Atom<any> },
--[=[
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

View file

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

View file

@ -10,24 +10,13 @@ type AnyMap<K, V> =
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<State> extends Selector<State> {
/**
* @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?: State | ((prev: State) => State)) => State;
/**
* A function that depends on one or more atoms and produces a value. Can be

View file

@ -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)

View file

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

View file

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

View file

@ -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)