refactor!: rename derive, map

This commit is contained in:
Richard 2024-05-26 21:31:28 -07:00
parent f2f7a16599
commit b715b81fec
18 changed files with 163 additions and 133 deletions

View file

@ -18,18 +18,15 @@
</div> </div>
**Charm** is a Roblox state management library, inspired by [Jotai](https://jotai.org). Designed to be an alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to bring an atomic and composable approach to your game's state. **Charm** is an atomic state management library inspired by [Jotai](https://jotai.org). Designed to be a unique alternative to [Reflex](https://littensy.github.io/reflex), Charm aims to provide a simple and composable interface for your game's state.
> [!NOTE] > [!NOTE]
> Charm is incomplete and not ready for use. > Charm is incomplete and not ready for production use.
## 📦 Setup ## 📦 Setup
### TypeScript ### TypeScript
Install Charm through your package manager of choice. \
[React](https://github.com/littensy/rbxts-react) is optional, but highly recommended.
```sh ```sh
npm install @rbxts/charm npm install @rbxts/charm
yarn add @rbxts/charm yarn add @rbxts/charm
@ -49,17 +46,17 @@ Charm = "littensy/charm@VERSION"
### Charm ### Charm
#### `atom(state)` #### `atom(state, options?)`
#### `effect(callback)` #### `effect(callback)`
#### `subscribe(atom, listener)` #### `subscribe(atom, listener)`
#### `derive(atom, equals?)` #### `computed(atom, options?)`
#### `observe(atom, observer)` #### `observe(atom, observer)`
#### `map(atom, mapper)` #### `mapped(atom, mapper)`
#### `peek(atom)` #### `peek(atom)`

View file

@ -1,5 +1,5 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive) local computed = require(script.Parent.Parent.computed)
local effect = require(script.Parent.Parent.effect) local effect = require(script.Parent.Parent.effect)
local store = require(script.Parent.Parent.store) local store = require(script.Parent.Parent.store)
local subscribe = require(script.Parent.Parent.subscribe) local subscribe = require(script.Parent.Parent.subscribe)
@ -37,15 +37,15 @@ return function()
expect(value).to.equal(1) expect(value).to.equal(1)
end) end)
it("batches derived atoms", function() it("batches computed atoms", function()
local source = atom(1) local source = atom(1)
local updates = 0 local updates = 0
local computed = 0 local computations = 0
local double = derive(function() local double = computed(function()
computed += 1 computations += 1
return source() * 2 return source() * 2
end) end)
computed = 0 -- derived atoms will run once initially computations = 0 -- computed atoms will run once initially
subscribe(double, function() subscribe(double, function()
updates += 1 updates += 1
end) end)
@ -55,7 +55,7 @@ return function()
source(4) source(4)
end) end)
expect(updates).to.equal(1) expect(updates).to.equal(1)
expect(computed).to.equal(1) expect(computations).to.equal(1)
expect(double()).to.equal(8) expect(double()).to.equal(8)
end) end)
end end

View file

@ -4,7 +4,7 @@
local Package = game:GetService("ReplicatedStorage").Packages.charm local Package = game:GetService("ReplicatedStorage").Packages.charm
local atom = require(Package.atom) local atom = require(Package.atom)
local derive = require(Package.derive) local computed = require(Package.computed)
local function parameterGenerator() local function parameterGenerator()
return atom({ count = 0 }) return atom({ count = 0 })
@ -17,8 +17,8 @@ local benchmarks = {
end end
end, end,
derived = function(_, source: any) computed = function(_, source: any)
derive(function() computed(function()
return { count = 2 * source().count } return { count = 2 * source().count }
end) end)
for _ = 1, 500 do for _ = 1, 500 do
@ -26,11 +26,11 @@ local benchmarks = {
end end
end, end,
derived_nested = function(_, source: any) computed_nested = function(_, source: any)
local double = derive(function() local double = computed(function()
return { count = 2 * source().count } return { count = 2 * source().count }
end) end)
derive(function() computed(function()
return { count = 2 * double().count } return { count = 2 * double().count }
end) end)
for _ = 1, 500 do for _ = 1, 500 do

View file

@ -1,5 +1,5 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive) local computed = require(script.Parent.Parent.computed)
local store = require(script.Parent.Parent.store) local store = require(script.Parent.Parent.store)
local collect = require(script.Parent.Parent.utils.collect) local collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count) local count = require(script.Parent.Parent.utils.count)
@ -9,7 +9,7 @@ return function()
local calls = 0 local calls = 0
local a = atom(1) local a = atom(1)
local b = atom(2) local b = atom(2)
local double = derive(function() local double = computed(function()
calls += 1 calls += 1
return a() * b() return a() * b()
end) end)
@ -26,10 +26,10 @@ return function()
it("can be nested", function() it("can be nested", function()
local source = atom(1) local source = atom(1)
local double = derive(function() local double = computed(function()
return source() * 2 return source() * 2
end) end)
local quadruple = derive(function() local quadruple = computed(function()
return double() * 2 return double() * 2
end) end)
expect(quadruple()).to.equal(4) expect(quadruple()).to.equal(4)
@ -37,34 +37,35 @@ return function()
expect(quadruple()).to.equal(8) expect(quadruple()).to.equal(8)
end) end)
it("collects derived atoms", function() it("collects computed atoms", function()
local source = atom(0) local source = atom(0)
derive(function() computed(function()
return source() return source()
end) end)
collect() collect()
assert(count(store.listeners[source]) == 0, "derived atom did not gc") assert(count(store.listeners[source]) == 0, "computed atom did not gc")
end) end)
it("collects nested derived atoms", function() it("collects nested computed atoms", function()
local source = atom(0) local source = atom(0)
do do
-- Declare this in another scope to ensure no references are kept -- Declare this in another scope to ensure no references exist at the
local derived = derive(function() -- time of garbage collection.
return source() local double = computed(function()
return source() * 2
end) end)
derive(function() computed(function()
return derived() * 2 return double() * 2
end) end)
end end
collect() collect()
assert(count(store.listeners[source]) == 0, "nested derived atom did not gc") assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
end) end)
end end

View file

@ -1,12 +1,12 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local map = require(script.Parent.Parent.map) local mapped = require(script.Parent.Parent.mapped)
local observe = require(script.Parent.Parent.observe) local observe = require(script.Parent.Parent.observe)
local store = require(script.Parent.Parent.store) local store = require(script.Parent.Parent.store)
return function() return function()
it("returns mapped atoms", function() it("returns mapped atoms", function()
local itemsAtom = atom({ "a", "b", "c" }) local itemsAtom = atom({ "a", "b", "c" })
local mappedAtom = map(itemsAtom, function(value, key) local mappedAtom = mapped(itemsAtom, function(value, key)
return string.upper(value), key return string.upper(value), key
end) end)
local updates = 0 local updates = 0
@ -35,7 +35,7 @@ return function()
it("uses key extractor", function() it("uses key extractor", function()
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } }) local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
local mappedAtom = map(itemsAtom, function(item) local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key return item, item.key
end) end)
@ -61,7 +61,7 @@ return function()
{ key = 3, value = "c" }, { key = 3, value = "c" },
}) })
local mappedAtom = map(itemsAtom, function(item) local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key return item, item.key
end) end)

View file

@ -1,5 +1,5 @@
local atom = require(script.Parent.Parent.atom) local atom = require(script.Parent.Parent.atom)
local derive = require(script.Parent.Parent.derive) local computed = require(script.Parent.Parent.computed)
local store = require(script.Parent.Parent.store) local store = require(script.Parent.Parent.store)
local subscribe = require(script.Parent.Parent.subscribe) local subscribe = require(script.Parent.Parent.subscribe)
local collect = require(script.Parent.Parent.utils.collect) local collect = require(script.Parent.Parent.utils.collect)
@ -31,22 +31,22 @@ return function()
local updates = 0 local updates = 0
-- Create a new scope to encapsulate variables with a limited lifetime, like -- Create a new scope to encapsulate variables with a limited lifetime, like
-- 'unsubscribe' and 'derived', to ensure they are garbage collected. -- 'unsubscribe' and 'computed', to ensure they are garbage collected.
-- --
-- The idea here is to test whether subscribe() prevents every derived -- The idea here is to test whether subscribe() prevents every computed
-- atom in a chain from being garbage collected. If any are GCed, then -- atom in a chain from being garbage collected. If any are GCed, then
-- the subscription will stop updating and the test will fail. -- the subscription will stop updating and the test will fail.
do do
local unsubscribe local unsubscribe
do do
local derived = derive(function() local double = computed(function()
return source() return source() * 2
end) end)
unsubscribe = subscribe( unsubscribe = subscribe(
derive(function() computed(function()
return derived() * 2 return double() * 2
end), end),
function() function()
updates += 1 updates += 1
@ -58,13 +58,13 @@ return function()
source(source() + 1) source(source() + 1)
assert(updates == 1, "subscription stopped updating") assert(updates == 1, "subscription stopped updating")
assert(count(store.listeners[source]) == 1, "derived atom was garbage collected") assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
unsubscribe() unsubscribe()
end end
collect() collect()
assert(count(store.listeners[source]) == 0, "derived atom did not collect") assert(count(store.listeners[source]) == 0, "computed atom did not collect")
end) end)
end end

View file

@ -1,5 +1,6 @@
local atom = require(script.Parent.Parent.Parent.atom) local atom = require(script.Parent.Parent.Parent.atom)
local sync = require(script.Parent.Parent.Parent.sync) local sync = require(script.Parent.Parent.Parent.sync)
local patch = require(script.Parent.Parent.Parent.sync.patch)
local types = require(script.Parent.Parent.Parent.types) local types = require(script.Parent.Parent.Parent.types)
return function() return function()
@ -24,7 +25,7 @@ return function()
end) end)
it("sends state patches", function() it("sends state patches", function()
local a = atom({ b = 1, c = 2 }) local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = sync.server({ atoms = { a = a }, interval = -1 }) local server = sync.server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: types.SyncPayload local player: Player, payload: types.SyncPayload
@ -33,13 +34,20 @@ return function()
end) end)
a({ b = 1, c = 5 }) a({ b = 1, c = 5 })
server:flush(target) server:_sendPatch(target)
expect(player).to.equal(target) expect(player).to.equal(target)
expect(payload.type).to.equal("patch") expect(payload.type).to.equal("patch")
expect(payload.data.a.b).to.never.be.ok() expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5) expect(payload.data.a.c).to.equal(5)
a({ b = 1, d = 1 })
server:_sendPatch(target)
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(patch.NONE)
expect(payload.data.a.d).to.equal(1)
disconnect() disconnect()
end) end)
end end

View file

@ -16,7 +16,6 @@ local function atom<T>(state: T, options: AtomOptions<T>?): Atom<T>
return state return state
end end
-- Atoms called in a function should not be a part of the dependency tree
local nextState = peek(..., state) local nextState = peek(..., state)
if state ~= nextState and not (equals and equals(state, nextState)) then if state ~= nextState and not (equals and equals(state, nextState)) then

26
src/computed.luau Normal file
View file

@ -0,0 +1,26 @@
local atom = require(script.Parent.atom)
local store = require(script.Parent.store)
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
type Molecule<T> = types.Molecule<T>
local function computed<T>(molecule: Molecule<T>, options: AtomOptions<T>?): Molecule<T>
local captured, state = store.capture(molecule)
local computedAtom = atom(state, options)
local computedRef = setmetatable({ current = computedAtom }, { __mode = "v" })
local function listener()
if computedRef.current then
computedRef.current(molecule())
end
end
for atom in captured do
store.listeners[atom][listener] = computedAtom
end
return computedAtom
end
return computed

View file

@ -1,26 +0,0 @@
local atom = require(script.Parent.atom)
local store = require(script.Parent.store)
local types = require(script.Parent.types)
type Atom<T> = types.Atom<T>
type AtomOptions<T> = types.AtomOptions<T>
type Molecule<T> = types.Molecule<T>
local function derive<T>(molecule: Molecule<T>, options: AtomOptions<T>?): Molecule<T>
local atoms, state = store.capture(molecule)
local derived = atom(state, options)
local derivedRef = setmetatable({ current = derived }, { __mode = "v" })
local function listener()
if derivedRef.current then
derivedRef.current(molecule())
end
end
for atom in atoms do
store.listeners[atom][listener] = derived
end
return derived
end
return derive

View file

@ -1,18 +1,18 @@
local store = require(script.Parent.store) local store = require(script.Parent.store)
local function effect(molecule: () -> nil): () -> () local function effect(molecule: () -> nil): () -> ()
local atoms = store.capture(molecule) local captured = store.capture(molecule)
local function listener() local function listener()
molecule() molecule()
end end
for atom in atoms do for atom in captured do
store.listeners[atom][listener] = true store.listeners[atom][listener] = true
end end
return function() return function()
for atom in atoms do for atom in captured do
store.listeners[atom][listener] = nil store.listeners[atom][listener] = nil
end end
end end

8
src/index.d.ts vendored
View file

@ -22,7 +22,7 @@ declare namespace Charm {
function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>; function atom<State>(state: State, options?: AtomOptions<State>): Atom<State>;
function derive<State>(molecule: Molecule<State>, options?: AtomOptions<State>): Molecule<State>; function computed<State>(molecule: Molecule<State>, options?: AtomOptions<State>): Molecule<State>;
function subscribe<State>(molecule: Molecule<State>, callback: (state: State, prev: State) => void): Cleanup; function subscribe<State>(molecule: Molecule<State>, callback: (state: State, prev: State) => void): Cleanup;
@ -48,17 +48,17 @@ declare namespace Charm {
factory: (item: Item, key: Key) => Cleanup | void, factory: (item: Item, key: Key) => Cleanup | void,
): Cleanup; ): Cleanup;
function map<V0, K1, V1>( function mapped<V0, K1, V1>(
molecule: Molecule<readonly V0[]>, molecule: Molecule<readonly V0[]>,
mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>, mapper: (value: V0, index: number) => LuaTuple<[value: V1 | undefined, key: K1]>,
): Molecule<ReadonlyMap<K1, V1>>; ): Molecule<ReadonlyMap<K1, V1>>;
function map<V0, V1>( function mapped<V0, V1>(
molecule: Molecule<readonly V0[]>, molecule: Molecule<readonly V0[]>,
mapper: (value: V0, index: number) => V1, mapper: (value: V0, index: number) => V1,
): Molecule<readonly V1[]>; ): Molecule<readonly V1[]>;
function map<K0, V0, K1 = K0, V1 = V0>( function mapped<K0, V0, K1 = K0, V1 = V0>(
molecule: Molecule<AnyMap<K0, V0>>, molecule: Molecule<AnyMap<K0, V0>>,
mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1, mapper: (value: V0, key: K0) => LuaTuple<[value: V1 | undefined, key: K1]> | V1,
): Molecule<ReadonlyMap<K1, V1>>; ): Molecule<ReadonlyMap<K1, V1>>;

View file

@ -1,9 +1,9 @@
_G[script.modules.Promise] = _G[script] _G[script.modules.Promise] = _G[script]
local atom = require(script.atom) local atom = require(script.atom)
local derive = require(script.derive) local computed = require(script.computed)
local effect = require(script.effect) local effect = require(script.effect)
local map = require(script.map) local mapped = require(script.mapped)
local observe = require(script.observe) local observe = require(script.observe)
local peek = require(script.peek) local peek = require(script.peek)
local useAtom = require(script.react.useAtom) local useAtom = require(script.react.useAtom)
@ -18,9 +18,9 @@ export type SyncPayload = types.SyncPayload
return { return {
atom = atom, atom = atom,
derive = derive, computed = computed,
effect = effect, effect = effect,
map = map, mapped = mapped,
observe = observe, observe = observe,
peek = peek, peek = peek,
useAtom = useAtom, useAtom = useAtom,

View file

@ -1,5 +1,6 @@
local atom = require(script.Parent.atom) local atom = require(script.Parent.atom)
local subscribe = require(script.Parent.subscribe) local effect = require(script.Parent.effect)
local peek = require(script.Parent.peek)
local types = require(script.Parent.types) local types = require(script.Parent.types)
type Atom<T> = types.Atom<T> type Atom<T> = types.Atom<T>
type Molecule<T> = types.Molecule<T> type Molecule<T> = types.Molecule<T>
@ -9,18 +10,19 @@ type Map =
& (<K0, V0, V1>(fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Molecule<{ [K0]: V1 }>) & (<K0, V0, V1>(fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> V1?) -> Molecule<{ [K0]: V1 }>)
& (<K0, V0, K1, V1>(fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Molecule<{ [K1]: V1 }>) & (<K0, V0, K1, V1>(fn: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)) -> Molecule<{ [K1]: V1 }>)
local function map<K0, V0, K1, V1>(getItems: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Molecule<{ [K1]: V1 }> local function mapped<K0, V0, K1, V1>(molecule: Molecule<{ [K0]: V0 }>, mapper: (V0, K0) -> (V1?, K1?)): Molecule<{ [K1]: V1 }>
local mappedAtom = atom({}) local mappedAtom = atom({})
local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" }) local mappedAtomRef = setmetatable({ current = mappedAtom }, { __mode = "v" })
local prevMappedItems: { [K1]: V1 } = {} local prevMappedItems: { [K1]: V1 } = {}
local unsubscribe local unsubscribe
local function listener(items: { [K0]: V0 }) unsubscribe = effect(function()
if not mappedAtomRef.current then if not mappedAtomRef.current then
return unsubscribe() return unsubscribe()
end end
local mappedItems = table.clone(mappedAtomRef.current()) local items = molecule()
local mappedItems = table.clone(peek(mappedAtomRef.current))
local mappedKeys = {} local mappedKeys = {}
for key, item in items do for key, item in items do
@ -43,12 +45,9 @@ local function map<K0, V0, K1, V1>(getItems: Molecule<{ [K0]: V0 }>, mapper: (V0
prevMappedItems = mappedItems prevMappedItems = mappedItems
mappedAtom(mappedItems) mappedAtom(mappedItems)
end end)
unsubscribe = subscribe(getItems, listener)
listener(getItems())
return mappedAtom return mappedAtom
end end
return map :: Map return mapped :: Map

View file

@ -3,7 +3,7 @@ local types = require(script.Parent.types)
type Molecule<T> = types.Molecule<T> type Molecule<T> = types.Molecule<T>
local function subscribe<T>(molecule: Molecule<T>, callback: (state: T, prev: T) -> ()): () -> () local function subscribe<T>(molecule: Molecule<T>, callback: (state: T, prev: T) -> ()): () -> ()
local atoms, state = store.capture(molecule) local captured, state = store.capture(molecule)
local function listener() local function listener()
local prevState = state local prevState = state
@ -11,12 +11,12 @@ local function subscribe<T>(molecule: Molecule<T>, callback: (state: T, prev: T)
callback(state, prevState) callback(state, prevState)
end end
for atom in atoms do for atom in captured do
store.listeners[atom][listener] = true store.listeners[atom][listener] = true
end end
return function() return function()
for atom in atoms do for atom in captured do
store.listeners[atom][listener] = nil store.listeners[atom][listener] = nil
end end
end end

View file

@ -15,6 +15,7 @@ local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms local atoms = options.atoms
local self = {} :: ClientSyncer local self = {} :: ClientSyncer
-- Apply the state changes sent by the server.
function self:sync(payload) function self:sync(payload)
for key, state in payload.data do for key, state in payload.data do
if payload.type == "patch" then if payload.type == "patch" then

View file

@ -49,6 +49,7 @@ local function apply(state: any, patches: any): any
end end
return { return {
NONE = NONE,
diff = diff, diff = diff,
apply = apply, apply = apply,
} }

View file

@ -1,5 +1,6 @@
local Players = game:GetService("Players") local Players = game:GetService("Players")
local subscribe = require(script.Parent.Parent.subscribe)
local types = require(script.Parent.Parent.types) local types = require(script.Parent.Parent.types)
type Atom<T> = types.Atom<T> type Atom<T> = types.Atom<T>
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
@ -13,8 +14,8 @@ type ServerOptions = {
type ServerSyncer = { type ServerSyncer = {
connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (), connect: (self: ServerSyncer, callback: (player: Player, payload: SyncPayload) -> ()) -> () -> (),
flush: (self: ServerSyncer, player: Player) -> (),
hydrate: (self: ServerSyncer, player: Player) -> (), hydrate: (self: ServerSyncer, player: Player) -> (),
_sendPatch: (self: ServerSyncer, player: Player) -> (),
} }
local function server(options: ServerOptions): ServerSyncer local function server(options: ServerOptions): ServerSyncer
@ -23,53 +24,76 @@ local function server(options: ServerOptions): ServerSyncer
local self = {} :: ServerSyncer local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> () local sync: (player: Player, payload: SyncPayload) -> ()
local state: {} local state: { [string]: any } = {}
local stateSnapshot = {}
local function getState() local changed = false
local state = {}
for key, atom in atoms do
state[key] = atom()
end
return state
end
-- Start the interval to send state patches over the network.
function self:connect(callback) function self:connect(callback)
local cleanups = {}
sync = callback sync = callback
state = getState()
return setInterval(function() -- Populate the initial state and snapshot for each atom.
local previous = state -- Subscribe to each atom and update the state when it changes.
state = getState() for key, atom in atoms do
cleanups[key] = subscribe(atom, function(current)
state[key] = current
changed = true
end)
if state ~= previous then state[key] = atom()
local diffs = patch.diff(previous, state) stateSnapshot[key] = atom()
end
for _, player in Players:GetPlayers() do local disconnect = setInterval(function()
callback(player, { if not changed then
type = "patch", return
data = diffs, end
})
end local diffs = patch.diff(stateSnapshot, state)
stateSnapshot = table.clone(state)
changed = false
for _, player in Players:GetPlayers() do
callback(player, {
type = "patch",
data = diffs,
})
end end
end, interval) end, interval)
return function()
disconnect()
for _, cleanup in cleanups do
cleanup()
end
end
end end
function self:flush(player: Player) -- Send the initial state to a player when they join the server.
local previous = state
state = getState()
sync(player, {
type = "patch",
data = patch.diff(previous, state),
})
end
function self:hydrate(player) function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()") assert(sync, "connect() must be called before hydrate()")
sync(player, { sync(player, {
type = "init", type = "init",
data = getState(), data = state,
})
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local diffs = patch.diff(stateSnapshot, state)
stateSnapshot = table.clone(state)
changed = false
sync(player, {
type = "patch",
data = diffs,
}) })
end end