mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 21:01:38 +00:00
perf: sync atomic changes to keys
This commit is contained in:
parent
13e06dd44c
commit
ecf808c706
13 changed files with 368 additions and 402 deletions
3
.luaurc
Normal file
3
.luaurc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"languageMode": "strict"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
src/charm-sync/src/index.d.ts
vendored
71
src/charm-sync/src/index.d.ts
vendored
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
100
src/charm-sync/src/packets.luau
Normal file
100
src/charm-sync/src/packets.luau
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
src/charm/src/index.d.ts
vendored
17
src/charm/src/index.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
68
tests/charm-sync/packets.spec.luau
Normal file
68
tests/charm-sync/packets.spec.luau
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue