mirror of
https://github.com/littensy/charm.git
synced 2026-10-21 11:44:08 +00:00
feat: add reactive()
This commit is contained in:
parent
7bad215560
commit
bd23d54374
26 changed files with 1129 additions and 569 deletions
|
|
@ -40,6 +40,31 @@ test("should merge patch", function()
|
|||
assert(a().c == 2, "should not change c")
|
||||
end)
|
||||
|
||||
test("should merge patch (reactive)", function()
|
||||
local a = Charm.reactive({ b = 1, c = 2 })
|
||||
local runs = 0
|
||||
|
||||
Charm.effect(function()
|
||||
local _ = a.b + a.c
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = { b = a.b, c = a.c } } },
|
||||
})
|
||||
|
||||
assert(runs == 1, "should not trigger effect for redundant init")
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { b = 3 } } },
|
||||
})
|
||||
|
||||
assert(a.b == 3, "should set b to the new value")
|
||||
assert(a.c == 2, "should not change c")
|
||||
assert(runs == 2, "should trigger effect for patch")
|
||||
end)
|
||||
|
||||
test("should fix sparse array patches", function()
|
||||
local a = Charm.atom({ 1 })
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ end)
|
|||
test("should replicate server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = Charm.atom({ count = 0 })
|
||||
local serverB = Charm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.reactive({ count = -1 })
|
||||
|
||||
client.addSignals({ a = clientA, b = clientB })
|
||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||
|
|
@ -30,27 +30,32 @@ test("should replicate server state to client", function()
|
|||
|
||||
server.flush()
|
||||
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
||||
assert(clientB().count == 0, "Client atom B should be initialized to 0")
|
||||
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverB({ count = 1 })
|
||||
serverB.count = 1
|
||||
server.flush()
|
||||
assert(clientA().count == 1, "Client atom A should be updated to 1")
|
||||
assert(clientB().count == 1, "Client atom B should be updated to 1")
|
||||
assert(clientB.count == 1, "Client reactive B should be updated to 1")
|
||||
|
||||
local runs = -1
|
||||
Charm.effect(function()
|
||||
local _ = clientB.count
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
local prevClientB = clientB()
|
||||
serverA({ count = 2 })
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||
assert(runs == 0, "Client reactive B should not trigger update")
|
||||
end)
|
||||
|
||||
test("should replicate historical server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = Charm.atom({ count = 0 })
|
||||
local serverB = Charm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.reactive({ count = -1 })
|
||||
|
||||
config.preserveHistory = true
|
||||
|
||||
|
|
@ -67,30 +72,28 @@ test("should replicate historical server state to client", function()
|
|||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedA, 1)
|
||||
assert(expected == clientA().count, `Client atom A should be {expected}`)
|
||||
assert(expected == clientA().count, `Client reactive A should be {expected}`)
|
||||
end)
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedB, 1)
|
||||
assert(expected == clientB().count, `Client atom B should be {expected}`)
|
||||
assert(expected == clientB.count, `Client reactive B should be {expected}`)
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverA({ count = 2 })
|
||||
serverB({ count = 1 })
|
||||
serverB({ count = 2 })
|
||||
serverB.count = 1
|
||||
serverB.count = 2
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(clientB().count == 2, "Client atom B should be updated to 2")
|
||||
assert(clientB.count == 2, "Client reactive B should be updated to 2")
|
||||
|
||||
local prevClientB = clientB()
|
||||
serverA({ count = 3 })
|
||||
serverA({ count = 4 })
|
||||
server.flush()
|
||||
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||
assert(clientB() == prevClientB, "Client atom B should not be updated")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -31,17 +31,17 @@ end)
|
|||
test("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local diffs = 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")
|
||||
assert(diffs ~= nil, "should return a patch")
|
||||
assert(diffs.baz == "quux", "should return the changed value")
|
||||
assert(diffs.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)
|
||||
local diffs = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied ~= nil, "should return a new state")
|
||||
assert(applied.baz == "quux", "should apply the changed value")
|
||||
|
|
@ -49,11 +49,11 @@ test("applies a patch", function()
|
|||
assert(applied.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
test("allows nested patches", function()
|
||||
test("allows nested diffs", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local patches = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, patches)
|
||||
local diffs = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
||||
end)
|
||||
|
|
@ -78,8 +78,8 @@ test("preserves array density after serdes", function()
|
|||
local prevState = { "a", "b", "c", "d", "e" }
|
||||
local nextState = { "A", "b", "c", "D" }
|
||||
|
||||
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
for index in prevState do
|
||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||
|
|
@ -96,10 +96,10 @@ test("cannot preserve sparse arrays", function()
|
|||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, patches)
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
-- Not enough information is available to determine whether 'patches'
|
||||
-- Not enough information is available to determine whether 'diffs'
|
||||
-- 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")
|
||||
|
|
@ -119,20 +119,12 @@ test("optionally fixes sparse arrays", function()
|
|||
end
|
||||
end)
|
||||
|
||||
test("optionally validates state", function()
|
||||
local success = pcall(function()
|
||||
config.validatePatches = true
|
||||
patch.diff({}, { [1] = 1, a = true })
|
||||
end)
|
||||
|
||||
assert(not success, "should not allow mixed tables")
|
||||
|
||||
success = pcall(function()
|
||||
config.validatePatches = false
|
||||
patch.diff({}, { [1] = 1, a = true })
|
||||
end)
|
||||
|
||||
assert(success, "should skip validation if not requested")
|
||||
test("patches mutable state", function()
|
||||
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
|
||||
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
|
||||
assert(targetState.baz == "quux", "should apply the changed value")
|
||||
assert(targetState.foo == "bar", "should keep the unchanged value")
|
||||
assert(targetState.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,33 @@ test("should replicate partial state patches", function()
|
|||
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
||||
end)
|
||||
|
||||
test("should replicate partial state patches (reactive)", function()
|
||||
local client = {}
|
||||
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'")
|
||||
assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
|
||||
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
|
||||
|
||||
a.x = 1
|
||||
a.z = nil
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'")
|
||||
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
|
||||
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
|
||||
end)
|
||||
|
||||
test("should init new clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
|
|
@ -89,8 +116,8 @@ test("should init new clients", function()
|
|||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
a(2)
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||
|
|
@ -166,20 +193,6 @@ test("should only replicate to synced clients", function()
|
|||
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
||||
end)
|
||||
|
||||
test("should validate state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({})
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function() end)
|
||||
|
||||
a({ [0.5] = true })
|
||||
assert(not pcall(server.flush), "Expected server.flush to fail")
|
||||
|
||||
a({ [0.5] = true, valid = true })
|
||||
server.flush()
|
||||
end)
|
||||
|
||||
test("should serialize state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||
|
|
@ -236,6 +249,36 @@ describe("historical", function()
|
|||
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should replicate granular state changes (reactive)", function()
|
||||
local client = {}
|
||||
local a = Charm.reactive({ value = 0 })
|
||||
local b = Charm.reactive({ value = 0 })
|
||||
local c = Charm.reactive({ value = 0 })
|
||||
local target, payloads
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
|
||||
|
||||
a.value = 1
|
||||
a.value = 2
|
||||
b.value = 2
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should init new clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,4 @@ suite.describe("server", function()
|
|||
require("./server.spec")
|
||||
end)
|
||||
|
||||
suite.describe("validate", function()
|
||||
require("./validate.spec")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
local suite = require("../../../../test/suite")
|
||||
local validate = require("../validate")
|
||||
|
||||
local test = suite.test
|
||||
|
||||
test("validates inputs", function()
|
||||
local cases: { [string]: { value: any, expected: any } } = {
|
||||
array = { value = { 1, 2, 3 }, expected = true },
|
||||
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
|
||||
}
|
||||
|
||||
for key, case in cases do
|
||||
local success, result: any = pcall(validate, case.value)
|
||||
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 {}
|
||||
|
|
@ -4,64 +4,89 @@ local types = require("./types")
|
|||
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type Setter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||
type SignalSetter<T = any> = (update: ((T) -> T) | T) -> ()
|
||||
|
||||
local trackedSetters: { [string]: Setter } = {}
|
||||
type ReactiveSetter<T = any> = (update: ((T) -> ()) | T) -> ()
|
||||
|
||||
--- Registers a map of setters to the state with the corresponding keys on the
|
||||
--- server. When the server sends updates, these setters will be called with
|
||||
--- the new values.
|
||||
---
|
||||
--- Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
--- Note that `server.addSignalsToClient` still requires some way to get the
|
||||
--- state.
|
||||
---
|
||||
--- @param setters A map of setter functions to sync with the server.
|
||||
local function addSignals(setters: { [string]: Setter })
|
||||
for key, setter in setters do
|
||||
trackedSetters[key] = setter
|
||||
type Reactive<T = any> = typeof(setmetatable({}, {} :: { setValue: ReactiveSetter<T> }))
|
||||
|
||||
local trackedSignals: { [string]: SignalSetter } = {}
|
||||
local trackedReactives: { [string]: ReactiveSetter } = {}
|
||||
|
||||
--[[
|
||||
Registers a map of setters to the state with the corresponding keys on the
|
||||
server. When the server sends updates, these setters will be called with
|
||||
the new values.
|
||||
|
||||
Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
Note that `server.addSignalsToClient` still requires some way to get the
|
||||
state.
|
||||
|
||||
@param signals A map of setter functions to sync with the server.
|
||||
]]
|
||||
local function addSignals(signals: { [string]: SignalSetter | { [any]: any } })
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackedSignals[key] = signal
|
||||
else
|
||||
trackedReactives[key] = getmetatable((signal :: any) :: Reactive).setValue
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Unregisters from server state updates for the given keys. The signals
|
||||
--- will retain their current values, but will no longer receive updates
|
||||
--- from the server.
|
||||
--[[
|
||||
Unregisters from server state updates for the given keys. The signals
|
||||
will retain their current values, but will no longer receive updates
|
||||
from the server.
|
||||
]]
|
||||
local function removeSignals(...: string)
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
trackedSetters[key] = nil
|
||||
trackedSignals[key] = nil
|
||||
trackedReactives[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Unsubscribes from all server state updates.
|
||||
--[[
|
||||
Unsubscribes from all server state updates.
|
||||
]]
|
||||
local function removeAllSignals()
|
||||
table.clear(trackedSetters)
|
||||
table.clear(trackedSignals)
|
||||
table.clear(trackedReactives)
|
||||
end
|
||||
|
||||
local function applyInitPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
local setter = trackedSetters[key]
|
||||
if setter then
|
||||
setter(value)
|
||||
local trackedSignal: SignalSetter? = trackedSignals[key] or trackedReactives[key]
|
||||
if trackedSignal then
|
||||
trackedSignal(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyPatchPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
local setter = trackedSetters[key]
|
||||
if setter then
|
||||
setter(function(current)
|
||||
return patch.apply(current, value)
|
||||
for key, diffs in data do
|
||||
local trackedSignal = trackedSignals[key]
|
||||
local trackedReactive = trackedReactives[key]
|
||||
|
||||
if trackedSignal then
|
||||
trackedSignal(function(value)
|
||||
return patch.apply(value, diffs)
|
||||
end)
|
||||
elseif trackedReactive then
|
||||
trackedReactive(function(value)
|
||||
patch.applyMutable(value, diffs)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Merges the server's state patches into the client's state, either
|
||||
--- initializing it with the full state or merging a partial patch.
|
||||
---
|
||||
--- @param payloads The state updates received from the server.
|
||||
--[[
|
||||
Merges the server's state patches into the client's state, either
|
||||
initializing it with the full state or merging a partial patch.
|
||||
|
||||
@param payloads The state updates received from the server.
|
||||
]]
|
||||
local function patch(payloads: { SyncPayload })
|
||||
for _, payload in payloads do
|
||||
if payload.type == "init" then
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
return {
|
||||
--- The interval at which to send patches to the client, in seconds.
|
||||
--- Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
--- negative value to disable automatic syncing.
|
||||
--[[
|
||||
The interval at which to send patches to the client, in seconds.
|
||||
Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
negative value to disable automatic syncing.
|
||||
]]
|
||||
interval = 0,
|
||||
--- Whether the history of state changes since the client's last update
|
||||
--- should be preserved. This is useful for values that change multiple
|
||||
--- times per frame, where each individual change is important. Defaults
|
||||
--- to `false`.
|
||||
--[[
|
||||
Whether the history of state changes since the client's last update
|
||||
should be preserved. This is useful for values that change multiple
|
||||
times per frame, where each individual change is important. Defaults
|
||||
to `false`.
|
||||
]]
|
||||
preserveHistory = false,
|
||||
--- When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
--- to address remote event argument limitations. Defaults to `true`.
|
||||
--- This option should be disabled if your network library uses a custom
|
||||
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
--[[
|
||||
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.
|
||||
]]
|
||||
fixArrays = true,
|
||||
--- When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
--- tables will throw an error. Only checked in strict mode and if
|
||||
--- `fixArrays` is enabled. Defaults to `true`.
|
||||
--[[
|
||||
When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
tables will throw an error. Only checked in strict mode and if
|
||||
`fixArrays` is enabled. Defaults to `true`.
|
||||
]]
|
||||
validatePatches = true,
|
||||
}
|
||||
|
|
|
|||
33
packages/charm-sync/src/index.d.ts
vendored
33
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -11,8 +11,11 @@ export interface None {
|
|||
|
||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type GetterMap = Record<string, () => any>;
|
||||
type SetterMap = Record<string, Setter<any>>;
|
||||
type ReactiveObject = { [key: Key]: any };
|
||||
|
||||
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
|
||||
|
||||
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
|
|
@ -55,7 +58,7 @@ export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State
|
|||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
export type SyncPayload<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true> =
|
||||
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
|
||||
| { type: "init"; data: StateOfMap<Getters> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||
|
||||
|
|
@ -112,16 +115,18 @@ export namespace client {
|
|||
* Note that `server.addSignalsToClient` still requires some way to get the
|
||||
* state.
|
||||
*
|
||||
* @param setters A map of setter functions to sync with the server.
|
||||
* @param signals A map of setter functions to sync with the server.
|
||||
*/
|
||||
export function addSignals<Setters extends SetterMap = SetterMap>(setters: Setters): void;
|
||||
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
|
||||
|
||||
/**
|
||||
* Unregisters from server state updates for the given keys. The signals
|
||||
* will retain their current values, but will no longer receive updates
|
||||
* from the server.
|
||||
*/
|
||||
export function removeSignals<Setters extends GetterMap | SetterMap = SetterMap>(...keys: (keyof Setters)[]): void;
|
||||
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes from all server state updates.
|
||||
|
|
@ -134,7 +139,7 @@ export namespace client {
|
|||
*
|
||||
* @param payloads The state updates received from the server.
|
||||
*/
|
||||
export function patch<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
|
||||
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
payloads: SyncPayload<Getters, FixArrays>[],
|
||||
): void;
|
||||
}
|
||||
|
|
@ -152,9 +157,12 @@ export namespace server {
|
|||
* that `client.addSignals` still requires some way to set the state.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
* @param getters A map of getter functions to sync with the client.
|
||||
* @param signals A map of getter functions to sync with the client.
|
||||
*/
|
||||
export function addSignalsToClient<Getters extends GetterMap = GetterMap>(client: Player, getters: Getters): void;
|
||||
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
signals: Signals,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
|
|
@ -170,9 +178,9 @@ export namespace server {
|
|||
* @param client The client receiving state updates.
|
||||
* @param keys The keys of the state to unsubscribe from.
|
||||
*/
|
||||
export function removeSignalsFromClient<Getters extends GetterMap = GetterMap>(
|
||||
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
...keys: (keyof Getters)[]
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
|
|
@ -182,7 +190,7 @@ export namespace server {
|
|||
*
|
||||
* @param onSync Called when sending patches to a client.
|
||||
*/
|
||||
export function connect<Getters extends GetterMap = GetterMap, FixArrays extends boolean = true>(
|
||||
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
||||
): void;
|
||||
|
||||
|
|
@ -276,5 +284,6 @@ export function isNone(value: unknown): value is None;
|
|||
* @param getter A function that returns the current value of the signal.
|
||||
* @param setter A function that sets the value of the signal.
|
||||
* @returns An atom that can either get or set the signal's value.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
|
||||
*/
|
||||
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
||||
|
|
|
|||
|
|
@ -7,34 +7,38 @@ local flags = Charm.flags
|
|||
|
||||
export type None = types.None
|
||||
|
||||
--- A special symbol that denotes the absence of a value. Used to represent
|
||||
--- deleted values in patches.
|
||||
local None: None = { __none = "__none" }
|
||||
|
||||
--- 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.
|
||||
--- @return `true` if the value is `None`, otherwise `false`.
|
||||
--[[
|
||||
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.
|
||||
@return `true` if the value is `None`, otherwise `false`.
|
||||
]]
|
||||
local function isNone(value: any): boolean
|
||||
return type(value) == "table" and value.__none == "__none"
|
||||
end
|
||||
|
||||
--- Converts `nil` values to the special `None` symbol. This is useful when
|
||||
--- preparing data for serialization, since `nil` can represent both the absence
|
||||
--- of a value and the removal of a value in patches.
|
||||
--[[
|
||||
Converts `nil` values to the special `None` symbol. This is useful when
|
||||
preparing data for serialization, since `nil` can represent both the absence
|
||||
of a value and the removal of a value in patches.
|
||||
]]
|
||||
local function nilToNone<T>(value: T?): T | None
|
||||
return if value == nil then None else value
|
||||
end
|
||||
|
||||
--- JSON serialization can drop the last values of a sparse array. For example,
|
||||
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
|
||||
--- serialization of sparse arrays can be resolved by converting them to
|
||||
--- 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.
|
||||
--[[
|
||||
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 `patch` functions 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)
|
||||
|
||||
|
|
@ -52,68 +56,78 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
|||
return dictionary
|
||||
end
|
||||
|
||||
--- Compares two states and returns a patch that can be applied to the current
|
||||
--- state to produce the next state.
|
||||
---
|
||||
--- @param currentState The current state.
|
||||
--- @param nextState The next state.
|
||||
--- @return A patch that can be applied to the current state.
|
||||
local function diff(currentState: any, nextState: any): any
|
||||
if currentState == nextState then
|
||||
local function diff(oldState: any, newState: any): any
|
||||
if oldState == newState then
|
||||
return nil
|
||||
elseif nextState == nil then
|
||||
elseif newState == nil then
|
||||
return None
|
||||
elseif type(currentState) ~= "table" or type(nextState) ~= "table" then
|
||||
return nextState
|
||||
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
|
||||
return newState
|
||||
end
|
||||
|
||||
local patches: { [any]: any } = table.clone(nextState)
|
||||
local diffs: { [any]: any } = table.clone(newState)
|
||||
|
||||
for key, previous in currentState do
|
||||
patches[key] = diff(previous, nextState[key])
|
||||
for key, oldValue in oldState do
|
||||
diffs[key] = diff(oldValue, newState[key])
|
||||
end
|
||||
|
||||
if next(diffs) == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
if config.fixArrays then
|
||||
-- Convert sparse array patches that could be misserialized
|
||||
if currentState[1] ~= nil or nextState[1] ~= nil then
|
||||
patches = stringifySparseArray(patches)
|
||||
if oldState[1] ~= nil or newState[1] ~= nil then
|
||||
diffs = stringifySparseArray(diffs)
|
||||
end
|
||||
|
||||
if flags.strict and config.validatePatches then
|
||||
validate(patches)
|
||||
validate(diffs)
|
||||
end
|
||||
end
|
||||
|
||||
return patches
|
||||
return diffs
|
||||
end
|
||||
|
||||
--- Applies a patch to a state and returns the resulting value.
|
||||
---
|
||||
--- @param currentState The current state.
|
||||
--- @param statePatch The patches to apply.
|
||||
--- @return The new state with the patch applied.
|
||||
local function apply(currentState: any, statePatch: any): any
|
||||
if isNone(statePatch) then
|
||||
local function apply(oldState: any, diffs: any): any
|
||||
if isNone(diffs) then
|
||||
return nil
|
||||
elseif type(currentState) ~= "table" or type(statePatch) ~= "table" then
|
||||
return statePatch
|
||||
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
|
||||
return diffs
|
||||
end
|
||||
|
||||
local nextState = table.clone(currentState)
|
||||
local stateIsArray = currentState[1] ~= nil
|
||||
local newState = table.clone(oldState)
|
||||
local stateIsArray = oldState[1] ~= nil
|
||||
|
||||
for key, value in statePatch do
|
||||
for key, value in diffs 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], value)
|
||||
newState[key] = apply(newState[key], value)
|
||||
end
|
||||
|
||||
return nextState
|
||||
return newState
|
||||
end
|
||||
|
||||
local function applyMutable(targetState: any, sourceState: any): any
|
||||
if type(targetState) == "table" and type(sourceState) == "table" then
|
||||
local targetIsArray = targetState[1] ~= nil
|
||||
|
||||
for key, value in sourceState do
|
||||
if targetIsArray and type(key) == "string" then
|
||||
key = tonumber(key) or key
|
||||
end
|
||||
targetState[key] = applyMutable(targetState[key], value)
|
||||
end
|
||||
|
||||
return targetState
|
||||
elseif isNone(sourceState) then
|
||||
return nil
|
||||
else
|
||||
return sourceState
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
|
|
@ -121,4 +135,5 @@ return {
|
|||
nilToNone = nilToNone,
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
applyMutable = applyMutable,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ local Charm = require("../Charm")
|
|||
local config = require("./config")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
local utils = require("./utils")
|
||||
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
|
|
@ -9,11 +10,14 @@ type SyncStatus = number
|
|||
|
||||
type ClientKeys = { [string]: SyncStatus }
|
||||
|
||||
type GetterState<T = any> = {
|
||||
getter: () -> T,
|
||||
type Reactive<T> = typeof(setmetatable({}, {} :: { initialValue: T, track: () -> () }))
|
||||
|
||||
type TrackedState<T = any> = {
|
||||
target: any,
|
||||
currentValue: T,
|
||||
lastSyncedValue: T,
|
||||
dispose: () -> (),
|
||||
isReactive: boolean?,
|
||||
}
|
||||
|
||||
type StateUpdate = {
|
||||
|
|
@ -25,17 +29,17 @@ type StateUpdate = {
|
|||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||
|
||||
local trackedGetters: { [string]: GetterState } = {}
|
||||
local trackedStates: { [string]: TrackedState } = {}
|
||||
local globalUpdates: { StateUpdate } = {}
|
||||
local clients: { [any]: ClientKeys } = {}
|
||||
|
||||
local connection: RBXScriptConnection?
|
||||
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||
|
||||
local function startTracking<T>(key: string, getter: () -> T)
|
||||
if trackedGetters[key] then
|
||||
local function trackSignal<T>(key: string, getter: () -> T)
|
||||
if trackedStates[key] then
|
||||
-- Ignore duplicates, but warn against name collisions
|
||||
if trackedGetters[key].getter ~= getter then
|
||||
if trackedStates[key].target ~= getter then
|
||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||
end
|
||||
return
|
||||
|
|
@ -43,7 +47,7 @@ local function startTracking<T>(key: string, getter: () -> T)
|
|||
|
||||
local value = getter()
|
||||
local trackedState = {
|
||||
getter = getter,
|
||||
target = getter,
|
||||
currentValue = value,
|
||||
lastSyncedValue = value,
|
||||
}
|
||||
|
|
@ -61,14 +65,51 @@ local function startTracking<T>(key: string, getter: () -> T)
|
|||
trackedState.currentValue = newValue
|
||||
end)
|
||||
|
||||
trackedGetters[key] = trackedState
|
||||
trackedStates[key] = trackedState
|
||||
end
|
||||
|
||||
local function trackReactive<T>(key: string, reactive: Reactive<T>)
|
||||
if trackedStates[key] then
|
||||
if trackedStates[key].target ~= reactive then
|
||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local meta = getmetatable(reactive)
|
||||
local snapshot = utils.deepCopy(meta.initialValue)
|
||||
local firstRun = true
|
||||
|
||||
local trackedState = {
|
||||
target = reactive,
|
||||
currentValue = meta.initialValue,
|
||||
lastSyncedValue = snapshot,
|
||||
isReactive = true,
|
||||
}
|
||||
|
||||
trackedState.dispose = Charm.effect(function()
|
||||
meta.track()
|
||||
if not config.preserveHistory or firstRun then
|
||||
firstRun = false
|
||||
else
|
||||
local oldSnapshot = snapshot
|
||||
snapshot = utils.deepCopy(meta.initialValue)
|
||||
table.insert(globalUpdates, {
|
||||
key = key,
|
||||
newValue = snapshot,
|
||||
oldValue = oldSnapshot,
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
trackedStates[key] = trackedState
|
||||
end
|
||||
|
||||
local function stopTracking(key: string)
|
||||
local trackedState = trackedGetters[key]
|
||||
local trackedState = trackedStates[key]
|
||||
if trackedState then
|
||||
trackedState.dispose()
|
||||
trackedGetters[key] = nil
|
||||
trackedStates[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -81,7 +122,7 @@ local function purgeUntracked()
|
|||
end
|
||||
end
|
||||
|
||||
for key in trackedGetters do
|
||||
for key in trackedStates do
|
||||
if not active[key] then
|
||||
stopTracking(key)
|
||||
end
|
||||
|
|
@ -139,7 +180,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
|
|||
local initData: { [string]: any } = {}
|
||||
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedGetters[key]
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == PENDING_INITIAL_STATE and trackedState then
|
||||
initData[key] = trackedState.currentValue
|
||||
|
|
@ -158,10 +199,14 @@ local function diffGlobalState(): any
|
|||
local currentGlobalState: { [string]: any } = {}
|
||||
local previousGlobalState: { [string]: any } = {}
|
||||
|
||||
for key, trackedState in trackedGetters do
|
||||
currentGlobalState[key] = trackedState.currentValue
|
||||
for key, trackedState in trackedStates do
|
||||
local currentValue = if trackedState.isReactive
|
||||
then utils.deepCopy(trackedState.currentValue)
|
||||
else trackedState.currentValue
|
||||
|
||||
currentGlobalState[key] = currentValue
|
||||
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||
trackedState.lastSyncedValue = trackedState.currentValue
|
||||
trackedState.lastSyncedValue = currentValue
|
||||
end
|
||||
|
||||
return patch.diff(previousGlobalState, currentGlobalState)
|
||||
|
|
@ -181,7 +226,7 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
|
|||
-- Create patches for initialized signals that changed, and add signals
|
||||
-- that are still pending initial state to the init payload.
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedGetters[key]
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES then
|
||||
patchData[key] = globalPatch[key]
|
||||
|
|
@ -202,45 +247,55 @@ local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPaylo
|
|||
return payloads
|
||||
end
|
||||
|
||||
--- Subscribes a client to the given signals. When an update occurs, the client
|
||||
--- will receive a partial state patch to merge into their local state. May be
|
||||
--- called multiple times to subscribe to additional keys.
|
||||
---
|
||||
--- Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||
--- Note that `client.addSignals` still requires some way to set the state.
|
||||
---
|
||||
--- @param client The client receiving state updates.
|
||||
--- @param getters A map of getter functions to sync with the client.
|
||||
local function addSignalsToClient(client: any, getters: { [string]: () -> any })
|
||||
--[[
|
||||
Subscribes a client to the given signals. When an update occurs, the client
|
||||
will receive a partial state patch to merge into their local state. May be
|
||||
called multiple times to subscribe to additional keys.
|
||||
|
||||
Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||
Note that `client.addSignals` still requires some way to set the state.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
@param signals A map of getter functions to sync with the client.
|
||||
]]
|
||||
local function addSignalsToClient(client: any, signals: { [string]: { [any]: any } | () -> any })
|
||||
if not clients[client] then
|
||||
clients[client] = {}
|
||||
end
|
||||
|
||||
local clientKeys = clients[client]
|
||||
|
||||
for key, getter in getters do
|
||||
startTracking(key, if type(getter) == "function" then getter else getter.get)
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackSignal(key, signal)
|
||||
else
|
||||
trackReactive(key, signal :: any)
|
||||
end
|
||||
|
||||
if not clientKeys[key] then
|
||||
-- Mark the getter as pending initial state so we send its full
|
||||
-- Mark the signal as pending initial state so we send its full
|
||||
-- value the next time we sync.
|
||||
clientKeys[key] = PENDING_INITIAL_STATE
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
--- from specific keys, use `unsubscribeClientFrom` instead.
|
||||
---
|
||||
--- @param client The client receiving state updates.
|
||||
--[[
|
||||
Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
from specific keys, use `unsubscribeClientFrom` instead.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
]]
|
||||
local function removeClient(client: any)
|
||||
clients[client] = nil
|
||||
end
|
||||
|
||||
--- Unsubscribes a client from receiving updates for the given keys.
|
||||
---
|
||||
--- @param client The client receiving state updates.
|
||||
--- @param keys The keys of the state to unsubscribe from.
|
||||
--[[
|
||||
Unsubscribes a client from receiving updates for the given keys.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
@param keys The keys of the state to unsubscribe from.
|
||||
]]
|
||||
local function removeSignalsFromClient(client: any, ...: string)
|
||||
local clientKeys = clients[client]
|
||||
|
||||
|
|
@ -258,9 +313,11 @@ local function removeSignalsFromClient(client: any, ...: string)
|
|||
end
|
||||
end
|
||||
|
||||
--- Immediately sends pending updates to all clients. Normally, updates are
|
||||
--- sent at the interval specified in `config.interval`, but this function
|
||||
--- can be used to force an immediate sync.
|
||||
--[[
|
||||
Immediately sends pending updates to all clients. Normally, updates are
|
||||
sent at the interval specified in `config.interval`, but this function
|
||||
can be used to force an immediate sync.
|
||||
]]
|
||||
local function flush()
|
||||
if not syncCallback then
|
||||
return
|
||||
|
|
@ -287,11 +344,13 @@ local function flush()
|
|||
table.clear(globalUpdates)
|
||||
end
|
||||
|
||||
--- Uses the callback to send state updates to clients that are subscribed
|
||||
--- to state changes. Starts automatically sending updates at the interval
|
||||
--- specified in `config.interval`.
|
||||
---
|
||||
--- @param onSync Called when sending patches to a client.
|
||||
--[[
|
||||
Uses the callback to send state updates to clients that are subscribed
|
||||
to state changes. Starts automatically sending updates at the interval
|
||||
specified in `config.interval`.
|
||||
|
||||
@param onSync Called when sending patches to a client.
|
||||
]]
|
||||
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
|
|
@ -321,8 +380,10 @@ local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
|||
end)
|
||||
end
|
||||
|
||||
--- Stops syncing state updates to clients at the automatic interval.
|
||||
--- Flushing can still be performed manually via `flush`.
|
||||
--[[
|
||||
Stops syncing state updates to clients at the automatic interval.
|
||||
Flushing can still be performed manually via `flush`.
|
||||
]]
|
||||
local function disconnect()
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
|
|
@ -330,10 +391,12 @@ local function disconnect()
|
|||
end
|
||||
end
|
||||
|
||||
--- Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
--- from the automatic sync interval.
|
||||
--[[
|
||||
Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
from the automatic sync interval.
|
||||
]]
|
||||
local function reset()
|
||||
table.clear(trackedGetters)
|
||||
table.clear(trackedStates)
|
||||
table.clear(clients)
|
||||
table.clear(globalUpdates)
|
||||
disconnect()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
--- A payload that can be sent from the server to the client to synchronize
|
||||
--- the states of the client's atoms with the server.
|
||||
--[[
|
||||
A payload that can be sent from the server to the client to synchronize
|
||||
the states of the client's atoms with the server.
|
||||
]]
|
||||
export type SyncPayload =
|
||||
| {
|
||||
type: "init",
|
||||
|
|
@ -10,8 +12,10 @@ export type SyncPayload =
|
|||
data: { [string]: any },
|
||||
}
|
||||
|
||||
--- A special type that represents a removed value in a patch. This is used to
|
||||
--- differentiate between a value that was set to `nil` and an unchanged value.
|
||||
--[[
|
||||
A special type that represents a removed value in a patch. This is used to
|
||||
differentiate between a value that was set to `nil` and an unchanged value.
|
||||
]]
|
||||
export type None = { __none: "__none" }
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ type FlattenedTable = {
|
|||
[any]: any,
|
||||
}
|
||||
|
||||
--- Flattens a nested table into a single table with slash-separated keys.
|
||||
--- Useful for recursively collecting atoms returned by modules.
|
||||
---
|
||||
--- @param input A nested table.
|
||||
--- @return A flattened table with slash-separated keys.
|
||||
--[[
|
||||
Flattens a nested table into a single table with slash-separated keys.
|
||||
Useful for recursively collecting atoms returned by modules.
|
||||
|
||||
@param input A nested table.
|
||||
@return A flattened table with slash-separated keys.
|
||||
]]
|
||||
local function flatten(input: NestedTable): FlattenedTable
|
||||
local result: FlattenedTable = {}
|
||||
|
||||
|
|
@ -35,20 +37,36 @@ local function flatten(input: NestedTable): FlattenedTable
|
|||
return result
|
||||
end
|
||||
|
||||
--- Converts a signal getter and setter into an atom. This is useful for
|
||||
--- syncing client signals with the server, as the sync system requires both
|
||||
--- the getter and setter to update state.
|
||||
---
|
||||
--- @param getter A function that returns the current value of the signal.
|
||||
--- @param setter A function that sets the value of the signal.
|
||||
--- @return An atom that can either get or set the signal's value.
|
||||
--[[
|
||||
Converts a signal getter and setter into an atom. This is useful for
|
||||
syncing client signals with the server, as the sync system requires both
|
||||
the getter and setter to update state.
|
||||
|
||||
@param getter A function that returns the current value of the signal.
|
||||
@param setter A function that sets the value of the signal.
|
||||
@return An atom that can either get or set the signal's value.
|
||||
]]
|
||||
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
||||
return function(...)
|
||||
return if select("#", ...) == 0 then getter() else setter((...))
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns a deep copy of the given table.
|
||||
]]
|
||||
local function deepCopy<T>(object: T): T
|
||||
local copy: any = table.clone(object :: any)
|
||||
for key, value in copy do
|
||||
if type(value) == "table" then
|
||||
copy[key] = deepCopy(value)
|
||||
end
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
return {
|
||||
flatten = flatten,
|
||||
signalToAtom = signalToAtom,
|
||||
deepCopy = deepCopy,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,25 +25,23 @@ local function isTableUnsafe(object: { [any]: any })
|
|||
return false
|
||||
end
|
||||
|
||||
--- Throws an error if the value is a table that cannot be synced via
|
||||
--- RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||
--- with remote argument limitations, such as sparse arrays or mixed tables.
|
||||
---
|
||||
--- @param value The table value.
|
||||
--- @param key The key of the value.
|
||||
--- @error Throws an error if the value cannot be synced.
|
||||
--[[
|
||||
Emits a warning if the value is a table that cannot be synced via
|
||||
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||
with remote argument limitations, such as sparse arrays or mixed tables.
|
||||
|
||||
@param value The table value.
|
||||
@param key The key of the value.
|
||||
]]
|
||||
local function validate(value: { [any]: any })
|
||||
if isTableUnsafe(value) then
|
||||
-- Silence warnings during testing
|
||||
if game then
|
||||
warn("Cannot sync table:", value)
|
||||
end
|
||||
error(
|
||||
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
|
||||
warn(
|
||||
`Cannot sync tables unsupported by remote events! See the table below for more info.\n\n`
|
||||
.. "This can be for the following reasons:\n"
|
||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
||||
value
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue