chore: housekeeping

This commit is contained in:
littensy 2025-05-21 12:16:29 -07:00
parent 4931515a95
commit 4edc952c05
77 changed files with 230 additions and 602 deletions

View file

@ -0,0 +1,6 @@
{
"name": "CharmSync",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,76 @@
local Charm = require(script.Parent.Parent.Charm)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ClientOptions = {
--- The atoms to synchronize with the server.
atoms: AtomMap,
--- Whether to ignore patches sent before the client has been hydrated.
--- Default is `true`.
ignoreUnhydrated: boolean?,
}
type ClientSyncer = {
--- Applies a patch or initializes the state of the atoms with the given
--- payload from the server.
---
--- @param ...payloads The patches or hydration payloads to apply.
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--- Creates a `ClientSyncer` object that receives patches from the server and
--- applies them to the local state.
---
--- @param options The atoms to synchronize with the server.
--- @return A `ClientSyncer` object.
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer
local hydrated = false
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 apply(data: { [string | number]: any })
local target = {}
for key, atom in next, atoms do
target[key] = atom()
end
target = patch.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, ...)
Charm.batched(function()
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
end
end)
end
end
return self
end
return client

View file

@ -0,0 +1,29 @@
local types = require(script.Parent.types)
type AtomMap = types.AtomMap
type NestedAtomMap = {
[string]: NestedAtomMap | () -> (),
}
local function flatten(atoms: NestedAtomMap): AtomMap
local result: AtomMap = {}
local function visit(node: NestedAtomMap, path: string)
for key, value in node do
local location = if path == "" then key else path .. "/" .. key
if type(value) == "table" then
visit(value, location)
else
result[location] = value
end
end
end
visit(atoms, "")
return result
end
return flatten

View file

@ -0,0 +1,17 @@
local client = require(script.client)
local flatten = require(script.flatten)
local patch = require(script.patch)
local server = require(script.server)
local types = require(script.types)
local validate = require(script.validate)
export type SyncPayload = types.SyncPayload
return {
client = client,
server = server,
flatten = flatten,
validate = validate,
patch = patch,
isNone = patch.isNone,
}

View file

@ -0,0 +1,28 @@
local RunService = game:GetService("RunService")
--- Schedules a function to be called at a fixed interval of `seconds`.
---
--- @param callback The function to call.
--- @param seconds The interval in seconds.
--- @return A function that cancels the interval.
local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then
return function() end
end
local clock = 0
local connection = RunService.Heartbeat:Connect(function(delta)
clock += delta
if clock >= seconds then
clock = 0
callback()
end
end)
return function()
connection:Disconnect()
end
end
return interval

View file

@ -0,0 +1,109 @@
local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--- 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
--- JSON serialization can drop the last values of a sparse array. For example,
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- serialization of sparse arrays can be resolved by converting them to
--- dictionaries with string keys.
---
--- The `apply` function will convert these dictionaries back into arrays as
--- long as the target array is not sparse. This function ensures that the diff
--- function produces a patch that can be correctly applied.
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
-- The object is either not an array or a dense array
return object
end
local dictionary = {}
for index, value in next, object do
dictionary[tostring(index)] = value
end
return dictionary
end
--- Compares two states and returns a patch that can be applied to the previous
--- state to produce the next state.
---
--- @param prevState The previous state.
--- @param nextState The next state.
--- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
--- @returns A patch that can be applied to the previous state.
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
serialize = serialize ~= false
local patches = table.clone(nextState)
for key, previous in next, prevState do
local current = nextState[key]
if previous == current then
patches[key] = nil
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serialize)
end
end
-- Coerce sparse array patches into dictionaries
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if serialize and __DEV__ then
for key, value in next, patches do
validate(value, key)
end
end
return patches
end
--- Applies a patch to a state and returns the next state.
---
--- @param state The current state.
--- @param patches The patches to apply.
--- @returns The next state.
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
end
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
for key, patch in next, patches do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], patch)
end
return nextState
end
return {
isNone = isNone,
diff = diff,
apply = apply,
}

View file

@ -0,0 +1,252 @@
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 types = require(script.Parent.types)
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ServerOptions = {
--- The atoms to synchronize with the client.
atoms: AtomMap,
--- The interval at which to send patches to the client, in seconds.
--- Defaults to `0` (patches are sent up to once per frame). Set to a
--- negative value to disable automatic syncing.
interval: number?,
--- Whether the history of state changes since the client's last update
--- should be preserved. This is useful for values that change multiple times
--- per frame, where each individual change is important. Defaults to `false`.
preserveHistory: boolean?,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
---
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize: boolean?,
}
type ServerSyncer = {
--- Sets up a subscription to each atom that schedules a patch to be sent to
--- the client whenever the state changes. When a change occurs, the `callback`
--- is called with the player and the payload to send.
---
--- Note that the `payload` object should not be mutated. If you need to
--- modify the payload, apply the changes to a copy of the object.
---
--- @param callback The function to call when the state changes.
--- @return A cleanup function that unsubscribes all listeners.
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--- Hydrates the client's state with the server's state. This should be
--- called when a player joins the game and requires the server's state.
---
--- @param player The player to hydrate.
hydrate: (self: ServerSyncer, player: Player) -> (),
--- @deprecated For internal use only.
_sendPatch: (self: ServerSyncer, player: Player) -> (),
}
--- Creates a `ServerSyncer` object that sends patches to the client and
--- hydrates the client's state.
---
--- @param options The atoms to synchronize with the client.
--- @return A `ServerSyncer` object.
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot()
local snapshot = {}
for key, atom in next, atoms do
snapshot[key] = atom()
end
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 preserveHistory then
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in next, snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in next, snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end
return self
end
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self
end
return server

View file

@ -0,0 +1,19 @@
local Charm = require(script.Parent.Parent.Charm)
type Atom<T> = Charm.Atom<T>
--- A payload that can be sent from the server to the client to synchronize
--- state between the two.
export type SyncPayload = {
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
export type AtomMap = {
[string | number]: Atom<any>,
}
return nil

View file

@ -0,0 +1,66 @@
local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
-- All keys must have the same type
for key in next, object do
local currentType = type(key)
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
end
-- If there are more keys than the length of the array, it's an array with
-- non-sequential keys.
if objectSize > #object and keyType == "number" then
return true
end
return false
end
--- Validates a value to ensure it can be synced over a remote event.
---
--- @param value The value to validate.
--- @param key The key of the value in the table.
--- @error Throws an error if the value cannot be synced.
local function validate(value: any, key: any)
local typeOfKey = type(key)
local typeOfValue = type(value)
if not SAFE_KEYS[typeOfKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeOfValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\n"
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
)
end
end
if typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
end
end
return validate

View file

@ -0,0 +1,124 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local subscribe = Charm.subscribe
local client = CharmSync.client
local test = suite.test
test("should sync state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
})
assert(a() == 2, "should set a to the initial value")
end)
test("should sync state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
assert(a() == previous, "should set a to the initial value")
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
assert(a() ~= previous, "should apply patch")
assert(a().b == 3, "should set b to the new value")
assert(a().c == 2, "should not change c")
end)
test("should fix sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
-- 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 } },
})
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
end)
test("should apply multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
local calls = {}
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "init",
data = { a = a() },
})
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
})
assert(a() == 3, "should apply the last patch")
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
end)
test("should batch patches", function()
local a = atom(0)
local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {}
subscribe(a, function()
states.b = b()
end)
subscribe(b, function()
states.a = a()
end)
client:sync({
type = "init",
data = { a = a(), b = b() },
})
client:sync({
type = "patch",
data = { a = 1, b = 2 },
})
assert(a() == 1, "should set a to the new value")
assert(b() == 2, "should set b to the new value")
assert(states.a == 1, "sub to a should run after b updates")
assert(states.b == 2, "sub to b should run after a updates")
end)
return {}

View file

@ -0,0 +1,28 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local flatten = CharmSync.flatten
local test = suite.test
test("should flatten atoms", function()
local input = {
a = atom(0),
b = { c = atom(0) },
d = { e = { f = atom(0) } },
}
local result = flatten(input)
assert(result.a == input.a, "should flatten the first level")
assert(result["b/c"] == input.b.c, "should flatten the second level")
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
assert(result.b == nil, "should remove the original nested tables")
assert(result.d == nil, "should remove the original nested tables")
end)
return {}

View file

@ -0,0 +1,130 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local patch = CharmSync.patch
local test = suite.test
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
assert(patches ~= nil, "should return a patch")
assert(patches.baz == "quux", "should return the changed value")
assert(patches.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
assert(applied.foo == "bar", "should keep the unchanged value")
assert(applied.qaz == "qux", "should add the new value")
end)
test("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)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
test("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
assert(applied[1] == "a", "should keep a")
assert(applied[2] == "B", "should apply B")
assert(applied[3] == "c", "should keep c")
assert(applied[4] == "D", "should apply D")
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
end)
test("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" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
for index in next, applied do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
end)
test("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end
end)
test("optionally validates state", function()
local success = pcall(function()
patch.diff({}, { [0.1] = true }, true)
end)
assert(not success, "should not allow non-integer keys")
success = pcall(function()
patch.diff({}, { [0.1] = true }, false)
end)
assert(success, "should skip validation if not requested")
end)
return {}

View file

@ -0,0 +1,102 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
local patch = CharmSync.patch
local test = suite.test
local target: Player = {} :: any
test("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
assert(player == target, "should send to the target player")
assert(payload.type == "init", "should be an init payload")
assert(payload.data.a == 1, "should send the initial state")
disconnect()
end)
test("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
assert(player == target, "should send to the target player")
assert(payload.type == "patch", "should be a patch payload")
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(payload.data.a.c == 5, "should send changed values")
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values")
assert(payload.data.a.d == 1, "should send new values")
disconnect()
end)
test("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
assert(#payloads == 1, "should only have one payload")
assert(payloads[1].type == "patch", "should be a patch payload")
assert(payloads[1].data.a == 2, "should send the new value of a")
assert(payloads[1].data.b == 2, "should send the new value of b")
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2, "should have two payloads")
assert(payloads[1].type == "patch", "1 should be a patch payload")
assert(payloads[1].data.a == 3, "1 should send the new value of a")
assert(payloads[1].data.b == nil, "1 should not send unchanged values")
assert(payloads[2].type == "patch", "2 should be a patch payload")
assert(payloads[2].data.a == 4, "2 should send the new value of a")
assert(payloads[2].data.b == 3, "2 should send the new value of b")
disconnect()
end)
return {}

View file

@ -0,0 +1,23 @@
local ServerScriptService = game:GetService("ServerScriptService")
local suite = require(ServerScriptService.Tests.suite)
suite.describe("charm-sync", function()
suite.describe("client", function()
require("./client.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function()
require("./flatten.spec")
end)
suite.describe("patch", function()
require("./patch.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
end)
return {}

View file

@ -0,0 +1,38 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Charm = require(ReplicatedStorage.Packages.Charm)
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local suite = require(ServerScriptService.Tests.suite)
local atom = Charm.atom
local validate = CharmSync.validate
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
map_infinity_key = { value = { [math.huge] = true }, expected = false },
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
nested_atom = { value = atom(0), expected = false },
}
for key, case in next, cases do
local success, result = pcall(function()
validate(case.value, key)
end)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}

View file

@ -0,0 +1,9 @@
[package]
name = "littensy/charm-sync"
version = "0.3.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["tests"]
[dependencies]
Charm = "littensy/charm@^0.10"