mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor: start charm-sync rewrite
This commit is contained in:
parent
4a07297a13
commit
079abaacc1
18 changed files with 468 additions and 560 deletions
|
|
@ -1,3 +1,7 @@
|
||||||
|
if not script then
|
||||||
|
return require("../charm/src")
|
||||||
|
end
|
||||||
|
|
||||||
local Charm = require(script.Parent.Parent.charm.src)
|
local Charm = require(script.Parent.Parent.charm.src)
|
||||||
|
|
||||||
export type Atom<T = any> = Charm.Atom<T>
|
export type Atom<T = any> = Charm.Atom<T>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
local Charm = require("../../Charm")
|
local Charm = require("../../Charm")
|
||||||
local CharmSync = require("../")
|
local client = require("../client")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = Charm.atom
|
local atom = Charm.atom
|
||||||
local subscribe = Charm.subscribe
|
local subscribe = Charm.subscribe
|
||||||
local client = CharmSync.client
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
client.clearAtoms()
|
||||||
|
end)
|
||||||
|
|
||||||
test("should sync state payload", function()
|
test("should sync state payload", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local client = client({ atoms = { a = a } })
|
|
||||||
|
|
||||||
client:sync({
|
client.registerAtoms({ a = a })
|
||||||
type = "init",
|
client.applyPayloads({
|
||||||
data = { a = 2 },
|
{ type = "init", data = { a = 2 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == 2, "should set a to the initial value")
|
assert(a() == 2, "should set a to the initial value")
|
||||||
|
|
@ -22,18 +25,16 @@ end)
|
||||||
test("should sync state patch", function()
|
test("should sync state patch", function()
|
||||||
local a = atom({ b = 1, c = 2 })
|
local a = atom({ b = 1, c = 2 })
|
||||||
local previous = a()
|
local previous = a()
|
||||||
local client = client({ atoms = { a = a } })
|
|
||||||
|
|
||||||
client:sync({
|
client.registerAtoms({ a = a })
|
||||||
type = "init",
|
client.applyPayloads({
|
||||||
data = { a = a() },
|
{ type = "init", data = { a = a() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == previous, "should set a to the initial value")
|
assert(a() == previous, "should set a to the initial value")
|
||||||
|
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "patch",
|
{ type = "patch", data = { a = { b = 3 } } },
|
||||||
data = { a = { b = 3 } },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() ~= previous, "should apply patch")
|
assert(a() ~= previous, "should apply patch")
|
||||||
|
|
@ -43,19 +44,17 @@ end)
|
||||||
|
|
||||||
test("should fix sparse array patches", function()
|
test("should fix sparse array patches", function()
|
||||||
local a = atom({ 1 })
|
local a = atom({ 1 })
|
||||||
local client = client({ atoms = { a = a } })
|
|
||||||
|
|
||||||
client:sync({
|
client.registerAtoms({ a = a })
|
||||||
type = "init",
|
client.applyPayloads({
|
||||||
data = { a = a() },
|
{ type = "init", data = { a = a() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
|
-- 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
|
-- was JSON-serialized and deserialized. The client should be able to catch
|
||||||
-- this and convert the string key back to a number.
|
-- this and convert the string key back to a number.
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "patch",
|
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
||||||
data = { a = { ["2"] = 2 } },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
|
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
|
||||||
|
|
@ -64,24 +63,21 @@ end)
|
||||||
|
|
||||||
test("should apply multiple patches", function()
|
test("should apply multiple patches", function()
|
||||||
local a = atom(1)
|
local a = atom(1)
|
||||||
local client = client({ atoms = { a = a } })
|
|
||||||
local calls = {}
|
local calls = {}
|
||||||
|
|
||||||
|
client.registerAtoms({ a = a })
|
||||||
|
|
||||||
subscribe(a, function(value)
|
subscribe(a, function(value)
|
||||||
table.insert(calls, value)
|
table.insert(calls, value)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "init",
|
{ type = "init", data = { a = a() } },
|
||||||
data = { a = a() },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "patch",
|
{ type = "patch", data = { a = 2 } },
|
||||||
data = { a = 2 },
|
{ type = "patch", data = { a = 3 } },
|
||||||
}, {
|
|
||||||
type = "patch",
|
|
||||||
data = { a = 3 },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == 3, "should apply the last patch")
|
assert(a() == 3, "should apply the last patch")
|
||||||
|
|
@ -91,9 +87,10 @@ end)
|
||||||
test("should batch patches", function()
|
test("should batch patches", function()
|
||||||
local a = atom(0)
|
local a = atom(0)
|
||||||
local b = atom(0)
|
local b = atom(0)
|
||||||
local client = client({ atoms = { a = a, b = b } })
|
|
||||||
local states = {}
|
local states = {}
|
||||||
|
|
||||||
|
client.registerAtoms({ a = a, b = b })
|
||||||
|
|
||||||
subscribe(a, function()
|
subscribe(a, function()
|
||||||
states.b = b()
|
states.b = b()
|
||||||
end)
|
end)
|
||||||
|
|
@ -102,14 +99,12 @@ test("should batch patches", function()
|
||||||
states.a = a()
|
states.a = a()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "init",
|
{ type = "init", data = { a = a(), b = b() } },
|
||||||
data = { a = a(), b = b() },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
client:sync({
|
client.applyPayloads({
|
||||||
type = "patch",
|
{ type = "patch", data = { a = 1, b = 2 } },
|
||||||
data = { a = 1, b = 2 },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == 1, "should set a to the new value")
|
assert(a() == 1, "should set a to the new value")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
local Charm = require("../../Charm")
|
local Charm = require("../../Charm")
|
||||||
local CharmSync = require("../")
|
local flatten = require("../flatten")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = Charm.atom
|
local atom = Charm.atom
|
||||||
local flatten = CharmSync.flatten
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should flatten atoms", function()
|
test("should flatten atoms", function()
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
local HttpService = game:GetService("HttpService")
|
local config = require("../config")
|
||||||
|
local patch = require("../patch")
|
||||||
local CharmSync = require("../")
|
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local patch = CharmSync.patch
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
local function serdes(value: { [any]: any })
|
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||||
return HttpService:JSONDecode(HttpService:JSONEncode(value))
|
if object[1] then
|
||||||
|
local copy = {}
|
||||||
|
for index = 1, #object do
|
||||||
|
copy[index] = object[index]
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
elseif table.maxn(object) ~= #object then
|
||||||
|
local copy = {}
|
||||||
|
for key, value in object do
|
||||||
|
copy[tostring(key)] = value
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
else
|
||||||
|
return object
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
afterEach(function()
|
||||||
|
config.autoSerialize = true
|
||||||
|
config.validatePatches = true
|
||||||
|
end)
|
||||||
|
|
||||||
test("returns a patch", function()
|
test("returns a patch", function()
|
||||||
local prevState = { foo = "bar", baz = "qux" }
|
local prevState = { foo = "bar", baz = "qux" }
|
||||||
local nextState = { foo = "bar", baz = "quux" }
|
local nextState = { foo = "bar", baz = "quux" }
|
||||||
|
|
@ -60,7 +78,7 @@ test("preserves array density after serdes", function()
|
||||||
local prevState = { "a", "b", "c", "d", "e" }
|
local prevState = { "a", "b", "c", "d", "e" }
|
||||||
local nextState = { "A", "b", "c", "D" }
|
local nextState = { "A", "b", "c", "D" }
|
||||||
|
|
||||||
local patches = serdes(patch.diff(prevState, nextState))
|
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
local applied = patch.apply(prevState, patches)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
for index in next, prevState do
|
for index in next, prevState do
|
||||||
|
|
@ -72,27 +90,11 @@ test("preserves array density after serdes", function()
|
||||||
end
|
end
|
||||||
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()
|
test("cannot preserve sparse arrays", function()
|
||||||
local prevState = {}
|
local prevState = {}
|
||||||
local nextState = { [2] = "foo" }
|
local nextState = { [2] = "foo" }
|
||||||
|
|
||||||
local patches = serdes(patch.diff(prevState, nextState))
|
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
local applied = patch.apply(prevState, patches)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
-- Not enough information is available to determine whether 'patches'
|
-- Not enough information is available to determine whether 'patches'
|
||||||
|
|
@ -102,24 +104,30 @@ test("cannot preserve sparse arrays", function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("optionally fixes sparse arrays", function()
|
test("optionally fixes sparse arrays", function()
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
|
config.autoSerialize = true
|
||||||
|
|
||||||
|
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||||
end
|
end
|
||||||
|
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
|
config.autoSerialize = false
|
||||||
|
|
||||||
|
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||||
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("optionally validates state", function()
|
test("optionally validates state", function()
|
||||||
local success = pcall(function()
|
local success = pcall(function()
|
||||||
patch.diff({}, { [0.1] = true }, true)
|
config.validatePatches = true
|
||||||
|
patch.diff({}, { [0.1] = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(not success, "should not allow non-integer keys")
|
assert(not success, "should not allow non-integer keys")
|
||||||
|
|
||||||
success = pcall(function()
|
success = pcall(function()
|
||||||
patch.diff({}, { [0.1] = true }, false)
|
config.validatePatches = false
|
||||||
|
patch.diff({}, { [0.1] = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(success, "should skip validation if not requested")
|
assert(success, "should skip validation if not requested")
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
local Charm = require("../../Charm")
|
|
||||||
local CharmSync = require("../")
|
|
||||||
local suite = require("../../../../test/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 {}
|
|
||||||
|
|
@ -4,10 +4,6 @@ suite.describe("client", function()
|
||||||
require("./client.spec")
|
require("./client.spec")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
suite.describe("server", function()
|
|
||||||
require("./server.spec")
|
|
||||||
end)
|
|
||||||
|
|
||||||
suite.describe("flatten", function()
|
suite.describe("flatten", function()
|
||||||
require("./flatten.spec")
|
require("./flatten.spec")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
local Charm = require("../../Charm")
|
local Charm = require("../../Charm")
|
||||||
local CharmSync = require("../")
|
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
local validate = require("../validate")
|
||||||
|
|
||||||
local atom = Charm.atom
|
local atom = Charm.atom
|
||||||
local validate = CharmSync.validate
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("validates inputs", function()
|
test("validates inputs", function()
|
||||||
|
|
|
||||||
|
|
@ -2,75 +2,72 @@ local Charm = require("../Charm")
|
||||||
local patch = require("./patch")
|
local patch = require("./patch")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
|
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ClientOptions = {
|
local atomRegister: { [string]: Charm.Atom } = {}
|
||||||
--- 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 = {
|
--- @client
|
||||||
--- Applies a patch or initializes the state of the atoms with the given
|
local function registerAtoms(atoms: { [string]: Charm.Atom })
|
||||||
--- payload from the server.
|
for key, atom in atoms do
|
||||||
---
|
if atomRegister[key] and atomRegister[key] ~= atom then
|
||||||
--- @param ...payloads The patches or hydration payloads to apply.
|
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
||||||
sync: (self: ClientSyncer, ...SyncPayload) -> (),
|
continue
|
||||||
}
|
end
|
||||||
|
|
||||||
--- Creates a `ClientSyncer` object that receives patches from the server and
|
atomRegister[key] = atom
|
||||||
--- applies them to the local state.
|
end
|
||||||
---
|
end
|
||||||
--- @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
|
--- @client
|
||||||
local hydrated = false
|
local function clearAtoms()
|
||||||
|
table.clear(atomRegister)
|
||||||
|
end
|
||||||
|
|
||||||
local function hydrate(state: { [string | number]: any })
|
--- @client
|
||||||
hydrated = true
|
local function unregisterAtoms(...: string)
|
||||||
|
for index = 1, select("#", ...) do
|
||||||
|
local key = select(index, ...)
|
||||||
|
atomRegister[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
for key, value in state do
|
local function applyInitPayload(data: { [string]: any })
|
||||||
local atom = atoms[key]
|
for key, value in data do
|
||||||
|
local atom = atomRegister[key]
|
||||||
|
if atom then
|
||||||
atom(value)
|
atom(value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function apply(data: { [string | number]: any })
|
|
||||||
local target = {}
|
|
||||||
|
|
||||||
for key, atom in atoms do
|
|
||||||
target[key] = atom()
|
|
||||||
end
|
|
||||||
|
|
||||||
target = patch.apply(target, data)
|
|
||||||
|
|
||||||
for key, atom in 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
|
end
|
||||||
|
|
||||||
return client
|
local function applyPatchPayload(data: { [string | number]: any })
|
||||||
|
local target = {}
|
||||||
|
|
||||||
|
for key, atom in atomRegister do
|
||||||
|
target[key] = atom()
|
||||||
|
end
|
||||||
|
|
||||||
|
target = patch.apply(target, data)
|
||||||
|
|
||||||
|
for key, atom in atomRegister do
|
||||||
|
atom(target[key])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @client
|
||||||
|
local function applyPayloads(payloads: { SyncPayload })
|
||||||
|
for _, payload in payloads do
|
||||||
|
if payload.type == "init" then
|
||||||
|
Charm.batched(applyInitPayload, payload.data)
|
||||||
|
elseif payload.type == "patch" then
|
||||||
|
Charm.batched(applyPatchPayload, payload.data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerAtoms = registerAtoms,
|
||||||
|
clearAtoms = clearAtoms,
|
||||||
|
unregisterAtoms = unregisterAtoms,
|
||||||
|
applyPayloads = applyPayloads,
|
||||||
|
}
|
||||||
|
|
|
||||||
20
packages/charm-sync/src/config.luau
Normal file
20
packages/charm-sync/src/config.luau
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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.
|
||||||
|
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`.
|
||||||
|
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.
|
||||||
|
autoSerialize = true,
|
||||||
|
--- When `true`, patches will be validated before being sent to clients to
|
||||||
|
--- enforce remote event limitations. Only checked in strict mode and if
|
||||||
|
--- `autoSerialize` is enabled. Defaults to `true`.
|
||||||
|
validatePatches = true,
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
local types = require("./types")
|
|
||||||
|
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
|
|
||||||
type NestedAtomMap = {
|
type NestedAtomMap = {
|
||||||
[string]: NestedAtomMap | () -> (),
|
[string]: NestedAtomMap | () -> any,
|
||||||
}
|
}
|
||||||
|
|
||||||
local function flatten(atoms: NestedAtomMap): AtomMap
|
local function flatten(atoms: NestedAtomMap): { [string]: () -> any }
|
||||||
local result: AtomMap = {}
|
local result: { [string]: () -> any } = {}
|
||||||
|
|
||||||
local function visit(node: NestedAtomMap, path: string)
|
local function visit(node: NestedAtomMap, path: string)
|
||||||
for key, value in node do
|
for key, value in node do
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
local client = require("@self/client")
|
local client = require("@self/client")
|
||||||
|
local config = require("@self/config")
|
||||||
local flatten = require("@self/flatten")
|
local flatten = require("@self/flatten")
|
||||||
local patch = require("@self/patch")
|
local patch = require("@self/patch")
|
||||||
local server = require("@self/server")
|
local server = require("@self/server")
|
||||||
local types = require("@self/types")
|
local types = require("@self/types")
|
||||||
local validate = require("@self/validate")
|
|
||||||
|
|
||||||
|
export type None = types.None
|
||||||
export type SyncPayload = types.SyncPayload
|
export type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
config = config,
|
||||||
client = client,
|
client = client,
|
||||||
server = server,
|
server = server,
|
||||||
flatten = flatten,
|
|
||||||
validate = validate,
|
|
||||||
patch = patch,
|
patch = patch,
|
||||||
isNone = patch.isNone,
|
isNone = patch.isNone,
|
||||||
|
flatten = flatten,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
--- 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 = game:GetService("RunService").Heartbeat:Connect(function(delta)
|
|
||||||
clock += delta
|
|
||||||
if clock >= seconds then
|
|
||||||
clock = 0
|
|
||||||
callback()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
connection:Disconnect()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return interval
|
|
||||||
|
|
@ -1,16 +1,28 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../Charm")
|
||||||
|
local config = require("./config")
|
||||||
|
local types = require("./types")
|
||||||
local validate = require("./validate")
|
local validate = require("./validate")
|
||||||
|
|
||||||
local flags = Charm.flags
|
local flags = Charm.flags
|
||||||
|
|
||||||
|
export type None = types.None
|
||||||
|
|
||||||
--- A special symbol that denotes the absence of a value. Used to represent
|
--- A special symbol that denotes the absence of a value. Used to represent
|
||||||
--- deleted values in patches.
|
--- deleted values in patches.
|
||||||
local None = { __none = "__none" }
|
local None: None = { __none = "__none" }
|
||||||
|
|
||||||
|
--- Checks if a value is the special `None` symbol.
|
||||||
local function isNone(value: any): boolean
|
local function isNone(value: any): boolean
|
||||||
return type(value) == "table" and value.__none == "__none"
|
return type(value) == "table" and value.__none == "__none"
|
||||||
end
|
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.
|
||||||
|
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,
|
--- 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
|
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
|
||||||
--- serialization of sparse arrays can be resolved by converting them to
|
--- serialization of sparse arrays can be resolved by converting them to
|
||||||
|
|
@ -29,7 +41,7 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
||||||
|
|
||||||
local dictionary = {}
|
local dictionary = {}
|
||||||
|
|
||||||
for index, value in next, object do
|
for index, value in object do
|
||||||
dictionary[tostring(index)] = value
|
dictionary[tostring(index)] = value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -41,14 +53,11 @@ end
|
||||||
---
|
---
|
||||||
--- @param prevState The previous state.
|
--- @param prevState The previous state.
|
||||||
--- @param nextState The next 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.
|
--- @returns A patch that can be applied to the previous state.
|
||||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
|
local function diff(prevState: { [any]: any }, nextState: { [any]: any })
|
||||||
serialize = serialize ~= false
|
|
||||||
|
|
||||||
local patches = table.clone(nextState)
|
local patches = table.clone(nextState)
|
||||||
|
|
||||||
for key, previous in next, prevState do
|
for key, previous in prevState do
|
||||||
local current = nextState[key]
|
local current = nextState[key]
|
||||||
|
|
||||||
if previous == current then
|
if previous == current then
|
||||||
|
|
@ -56,18 +65,20 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial
|
||||||
elseif current == nil then
|
elseif current == nil then
|
||||||
patches[key] = None
|
patches[key] = None
|
||||||
elseif type(previous) == "table" and type(current) == "table" then
|
elseif type(previous) == "table" and type(current) == "table" then
|
||||||
patches[key] = diff(previous, current, serialize)
|
patches[key] = diff(previous, current)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Coerce sparse array patches into dictionaries
|
if config.autoSerialize then
|
||||||
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
|
-- Coerce sparse array patches into dictionaries
|
||||||
patches = stringifySparseArray(patches)
|
if prevState[1] ~= nil or nextState[1] ~= nil then
|
||||||
end
|
patches = stringifySparseArray(patches)
|
||||||
|
end
|
||||||
|
|
||||||
if serialize and flags.strict then
|
if flags.strict and config.validatePatches then
|
||||||
for key, value in next, patches do
|
for key, value in patches do
|
||||||
validate(value, key)
|
validate(value, key)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -80,7 +91,7 @@ end
|
||||||
--- @param patches The patches to apply.
|
--- @param patches The patches to apply.
|
||||||
--- @returns The next state.
|
--- @returns The next state.
|
||||||
local function apply(state: any, patches: any): any
|
local function apply(state: any, patches: any): any
|
||||||
if type(patches) == "table" and patches.__none == "__none" then
|
if isNone(patches) then
|
||||||
return nil
|
return nil
|
||||||
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
||||||
return patches
|
return patches
|
||||||
|
|
@ -89,7 +100,7 @@ local function apply(state: any, patches: any): any
|
||||||
local nextState = table.clone(state)
|
local nextState = table.clone(state)
|
||||||
local stateIsArray = state[1] ~= nil
|
local stateIsArray = state[1] ~= nil
|
||||||
|
|
||||||
for key, patch in next, patches do
|
for key, patch in patches do
|
||||||
-- Diff-checking an array produces a sparse array, which will not be
|
-- Diff-checking an array produces a sparse array, which will not be
|
||||||
-- preserved when converted to JSON. To prevent this, we turn string
|
-- preserved when converted to JSON. To prevent this, we turn string
|
||||||
-- keys back into numeric keys.
|
-- keys back into numeric keys.
|
||||||
|
|
@ -105,6 +116,7 @@ end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isNone = isNone,
|
isNone = isNone,
|
||||||
|
nilToNone = nilToNone,
|
||||||
diff = diff,
|
diff = diff,
|
||||||
apply = apply,
|
apply = apply,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,250 +1,265 @@
|
||||||
local Charm = require("../Charm")
|
local Charm = require("../Charm")
|
||||||
local interval = require("./interval")
|
local config = require("./config")
|
||||||
local patch = require("./patch")
|
local patch = require("./patch")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
|
|
||||||
type AtomMap = types.AtomMap
|
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
type ServerOptions = {
|
type RegisteredAtom<T = any> = {
|
||||||
--- The atoms to synchronize with the client.
|
atom: () -> T,
|
||||||
atoms: AtomMap,
|
value: T,
|
||||||
--- The interval at which to send patches to the client, in seconds.
|
valueLastSynced: T,
|
||||||
--- Defaults to `0` (patches are sent up to once per frame). Set to a
|
stop: () -> (),
|
||||||
--- 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 = {
|
type SyncStatus = number
|
||||||
--- 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
|
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||||
--- hydrates the client's state.
|
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||||
---
|
|
||||||
--- @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 atomRegister: { [string]: RegisteredAtom } = {}
|
||||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
local clientRegister: { [any]: { [string]: SyncStatus } } = {}
|
||||||
|
local globalUpdates: { { key: string, value: any, previousValue: any } } = {}
|
||||||
|
local connection: RBXScriptConnection?
|
||||||
|
|
||||||
local function createSnapshot()
|
local function registerAtom<T>(key: string, atom: () -> T)
|
||||||
local snapshot = {}
|
if atomRegister[key] then
|
||||||
|
if atomRegister[key].atom ~= atom then
|
||||||
for key, atom in atoms do
|
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
||||||
snapshot[key] = atom()
|
|
||||||
end
|
end
|
||||||
|
return
|
||||||
return snapshot
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Send the initial state to a player when they join the server.
|
local registeredAtom: RegisteredAtom<T> = {
|
||||||
function self:hydrate(player)
|
atom = atom,
|
||||||
assert(sync, "connect() must be called before hydrate()")
|
value = atom(),
|
||||||
|
valueLastSynced = atom(),
|
||||||
|
stop = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
sync(player, {
|
registeredAtom.stop = Charm.effect(function()
|
||||||
type = "init",
|
local value = atom()
|
||||||
data = createSnapshot(),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
-- If the history of state changes should be preserved, keep a list of
|
if config.preserveHistory and registeredAtom.value ~= value then
|
||||||
-- previous snapshots and diff-check each one from the last.
|
table.insert(globalUpdates, {
|
||||||
if preserveHistory then
|
key = key,
|
||||||
local snapshots = { createSnapshot() }
|
value = value,
|
||||||
local changed = false
|
previousValue = registeredAtom.value,
|
||||||
|
})
|
||||||
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 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 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 game:GetService("Players"):GetPlayers() do
|
|
||||||
callback(player, unpack(payloads))
|
|
||||||
end
|
|
||||||
end, syncRate)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
disconnect()
|
|
||||||
|
|
||||||
for _, unsubscribe in subscriptions do
|
|
||||||
unsubscribe()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function self:_sendPatch(player: Player)
|
registeredAtom.value = value
|
||||||
assert(sync, "connect() must be called before _sendPatch()")
|
end)
|
||||||
|
|
||||||
if not changed then
|
atomRegister[key] = registeredAtom
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local payloads: { SyncPayload } = {}
|
|
||||||
|
|
||||||
for index, snapshot in 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 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 game:GetService("Players"):GetPlayers() do
|
|
||||||
callback(player, payload)
|
|
||||||
end
|
|
||||||
end, syncRate)
|
|
||||||
|
|
||||||
return function()
|
|
||||||
disconnect()
|
|
||||||
|
|
||||||
for _, unsubscribe in 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
|
end
|
||||||
|
|
||||||
return server
|
local function unregisterAtom(key: string)
|
||||||
|
local registeredAtom = atomRegister[key]
|
||||||
|
|
||||||
|
if registeredAtom then
|
||||||
|
registeredAtom.stop()
|
||||||
|
atomRegister[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unregisterUnusedAtoms()
|
||||||
|
local used: { [string]: boolean } = {}
|
||||||
|
|
||||||
|
for _, clientAtoms in clientRegister do
|
||||||
|
for key in clientAtoms do
|
||||||
|
used[key] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for key in atomRegister do
|
||||||
|
if not used[key] then
|
||||||
|
unregisterAtom(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createHistoricalPatches(): { any }
|
||||||
|
local patchCount = #globalUpdates
|
||||||
|
local patches: { any } = table.create(patchCount)
|
||||||
|
|
||||||
|
for index, update in globalUpdates do
|
||||||
|
patches[index] = patch.diff(update.previousValue, update.value)
|
||||||
|
end
|
||||||
|
|
||||||
|
return patches
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload }
|
||||||
|
local syncedAtoms = clientRegister[client]
|
||||||
|
|
||||||
|
if not syncedAtoms then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local payloads: { SyncPayload } = {}
|
||||||
|
|
||||||
|
for index, update in globalUpdates do
|
||||||
|
local patchData = patches[index]
|
||||||
|
|
||||||
|
if not patchData then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local key = update.key
|
||||||
|
local status = syncedAtoms[key]
|
||||||
|
|
||||||
|
if status == LISTENING_FOR_CHANGES then
|
||||||
|
table.insert(payloads, {
|
||||||
|
type = "patch",
|
||||||
|
data = { [key] = patchData },
|
||||||
|
})
|
||||||
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
|
table.insert(payloads, {
|
||||||
|
type = "init",
|
||||||
|
data = { [key] = update.value },
|
||||||
|
})
|
||||||
|
|
||||||
|
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return payloads
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createPatch(): any
|
||||||
|
local current: { [string]: any } = {}
|
||||||
|
local previous: { [string]: any } = {}
|
||||||
|
|
||||||
|
for key, registeredAtom in atomRegister do
|
||||||
|
current[key] = registeredAtom.value
|
||||||
|
previous[key] = registeredAtom.valueLastSynced
|
||||||
|
registeredAtom.valueLastSynced = registeredAtom.value
|
||||||
|
end
|
||||||
|
|
||||||
|
return patch.diff(previous, current)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createPayloadsForClient(client: any, patchData: any): { SyncPayload }
|
||||||
|
local syncedAtoms = clientRegister[client]
|
||||||
|
local payloads: { SyncPayload } = {}
|
||||||
|
|
||||||
|
if not syncedAtoms then
|
||||||
|
return payloads
|
||||||
|
end
|
||||||
|
|
||||||
|
local shouldSendInitialState = false
|
||||||
|
local initPayload: { [string]: any } = {}
|
||||||
|
local patchPayload: { [string]: any } = {}
|
||||||
|
|
||||||
|
for key, status in syncedAtoms do
|
||||||
|
local registeredAtom = atomRegister[key]
|
||||||
|
|
||||||
|
if status == LISTENING_FOR_CHANGES then
|
||||||
|
patchPayload[key] = patchData[key]
|
||||||
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
|
initPayload[key] = registeredAtom.value
|
||||||
|
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
|
shouldSendInitialState = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if shouldSendInitialState then
|
||||||
|
table.insert(payloads, { type = "init", data = initPayload })
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(patchPayload) ~= nil then
|
||||||
|
table.insert(payloads, { type = "patch", data = patchPayload })
|
||||||
|
end
|
||||||
|
|
||||||
|
return payloads
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @server
|
||||||
|
local function syncAtomsToClient(client: any, atoms: { [string]: () -> any })
|
||||||
|
if not clientRegister[client] then
|
||||||
|
clientRegister[client] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local syncedAtoms = clientRegister[client]
|
||||||
|
|
||||||
|
for key, atom in atoms do
|
||||||
|
registerAtom(key, atom)
|
||||||
|
|
||||||
|
if not syncedAtoms[key] then
|
||||||
|
syncedAtoms[key] = PENDING_INITIAL_STATE
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @server
|
||||||
|
local function unsyncClient(client: any)
|
||||||
|
clientRegister[client] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @server
|
||||||
|
local function unsyncAtomsFromClient(client: any, ...: string)
|
||||||
|
local syncedAtoms = clientRegister[client]
|
||||||
|
|
||||||
|
if not syncedAtoms then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, select("#", ...) do
|
||||||
|
local key = select(index, ...)
|
||||||
|
syncedAtoms[key] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if next(syncedAtoms) == nil then
|
||||||
|
clientRegister[client] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @server
|
||||||
|
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
|
if connection then
|
||||||
|
connection:Disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
|
local elapsedTime = 0
|
||||||
|
|
||||||
|
local function sync()
|
||||||
|
local patches = if config.preserveHistory then createHistoricalPatches() else createPatch()
|
||||||
|
|
||||||
|
if Charm.flags.frozen then
|
||||||
|
Charm.deepFreeze(patches)
|
||||||
|
end
|
||||||
|
|
||||||
|
for client in clientRegister do
|
||||||
|
local payloads: { SyncPayload } = if config.preserveHistory
|
||||||
|
then createHistoricalPayloadsForClient(client, patches)
|
||||||
|
else createPayloadsForClient(client, patches)
|
||||||
|
|
||||||
|
if payloads[1] then
|
||||||
|
task.spawn(onSync, client, payloads)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unregisterUnusedAtoms()
|
||||||
|
table.clear(globalUpdates)
|
||||||
|
end
|
||||||
|
|
||||||
|
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number)
|
||||||
|
elapsedTime += deltaTime
|
||||||
|
|
||||||
|
if elapsedTime >= config.interval then
|
||||||
|
elapsedTime %= config.interval
|
||||||
|
|
||||||
|
debug.profilebegin("CharmSync")
|
||||||
|
sync()
|
||||||
|
debug.profileend()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncAtomsToClient = syncAtomsToClient,
|
||||||
|
unsyncClient = unsyncClient,
|
||||||
|
unsyncAtomsFromClient = unsyncAtomsFromClient,
|
||||||
|
startSync = startSync,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
local Charm = require("../Charm")
|
|
||||||
|
|
||||||
type Atom<T> = Charm.Atom<T>
|
|
||||||
|
|
||||||
--- A payload that can be sent from the server to the client to synchronize
|
--- A payload that can be sent from the server to the client to synchronize
|
||||||
--- state between the two.
|
--- the states of the client's atoms with the server.
|
||||||
export type SyncPayload =
|
export type SyncPayload =
|
||||||
| {
|
| {
|
||||||
type: "init",
|
type: "init",
|
||||||
data: { [string | number]: any },
|
data: { [string]: any },
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "patch",
|
type: "patch",
|
||||||
data: { [string | number]: any },
|
data: { [string]: any },
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AtomMap = {
|
--- A special type that represents a removed value in a patch. This is used to
|
||||||
[string | number]: Atom<any>,
|
--- differentiate between a value that was set to `nil` and an unchanged value.
|
||||||
}
|
export type None = { __none: "__none" }
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,26 @@ local SAFE_KEYS = { string = true, number = true }
|
||||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
||||||
|
|
||||||
local function isTableUnsafe(object: { [any]: any })
|
local function isTableUnsafe(object: { [any]: any })
|
||||||
local keyType = nil
|
local key = next(object)
|
||||||
local objectSize = 0
|
|
||||||
|
|
||||||
-- All keys must have the same type
|
if key == nil then
|
||||||
for key in next, object do
|
-- An empty table is always safe
|
||||||
local currentType = type(key)
|
return false
|
||||||
|
|
||||||
if not keyType and SAFE_KEYS[currentType] then
|
|
||||||
keyType = currentType
|
|
||||||
elseif keyType ~= currentType then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
objectSize += 1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- If there are more keys than the length of the array, it's an array with
|
local keyType = type(key)
|
||||||
-- non-sequential keys.
|
local keyCount = 0
|
||||||
if objectSize > #object and keyType == "number" then
|
|
||||||
|
-- Keys must either be all strings or all numbers
|
||||||
|
for key in object do
|
||||||
|
if type(key) ~= keyType then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
keyCount += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Array keys must be sequential
|
||||||
|
if keyType == "number" and keyCount > #object then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -33,19 +34,21 @@ end
|
||||||
--- @param key The key of the value in the table.
|
--- @param key The key of the value in the table.
|
||||||
--- @error Throws an error if the value cannot be synced.
|
--- @error Throws an error if the value cannot be synced.
|
||||||
local function validate(value: any, key: any)
|
local function validate(value: any, key: any)
|
||||||
local typeOfKey = type(key)
|
local typeKey = type(key)
|
||||||
local typeOfValue = type(value)
|
local typeValue = type(value)
|
||||||
|
|
||||||
if not SAFE_KEYS[typeOfKey] then
|
if not SAFE_KEYS[typeKey] then
|
||||||
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
|
error(`Invalid key type '{typeKey}' at key '{key}'`)
|
||||||
elseif UNSAFE_VALUES[typeOfValue] then
|
elseif UNSAFE_VALUES[typeValue] then
|
||||||
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
|
error(`Invalid value type '{typeValue}' at key '{key}'`)
|
||||||
elseif typeOfValue == "table" then
|
end
|
||||||
|
|
||||||
|
if typeValue == "table" then
|
||||||
if getmetatable(value) ~= nil then
|
if getmetatable(value) ~= nil then
|
||||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||||
elseif isTableUnsafe(value) then
|
elseif isTableUnsafe(value) then
|
||||||
error(
|
error(
|
||||||
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
|
`Cannot sync tables unsupported by remote events! The table was found at the key '{key}'.\n\n`
|
||||||
.. "This can be for the following reasons:\n"
|
.. "This can be for the following reasons:\n"
|
||||||
.. "1. The object is an array with non-sequential keys\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"
|
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
|
||||||
|
|
@ -54,7 +57,7 @@ local function validate(value: any, key: any)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if typeOfKey == "number" then
|
if typeKey == "number" then
|
||||||
if key == math.huge or key == -math.huge then
|
if key == math.huge or key == -math.huge then
|
||||||
error("Cannot sync infinity as key")
|
error("Cannot sync infinity as key")
|
||||||
elseif key ~= math.floor(key) then
|
elseif key ~= math.floor(key) then
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
--!nolint DeprecatedApi
|
|
||||||
|
|
||||||
local batched = require("@self/batched")
|
local batched = require("@self/batched")
|
||||||
local core = require("@self/core")
|
local core = require("@self/core")
|
||||||
local flags = require("@self/flags")
|
local flags = require("@self/flags")
|
||||||
|
|
@ -9,15 +7,11 @@ local observe = require("@self/observe")
|
||||||
local subscribe = require("@self/subscribe")
|
local subscribe = require("@self/subscribe")
|
||||||
local types = require("@self/types")
|
local types = require("@self/types")
|
||||||
local untracked = require("@self/untracked")
|
local untracked = require("@self/untracked")
|
||||||
|
local utils = require("@self/utils")
|
||||||
|
|
||||||
export type Atom<T = any> = types.Atom<T>
|
export type Atom<T = any> = types.Atom<T>
|
||||||
export type Selector<T = any> = types.Selector<T>
|
export type Selector<T = any> = types.Selector<T>
|
||||||
|
|
||||||
--- @deprecated Function `batch()` is deprecated, use `batched()` instead.
|
|
||||||
local batch = batched
|
|
||||||
--- @deprecated Function `peek()` is deprecated, use `untracked()` instead.
|
|
||||||
local peek = untracked
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
core = core,
|
core = core,
|
||||||
flags = flags,
|
flags = flags,
|
||||||
|
|
@ -32,7 +26,5 @@ return {
|
||||||
observe = observe,
|
observe = observe,
|
||||||
subscribe = subscribe,
|
subscribe = subscribe,
|
||||||
untracked = untracked,
|
untracked = untracked,
|
||||||
-- Temporary aliases for backwards compatibility
|
deepFreeze = utils.deepFreeze,
|
||||||
batch = batch,
|
|
||||||
peek = peek,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ suite.describe("charm", function()
|
||||||
require("../packages/charm/src/__tests__/spec")
|
require("../packages/charm/src/__tests__/spec")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
suite.describe.ingame("charm-sync", function()
|
suite.describe("charm-sync", function()
|
||||||
require("../packages/charm-sync/src/__tests__/spec")
|
require("../packages/charm-sync/src/__tests__/spec")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue