Compare commits

...

8 commits

Author SHA1 Message Date
littensy
af4eb5262d chore: bump pnpm
Some checks failed
CI / analyze (push) Has been cancelled
2026-09-30 19:31:07 -07:00
littensy
1b856b065e release: charm v0.11.1 2026-09-30 19:30:33 -07:00
littensy
aa84fa1e3d fix(charm-sync): warn if attempting to sync non-registered signal on the client 2026-09-30 19:25:45 -07:00
littensy
2fc3be34d0 fix(charm-sync): preserve global patch list order for nil patches
Resolves #67
2026-09-30 19:21:06 -07:00
littensy
c31e5c5a34 fix(charm-sync): run addSignalsToClient outside tracking scope 2026-09-30 19:06:38 -07:00
littensy
73fbfb2685 fix: resolve type errors for new luau version
Some checks are pending
CI / analyze (push) Waiting to run
2026-09-30 18:53:07 -07:00
richard
b05f3a9de6
docs: add missing playerReadyEvent call 2026-06-21 18:54:46 -07:00
richard
fdb506dc23
docs: update readme with new charm-sync details 2026-06-21 18:40:03 -07:00
21 changed files with 347 additions and 72 deletions

128
README.md
View file

@ -623,34 +623,43 @@ CharmSync = "littensy/charm-sync@VERSION"
### Quick Start ### Quick Start
Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals: Start by specifying the signals that the server should sync to clients. For this example, we'll replicate a round status and timer signal, and each player's inventory:
```luau ```luau
-- nameStore -- store.luau
local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe") local getStatus, setStatus = signal("intermission")
local ageAtom = atom(20) local timerAtom = atom(20)
local getInventories, setInventories = signal({} :: { [Username]: Inventory })
return { return {
getName = getName, getStatus = getStatus,
setName = setName, setStatus = setStatus,
getSurname = getSurname, timerAtom = timerAtom,
setSurname = setSurname, getInventories = getInventories,
ageAtom = ageAtom, setInventories = setInventories,
} }
``` ```
#### Server setup
When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates. When a player notifies the server that they're ready to start syncing, call `server.addSignalsToClient` with the signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to: Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to:
```luau ```luau
-- sync.server.luau
playerReadyEvent.OnServerEvent:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
-- Sync signal getters, computed signals, atoms, or reactive proxies -- Sync signal getters, computed signals, atoms, or reactive proxies
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid overwriting other synced signals
[`inventory-{player.UserId}`] = computed(function()
return store.getInventories()[player.Name]
end),
}) })
end) end)
@ -659,30 +668,54 @@ Players.PlayerRemoving:Connect(function(player)
end) end)
server.connect(function(player, updates) server.connect(function(player, updates)
-- Customize how you send state updates to clients -- Customize how you replicate state updates to clients
syncEvent:FireClient(player, updates) syncEvent:FireClient(player, updates)
end) end)
-- Changes now get replicated to clients
store.setStatus("started")
store.timerAtom(30)
``` ```
> [!NOTE] > [!WARNING]
> On the server, make sure each key corresponds to the same data across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning. > On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
>
> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
#### Client setup
To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts. To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts.
After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates. After the client receives updates from the server, call `client.patch` to patch the client's signals with the incoming state updates.
```luau ```luau
-- sync.client.luau
-- The server is replicating a signal containing the client's
-- inventory, so this signal will receive those updates
local getInventory, setInventory = signal({} :: Inventory)
-- Add signal setters, atoms, or reactive proxies -- Add signal setters, atoms, or reactive proxies
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid name collisions
[`inventory-{Players.LocalPlayer.UserId}`] = setInventory,
}) })
-- Update client signals when receiving updates from the server -- Update client signals when receiving updates from the server
syncEvent.OnClientEvent:Connect(function(updates) syncEvent.OnClientEvent:Connect(function(updates)
client.patch(updates) client.patch(updates)
end) end)
-- Notify the server that the client is ready to receive updates
playerReadyEvent:FireServer()
-- The signals will now update to match the server's state
subscribe(getInventory, function(inventory)
print("My inventory is", table.concat(inventory))
end)
``` ```
--- ---
@ -702,38 +735,42 @@ A configuration table that customizes the behavior of Charm Sync on the server.
### `server.addSignalsToClient(client, getters)` ### `server.addSignalsToClient(client, getters)`
The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed. The `addSignalsToClient` function replicates changes in the given signals to a specific client. When an update occurs, the client will receive a delta-compressed state patch containing only the values that changed.
You can pass signal getter functions, computed signals, atoms, and [reactive objects](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals. You can pass signal getter functions, computed signals, atoms, and [reactive proxies](#reactiveinitialvalue) in the `getters` table. This function can also be called multiple times on the same client to subscribe to new signals.
```luau ```luau
playerReadyEvent.OnServerEvent:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
age = nameStore.ageAtom,
}) })
end) end)
``` ```
You're also allowed to create new signals to sync to specific players, as long as the key is unique to that data: You're also allowed to create new signals to sync to specific players, as long as the key is unique to that signal:
```luau ```luau
playerReadyEvent.OnServerEvent:Connect(function(player) playerReadyEvent.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
-- 🟢 Good: Player-specific data is synced with a unique key -- 🟢 Good: Player-specific signals are synced with a unique key
[`data-{player.UserId}`] = computed(function() [`playerData-{player.UserId}`] = computed(function()
return getPlayerData(player.UserId) return clientStore.getPlayerData(player.UserId)
end), end),
-- 🔴 Bad: Syncing different data with the same keys does not work -- 🔴 Bad: Syncing different signals with the same keys does not work
playerData = computed(function() playerData = computed(function()
return getPlayerData(player.UserId) return clientStore.getPlayerData(player.UserId)
end), end),
}) })
end) end)
``` ```
> [!NOTE]
> On the server, make sure each key corresponds to the same signal across all players. In other words, if you're syncing a signal that is unique to a player, ensure that the key is also unique to that player.
>
> If two players subscribe to the same key, but were assigned different signals for that key, Charm will output a warning.
--- ---
### `server.removeSignalsFromClient(client, ...keys)` ### `server.removeSignalsFromClient(client, ...keys)`
@ -742,11 +779,11 @@ The `removeSignalsFromClient` function unsubscribes the client from a list of ke
```luau ```luau
server.addSignalsToClient(player, { server.addSignalsToClient(player, {
name = nameStore.getName, status = store.getStatus,
surname = nameStore.getSurname, timer = store.timerAtom,
}) })
server.removeSignalsFromClient(player, "surname") server.removeSignalsFromClient(player, "status")
``` ```
--- ---
@ -807,9 +844,11 @@ You can pass either writable signals, atoms, or [reactive objects](#reactiveinit
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
age = nameStore.ageAtom, -- Signals unique to a player should also have a key unique to
-- that player, to avoid name collisions
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
}) })
``` ```
@ -821,11 +860,11 @@ Unsubscribes from each signal with the corresponding keys. The signals will reta
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
}) })
client.removeSignals("name") client.removeSignals("status")
``` ```
--- ---
@ -838,8 +877,9 @@ You should call `patch` when receiving updates from the server from a remote eve
```luau ```luau
client.addSignals({ client.addSignals({
name = nameStore.setName, status = store.setStatus,
surname = nameStore.setSurname, timer = store.timerAtom,
[`inventory-{Players.LocalPlayer.UserId}`] = clientStore.setInventory,
}) })
syncEvent.OnClientEvent:Connect(function(updates) syncEvent.OnClientEvent:Connect(function(updates)
@ -855,8 +895,8 @@ If you have a lot of signals to sync between the server and clients, it might be
```luau ```luau
local sharedState = { local sharedState = {
name = signalToAtom(nameStore.getName, nameStore.setName), status = signalToAtom(store.getStatus, store.setStatus),
surname = signalToAtom(nameStore.getSurname, nameStore.setSurname), inventories = signalToAtom(store.getInventories, store.setInventories),
} }
client.addSignals(sharedState) client.addSignals(sharedState)
@ -869,7 +909,7 @@ server.addSignalsToClient(client, sharedState)
Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`. Charm Sync will only send clients the differences between the current state and the last-synced state through a process called _delta compression_. In this case, tables are recursively scanned for changes, and unchanged properties are omitted by setting them to `nil`.
But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. We chose to address this by representing deleted values with a special `None` symbol denoted by `{ __none = "__none" }`. But it's hard to differentiate between an unchanged value and a removed value, as both cases are represented by `nil`. Charm addresses this by representing deleted values with a special `None` value denoted by `{ __none = "__none" }`.
This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload. This means nilable values may be replaced with `None` in patches, and code working with update payloads (usually for remote argument serialization) should account for nilable values possibly being sent as `None` in the payload.

View file

@ -7,7 +7,7 @@
"publish:npm": "pnpm publish -r --access public", "publish:npm": "pnpm publish -r --access public",
"publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally" "publish:wally": "pnpm -r --workspace-concurrency 1 publish:wally"
}, },
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@12.8.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@rbxts/compiler-types": "3.0.0-types.0", "@rbxts/compiler-types": "3.0.0-types.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm-sync", "name": "@rbxts/charm-sync",
"version": "0.4.0", "version": "0.4.1",
"description": "Simple server-to-client sync for Charm", "description": "Simple server-to-client sync for Charm",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -64,6 +64,8 @@ local function applyInitPayload(data: { [string]: any })
trackedSignals[key](value) trackedSignals[key](value)
elseif trackedReactives[key] then elseif trackedReactives[key] then
shallowMergeReactive(trackedReactives[key], value) shallowMergeReactive(trackedReactives[key], value)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end
@ -80,6 +82,8 @@ local function applyPatchPayload(data: { [string]: any })
elseif trackedReactive then elseif trackedReactive then
patch.applyMutable(trackedReactive.__target, diffs) patch.applyMutable(trackedReactive.__target, diffs)
Charm.trigger(trackedReactive.__track) Charm.trigger(trackedReactive.__track)
else
warn(`charm-sync failed to sync key '{key}' because it is not currently registered by addSignals()`)
end end
end end
end end

View file

@ -39,10 +39,10 @@ local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
support custom implementations of reactive proxies. support custom implementations of reactive proxies.
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
value = value :: types.ReactiveProxy<T> local proxy = value :: types.ReactiveProxy<T>
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then if type(proxy) == "table" and type(proxy.__target) == "table" and type(proxy.__track) == "function" then
value.__track() proxy.__track()
return toRaw(value.__target) return toRaw(proxy.__target)
end end
return value return value
end end
@ -61,7 +61,7 @@ local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
local rawInitialValue = toRaw(initialValue) local rawInitialValue = toRaw(initialValue)
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
local dispose = Charm.effect(function() local dispose = Charm.untracked(Charm.effect, function()
local newValue = getter() local newValue = getter()
local rawValue = toRaw(newValue) local rawValue = toRaw(newValue)
local isReactive = newValue ~= rawValue local isReactive = newValue ~= rawValue
@ -125,8 +125,8 @@ local function diffGlobalUpdateBuffer(): { any }
local patchCount = #globalUpdates local patchCount = #globalUpdates
local patches = table.create(patchCount) local patches = table.create(patchCount)
for _, update in globalUpdates do for index, update in globalUpdates do
table.insert(patches, patch.diff(update.oldValue, update.newValue)) patches[index] = patch.diff(update.oldValue, update.newValue)
end end
return patches return patches
@ -146,7 +146,7 @@ local function selectFromGlobalPatchList(client: any, globalPatches: { any }): {
for index, update in globalUpdates do for index, update in globalUpdates do
local patchData = globalPatches[index] local patchData = globalPatches[index]
if not patchData then if patchData == nil then
continue continue
end end

View file

@ -403,6 +403,54 @@ test.describe("historical", function()
server.flush() server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state") assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end) end)
test("should maintain patch list order for stale update", function()
local a = Charm.atom({ x = 1 })
local b = Charm.atom({ y = 1 })
local payloads
server.connect(function(_client, _payloads)
payloads = _payloads
end)
server.addSignalsToClient("client", { a = a, b = b })
server.flush()
payloads = nil
a({ x = 1 }) -- new table, same contents: diff is nil
b({ y = 2 })
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected payload to be patch")
assert(payloads[1].data.b.y == 2, "Expected payload to contain atom 'b'")
end)
end)
test("addSignalsToClient should ignore tracking context", function()
local counter = Charm.atom(0)
local payloads: { any }?
server.connect(function(_client, _payloads)
payloads = _payloads
end)
local dispose = Charm.effectScope(function()
server.addSignalsToClient("PLAYER_ID", { counter = counter })
end)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.counter == 0, "Expected init payload to contain atom 'counter'")
dispose()
payloads = nil
counter(1)
server.flush()
assert(payloads, "Expected payloads to be sent")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.counter == 1, "Expected patch payload to contain atom 'counter'")
end) end)
return {} return {}

View file

@ -6,7 +6,7 @@ test("should flatten functions", function()
local input = { local input = {
a = Charm.atom(0), a = Charm.atom(0),
b = { c = Charm.atom(0) }, b = { c = Charm.atom(0) },
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } }, d = { e = { f = utils.signalToAtom<<number>>(Charm.signal(0)) } },
} }
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input) local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm-sync" name = "littensy/charm-sync"
version = "0.4.0" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/charm", "name": "@rbxts/charm",
"version": "0.11.0", "version": "0.11.1",
"description": "Atomic state management based on alien-signals", "description": "Atomic state management based on alien-signals",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -486,7 +486,7 @@ local function signal<T>(initialValue: T, equals: Equals<T>?): (Getter<T>, Sette
end end
-- Check if the new value is different from the pending value -- Check if the new value is different from the pending value
if if equals then not equals(node.pendingValue, value) else node.pendingValue ~= value then if if equals then not equals(node.pendingValue, value :: T) else node.pendingValue ~= value then
if flags.frozen then if flags.frozen then
deepFreeze(value) deepFreeze(value)
end end

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/charm" name = "littensy/charm"
version = "0.11.0" version = "0.11.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/deep-charm", "name": "@rbxts/deep-charm",
"version": "0.1.0", "version": "0.1.1",
"description": "Use Charm with plain Luau tables", "description": "Use Charm with plain Luau tables",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -36,9 +36,9 @@ end
]] ]]
local function toRaw<T>(value: T): T local function toRaw<T>(value: T): T
if isReactive(value) then if isReactive(value) then
value = value :: ReactiveProxy<T> local proxy = value :: ReactiveProxy<T>
value.__track() proxy.__track()
return toRaw(value.__target) return toRaw(proxy.__target)
end end
return value return value
end end
@ -115,7 +115,7 @@ end
@return A reactive proxy if the value is a table @return A reactive proxy if the value is a table
]] ]]
local function toReactive<T>(value: T, trackParent: (() -> ())?): T local function toReactive<T>(value: T, trackParent: (() -> ())?): T
return if type(value) == "table" then reactive(value, trackParent) else value return if type(value) == "table" then reactive(value, trackParent) :: T else value :: T
end end
function ReactiveProxy.__index(self: ReactiveProxy, key: any): any function ReactiveProxy.__index(self: ReactiveProxy, key: any): any

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/deep-charm" name = "littensy/deep-charm"
version = "0.1.0" version = "0.1.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = ["node_modules", "test", "src/index.d.ts", "package.json"] exclude = ["node_modules", "test", "src/index.d.ts", "package.json"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/react-charm", "name": "@rbxts/react-charm",
"version": "0.4.0", "version": "0.4.1",
"description": "React bindings for Charm state management", "description": "React bindings for Charm state management",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -14,7 +14,7 @@ local React = require("./React")
@return The current state. @return The current state.
]] ]]
local function useSignalState<T>(getter: () -> T, dependencies: {}?): T local function useSignalState<T>(getter: () -> T, dependencies: {}?): T
local value, setValue = React.useState(getter) local value: T, setValue = React.useState(getter)
React.useLayoutEffect(function(): () -> () React.useLayoutEffect(function(): () -> ()
return Charm.listen(getter, setValue :: (T, T?) -> ()) return Charm.listen(getter, setValue :: (T, T?) -> ())

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/react-charm" name = "littensy/react-charm"
version = "0.4.0" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/vide-charm", "name": "@rbxts/vide-charm",
"version": "0.4.0", "version": "0.4.1",
"description": "Vide bindings for Charm state management", "description": "Vide bindings for Charm state management",
"license": "MIT", "license": "MIT",
"main": "src/init.luau", "main": "src/init.luau",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "littensy/vide-charm" name = "littensy/vide-charm"
version = "0.4.0" version = "0.4.1"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
exclude = [ exclude = [

183
pnpm-lock.yaml generated
View file

@ -1,3 +1,186 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.8.1
version: 12.8.1
pnpm:
specifier: 12.8.1
version: 12.8.1
packages:
'@pnpm/exe.android-arm64@12.8.1':
resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.8.1':
resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.8.1':
resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.8.1':
resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.8.1':
resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.8.1':
resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.8.1':
resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.8.1':
resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.8.1':
resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.8.1':
resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.8.1':
resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.8.1':
resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.8.1':
resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.8.1':
resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==}
cpu: [x64]
os: [win32]
'@pnpm/exe@12.8.1':
resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.8.1:
resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.8.1':
optional: true
'@pnpm/exe.android-x64@12.8.1':
optional: true
'@pnpm/exe.darwin-arm64@12.8.1':
optional: true
'@pnpm/exe.darwin-x64@12.8.1':
optional: true
'@pnpm/exe.freebsd-x64@12.8.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.8.1':
optional: true
'@pnpm/exe.linux-arm64@12.8.1':
optional: true
'@pnpm/exe.linux-ppc64@12.8.1':
optional: true
'@pnpm/exe.linux-riscv64@12.8.1':
optional: true
'@pnpm/exe.linux-s390x@12.8.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.8.1':
optional: true
'@pnpm/exe.linux-x64@12.8.1':
optional: true
'@pnpm/exe.win32-arm64@12.8.1':
optional: true
'@pnpm/exe.win32-x64@12.8.1':
optional: true
'@pnpm/exe@12.8.1':
optionalDependencies:
'@pnpm/exe.android-arm64': 12.8.1
'@pnpm/exe.android-x64': 12.8.1
'@pnpm/exe.darwin-arm64': 12.8.1
'@pnpm/exe.darwin-x64': 12.8.1
'@pnpm/exe.freebsd-x64': 12.8.1
'@pnpm/exe.linux-arm64': 12.8.1
'@pnpm/exe.linux-arm64-musl': 12.8.1
'@pnpm/exe.linux-ppc64': 12.8.1
'@pnpm/exe.linux-riscv64': 12.8.1
'@pnpm/exe.linux-s390x': 12.8.1
'@pnpm/exe.linux-x64': 12.8.1
'@pnpm/exe.linux-x64-musl': 12.8.1
'@pnpm/exe.win32-arm64': 12.8.1
'@pnpm/exe.win32-x64': 12.8.1
pnpm@12.8.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.8.1
'@pnpm/exe.android-x64': 12.8.1
'@pnpm/exe.darwin-arm64': 12.8.1
'@pnpm/exe.darwin-x64': 12.8.1
'@pnpm/exe.freebsd-x64': 12.8.1
'@pnpm/exe.linux-arm64': 12.8.1
'@pnpm/exe.linux-arm64-musl': 12.8.1
'@pnpm/exe.linux-ppc64': 12.8.1
'@pnpm/exe.linux-riscv64': 12.8.1
'@pnpm/exe.linux-s390x': 12.8.1
'@pnpm/exe.linux-x64': 12.8.1
'@pnpm/exe.linux-x64-musl': 12.8.1
'@pnpm/exe.win32-arm64': 12.8.1
'@pnpm/exe.win32-x64': 12.8.1
---
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:

View file

@ -1,6 +1,6 @@
[tools] [tools]
wally = "UpliftGames/wally@0.3.2" wally = "UpliftGames/wally@0.3.2"
rojo = "rojo-rbx/rojo@7.7.0-rc.1" rojo = "rojo-rbx/rojo@7.7.0"
stylua = "johnnymorganz/stylua@2.5.2" stylua = "johnnymorganz/stylua@2.5.2"
luau-lsp = "johnnymorganz/luau-lsp@1.68.1" luau-lsp = "johnnymorganz/luau-lsp@1.70.1"
lute = "luau-lang/lute@1.0.0" lute = "luau-lang/lute@1.0.0"