From 172a63f6649f7a194b7b99d67f9c33e425fabc6c Mon Sep 17 00:00:00 2001 From: Sovereignty Date: Fri, 13 Jun 2025 22:52:16 +0200 Subject: [PATCH] Chemical v0.2.5 --- .gitignore | 8 + aftman.toml | 7 + default.project.json | 30 + selene.toml | 1 + src/Chemical/Cache.lua | 123 + src/Chemical/Classes/Object.lua | 41 + src/Chemical/Classes/init.meta.json | 3 + src/Chemical/ECS.lua | 109 + src/Chemical/Factories/Computed.lua | 46 + src/Chemical/Factories/Effect.lua | 43 + src/Chemical/Factories/Map.lua | 44 + src/Chemical/Factories/Observer.lua | 70 + src/Chemical/Factories/Reaction.lua | 53 + src/Chemical/Factories/Table.lua | 48 + src/Chemical/Factories/Value.lua | 65 + src/Chemical/Factories/Watch.lua | 47 + src/Chemical/Factories/init.meta.json | 3 + src/Chemical/Functions/Alive.lua | 8 + src/Chemical/Functions/Array.lua | 80 + src/Chemical/Functions/Blueprint.lua | 78 + src/Chemical/Functions/Compare.lua | 57 + src/Chemical/Functions/Compose.lua | 147 + src/Chemical/Functions/Destroy.lua | 38 + src/Chemical/Functions/GetInScope.lua | 13 + src/Chemical/Functions/GetSubscribers.lua | 20 + src/Chemical/Functions/GetSymbol.lua | 14 + src/Chemical/Functions/Has.lua | 12 + src/Chemical/Functions/Is.lua | 83 + src/Chemical/Functions/Peek.lua | 15 + src/Chemical/Functions/SetInScope.lua | 21 + src/Chemical/Functions/init.meta.json | 3 + src/Chemical/Mixins/Cleanable.lua | 19 + src/Chemical/Mixins/Computable.lua | 53 + src/Chemical/Mixins/Destroyable.lua | 37 + src/Chemical/Mixins/Effectable.lua | 33 + src/Chemical/Mixins/Numerical.lua | 28 + src/Chemical/Mixins/Observable.lua | 103 + src/Chemical/Mixins/Serializable.lua | 73 + src/Chemical/Mixins/Settable.lua | 24 + src/Chemical/Mixins/Stateful.lua | 23 + src/Chemical/Mixins/StatefulDictionary.lua | 52 + src/Chemical/Mixins/StatefulTable.lua | 71 + src/Chemical/Mixins/init.meta.json | 3 + src/Chemical/Packages/Datastore/Proxy.lua | 52 + src/Chemical/Packages/Datastore/Signal.lua | 219 ++ .../Datastore/SynchronousTaskManager.lua | 266 ++ src/Chemical/Packages/Datastore/init.lua | 734 +++++ .../Packages/Datastore/init.meta.json | 3 + src/Chemical/Packages/JECS.lua | 2716 +++++++++++++++++ src/Chemical/Packages/LinkedList.lua | 160 + src/Chemical/Packages/Packet/Signal.lua | 101 + src/Chemical/Packages/Packet/Task.lua | 46 + .../Packages/Packet/Types/Characters.lua | 6 + src/Chemical/Packages/Packet/Types/Enums.lua | 12 + .../Packages/Packet/Types/Static1.lua | 8 + .../Packages/Packet/Types/Static2.lua | 8 + .../Packages/Packet/Types/Static3.lua | 8 + src/Chemical/Packages/Packet/Types/init.lua | 705 +++++ src/Chemical/Packages/Packet/init.lua | 368 +++ src/Chemical/Packages/Promise.lua | 2203 +++++++++++++ src/Chemical/Packages/Queue.lua | 53 + src/Chemical/Packages/Signals.lua | 113 + src/Chemical/Packages/Trees.lua | 237 ++ src/Chemical/Packages/init.meta.json | 3 + src/Chemical/Singletons/Reactor.lua | 323 ++ src/Chemical/Singletons/Scheduler.lua | 95 + src/Chemical/Singletons/init.meta.json | 3 + src/Chemical/Symbols.lua | 26 + src/Chemical/Types/Gui.lua | 360 +++ src/Chemical/Types/Overrides.lua | 66 + src/Chemical/Types/init.lua | 17 + src/Chemical/init.lua | 66 + 72 files changed, 10826 insertions(+) create mode 100644 .gitignore create mode 100644 aftman.toml create mode 100644 default.project.json create mode 100644 selene.toml create mode 100644 src/Chemical/Cache.lua create mode 100644 src/Chemical/Classes/Object.lua create mode 100644 src/Chemical/Classes/init.meta.json create mode 100644 src/Chemical/ECS.lua create mode 100644 src/Chemical/Factories/Computed.lua create mode 100644 src/Chemical/Factories/Effect.lua create mode 100644 src/Chemical/Factories/Map.lua create mode 100644 src/Chemical/Factories/Observer.lua create mode 100644 src/Chemical/Factories/Reaction.lua create mode 100644 src/Chemical/Factories/Table.lua create mode 100644 src/Chemical/Factories/Value.lua create mode 100644 src/Chemical/Factories/Watch.lua create mode 100644 src/Chemical/Factories/init.meta.json create mode 100644 src/Chemical/Functions/Alive.lua create mode 100644 src/Chemical/Functions/Array.lua create mode 100644 src/Chemical/Functions/Blueprint.lua create mode 100644 src/Chemical/Functions/Compare.lua create mode 100644 src/Chemical/Functions/Compose.lua create mode 100644 src/Chemical/Functions/Destroy.lua create mode 100644 src/Chemical/Functions/GetInScope.lua create mode 100644 src/Chemical/Functions/GetSubscribers.lua create mode 100644 src/Chemical/Functions/GetSymbol.lua create mode 100644 src/Chemical/Functions/Has.lua create mode 100644 src/Chemical/Functions/Is.lua create mode 100644 src/Chemical/Functions/Peek.lua create mode 100644 src/Chemical/Functions/SetInScope.lua create mode 100644 src/Chemical/Functions/init.meta.json create mode 100644 src/Chemical/Mixins/Cleanable.lua create mode 100644 src/Chemical/Mixins/Computable.lua create mode 100644 src/Chemical/Mixins/Destroyable.lua create mode 100644 src/Chemical/Mixins/Effectable.lua create mode 100644 src/Chemical/Mixins/Numerical.lua create mode 100644 src/Chemical/Mixins/Observable.lua create mode 100644 src/Chemical/Mixins/Serializable.lua create mode 100644 src/Chemical/Mixins/Settable.lua create mode 100644 src/Chemical/Mixins/Stateful.lua create mode 100644 src/Chemical/Mixins/StatefulDictionary.lua create mode 100644 src/Chemical/Mixins/StatefulTable.lua create mode 100644 src/Chemical/Mixins/init.meta.json create mode 100644 src/Chemical/Packages/Datastore/Proxy.lua create mode 100644 src/Chemical/Packages/Datastore/Signal.lua create mode 100644 src/Chemical/Packages/Datastore/SynchronousTaskManager.lua create mode 100644 src/Chemical/Packages/Datastore/init.lua create mode 100644 src/Chemical/Packages/Datastore/init.meta.json create mode 100644 src/Chemical/Packages/JECS.lua create mode 100644 src/Chemical/Packages/LinkedList.lua create mode 100644 src/Chemical/Packages/Packet/Signal.lua create mode 100644 src/Chemical/Packages/Packet/Task.lua create mode 100644 src/Chemical/Packages/Packet/Types/Characters.lua create mode 100644 src/Chemical/Packages/Packet/Types/Enums.lua create mode 100644 src/Chemical/Packages/Packet/Types/Static1.lua create mode 100644 src/Chemical/Packages/Packet/Types/Static2.lua create mode 100644 src/Chemical/Packages/Packet/Types/Static3.lua create mode 100644 src/Chemical/Packages/Packet/Types/init.lua create mode 100644 src/Chemical/Packages/Packet/init.lua create mode 100644 src/Chemical/Packages/Promise.lua create mode 100644 src/Chemical/Packages/Queue.lua create mode 100644 src/Chemical/Packages/Signals.lua create mode 100644 src/Chemical/Packages/Trees.lua create mode 100644 src/Chemical/Packages/init.meta.json create mode 100644 src/Chemical/Singletons/Reactor.lua create mode 100644 src/Chemical/Singletons/Scheduler.lua create mode 100644 src/Chemical/Singletons/init.meta.json create mode 100644 src/Chemical/Symbols.lua create mode 100644 src/Chemical/Types/Gui.lua create mode 100644 src/Chemical/Types/Overrides.lua create mode 100644 src/Chemical/Types/init.lua create mode 100644 src/Chemical/init.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fbf856 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Project place file +/*.rbxl +sourcemap.json* +# Roblox Studio lock files +/*.rbxlx.lock +/*.rbxl.lock +/.vscode +/.png \ No newline at end of file diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..159fdf4 --- /dev/null +++ b/aftman.toml @@ -0,0 +1,7 @@ +# This file lists tools managed by Aftman, a cross-platform toolchain manager. +# For more information, see https://github.com/LPGhatguy/aftman + +# To add a new tool, add an entry to this table. +[tools] +rojo = "rojo-rbx/rojo@7.5.1" +# rojo = "rojo-rbx/rojo@6.2.0" \ No newline at end of file diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..5cd37c7 --- /dev/null +++ b/default.project.json @@ -0,0 +1,30 @@ +{ + "name": "project", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$ignoreUnknownInstances": true, + "$path": "src/ReplicatedStorage" + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "$ignoreUnknownInstances": true, + "$path": "src/ServerScriptService" + }, + "StarterPlayer": { + "$className": "StarterPlayer", + "StarterPlayerScripts": { + "$className": "StarterPlayerScripts", + "$ignoreUnknownInstances": true, + "$path": "src/StarterPlayer/StarterPlayerScripts" + }, + "$ignoreUnknownInstances": true + }, + "TestService": { + "$className": "TestService", + "$ignoreUnknownInstances": true, + "$path": "src/TestService" + } + } +} \ No newline at end of file diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..1f1e170 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "roblox" \ No newline at end of file diff --git a/src/Chemical/Cache.lua b/src/Chemical/Cache.lua new file mode 100644 index 0000000..cfc13cf --- /dev/null +++ b/src/Chemical/Cache.lua @@ -0,0 +1,123 @@ +local module = {} +module.Tokens = {} +module.Stack = {} +module.Queues = {} + +do + local tokenCount = 0 + local stringsToTokens = {} + local tokensToSrings = {} + + local tokenMap = {} + + function module:Tokenize(key: string): number + if stringsToTokens[key] then return stringsToTokens[key] end + + tokenCount += 1 + + stringsToTokens[key] = tokenCount + tokensToSrings[tokenCount] = key + + return tokenCount + end + + function module:FromToken(token: number): string + return tokensToSrings[token] + end + + + function module:MapAToken(key: string, token: number) + tokenMap[token] = key + end + + function module:FromAMap(token: number): string + return tokenMap[token] + end + + function module:TokenClear(key: string | number) + if typeof(key) == "string" then + local token = stringsToTokens[key] + stringsToTokens[key] = nil + tokensToSrings[token] = nil + elseif typeof(key) == "number" then + local str = tokensToSrings[key] + stringsToTokens[str] = nil + tokensToSrings[key] = nil + end + end +end + +function module.Tokens.new() + local tokenCount = 0 + local stringsToTokens = {} + local tokensToSrings = {} + + return { + ToToken = function(self: {}, key: string): number + if stringsToTokens[key] then return stringsToTokens[key] end + + tokenCount += 1 + + stringsToTokens[key] = tokenCount + tokensToSrings[tokenCount] = key + return tokenCount + end, + + ToTokenPath = function(self: {}, keys: {string}): { number } + local tokens = {} + + for _, key in keys do + table.insert(tokens, self:ToToken(key)) + end + + return tokens + end, + + Is = function(self: {}, key: string): boolean + return stringsToTokens[key] ~= nil + end, + + From = function(self: {}, token: number): string + return tokensToSrings[token] + end, + + FromPath = function(self: {}, tokens: { number }): { string } + local strings = {} + for _, token in tokens do + table.insert(strings, tokensToSrings[token]) + end + return strings + end, + + Map = function(self: {}, stringsToTokens: { [string]: number }) + for key, value in stringsToTokens do + if typeof(value) == "table" then + self:Map(value) + continue + end + stringsToTokens[key] = value + tokensToSrings[value] = key + end + end, + } +end + + +local stack = {} +function module.Stack:Push(entry: any): number + table.insert(stack, entry) + return #stack +end + +function module.Stack:Top(): any + return stack[#stack] +end + +function module.Stack:Pop(index: number?) + stack[index or #stack] = nil +end + + + + +return module diff --git a/src/Chemical/Classes/Object.lua b/src/Chemical/Classes/Object.lua new file mode 100644 index 0000000..ec334f7 --- /dev/null +++ b/src/Chemical/Classes/Object.lua @@ -0,0 +1,41 @@ +local RootFolder = script.Parent.Parent + +local Types = require(RootFolder.Types) +local ECS = require(RootFolder.ECS) + +local module = {} + +type Object = Types.HasEntity & { + use: (self: Object, ...{}) -> (), +} + +function module.new(metamethods: {}?): Object + local inherits = {} + metamethods = metamethods or {} + + metamethods.__index = function(self, index) + local has = rawget(self, index) + if has then return has + else if inherits[index] then return inherits[index] end + end + end + + local object = setmetatable({ + entity = ECS.World:entity() + }, metamethods) + + object.use = function(self, ...: {}) + local classes = { ... } + for _, class in classes do + for key, value in class do + inherits[key] = value + end + end + + object.use = nil + end + + return object +end + +return module diff --git a/src/Chemical/Classes/init.meta.json b/src/Chemical/Classes/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Classes/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/ECS.lua b/src/Chemical/ECS.lua new file mode 100644 index 0000000..62d416f --- /dev/null +++ b/src/Chemical/ECS.lua @@ -0,0 +1,109 @@ +--!nonstrict +local Packages = script.Parent.Packages +local ECS = require(Packages.JECS) + +export type Entity = ECS.Entity + +local Tags = { + -- State Management Tags + IsStatic = ECS.tag(), + IsPrimitive = ECS.tag(), + IsSettable = ECS.tag(), + IsStateful = ECS.tag(), + IsStatefulTable = ECS.tag(), + IsStatefulDictionary = ECS.tag(), + IsComputed = ECS.tag(), + IsEffect = ECS.tag(), + IsDirty = ECS.tag(), + IsDeepComparable = ECS.tag(), + + -- Relationship Tags + SubscribesTo = ECS.tag(), + HasSubscriber = ECS.tag(), + InScope = ECS.tag(), + ChildOf = ECS.ChildOf, + + -- UI-specific Tags TODO + IsHost = ECS.tag(), + ManagedBy = ECS.tag(), + UIParent = ECS.tag(), +} + +local World = ECS.world() + + +local Components = { + Name = ECS.Name, + Object = World:component(), + Value = World:component(), + PrevValue = World:component(), + Callback = World:component(), + CallbackList = World:component(), + OnChangeCallbacks = World:component(), + OnKVChangeCallbacks = World:component(), + Connection = World:component() :: ECS.Entity, + ConnectionList = World:component() :: ECS.Entity<{RBXScriptConnection}>, + Instance = World:component(), + ManagedItems = World:component(), + LoopType = World:component(), + + + ComputeFn = World:component(), + EffectFn = World:component(), + CleanupFn = World:component(), +} + +World:set(Components.Connection, ECS.OnRemove, function(entity) + local connection = World:get(entity, Components.Connection) + if connection then + connection:Disconnect() + end +end) + +World:set(Components.ConnectionList, ECS.OnRemove, function(entity) + local connections = World:get(entity, Components.ConnectionList) + if connections then + for _, conn in ipairs(connections) do + conn:Disconnect() + end + end +end) + +World:set(Components.Instance, ECS.OnRemove, function(entity) + if World:has(entity, Tags.IsHost) then + for effectEntity in World:each(ECS.pair(Tags.InScope, entity)) do + World:delete(effectEntity) + end + if World:has(entity, Components.ConnectionList) then + World:remove(entity, Components.ConnectionList) + end + end +end) + +World:set(Components.Value, ECS.OnChange, function(entity, id, data) + if World:has(entity, Tags.IsSettable) then + World:add(entity, Tags.IsDirty) + end +end) + +World:set(Components.Object, ECS.OnRemove, function(entity: ECS.Entity, id: ECS.Id) + local object = World:get(entity, Components.Object) + if object and object.__internalDestroy then + object:__internalDestroy() + end +end) + +World:add(Tags.SubscribesTo, ECS.pair(ECS.OnDeleteTarget, ECS.Delete)) +World:add(Tags.HasSubscriber, ECS.pair(ECS.OnDeleteTarget, ECS.Delete)) +World:add(Tags.InScope, ECS.pair(ECS.OnDeleteTarget, ECS.Delete)) +World:add(Tags.ManagedBy, ECS.pair(ECS.OnDeleteTarget, ECS.Delete)) +World:add(Tags.UIParent, ECS.pair(ECS.OnDeleteTarget, ECS.Delete)) + +local module = { + Components = Components, + Tags = Tags, + JECS = ECS, + World = World, +} + +return module \ No newline at end of file diff --git a/src/Chemical/Factories/Computed.lua b/src/Chemical/Factories/Computed.lua new file mode 100644 index 0000000..9876f32 --- /dev/null +++ b/src/Chemical/Factories/Computed.lua @@ -0,0 +1,46 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) + +local Object = require(RootFolder.Classes.Object) + +local Stateful = require(RootFolder.Mixins.Stateful) +local Destroyable = require(RootFolder.Mixins.Destroyable) +local Cleanable = require(RootFolder.Mixins.Cleanable) +local Computable = require(RootFolder.Mixins.Computable) + + +export type Computed = Stateful.Stateful & Computable.Computable & Destroyable.Destroyable & Cleanable.Cleanable + +return function(computeFn: () -> T, cleanupFn: (T) -> ()?): Computed + local obj = Object.new({ + __tostring = function(self) + local rawValue = ECS.World:get(self.entity, ECS.Components.Value) + return `Computed<{tostring(rawValue)}>` + end, + }) + ECS.World:add(obj.entity, ECS.Tags.IsStateful) + ECS.World:add(obj.entity, ECS.Tags.IsComputed) + + + ECS.World:set(obj.entity, ECS.Components.ComputeFn, computeFn) + if cleanupFn then ECS.World:set(obj.entity, ECS.Components.CleanupFn, cleanupFn) end + + + obj:use( + Computable, + Stateful, + Destroyable, + Cleanable + ) + + + obj:compute() + + + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end \ No newline at end of file diff --git a/src/Chemical/Factories/Effect.lua b/src/Chemical/Factories/Effect.lua new file mode 100644 index 0000000..e9dcf32 --- /dev/null +++ b/src/Chemical/Factories/Effect.lua @@ -0,0 +1,43 @@ +local RootFolder = script.Parent.Parent + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins + +local ECS = require(RootFolder.ECS) +local Object = require(Classes.Object) + +local Effectable = require(Mixins.Effectable) +local Destroyable = require(Mixins.Destroyable) +local Cleanable = require(Mixins.Cleanable) + +export type Effect = Effectable.Effectable & Destroyable.Destroyable & Cleanable.Cleanable +type CleanUp = () -> () + +--- Effect +-- Effects will fire after the batch of stateful object changes are propogated. +-- The optional cleanup function will fire first, and then the effect's function. +-- The effect function can optionally return a cleanup function. +-- Effects will be deleted when any one of its dependent objects are destroyed. +return function(effectFn: () -> ( CleanUp | nil )): Effect + local obj = Object.new() + + + ECS.World:add(obj.entity, ECS.Tags.IsEffect) + ECS.World:set(obj.entity, ECS.Components.EffectFn, effectFn) + + + obj:use( + Cleanable, + Destroyable, + Effectable + ) + + + obj:run() + + + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end \ No newline at end of file diff --git a/src/Chemical/Factories/Map.lua b/src/Chemical/Factories/Map.lua new file mode 100644 index 0000000..b76fcdb --- /dev/null +++ b/src/Chemical/Factories/Map.lua @@ -0,0 +1,44 @@ +local RootFolder = script.Parent.Parent + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins + +local ECS = require(RootFolder.ECS) + +local Object = require(Classes.Object) + +local Stateful = require(Mixins.Stateful) +local StatefulTable = require(Mixins.StatefulTable) +local StatefulDictionary = require(Mixins.StatefulDictionary) + +local Settable = require(Mixins.Settable) +local Numerical = require(Mixins.Numerical) +local Destroyable = require(Mixins.Destroyable) + +export type Value = Stateful.Stateful & Settable.Settable & StatefulDictionary.StatefulDictionary & Destroyable.Destroyable + +return function(value: T): Value + local obj = Object.new({ + __tostring = function(self) + return `Map<{self.entity}>` + end, + }) + ECS.World:add(obj.entity, ECS.Tags.IsStateful) + ECS.World:add(obj.entity, ECS.Tags.IsStatefulDictionary) + ECS.World:add(obj.entity, ECS.Tags.IsSettable) + + + obj:use( + Stateful, + Settable, + StatefulDictionary, + Destroyable + ) + + + ECS.World:set(obj.entity, ECS.Components.Value, value) + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end diff --git a/src/Chemical/Factories/Observer.lua b/src/Chemical/Factories/Observer.lua new file mode 100644 index 0000000..97501ae --- /dev/null +++ b/src/Chemical/Factories/Observer.lua @@ -0,0 +1,70 @@ +--!strict + +local RootFolder = script.Parent.Parent + + +local LinkedList = require(RootFolder.Packages.LinkedList) + + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins +local Functions = RootFolder.Functions + + +local ECS = require(RootFolder.ECS) +local Is = require(Functions.Is) + + +local Object = require(Classes.Object) +local Stateful = require(Mixins.Stateful) +local Observable = require(Mixins.Observable) +local Destroyable = require(Mixins.Destroyable) + + +export type Observer = Observable.Observable & Destroyable.Destroyable +export type ObserverTable = Observable.ObservableTable & Destroyable.Destroyable + +export type ObserverFactory = ((sourceObject: Stateful.Stateful<{T}>) -> ObserverTable) +& ((sourceObject: Stateful.Stateful) -> Observer) + + +--- Observer +-- Creates an observer that reacts to changes in a stateful source. +-- If the subject's value is a table, upon first creation of Observer, onKVChange callbacks will be supported. +-- @param sourceObject The stateful object to observe. +-- @return A new observer object. +local function createObserver(sourceObject: Stateful.Stateful) + if not Is.Stateful(sourceObject) then + error("The first argument of an Observer must be a stateful object.", 2) + end + + local obj = Object.new() + ECS.World:add(obj.entity, ECS.Tags.IsEffect) + + + ECS.World:set(obj.entity, ECS.Components.OnChangeCallbacks, LinkedList.new()) + + + if typeof(sourceObject:get()) == "table" then + ECS.World:add(sourceObject.entity, ECS.Tags.IsDeepComparable) + ECS.World:set(obj.entity, ECS.Components.OnKVChangeCallbacks, LinkedList.new()) + end + + + ECS.World:add(obj.entity, ECS.JECS.pair(ECS.Tags.SubscribesTo, sourceObject.entity)) + ECS.World:add(sourceObject.entity, ECS.JECS.pair(ECS.Tags.HasSubscriber, obj.entity)) + + + obj:use( + Destroyable, + Observable + ) + + + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end + +return (createObserver :: any) :: ObserverFactory \ No newline at end of file diff --git a/src/Chemical/Factories/Reaction.lua b/src/Chemical/Factories/Reaction.lua new file mode 100644 index 0000000..ce08389 --- /dev/null +++ b/src/Chemical/Factories/Reaction.lua @@ -0,0 +1,53 @@ +--!strict + +local RootFolder = script.Parent.Parent + + +local LinkedList = require(RootFolder.Packages.LinkedList) + + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins +local Functions = RootFolder.Functions + + +local ECS = require(RootFolder.ECS) +local Is = require(Functions.Is) +local SetInScope = require(Functions.SetInScope) + + +local Object = require(Classes.Object) +local Stateful = require(Mixins.Stateful) +local Destroyable = require(Mixins.Destroyable) +local Serializable = require(Mixins.Serializable) + +export type Reaction = Stateful.Stateful & Destroyable.Destroyable & Serializable.Serializable & T + +--- Reaction +-- A Stateful container with helper methods for converting data into different formats. +local function createReaction(name: string, key: string, container: T): Reaction + + local obj = Object.new({ + __tostring = function(self) + local isAlive = Is.Dead(self) and "Dead" or "Alive" + return `Reaction<{name}/{key}> - {isAlive}` + end, + }) + ECS.World:add(obj.entity, ECS.Tags.IsStateful) + + SetInScope(container :: any, obj.entity) + + obj:use( + Stateful, + Destroyable, + Serializable, + (container :: any) + ) + + ECS.World:set(obj.entity, ECS.Components.Value, container) + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + return obj :: Reaction +end + +return createReaction \ No newline at end of file diff --git a/src/Chemical/Factories/Table.lua b/src/Chemical/Factories/Table.lua new file mode 100644 index 0000000..72ddff3 --- /dev/null +++ b/src/Chemical/Factories/Table.lua @@ -0,0 +1,48 @@ +local RootFolder = script.Parent.Parent + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins + +local ECS = require(RootFolder.ECS) + +local Object = require(Classes.Object) + +local Stateful = require(Mixins.Stateful) +local StatefulTable = require(Mixins.StatefulTable) +local StatefulDictionary = require(Mixins.StatefulDictionary) + +local Settable = require(Mixins.Settable) +local Numerical = require(Mixins.Numerical) +local Destroyable = require(Mixins.Destroyable) + +export type Value = Stateful.Stateful & Settable.Settable & StatefulTable.StatefulTable & Destroyable.Destroyable + +return function(value: T): Value + local obj = Object.new({ + __len = function(self) + return #ECS.World:get(self.entity, ECS.Components.Value) + end, + + __tostring = function(self) + return `Table<{ tostring(self.entity) }>` + end, + }) + ECS.World:add(obj.entity, ECS.Tags.IsStateful) + ECS.World:add(obj.entity, ECS.Tags.IsStatefulTable) + ECS.World:add(obj.entity, ECS.Tags.IsSettable) + + + obj:use( + Stateful, + Settable, + StatefulTable, + Destroyable + ) + + + ECS.World:set(obj.entity, ECS.Components.Value, value) + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end diff --git a/src/Chemical/Factories/Value.lua b/src/Chemical/Factories/Value.lua new file mode 100644 index 0000000..e18f8b2 --- /dev/null +++ b/src/Chemical/Factories/Value.lua @@ -0,0 +1,65 @@ +local RootFolder = script.Parent.Parent + +local Classes = RootFolder.Classes +local Mixins = RootFolder.Mixins + +local ECS = require(RootFolder.ECS) + +local Object = require(Classes.Object) + +local Stateful = require(Mixins.Stateful) +local StatefulTable = require(Mixins.StatefulTable) +local StatefulDictionary = require(Mixins.StatefulDictionary) + +local Settable = require(Mixins.Settable) +local Numerical = require(Mixins.Numerical) +local Destroyable = require(Mixins.Destroyable) + +type Value = Stateful.Stateful & Settable.Settable & Destroyable.Destroyable + +export type ValueFactory = ( + ((value: number) -> Value & Numerical.Numerical) & + ((value: T) -> Value) +) + +--- Value +-- Stateful value container which enables reactivity. +-- Depending on the type of the initial value, certain methods are exposed correlating to the value type. +-- @param value any -- The initial value to set. +-- @return The Value object. +return function(value: T): Value + local obj = Object.new({ + __len = typeof(value) == "table" and function(self) + return #ECS.World:get(self.entity, ECS.Components.Value) + end or nil, + + __tostring = function(self) + local rawValue = ECS.World:get(self.entity, ECS.Components.Value) + return `Value<{tostring(rawValue)}>` + end, + }) + ECS.World:add(obj.entity, ECS.Tags.IsStateful) + ECS.World:add(obj.entity, ECS.Tags.IsSettable) + + + local mtMethods: { {} } = { + Stateful, + Settable, + Destroyable, + } + + + if typeof(value) == "number" then + table.insert(mtMethods, Numerical) + end + + + obj:use(table.unpack(mtMethods)) + + + ECS.World:set(obj.entity, ECS.Components.Value, value) + ECS.World:set(obj.entity, ECS.Components.Object, obj) + + + return obj +end diff --git a/src/Chemical/Factories/Watch.lua b/src/Chemical/Factories/Watch.lua new file mode 100644 index 0000000..f6bafac --- /dev/null +++ b/src/Chemical/Factories/Watch.lua @@ -0,0 +1,47 @@ +local RootFolder = script.Parent.Parent + +local Types = require(RootFolder.Types) + +local Observer = require(RootFolder.Factories.Observer) + +local Stateful = require(RootFolder.Mixins.Stateful) +local Computable = require(RootFolder.Mixins.Computable) + + +export type WatchHandle = { + destroy: (self: WatchHandle) -> () +} + + +type Watchable = Stateful.Stateful | Computable.Computable + +--- Creates a watcher that runs a callback function whenever a reactive source changes. +-- @param source The Value or Computed object to watch. +-- @param watchCallback A function that will be called with (newValue, oldValue). +-- @returns A handle with a :destroy() method to stop watching. +return function(source: Watchable, watchCallback: (new: T, old: T) -> ()): WatchHandle + if not source or not source.entity then + error("Chemical.Watch requires a valid Value or Computed object as its first argument.", 2) + end + + if typeof(watchCallback) ~= "function" then + error("Chemical.Watch requires a function as its second argument.", 2) + end + + local obs = Observer(source) + + obs:onChange(function(newValue, oldValue) + local success, err = pcall(watchCallback, newValue, oldValue) + if not success then + warn("Chemical Watch Error: ", err) + end + end) + + local handle: WatchHandle = { + destroy = function() + obs:destroy() + end, + } + + return handle +end \ No newline at end of file diff --git a/src/Chemical/Factories/init.meta.json b/src/Chemical/Factories/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Factories/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Functions/Alive.lua b/src/Chemical/Functions/Alive.lua new file mode 100644 index 0000000..27e0b3f --- /dev/null +++ b/src/Chemical/Functions/Alive.lua @@ -0,0 +1,8 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +return function(obj: Types.HasEntity): boolean + return ECS.World:contains(obj.entity) +end diff --git a/src/Chemical/Functions/Array.lua b/src/Chemical/Functions/Array.lua new file mode 100644 index 0000000..92c86a7 --- /dev/null +++ b/src/Chemical/Functions/Array.lua @@ -0,0 +1,80 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Types = require(RootFolder.Types) + +local Is = require(RootFolder.Functions.Is) + + + + +local module = {} + +function module.Transform(tbl: { [K]: V }, doFn: (k: K, v: V) -> R): { [K]: R } + local newTbl = {} + for key, value in tbl do + if Is.Array(value) then + newTbl[key] = module.Transform(value, doFn) + else + newTbl[key] = doFn(key, value) + end + end + return newTbl +end + +function module.ShallowTransform(tbl: { [K]: V }, doFn: (k: K, v: V) -> R): { [K]: R } + local newTbl = {} + for key, value in tbl do + newTbl[key] = doFn(key, value) + end + return newTbl +end + + +function module.Traverse(tbl: {}, doFn: (k: any, v: any) -> ()) + for key, value in tbl do + if Is.Array(value) then + module.Traverse(value, doFn) + else + doFn(key, value) + end + end +end + +--[[ + Recursively walks a table, calling a visitor function for every + value encountered. The visitor receives the path (an array of keys) + and the value at that path. + + @param target The table to walk. + @param visitor The function to call, with signature: (path: {any}, value: any) -> () +--]] +function module.Walk(target: {any}, visitor: (path: {any}, value: any) -> ()) + local function _walk(currentValue: any, currentPath: {any}) + visitor(currentPath, currentValue) + + if Is.Array(currentValue) then + for key, childValue in pairs(currentValue) do + local childPath = table.clone(currentPath) + table.insert(childPath, key) + _walk(childValue, childPath) + end + end + end + + _walk(target, {}) +end + + + +function module.FindOnPath(tbl: {[K]: V}, path: { number | string }): V + local current = tbl + for _, key in path do + current = current[key] + end + + return current +end + +return module diff --git a/src/Chemical/Functions/Blueprint.lua b/src/Chemical/Functions/Blueprint.lua new file mode 100644 index 0000000..b14be91 --- /dev/null +++ b/src/Chemical/Functions/Blueprint.lua @@ -0,0 +1,78 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Types = require(RootFolder.Types) + +local Value = require(RootFolder.Factories.Value) +local Table = require(RootFolder.Factories.Table) +local Map = require(RootFolder.Factories.Map) + +local Is = require(RootFolder.Functions.Is) +local Peek = require(RootFolder.Functions.Peek) +local Array = require(RootFolder.Functions.Array) + +type Blueprint = { T: ECS.Entity, V: any } + +local module = {} + + +local function blueprintTreeFromValue(value: any): Blueprint + if Is.Stateful(value) then + if Is.StatefulTable(value) then + return { T = ECS.Tags.IsStatefulTable, V = Peek(value) } + elseif Is.StatefulDictionary(value) then + return { T = ECS.Tags.IsStatefulDictionary, V = Peek(value) } + else + return { T = ECS.Tags.IsStateful, V = Peek(value) } + end + elseif typeof(value) == "table" then + local childrenAsBlueprints = Array.ShallowTransform(value, function(k, v) + return blueprintTreeFromValue(v) + end) + return { T = ECS.Tags.IsStatic, V = childrenAsBlueprints } + else + return { T = ECS.Tags.IsStatic, V = value } + end +end + + +function module:From(value: any): Blueprint + return blueprintTreeFromValue(value) +end + + +local buildFromBlueprintTree + + +local function buildFromABlueprint(blueprint: Blueprint) + if blueprint.T == ECS.Tags.IsStateful then + return Value(blueprint.V) + elseif blueprint.T == ECS.Tags.IsStatefulTable then + return Table(blueprint.V) + elseif blueprint.T == ECS.Tags.IsStatefulDictionary then + return Map(blueprint.V) + elseif blueprint.T == ECS.Tags.IsStatic then + if typeof(blueprint.V) == "table" then + return buildFromBlueprintTree(blueprint.V) + else + return blueprint.V + end + end + + return nil +end + + +buildFromBlueprintTree = function(blueprintTable: {any}) + return Array.ShallowTransform(blueprintTable, function(key, value) + return buildFromABlueprint(value) + end) +end + +function module:Read(rootBlueprint: Blueprint) + return buildFromABlueprint(rootBlueprint) +end + + +return module \ No newline at end of file diff --git a/src/Chemical/Functions/Compare.lua b/src/Chemical/Functions/Compare.lua new file mode 100644 index 0000000..2ceafd4 --- /dev/null +++ b/src/Chemical/Functions/Compare.lua @@ -0,0 +1,57 @@ +export type Change = { + Path: {string | number}, + OldValue: any, + NewValue: any, +} + +local DeepCompare = {} + +local function compare(oldTable, newTable, path, changes) + path = path or {} + changes = changes or {} + + for key, newValue in pairs(newTable) do + local oldValue = oldTable and oldTable[key] + local currentPath = table.clone(path) + table.insert(currentPath, key) + + if oldValue ~= newValue then + if typeof(newValue) == "table" and typeof(oldValue) == "table" then + compare(oldValue, newValue, currentPath, changes) + else + table.insert(changes, { + Path = currentPath, + OldValue = oldValue, + NewValue = newValue, + }) + end + end + end + + if oldTable then + for key, oldValue in pairs(oldTable) do + if newTable[key] == nil then + local currentPath = table.clone(path) + table.insert(currentPath, key) + + table.insert(changes, { + Path = currentPath, + OldValue = oldValue, + NewValue = nil, + }) + end + end + end + + return changes +end + +--- Compares two tables deeply and returns an array of changes. +-- Each change object contains a `Path`, `OldValue`, and `NewValue`. +return function(oldTable: {}, newTable: {}): {Change} + if typeof(oldTable) ~= "table" or typeof(newTable) ~= "table" then + return {} + end + + return compare(oldTable, newTable) +end \ No newline at end of file diff --git a/src/Chemical/Functions/Compose.lua b/src/Chemical/Functions/Compose.lua new file mode 100644 index 0000000..7867e20 --- /dev/null +++ b/src/Chemical/Functions/Compose.lua @@ -0,0 +1,147 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Symbols = require(RootFolder.Symbols) + +local Effect = require(RootFolder.Factories.Effect) + +local Is = require(RootFolder.Functions.Is) +local Has = require(RootFolder.Functions.Has) +local GetInScope = require(RootFolder.Functions.GetInScope) + +local JECS = ECS.JECS +local World = ECS.World +local Components = ECS.Components +local Tags = ECS.Tags + +local RESERVED_KEYS = { "Children", "Parent" } + +local INSTANCE_TO_ENTITY = setmetatable({}, { __mode = "k" }) + +local function applyProperty(instance, prop, value) + instance[prop] = value +end + +local function bindEvent(instance: Instance, instanceEntity: ECS.Entity, event, callback) + local connection = instance[event]:Connect(callback) + local connectionList = World:get(instanceEntity, Components.ConnectionList) + table.insert(connectionList, connection) + World:set(instanceEntity, Components.ConnectionList, connectionList) +end + +local function bindChange(instance: Instance, instanceEntity: ECS.Entity, prop, action) + local connection + + if Is.Settable(action) then + connection = instance:GetPropertyChangedSignal(prop):Connect(function(...: any) action:set(instance[prop]) end) + else + connection = instance:GetPropertyChangedSignal(prop):Connect(action) + end + + local connectionList = World:get(instanceEntity, Components.ConnectionList) + table.insert(connectionList, connection) + World:set(instanceEntity, Components.ConnectionList, connectionList) +end + +local function bindReactive(instance: Instance, instanceEntity: ECS.Entity, prop, value): Effect.Effect + local propType = typeof(instance[prop]) + local propIsString = propType == "string" + + local propEffect = Effect(function() + local currentValue = value:get() + + if propIsString and typeof(currentValue) ~= "string" then + instance[prop] = tostring(currentValue) + else + instance[prop] = currentValue + end + end) + + applyProperty(instance, prop, value:get()) + + World:add(propEffect.entity, JECS.pair(Tags.InScope, instanceEntity)) +end + +local function applyVirtualNode(instance: Instance, instanceEntity: ECS.Entity, properties: {}) + for key, value in properties do + if table.find(RESERVED_KEYS, key) then continue end + + if Is.Symbol(key) then + if Has.Symbol("Event", key) then + if Is.Stateful(value) then error("Chemical OnEvent Error: Chemical does not currently support Stateful values.") end + if typeof(value) ~= "function" then error("Chemical OnEvent Error: can only be bound to a callback", 2) end + + + bindEvent(instance, instanceEntity, key.Symbol, value) + elseif Has.Symbol("Change", key) then + if typeof(value) ~= "function" + and not Is.Settable(value) then error("Chemical OnChange Error: can only be bound to a callback or settable Stateful object.", 2) end + + + bindChange(instance, instanceEntity, key.Symbol, value) + elseif Has.Symbol("Children", key) then + for _, child in value do + child.Parent = instance + end + end + elseif Is.Stateful(value) then + bindReactive(instance, instanceEntity, key, value) + elseif Is.Literal(value) then + applyProperty(instance, key, value) + end + end +end + +local function Compose(target: string | Instance) + return function(properties: {}) + local instance: Instance + local instanceEntity: ECS.Entity + + if typeof(target) == "string" then + instance = Instance.new(target) + instanceEntity = World:entity() + + World:add(instanceEntity, Tags.IsHost) + World:set(instanceEntity, Components.Instance, instance) + World:set(instanceEntity, Components.ConnectionList, {}) + + World:set(instanceEntity, Components.Connection, instance.Destroying:Once(function() + INSTANCE_TO_ENTITY[instance] = nil + + if World:contains(instanceEntity) then + World:delete(instanceEntity) + end + end)) + else + instance = target + instanceEntity = INSTANCE_TO_ENTITY[instance] + + if not instanceEntity or not World:contains(instanceEntity) then + instanceEntity = World:entity() + INSTANCE_TO_ENTITY[instance] = instanceEntity + + World:add(instanceEntity, Tags.IsHost) + World:set(instanceEntity, Components.Instance, instance) + World:set(instanceEntity, Components.ConnectionList, {}) + + World:set(instanceEntity, Components.Connection, instance.Destroying:Once(function() + INSTANCE_TO_ENTITY[instance] = nil + + if World:contains(instanceEntity) then + World:delete(instanceEntity) + end + end)) + end + end + + applyVirtualNode(instance, instanceEntity, properties) + + if properties.Parent and not instance.Parent then + instance.Parent = properties.Parent + end + + return instance + end +end + +return Compose \ No newline at end of file diff --git a/src/Chemical/Functions/Destroy.lua b/src/Chemical/Functions/Destroy.lua new file mode 100644 index 0000000..7bc3868 --- /dev/null +++ b/src/Chemical/Functions/Destroy.lua @@ -0,0 +1,38 @@ +export type Destroyable = Computed | Value | Observer | { destroy: (self: {}) -> () } | Instance | RBXScriptConnection | { Destroyable } | () -> () | thread + +local function Destroy(subject: Destroyable ) + if typeof(subject) == "table" then + if subject.destroy then + subject:destroy() + + return + end + + if subject.Destroy then + subject:Destroy() + + return + end + + if getmetatable(subject) then + setmetatable(subject, nil) + table.clear(subject) + + return + end + + for _, value in subject do + Destroy(value) + end + elseif typeof(subject) == "Instance" then + subject:Destroy() + elseif typeof(subject) == "RBXScriptConnection" then + subject:Disconnect() + elseif typeof(subject) == "function" then + subject() + elseif typeof(subject) == "thread" then + task.cancel(subject) + end +end + +return Destroy diff --git a/src/Chemical/Functions/GetInScope.lua b/src/Chemical/Functions/GetInScope.lua new file mode 100644 index 0000000..46b05dc --- /dev/null +++ b/src/Chemical/Functions/GetInScope.lua @@ -0,0 +1,13 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + +local function getInScope(entity: ECS.Entity) + local scoped = {} + for scopedEntity in ECS.World:each(ECS.JECS.pair(ECS.Tags.InScope, entity)) do + table.insert(scoped, scopedEntity) + end + return scoped +end + +return getInScope \ No newline at end of file diff --git a/src/Chemical/Functions/GetSubscribers.lua b/src/Chemical/Functions/GetSubscribers.lua new file mode 100644 index 0000000..f90c909 --- /dev/null +++ b/src/Chemical/Functions/GetSubscribers.lua @@ -0,0 +1,20 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + + + +local function getSubscribers(entity: ECS.Entity) + local subscribers = {} + + local subscriberQuery = ECS.World:query(ECS.Components.Object) + :with(ECS.JECS.pair(ECS.Tags.SubscribesTo, entity)) + + for subscriberEntity, _ in subscriberQuery:iter() do + table.insert(subscribers, subscriberEntity) + end + + return subscribers +end + +return getSubscribers \ No newline at end of file diff --git a/src/Chemical/Functions/GetSymbol.lua b/src/Chemical/Functions/GetSymbol.lua new file mode 100644 index 0000000..9587b02 --- /dev/null +++ b/src/Chemical/Functions/GetSymbol.lua @@ -0,0 +1,14 @@ +local RootFolder = script.Parent.Parent + +local symbolMap = {} + +export type Symbol = { Symbol: S, Type: T } + +return function(symbolName: S, symbolType: T): Symbol + if symbolMap[symbolName] then return symbolMap[symbolName] end + + local symbol = { Symbol = symbolName, Type = symbolType } + symbolMap[symbolName] = symbol + + return symbol +end diff --git a/src/Chemical/Functions/Has.lua b/src/Chemical/Functions/Has.lua new file mode 100644 index 0000000..fb1195c --- /dev/null +++ b/src/Chemical/Functions/Has.lua @@ -0,0 +1,12 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + +local module = {} + +function module.Symbol(typeOf: string, obj: {}): boolean + return obj.Type == typeOf +end + + +return module diff --git a/src/Chemical/Functions/Is.lua b/src/Chemical/Functions/Is.lua new file mode 100644 index 0000000..49db49a --- /dev/null +++ b/src/Chemical/Functions/Is.lua @@ -0,0 +1,83 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + +local module = {} + +function module.Stateful(obj: any): boolean + if typeof(obj) == "table" and obj.entity then + return ECS.World:has(obj.entity, ECS.Tags.IsStateful) + end + + return false +end + +function module.Settable(obj: any): boolean + if typeof(obj) == "table" and obj.entity then + return ECS.World:has(obj.entity, ECS.Tags.IsSettable) + end + + return false +end + +function module.Primitive(obj: any): boolean + local typeA = typeof(obj) + return typeA ~= "table" and typeA ~= "userdata" +end + +function module.Literal(obj: any): boolean + local typeA = typeof(obj) + return typeA ~= "table" and typeA ~= "userdata" and typeA ~= "thread" and typeA ~= "function" and typeA ~= "Instance" +end + +function module.Symbol(obj: any, typeOf: string?): boolean + local is = typeof(obj) == "table" and obj.Type and obj.Symbol + return typeOf == nil and is or is and obj.Type == typeOf +end + +function module.Array(obj: any): boolean + return typeof(obj) == "table" and obj.entity == nil +end + +function module.StatefulTable(obj: any): boolean + if typeof(obj) == "table" and obj.entity then + return ECS.World:has(obj.entity, ECS.Tags.IsStateful) and ECS.World:has(obj.entity, ECS.Tags.IsStatefulTable) + end + + return false +end + +function module.StatefulDictionary(obj: any): boolean + if typeof(obj) == "table" and obj.entity then + return ECS.World:has(obj.entity, ECS.Tags.IsStateful) and ECS.World:has(obj.entity, ECS.Tags.IsStatefulDictionary) + end + + return false +end + +function module.Blueprint(obj: any): boolean + if typeof(obj) == "table" and obj.T and obj.V then + return true + end + + return false +end + +function module.Dead(obj: any) + return typeof(obj) == "table" and obj.__destroyed +end + +function module.Destroyed(obj: Instance): boolean + if obj.Parent == nil then + local Success, Error = pcall(function() + obj.Parent = UserSettings() :: any + end) + + return Error ~= "Not allowed to add that under settings" + end + + return false +end + + +return module diff --git a/src/Chemical/Functions/Peek.lua b/src/Chemical/Functions/Peek.lua new file mode 100644 index 0000000..b6042d2 --- /dev/null +++ b/src/Chemical/Functions/Peek.lua @@ -0,0 +1,15 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + +local Is = require(RootFolder.Functions.Is) + +--- Peek +-- View a stateful's value without triggering and scoped dependencies/subscriptions. +return function(obj: any): any? + if Is.Stateful(obj) then + return ECS.World:get(obj.entity, ECS.Components.Value) + end + + return nil +end diff --git a/src/Chemical/Functions/SetInScope.lua b/src/Chemical/Functions/SetInScope.lua new file mode 100644 index 0000000..bb877be --- /dev/null +++ b/src/Chemical/Functions/SetInScope.lua @@ -0,0 +1,21 @@ +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Array = require(RootFolder.Functions.Array) +local Is = require(RootFolder.Functions.Is) + +local function setInScope(scopable: {ECS.Entity} | ECS.Entity, entity: ECS.Entity) + if Is.Stateful(scopable) then + ECS.World:add(scopable.entity, ECS.JECS.pair(ECS.Tags.InScope, entity)) + + return + end + + Array.Traverse(scopable, function(k, v) + if Is.Stateful(v) then + ECS.World:add(v.entity, ECS.JECS.pair(ECS.Tags.InScope, entity)) + end + end) +end + +return setInScope \ No newline at end of file diff --git a/src/Chemical/Functions/init.meta.json b/src/Chemical/Functions/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Functions/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Cleanable.lua b/src/Chemical/Mixins/Cleanable.lua new file mode 100644 index 0000000..67dd8f4 --- /dev/null +++ b/src/Chemical/Mixins/Cleanable.lua @@ -0,0 +1,19 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +export type Cleanable = Types.HasEntity & { + clean: (self: Cleanable) -> (), +} + +return { + clean = function(self: Cleanable) + local cleanupFn = ECS.World:get(self.entity, ECS.Components.CleanupFn) + if cleanupFn then + cleanupFn() + end + end, +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Computable.lua b/src/Chemical/Mixins/Computable.lua new file mode 100644 index 0000000..402eb46 --- /dev/null +++ b/src/Chemical/Mixins/Computable.lua @@ -0,0 +1,53 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Types = require(RootFolder.Types) + +local GetSubscribers = require(RootFolder.Functions.GetSubscribers) + +export type Computable = Types.HasEntity & { + compute: (self: Computable) -> (), +} + +type MaybeCleanable = { + clean: (self: MaybeCleanable) -> () +} + +return { + compute = function(self: Computable & MaybeCleanable) + local computeFn = ECS.World:get(self.entity, ECS.Components.ComputeFn) + if not computeFn then return end + + + local oldValue = ECS.World:get(self.entity, ECS.Components.Value) + local cleanupFn = ECS.World:get(self.entity, ECS.Components.CleanupFn) + + if oldValue and cleanupFn then + cleanupFn(oldValue) + end + + Cache.Stack:Push(self.entity) + local s, result = pcall(computeFn) + Cache.Stack:Pop() + + if not s then + warn("Chemical Computed Error: ", result) + return + end + + if result ~= oldValue then + ECS.World:set(self.entity, ECS.Components.PrevValue, oldValue) + ECS.World:set(self.entity, ECS.Components.Value, result) + + local subscribers = GetSubscribers(self.entity) + for _, subscriberEntity in ipairs(subscribers) do + if not ECS.World:has(subscriberEntity, ECS.Tags.IsDirty) then + ECS.World:add(subscriberEntity, ECS.Tags.IsDirty) + end + end + end + end, +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Destroyable.lua b/src/Chemical/Mixins/Destroyable.lua new file mode 100644 index 0000000..2df6d6e --- /dev/null +++ b/src/Chemical/Mixins/Destroyable.lua @@ -0,0 +1,37 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Types = require(RootFolder.Types) +local ECS = require(RootFolder.ECS) + +export type Destroyable = Types.HasEntity & { + __destroyed: boolean, + + destroy: (self: Destroyable) -> (), + --__internalDestroy: (self: Destroyable) -> (), +} + +local methods = {} + +function methods:__internalDestroy() + if self.__destroyed then return end + self.__destroyed = true + + local cleanupFn = ECS.World:get(self.entity, ECS.Components.CleanupFn) + if cleanupFn then + cleanupFn() + + ECS.World:remove(self.entity, ECS.Components.CleanupFn) + end +end + +function methods:destroy() + if self.__destroyed then return end + + self:__internalDestroy() + + ECS.World:delete(self.entity) +end + +return methods \ No newline at end of file diff --git a/src/Chemical/Mixins/Effectable.lua b/src/Chemical/Mixins/Effectable.lua new file mode 100644 index 0000000..2781cdb --- /dev/null +++ b/src/Chemical/Mixins/Effectable.lua @@ -0,0 +1,33 @@ +--!strict +local RootFolder = script.Parent.Parent +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Types = require(RootFolder.Types) + +export type Effectable = Types.HasEntity & { + run: (self: Effectable) -> (), +} + +return { + run = function(self: Effectable) + local effectFn = ECS.World:get(self.entity, ECS.Components.EffectFn) + if not effectFn then return end + + + local oldCleanupFn = ECS.World:get(self.entity, ECS.Components.CleanupFn) + if oldCleanupFn then + oldCleanupFn() + + + ECS.World:remove(self.entity, ECS.Components.CleanupFn) + end + + Cache.Stack:Push(self.entity) + local newCleanupFn = effectFn() + Cache.Stack:Pop() + + if newCleanupFn then + ECS.World:set(self.entity, ECS.Components.CleanupFn, newCleanupFn) + end + end, +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Numerical.lua b/src/Chemical/Mixins/Numerical.lua new file mode 100644 index 0000000..e342c7b --- /dev/null +++ b/src/Chemical/Mixins/Numerical.lua @@ -0,0 +1,28 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Cache = require(RootFolder.Cache) +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +export type Numerical = Types.HasEntity & { + increment: (self: Numerical, n: number) -> (), + decrement: (self: Numerical, n: number) -> () +} + +return { + increment = function(self: Numerical, n: number) + local cachedValue = ECS.World:get(self.entity, ECS.Components.Value) + + ECS.World:set(self.entity, ECS.Components.PrevValue, cachedValue) + ECS.World:set(self.entity, ECS.Components.Value, cachedValue + n) + end, + + decrement = function(self: Numerical, n: number) + local cachedValue = ECS.World:get(self.entity, ECS.Components.Value) + + ECS.World:set(self.entity, ECS.Components.PrevValue, cachedValue) + ECS.World:set(self.entity, ECS.Components.Value, cachedValue - n) + end, +} diff --git a/src/Chemical/Mixins/Observable.lua b/src/Chemical/Mixins/Observable.lua new file mode 100644 index 0000000..6215454 --- /dev/null +++ b/src/Chemical/Mixins/Observable.lua @@ -0,0 +1,103 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local LinkedList = require(RootFolder.Packages.LinkedList) + +local Types = require(RootFolder.Types) +local ECS = require(RootFolder.ECS) +local DeepCompare = require(RootFolder.Functions.Compare) + + +export type Observable = Types.HasEntity & { + __destroyed: boolean, + onChange: (self: Observable, callback: (new: T, old: T) -> ()) -> { disconnect: () -> () }, + run: (self: Observable) -> (), + destroy: (self: Observable) -> (), +} + +export type ObservableTable = Observable & { + onKVChange: (self: ObservableTable, callback: (path: {string|number}, new: any, old: any) -> ()) -> { disconnect: () -> () }, +} + +return { + onChange = function(self: Observable, callback: (any, any) -> ()) + local callbackList = ECS.World:get(self.entity, ECS.Components.OnChangeCallbacks) + callbackList:InsertBack(callback) + + return { + disconnect = function() + callbackList:Remove(callback) + end, + } + end, + + onKVChange = function(self: Observable, callback: (path: {any}, any, any) -> ()) + local kvCallbackList = ECS.World:get(self.entity, ECS.Components.OnKVChangeCallbacks) + kvCallbackList:InsertBack(callback) + + return { + disconnect = function() + kvCallbackList:Remove(callback) + end, + } + end, + + run = function(self: Observable) + local sourceEntity = ECS.World:target(self.entity, ECS.Tags.SubscribesTo) + if not sourceEntity then return end + + local newValue = ECS.World:get(sourceEntity, ECS.Components.Value) + local oldValue = ECS.World:get(sourceEntity, ECS.Components.PrevValue) + + + local callbacksList = ECS.World:get(self.entity, ECS.Components.OnChangeCallbacks) + if callbacksList then + for link, callback in callbacksList:IterateForward() do + local s, err = pcall(callback, newValue, oldValue) + if not s then warn("Chemical Observer Error: onChange: ", err) end + end + end + + + if ECS.World:has(sourceEntity, ECS.Tags.IsDeepComparable) then + local kvCallbackList = ECS.World:get(self.entity, ECS.Components.OnKVChangeCallbacks) + if kvCallbackList then + local changes = DeepCompare(oldValue, newValue) + for _, change in ipairs(changes) do + for link, callback in kvCallbackList:IterateForward() do + local s, err = pcall(callback, change.Path, change.NewValue, change.OldValue) + if not s then warn("Chemical Observer Error: onKVChange: ", err) end + end + end + end + end + end, + + + __internalDestroy = function(self: Observable & Types.MaybeCleanable) + if self.__destroyed then return end + self.__destroyed = true + + + local callbacksList = ECS.World:get(self.entity, ECS.Components.OnChangeCallbacks) + if callbacksList then callbacksList:Destroy() end + + local kvCallbackList = ECS.World:get(self.entity, ECS.Components.OnKVChangeCallbacks) + if kvCallbackList then kvCallbackList:Destroy() end + + + if self.clean then self:clean() end + + + setmetatable(self, nil) + end, + + destroy = function(self: Observable & Types.MaybeCleanable & Types.MaybeDestroyable) + if self.__destroyed then return end + + self:__internalDestroy() + + ECS.World:delete(self.entity) + end, +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Serializable.lua b/src/Chemical/Mixins/Serializable.lua new file mode 100644 index 0000000..36ade4f --- /dev/null +++ b/src/Chemical/Mixins/Serializable.lua @@ -0,0 +1,73 @@ +--!strict +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Types = require(RootFolder.Types) + +local Is = require(RootFolder.Functions.Is) +local Peek = require(RootFolder.Functions.Peek) +local Array = require(RootFolder.Functions.Array) +local Blueprint = require(RootFolder.Functions.Blueprint) + +export type Serializable = Types.HasEntity & { + serialize: (self: Serializable) -> (any), + snapshot: (self: Serializable) -> (any), + blueprint: (self: Serializable) -> (any | { any }), +} + +return { + serialize = function(self: Serializable): any + local value = ECS.World:get(self.entity, ECS.Components.Value) + local serialized + + if Is.Stateful(value) then + local theValue = Peek(value) + serialized = Is.Primitive(theValue) and theValue + + elseif Is.Array(value) then + serialized = Array.Transform(value, function(k, v) + if Is.Stateful(v) then + local theValue = Peek(v) + return Is.Primitive(theValue) and theValue or nil + elseif Is.Primitive(v) then + return v + end + + return nil + end) + + elseif Is.Primitive(value) then + serialized = value + + end + + return serialized, if not serialized then warn("There was nothing to serialize, or the value was unserializable.") else nil + end, + + snapshot = function(self: Serializable): any + local value = ECS.World:get(self.entity, ECS.Components.Value) + + if Is.Stateful(value) then + return Peek(value) + elseif Is.Array(value) then + return Array.Transform(value, function(k, v) + if Is.Stateful(v) then + local theValue = Peek(v) + return Peek(v) + elseif Is.Primitive(v) then + return v + end + + return nil + end) + else + return value + end + end, + + blueprint = function(self: Serializable): any | { any } + local value = ECS.World:get(self.entity, ECS.Components.Value) + return Blueprint:From(value) + end, +} \ No newline at end of file diff --git a/src/Chemical/Mixins/Settable.lua b/src/Chemical/Mixins/Settable.lua new file mode 100644 index 0000000..0ea62d3 --- /dev/null +++ b/src/Chemical/Mixins/Settable.lua @@ -0,0 +1,24 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Cache = require(RootFolder.Cache) +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +export type Settable = Types.HasEntity & { + set: (self: Settable, T) -> () +} + +return { + set = function(self: Settable, value: any) + local cachedValue = ECS.World:get(self.entity, ECS.Components.Value) + + if value == cachedValue then + return + end + + ECS.World:set(self.entity, ECS.Components.PrevValue, cachedValue) + ECS.World:set(self.entity, ECS.Components.Value, value) + end, +} diff --git a/src/Chemical/Mixins/Stateful.lua b/src/Chemical/Mixins/Stateful.lua new file mode 100644 index 0000000..70ac6c7 --- /dev/null +++ b/src/Chemical/Mixins/Stateful.lua @@ -0,0 +1,23 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Cache = require(RootFolder.Cache) +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +export type Stateful = Types.HasEntity & { + get: (self: Stateful) -> (T) +} + +return { + get = function(self: Stateful) + local withinEntity = Cache.Stack:Top() + if withinEntity then + ECS.World:add(withinEntity, ECS.JECS.pair(ECS.Tags.SubscribesTo, self.entity)) + ECS.World:add(self.entity, ECS.JECS.pair(ECS.Tags.HasSubscriber, withinEntity)) + end + + return ECS.World:get(self.entity, ECS.Components.Value) + end, +} diff --git a/src/Chemical/Mixins/StatefulDictionary.lua b/src/Chemical/Mixins/StatefulDictionary.lua new file mode 100644 index 0000000..dc0e960 --- /dev/null +++ b/src/Chemical/Mixins/StatefulDictionary.lua @@ -0,0 +1,52 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Cache = require(RootFolder.Cache) +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +local Destroy = require(RootFolder.Functions:FindFirstChild("Destroy")) + +export type StatefulDictionary = Types.HasEntity & { + key: (self: StatefulDictionary, key: K, value: V?) -> (), + clear: (self: StatefulDictionary, cleanup: (value: V) -> ()?) -> (any?), +} + +local function recursive(tbl, func) + for key, value in tbl do + if typeof(value) == "table" and not value.type then + recursive(value, func) + + continue + end + + func(value) + end +end + +return { + key = function(self: StatefulDictionary, key: T, value: V?) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + newTbl[key] = value + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + end, + + clear = function(self: StatefulDictionary, cleanup: (any) -> ()) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + if cleanup then + recursive(newTbl, cleanup) + end + + newTbl = {} + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + + end, + +} diff --git a/src/Chemical/Mixins/StatefulTable.lua b/src/Chemical/Mixins/StatefulTable.lua new file mode 100644 index 0000000..4123084 --- /dev/null +++ b/src/Chemical/Mixins/StatefulTable.lua @@ -0,0 +1,71 @@ +--!strict + +local RootFolder = script.Parent.Parent + +local Cache = require(RootFolder.Cache) +local ECS = require(RootFolder.ECS) +local Types = require(RootFolder.Types) + +export type StatefulTable = Types.HasEntity & { + insert: (self: StatefulTable, value: V) -> (), + remove: (self: StatefulTable, value: V) -> (), + find: (self: StatefulTable, value: V) -> (number)?, + + setAt: (self: StatefulTable, index: number, value: V) -> (), + getAt: (self: StatefulTable, index: number) -> (any?), + + clear: (self: StatefulTable) -> (), +} + +return { + insert = function(self: StatefulTable, value: T) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + table.insert(newTbl, value) + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + end, + + remove = function(self: StatefulTable, value: T) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + local index = table.find(newTbl, value) + local poppedValue = table.remove(newTbl, index) + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + + return poppedValue + end, + + find = function(self: StatefulTable, value: T) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local found = table.find(tbl, value) + + return found + end, + + setAt = function(self: StatefulTable, index: number, value: T) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + newTbl[index] = value + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + end, + + getAt = function(self: StatefulTable, index: number) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + return tbl[index] + end, + + clear = function(self: StatefulTable) + local tbl = ECS.World:get(self.entity, ECS.Components.Value) + local newTbl = table.clone(tbl) + + table.clear(newTbl) + + ECS.World:set(self.entity, ECS.Components.Value, newTbl) + end, +} diff --git a/src/Chemical/Mixins/init.meta.json b/src/Chemical/Mixins/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Mixins/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Packages/Datastore/Proxy.lua b/src/Chemical/Packages/Datastore/Proxy.lua new file mode 100644 index 0000000..20ec3be --- /dev/null +++ b/src/Chemical/Packages/Datastore/Proxy.lua @@ -0,0 +1,52 @@ +-- Variables +local Constructor = {} +local Index, NewIndex + + + + +-- Types +export type Constructor = { + new: (data: {[any]: any}, public: {[any]: any}?) -> ({}, {[any]: any}), +} + + + + +-- Constructor +Constructor.new = function(data, public) + local proxy = newproxy(true) + local metatable = getmetatable(proxy) + for index, value in data do metatable[index] = value end + metatable.__index = Index + metatable.__newindex = NewIndex + metatable.__public = public or {} + return proxy, metatable +end + + + + +-- Functions +Index = function(proxy, index) + local metatable = getmetatable(proxy) + local public = metatable.__public[index] + return if public == nil then metatable.__shared[index] else public +end + +NewIndex = function(proxy, index, value) + local metatable = getmetatable(proxy) + local set = metatable.__set[index] + if set == nil then + metatable.__public[index] = value + elseif set == false then + error("Attempt to modify a readonly value", 2) + else + set(proxy, metatable, value) + end +end + + + + +return table.freeze(Constructor) :: Constructor \ No newline at end of file diff --git a/src/Chemical/Packages/Datastore/Signal.lua b/src/Chemical/Packages/Datastore/Signal.lua new file mode 100644 index 0000000..c5723be --- /dev/null +++ b/src/Chemical/Packages/Datastore/Signal.lua @@ -0,0 +1,219 @@ +-- Variables +local Proxy = require(script.Parent.Proxy) +local Constructor, Signal, Connection = {}, {}, {} +local Thread, Call +local threads = {} + + + + +-- Types +export type Constructor = { + new: () -> Signal, +} + +export type Signal = { + [any]: any, + Connections: number, + Connected: (connected: boolean, signal: Signal) -> ()?, + Connect: (self: Signal, func: (...any) -> (), ...any) -> Connection, + Once: (self: Signal, func: (...any) -> (), ...any) -> Connection, + Wait: (self: Signal, ...any) -> ...any, + Fire: (self: Signal, ...any) -> (), + FastFire: (self: Signal, ...any) -> (), + DisconnectAll: (self: Signal) -> (), +} + +export type Connection = { + [any]: any, + Signal: Signal?, + Disconnect: (self: Connection) -> (), +} + + + + +-- Constructor +Constructor.new = function() + local proxy, signal = Proxy.new(Signal, {Connections = 0}) + return proxy +end + + + + +-- Signal +Signal.__tostring = function(proxy) + return "Signal" +end + +Signal.__shared = { + Connect = function(proxy, func, ...) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to Connect failed: Passed value is not a Signal", 3) end + if type(func) ~= "function" then error("Attempt to Connect failed: Passed value is not a function", 3) end + signal.__public.Connections += 1 + local connectionProxy, connection = Proxy.new(Connection, {Signal = proxy}) + connection.FunctionOrThread = func + connection.Parameters = if ... == nil then nil else {...} + if signal.Last == nil then signal.First, signal.Last = connection, connection else connection.Previous, signal.Last.Next, signal.Last = signal.Last, connection, connection end + if signal.__public.Connections == 1 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, true, proxy) end + return connectionProxy + end, + Once = function(proxy, func, ...) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to Connect failed: Passed value is not a Signal", 3) end + if type(func) ~= "function" then error("Attempt to Connect failed: Passed value is not a function", 3) end + signal.__public.Connections += 1 + local connectionProxy, connection = Proxy.new(Connection, {Signal = proxy}) + connection.FunctionOrThread = func + connection.Once = true + connection.Parameters = if ... == nil then nil else {...} + if signal.Last == nil then signal.First, signal.Last = connection, connection else connection.Previous, signal.Last.Next, signal.Last = signal.Last, connection, connection end + if signal.__public.Connections == 1 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, true, proxy) end + return connectionProxy + end, + Wait = function(proxy, ...) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to Connect failed: Passed value is not a Signal", 3) end + signal.__public.Connections += 1 + local connectionProxy, connection = Proxy.new(Connection, {Signal = proxy}) + connection.FunctionOrThread = coroutine.running() + connection.Once = true + connection.Parameters = if ... == nil then nil else {...} + if signal.Last == nil then signal.First, signal.Last = connection, connection else connection.Previous, signal.Last.Next, signal.Last = signal.Last, connection, connection end + if signal.__public.Connections == 1 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, true, proxy) end + return coroutine.yield() + end, + Fire = function(proxy, ...) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to connect failed: Passed value is not a Signal", 3) end + local connection = signal.First + while connection ~= nil do + if connection.Once == true then + signal.__public.Connections -= 1 + connection.__public.Signal = nil + if signal.First == connection then signal.First = connection.Next end + if signal.Last == connection then signal.Last = connection.Previous end + if connection.Previous ~= nil then connection.Previous.Next = connection.Next end + if connection.Next ~= nil then connection.Next.Previous = connection.Previous end + if signal.__public.Connections == 0 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, false, proxy) end + end + if type(connection.FunctionOrThread) == "thread" then + if connection.Parameters == nil then + task.spawn(connection.FunctionOrThread, ...) + else + local parameters = {...} + task.spawn(connection.FunctionOrThread, table.unpack(table.move(connection.Parameters, 1, #connection.Parameters, #parameters + 1, parameters))) + end + else + local thread = table.remove(threads) + if thread == nil then thread = coroutine.create(Thread) coroutine.resume(thread) end + if connection.Parameters == nil then + task.spawn(thread, thread, connection.FunctionOrThread, ...) + else + local parameters = {...} + task.spawn(thread, thread, connection.FunctionOrThread, table.unpack(table.move(connection.Parameters, 1, #connection.Parameters, #parameters + 1, parameters))) + end + end + connection = connection.Next + end + end, + FastFire = function(proxy, ...) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to connect failed: Passed value is not a Signal", 3) end + local connection = signal.First + while connection ~= nil do + if connection.Once == true then + signal.__public.Connections -= 1 + connection.__public.Signal = nil + if signal.First == connection then signal.First = connection.Next end + if signal.Last == connection then signal.Last = connection.Previous end + if connection.Previous ~= nil then connection.Previous.Next = connection.Next end + if connection.Next ~= nil then connection.Next.Previous = connection.Previous end + if signal.__public.Connections == 0 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, false, proxy) end + end + if type(connection.FunctionOrThread) == "thread" then + if connection.Parameters == nil then + coroutine.resume(connection.FunctionOrThread, ...) + else + local parameters = {...} + coroutine.resume(connection.FunctionOrThread, table.unpack(table.move(connection.Parameters, 1, #connection.Parameters, #parameters + 1, parameters))) + end + else + if connection.Parameters == nil then + connection.FunctionOrThread(...) + else + local parameters = {...} + connection.FunctionOrThread(table.unpack(table.move(connection.Parameters, 1, #connection.Parameters, #parameters + 1, parameters))) + end + end + connection = connection.Next + end + end, + DisconnectAll = function(proxy) + local signal = getmetatable(proxy) + if type(signal) ~= "table" or signal.__shared ~= Signal.__shared then error("Attempt to Connect failed: Passed value is not a Signal", 3) end + local connection = signal.First + if connection == nil then return end + while connection ~= nil do + connection.__public.Signal = nil + if type(connection.FunctionOrThread) == "thread" then task.cancel(connection.FunctionOrThread) end + connection = connection.Next + end + if signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, false, proxy) end + signal.__public.Connections, signal.First, signal.Last = 0, nil, nil + end, +} + +Signal.__set = { + Connections = false, +} + + + + +-- Connection +Connection.__tostring = function(proxy) + return "Connection" +end + +Connection.__shared = { + Disconnect = function(proxy) + local connection = getmetatable(proxy) + if type(connection) ~= "table" or connection.__shared ~= Connection.__shared then error("Attempt to Disconnect failed: Passed value is not a Connection", 3) end + local signal = getmetatable(connection.__public.Signal) + if signal == nil then return end + signal.__public.Connections -= 1 + connection.__public.Signal = nil + if signal.First == connection then signal.First = connection.Next end + if signal.Last == connection then signal.Last = connection.Previous end + if connection.Previous ~= nil then connection.Previous.Next = connection.Next end + if connection.Next ~= nil then connection.Next.Previous = connection.Previous end + if type(connection.FunctionOrThread) == "thread" then task.cancel(connection.FunctionOrThread) end + if signal.__public.Connections == 0 and signal.__public.Connected ~= nil then task.defer(signal.__public.Connected, false, proxy) end + end, +} + +Connection.__set = { + Signal = false, +} + + + + +-- Functions +Thread = function() + while true do Call(coroutine.yield()) end +end + +Call = function(thread, func, ...) + func(...) + if #threads >= 16 then return end + table.insert(threads, thread) +end + + + + +return table.freeze(Constructor) :: Constructor \ No newline at end of file diff --git a/src/Chemical/Packages/Datastore/SynchronousTaskManager.lua b/src/Chemical/Packages/Datastore/SynchronousTaskManager.lua new file mode 100644 index 0000000..39245a5 --- /dev/null +++ b/src/Chemical/Packages/Datastore/SynchronousTaskManager.lua @@ -0,0 +1,266 @@ +-- Variables +local Proxy = require(script.Parent.Proxy) +local Constructor, TaskManager, SynchronousTask, RunningTask = {}, {}, {}, {} +local Run + + + + +-- Types +export type Constructor = { + new: () -> TaskManager, +} + +export type TaskManager = { + [any]: any, + Enabled: boolean, + Tasks: number, + Running: SynchronousTask?, + InsertFront: (self: TaskManager, func: (RunningTask, ...any) -> (), ...any) -> SynchronousTask, + InsertBack: (self: TaskManager, func: (RunningTask, ...any) -> (), ...any) -> SynchronousTask, + FindFirst: (self: TaskManager, func: (RunningTask, ...any) -> ()) -> (SynchronousTask?, number?), + FindLast: (self: TaskManager, func: (RunningTask, ...any) -> ()) -> (SynchronousTask?, number?), + CancelAll: (self: TaskManager, func: (RunningTask, ...any) -> ()?) -> (), +} + +export type SynchronousTask = { + [any]: any, + TaskManager: TaskManager?, + Running: boolean, + Wait: (self: SynchronousTask, ...any) -> ...any, + Cancel: (self: SynchronousTask) -> (), +} + +export type RunningTask = { + Next: (self: RunningTask) -> (thread, ...any), + Iterate: (self: RunningTask) -> ((self: RunningTask) -> (thread, ...any), RunningTask), + End: (self: RunningTask) -> (), +} + + + + +-- Constructor +Constructor.new = function() + local proxy, taskManager = Proxy.new(TaskManager, {Enabled = true, Tasks = 0}) + taskManager.Active = false + return proxy +end + + + + +-- TaskManager +TaskManager.__tostring = function(proxy) + return "Task Manager" +end + +TaskManager.__shared = { + InsertFront = function(proxy, func, ...) + local taskManager = getmetatable(proxy) + if type(taskManager) ~= "table" or taskManager.__shared ~= TaskManager.__shared then error("Attempt to InsertFront failed: Passed value is not a Task Manager", 3) end + if type(func) ~= "function" then error("Attempt to InsertFront failed: Passed value is not a function", 3) end + taskManager.__public.Tasks += 1 + local proxy, synchronousTask = Proxy.new(SynchronousTask, {TaskManager = proxy, Running = false}) + synchronousTask.Active = true + synchronousTask.Function = func + synchronousTask.Parameters = if ... == nil then nil else {...} + if taskManager.First == nil then taskManager.First, taskManager.Last = proxy, proxy else synchronousTask.Next, getmetatable(taskManager.First).Previous, taskManager.First = taskManager.First, proxy, proxy end + if taskManager.Active == false and taskManager.__public.Enabled == true then taskManager.Active = true task.defer(Run, taskManager) end + return proxy + end, + InsertBack = function(proxy, func, ...) + local taskManager = getmetatable(proxy) + if type(taskManager) ~= "table" or taskManager.__shared ~= TaskManager.__shared then error("Attempt to InsertBack failed: Passed value is not a Task Manager", 3) end + if type(func) ~= "function" then error("Attempt to InsertBack failed: Passed value is not a function", 3) end + taskManager.__public.Tasks += 1 + local proxy, synchronousTask = Proxy.new(SynchronousTask, {TaskManager = proxy, Running = false}) + synchronousTask.Active = true + synchronousTask.Function = func + synchronousTask.Parameters = if ... == nil then nil else {...} + if taskManager.Last == nil then taskManager.First, taskManager.Last = proxy, proxy else synchronousTask.Previous, getmetatable(taskManager.Last).Next, taskManager.Last = taskManager.Last, proxy, proxy end + if taskManager.Active == false and taskManager.__public.Enabled == true then taskManager.Active = true task.defer(Run, taskManager) end + return proxy + end, + FindFirst = function(proxy, func) + local taskManager = getmetatable(proxy) + if type(taskManager) ~= "table" or taskManager.__shared ~= TaskManager.__shared then error("Attempt to FindFirst failed: Passed value is not a Task Manager", 3) end + if type(func) ~= "function" then error("Attempt to FindFirst failed: Passed value is not a function", 3) end + proxy = taskManager.__public.Running + if proxy ~= nil then + local synchronousTask = getmetatable(proxy) + if synchronousTask.Active == true and synchronousTask.Function == func then return proxy, 0 end + end + local index = 1 + proxy = taskManager.First + while proxy ~= nil do + local synchronousTask = getmetatable(proxy) + if synchronousTask.Function == func then return proxy, index end + proxy = synchronousTask.Next + index += 1 + end + end, + FindLast = function(proxy, func) + local taskManager = getmetatable(proxy) + if type(taskManager) ~= "table" or taskManager.__shared ~= TaskManager.__shared then error("Attempt to FindLast failed: Passed value is not a Task Manager", 3) end + if type(func) ~= "function" then error("Attempt to FindFirst failed: Passed value is not a function", 3) end + local index = if taskManager.__public.Running == nil then taskManager.__public.Tasks else taskManager.__public.Tasks - 1 + proxy = taskManager.Last + while proxy ~= nil do + local synchronousTask = getmetatable(proxy) + if synchronousTask.Function == func then return proxy, index end + proxy = synchronousTask.Previous + index -= 1 + end + proxy = taskManager.__public.Running + if proxy ~= nil then + local synchronousTask = getmetatable(proxy) + if synchronousTask.Active == true and synchronousTask.Function == func then return proxy, 0 end + end + end, + CancelAll = function(proxy, func) + local taskManager = getmetatable(proxy) + if type(taskManager) ~= "table" or taskManager.__shared ~= TaskManager.__shared then error("Attempt to FindLast failed: Passed value is not a Task Manager", 3) end + if func == nil then + local proxy = taskManager.First + taskManager.First = nil + taskManager.Last = nil + if taskManager.__public.Running == nil then taskManager.__public.Tasks = 0 else taskManager.__public.Tasks = 1 end + while proxy ~= nil do + local synchronousTask = getmetatable(proxy) + proxy, synchronousTask.Active, synchronousTask.__public.TaskManager, synchronousTask.Previous, synchronousTask.Next = synchronousTask.Next, false, nil, nil, nil + end + else + if type(func) ~= "function" then error("Attempt to CancelAll failed: Passed value is not nil or function", 3) end + local proxy = taskManager.First + while proxy ~= nil do + local synchronousTask = getmetatable(proxy) + if synchronousTask.Function == func then + taskManager.__public.Tasks -= 1 + if taskManager.First == proxy then taskManager.First = synchronousTask.Next end + if taskManager.Last == proxy then taskManager.Last = synchronousTask.Previous end + if synchronousTask.Previous ~= nil then getmetatable(synchronousTask.Previous).Next = synchronousTask.Next end + if synchronousTask.Next ~= nil then getmetatable(synchronousTask.Next).Previous = synchronousTask.Previous end + proxy, synchronousTask.Active, synchronousTask.__public.TaskManager, synchronousTask.Previous, synchronousTask.Next = synchronousTask.Next, false, nil, nil, nil + else + proxy = synchronousTask.Next + end + end + end + end, +} + +TaskManager.__set = { + Enabled = function(proxy, taskManager, value) + if type(value) ~= "boolean" then error("Attempt to set Enabled failed: Passed value is not a boolean", 3) end + taskManager.__public.Enabled = value + if value == false or taskManager.First == nil or taskManager.Active == true then return end + taskManager.Active = true + task.defer(Run, taskManager) + end, + Tasks = false, + Running = false, +} + + + + +-- SynchronousTask +SynchronousTask.__tostring = function(proxy) + return "Synchronous Task" +end + +SynchronousTask.__shared = { + Wait = function(proxy, ...) + local synchronousTask = getmetatable(proxy) + if type(synchronousTask) ~= "table" or synchronousTask.__shared ~= SynchronousTask.__shared then error("Attempt to Wait failed: Passed value is not a Synchronous Task", 3) end + if synchronousTask.Active == false then return end + local waiter = {coroutine.running(), ...} + if synchronousTask.Last == nil then synchronousTask.First, synchronousTask.Last = waiter, waiter else synchronousTask.Last.Next, synchronousTask.Last = waiter, waiter end + return coroutine.yield() + end, + Cancel = function(proxy) + local synchronousTask = getmetatable(proxy) + if type(synchronousTask) ~= "table" or synchronousTask.__shared ~= SynchronousTask.__shared then error("Attempt to Cancel failed: Passed value is not a Synchronous Task", 3) end + if synchronousTask.__public.Running == true then return false end + local taskManager = synchronousTask.__public.TaskManager + if taskManager == nil then return false end + taskManager = getmetatable(taskManager) + taskManager.__public.Tasks -= 1 + if taskManager.First == proxy then taskManager.First = synchronousTask.Next end + if taskManager.Last == proxy then taskManager.Last = synchronousTask.Previous end + if synchronousTask.Previous ~= nil then getmetatable(synchronousTask.Previous).Next = synchronousTask.Next end + if synchronousTask.Next ~= nil then getmetatable(synchronousTask.Next).Previous = synchronousTask.Previous end + synchronousTask.Active, synchronousTask.__public.TaskManager, synchronousTask.Previous, synchronousTask.Next = false, nil, nil, nil + return true + end, +} + +SynchronousTask.__set = { + TaskManager = false, + Running = false, +} + + + + +-- RunningTask +RunningTask.__tostring = function(proxy) + return "Running Task" +end + +RunningTask.__shared = { + Next = function(proxy) + local runningTask = getmetatable(proxy) + if type(runningTask) ~= "table" or runningTask.__shared ~= RunningTask.__shared then error("Attempt to Next failed: Passed value is not a Running Task", 3) end + local synchronousTask = runningTask.SynchronousTask + local waiter = synchronousTask.First + if waiter == nil then return end + synchronousTask.First = waiter.Next + if synchronousTask.Last == waiter then synchronousTask.Last = nil end + return table.unpack(waiter) + end, + Iterate = function(proxy) + local runningTask = getmetatable(proxy) + if type(runningTask) ~= "table" or runningTask.__shared ~= RunningTask.__shared then error("Attempt to Iterate failed: Passed value is not a Running Task", 3) end + return runningTask.__shared.Next, proxy + end, + End = function(proxy) + local runningTask = getmetatable(proxy) + if type(runningTask) ~= "table" or runningTask.__shared ~= RunningTask.__shared then error("Attempt to End failed: Passed value is not a Running Task", 3) end + runningTask.SynchronousTask.Active = false + end, +} + +RunningTask.__set = { + +} + + + + +-- Functions +Run = function(taskManager) + if taskManager.__public.Enabled == false then taskManager.Active = false return end + local proxy = taskManager.First + if proxy == nil then taskManager.Active = false return end + local synchronousTask = getmetatable(proxy) + taskManager.__public.Running = proxy + taskManager.First = synchronousTask.Next + synchronousTask.__public.Running = true + if synchronousTask.Next == nil then taskManager.Last = nil else getmetatable(synchronousTask.Next).Previous = nil synchronousTask.Next = nil end + local proxy, runningTask = Proxy.new(RunningTask) + runningTask.SynchronousTask = synchronousTask + if synchronousTask.Parameters == nil then synchronousTask.Function(proxy) else synchronousTask.Function(proxy, table.unpack(synchronousTask.Parameters)) end + taskManager.__public.Tasks -= 1 + taskManager.__public.Running = nil + synchronousTask.Active = false + synchronousTask.__public.TaskManager = nil + synchronousTask.__public.Running = false + if taskManager.__public.Enabled == false or taskManager.First == nil then taskManager.Active = false else task.defer(Run, taskManager) end +end + + + + +return table.freeze(Constructor) :: Constructor \ No newline at end of file diff --git a/src/Chemical/Packages/Datastore/init.lua b/src/Chemical/Packages/Datastore/init.lua new file mode 100644 index 0000000..71d0f42 --- /dev/null +++ b/src/Chemical/Packages/Datastore/init.lua @@ -0,0 +1,734 @@ +-- Variables +local Proxy = require(script.Proxy) +local Signal = require(script.Signal) +local SynchronousTaskManager = require(script.SynchronousTaskManager) +local dataStoreService, memoryStoreService, httpService = game:GetService("DataStoreService"), game:GetService("MemoryStoreService"), game:GetService("HttpService") +local Constructor, DataStore = {}, {} +local OpenTask, ReadTask, LockTask, SaveTask, CloseTask, DestroyTask, Lock, Unlock, Load, Save, StartSaveTimer, StopSaveTimer, SaveTimerEnded, StartLockTimer, StopLockTimer, LockTimerEnded, ProcessQueue, SignalConnected, Clone, Reconcile, Compress, Decompress, Encode, Decode, BindToClose +local dataStores, bindToClose, active = {}, {}, true +local characters = {[0] = "0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","!","$","%","&","'",",",".","/",":",";","=","?","@","[","]","^","_","`","{","}","~"} +local bytes = {} for i = (0), #characters do bytes[string.byte(characters[i])] = i end +local base = #characters + 1 + + + + +-- Types +export type Constructor = { + new: (name: string, scope: string, key: string?) -> DataStore, + hidden: (name: string, scope: string, key: string?) -> DataStore, + find: (name: string, scope: string, key: string?) -> DataStore?, + Response: {Success: string, Saved: string, Locked: string, State: string, Error: string}, +} + +export type DataStore = { + Value: any, + Metadata: {[string]: any}, + UserIds: {any}, + SaveInterval: number, + SaveDelay: number, + LockInterval: number, + LockAttempts: number, + SaveOnClose: boolean, + Id: string, + UniqueId: string, + Key: string, + State: boolean?, + Hidden: boolean, + AttemptsRemaining: number, + CreatedTime: number, + UpdatedTime: number, + Version: string, + CompressedValue: string, + StateChanged: Signal.Signal, + Saving: Signal.Signal, + Saved: Signal.Signal, + AttemptsChanged: Signal.Signal, + ProcessQueue: Signal.Signal, + Open: (self: DataStore, template: any?) -> (string, any), + Read: (self: DataStore, template: any?) -> (string, any), + Save: (self: DataStore) -> (string, any), + Close: (self: DataStore) -> (string, any), + Destroy: (self: DataStore) -> (string, any), + Queue: (self: DataStore, value: any, expiration: number?, priority: number?) -> (string, any), + Remove: (self: DataStore, id: string) -> (string, any), + Clone: (self: DataStore) -> any, + Reconcile: (self: DataStore, template: any) -> (), + Usage: (self: DataStore) -> (number, number), +} + + + + +-- Constructor +Constructor.new = function(name, scope, key) + if key == nil then key, scope = scope, "global" end + local id = name .. "/" .. scope .. "/" .. key + if dataStores[id] ~= nil then return dataStores[id] end + local proxy, dataStore = Proxy.new(DataStore, { + Metadata = {}, + UserIds = {}, + SaveInterval = 30, + SaveDelay = 0, + LockInterval = 60, + LockAttempts = 5, + SaveOnClose = true, + Id = id, + UniqueId = httpService:GenerateGUID(false), + Key = key, + State = false, + Hidden = false, + AttemptsRemaining = 0, + CreatedTime = 0, + UpdatedTime = 0, + Version = "", + CompressedValue = "", + StateChanged = Signal.new(), + Saving = Signal.new(), + Saved = Signal.new(), + AttemptsChanged = Signal.new(), + ProcessQueue = Signal.new(), + }) + dataStore.TaskManager = SynchronousTaskManager.new() + dataStore.LockTime = -math.huge + dataStore.SaveTime = -math.huge + dataStore.ActiveLockInterval = 0 + dataStore.ProcessingQueue = false + dataStore.DataStore = dataStoreService:GetDataStore(name, scope) + dataStore.MemoryStore = memoryStoreService:GetSortedMap(id) + dataStore.Queue = memoryStoreService:GetQueue(id) + dataStore.Options = Instance.new("DataStoreSetOptions") + dataStore.__public.ProcessQueue.DataStore = proxy + dataStore.__public.ProcessQueue.Connected = SignalConnected + dataStores[id] = proxy + if active == true then bindToClose[dataStore.__public.UniqueId] = proxy end + return proxy +end + +Constructor.hidden = function(name, scope, key) + if key == nil then key, scope = scope, "global" end + local id = name .. "/" .. scope .. "/" .. key + local proxy, dataStore = Proxy.new(DataStore, { + Metadata = {}, + UserIds = {}, + SaveInterval = 30, + SaveDelay = 0, + LockInterval = 60, + LockAttempts = 5, + SaveOnClose = true, + Id = id, + UniqueId = httpService:GenerateGUID(false), + Key = key, + State = false, + Hidden = true, + AttemptsRemaining = 0, + CreatedTime = 0, + UpdatedTime = 0, + Version = "", + CompressedValue = "", + StateChanged = Signal.new(), + Saving = Signal.new(), + Saved = Signal.new(), + AttemptsChanged = Signal.new(), + ProcessQueue = Signal.new(), + }) + dataStore.TaskManager = SynchronousTaskManager.new() + dataStore.LockTime = -math.huge + dataStore.SaveTime = -math.huge + dataStore.ActiveLockInterval = 0 + dataStore.ProcessingQueue = false + dataStore.DataStore = dataStoreService:GetDataStore(name, scope) + dataStore.MemoryStore = memoryStoreService:GetSortedMap(id) + dataStore.Queue = memoryStoreService:GetQueue(id) + dataStore.Options = Instance.new("DataStoreSetOptions") + dataStore.__public.ProcessQueue.DataStore = proxy + dataStore.__public.ProcessQueue.Connected = SignalConnected + if active == true then bindToClose[dataStore.__public.UniqueId] = proxy end + return proxy +end + +Constructor.find = function(name, scope, key) + if key == nil then key, scope = scope, "global" end + local id = name .. "/" .. scope .. "/" .. key + return dataStores[id] +end + +Constructor.Response = {Success = "Success", Saved = "Saved", Locked = "Locked", State = "State", Error = "Error"} + + + + +-- DataStore +DataStore.__tostring = function(proxy) + return "DataStore" +end + +DataStore.__shared = { + Open = function(proxy, template) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Open failed: Passed value is not a DataStore", 3) end + if dataStore.__public.State == nil then return "State", "Destroyed" end + local synchronousTask = dataStore.TaskManager:FindFirst(OpenTask) + if synchronousTask ~= nil then return synchronousTask:Wait(template) end + if dataStore.TaskManager:FindLast(DestroyTask) ~= nil then return "State", "Destroying" end + if dataStore.__public.State == true and dataStore.TaskManager:FindLast(CloseTask) == nil then + if dataStore.__public.Value == nil then + dataStore.__public.Value = Clone(template) + elseif type(dataStore.__public.Value) == "table" and type(template) == "table" then + Reconcile(dataStore.__public.Value, template) + end + return "Success" + end + return dataStore.TaskManager:InsertBack(OpenTask, proxy):Wait(template) + end, + Read = function(proxy, template) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Read failed: Passed value is not a DataStore", 3) end + local synchronousTask = dataStore.TaskManager:FindFirst(ReadTask) + if synchronousTask ~= nil then return synchronousTask:Wait(template) end + if dataStore.__public.State == true and dataStore.TaskManager:FindLast(CloseTask) == nil then return "State", "Open" end + return dataStore.TaskManager:InsertBack(ReadTask, proxy):Wait(template) + end, + Save = function(proxy) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Save failed: Passed value is not a DataStore", 3) end + if dataStore.__public.State == false then return "State", "Closed" end + if dataStore.__public.State == nil then return "State", "Destroyed" end + local synchronousTask = dataStore.TaskManager:FindFirst(SaveTask) + if synchronousTask ~= nil then return synchronousTask:Wait() end + if dataStore.TaskManager:FindLast(CloseTask) ~= nil then return "State", "Closing" end + if dataStore.TaskManager:FindLast(DestroyTask) ~= nil then return "State", "Destroying" end + return dataStore.TaskManager:InsertBack(SaveTask, proxy):Wait() + end, + Close = function(proxy) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Close failed: Passed value is not a DataStore", 3) end + if dataStore.__public.State == nil then return "Success" end + local synchronousTask = dataStore.TaskManager:FindFirst(CloseTask) + if synchronousTask ~= nil then return synchronousTask:Wait() end + if dataStore.__public.State == false and dataStore.TaskManager:FindLast(OpenTask) == nil then return "Success" end + local synchronousTask = dataStore.TaskManager:FindFirst(DestroyTask) + if synchronousTask ~= nil then return synchronousTask:Wait() end + StopLockTimer(dataStore) + StopSaveTimer(dataStore) + return dataStore.TaskManager:InsertBack(CloseTask, proxy):Wait() + end, + Destroy = function(proxy) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Destroy failed: Passed value is not a DataStore", 3) end + if dataStore.__public.State == nil then return "Success" end + dataStores[dataStore.__public.Id] = nil + StopLockTimer(dataStore) + StopSaveTimer(dataStore) + return (dataStore.TaskManager:FindFirst(DestroyTask) or dataStore.TaskManager:InsertBack(DestroyTask, proxy)):Wait() + end, + Queue = function(proxy, value, expiration, priority) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Queue failed: Passed value is not a DataStore", 3) end + if expiration ~= nil and type(expiration) ~= "number" then error("Attempt to Queue failed: Passed value is not nil or number", 3) end + if priority ~= nil and type(priority) ~= "number" then error("Attempt to Queue failed: Passed value is not nil or number", 3) end + local success, errorMessage + for i = 1, 3 do + if i > 1 then task.wait(1) end + success, errorMessage = pcall(dataStore.Queue.AddAsync, dataStore.Queue, value, expiration or 604800, priority) + if success == true then return "Success" end + end + return "Error", errorMessage + end, + Remove = function(proxy, id) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Remove failed: Passed value is not a DataStore", 3) end + if type(id) ~= "string" then error("Attempt to RemoveQueue failed: Passed value is not a string", 3) end + local success, errorMessage + for i = 1, 3 do + if i > 1 then task.wait(1) end + success, errorMessage = pcall(dataStore.Queue.RemoveAsync, dataStore.Queue, id) + if success == true then return "Success" end + end + return "Error", errorMessage + end, + Clone = function(proxy) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Clone failed: Passed value is not a DataStore", 3) end + return Clone(dataStore.__public.Value) + end, + Reconcile = function(proxy, template) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Reconcile failed: Passed value is not a DataStore", 3) end + if dataStore.__public.Value == nil then + dataStore.__public.Value = Clone(template) + elseif type(dataStore.__public.Value) == "table" and type(template) == "table" then + Reconcile(dataStore.__public.Value, template) + end + end, + Usage = function(proxy) + local dataStore = getmetatable(proxy) + if type(dataStore) ~= "table" or dataStore.__shared ~= DataStore.__shared then error("Attempt to Usage failed: Passed value is not a DataStore", 3) end + if dataStore.__public.Value == nil then return 0, 0 end + if type(dataStore.__public.Metadata.Compress) ~= "table" then + local characters = #httpService:JSONEncode(dataStore.__public.Value) + return characters, characters / 4194303 + else + local level = dataStore.__public.Metadata.Compress.Level or 2 + local decimals = 10 ^ (dataStore.__public.Metadata.Compress.Decimals or 3) + local safety = if dataStore.__public.Metadata.Compress.Safety == nil then true else dataStore.__public.Metadata.Compress.Safety + dataStore.__public.CompressedValue = Compress(dataStore.__public.Value, level, decimals, safety) + local characters = #httpService:JSONEncode(dataStore.__public.CompressedValue) + return characters, characters / 4194303 + end + end, +} + +DataStore.__set = { + Metadata = function(proxy, dataStore, value) + if type(value) ~= "table" then error("Attempt to set Metadata failed: Passed value is not a table", 3) end + dataStore.__public.Metadata = value + end, + UserIds = function(proxy, dataStore, value) + if type(value) ~= "table" then error("Attempt to set UserIds failed: Passed value is not a table", 3) end + dataStore.__public.UserIds = value + end, + SaveInterval = function(proxy, dataStore, value) + if type(value) ~= "number" then error("Attempt to set SaveInterval failed: Passed value is not a number", 3) end + if value < 10 and value ~= 0 then error("Attempt to set SaveInterval failed: Passed value is less then 10 and not 0", 3) end + if value > 1000 then error("Attempt to set SaveInterval failed: Passed value is more then 1000", 3) end + if value == dataStore.__public.SaveInterval then return end + dataStore.__public.SaveInterval = value + if dataStore.__public.State ~= true then return end + if value == 0 then + StopSaveTimer(dataStore) + elseif dataStore.TaskManager:FindLast(CloseTask) == nil and dataStore.TaskManager:FindLast(DestroyTask) == nil then + StartSaveTimer(proxy) + end + end, + SaveDelay = function(proxy, dataStore, value) + if type(value) ~= "number" then error("Attempt to set SaveDelay failed: Passed value is not a number", 3) end + if value < 0 then error("Attempt to set SaveDelay failed: Passed value is less then 0", 3) end + if value > 10 then error("Attempt to set SaveDelay failed: Passed value is more then 10", 3) end + dataStore.__public.SaveDelay = value + end, + LockInterval = function(proxy, dataStore, value) + if type(value) ~= "number" then error("Attempt to set LockInterval failed: Passed value is not a number", 3) end + if value < 10 then error("Attempt to set LockInterval failed: Passed value is less then 10", 3) end + if value > 1000 then error("Attempt to set LockInterval failed: Passed value is more then 1000", 3) end + dataStore.__public.LockInterval = value + end, + LockAttempts = function(proxy, dataStore, value) + if type(value) ~= "number" then error("Attempt to set LockAttempts failed: Passed value is not a number", 3) end + if value < 1 then error("Attempt to set LockAttempts failed: Passed value is less then 1", 3) end + if value > 100 then error("Attempt to set LockAttempts failed: Passed value is more then 100", 3) end + dataStore.__public.LockAttempts = value + end, + SaveOnClose = function(proxy, dataStore, value) + if type(value) ~= "boolean" then error("Attempt to set SaveOnClose failed: Passed value is not a boolean", 3) end + dataStore.__public.SaveOnClose = value + end, + Id = false, + UniqueId = false, + Key = false, + State = false, + Hidden = false, + AttemptsRemaining = false, + CreatedTime = false, + UpdatedTime = false, + Version = false, + CompressedValue = false, + StateChanged = false, + Saving = false, + Saved = false, + AttemptsChanged = false, + ProcessQueue = false, +} + + + + +-- Functions +OpenTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + local response, responseData = Lock(dataStore, 3) + if response ~= "Success" then for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end return end + local response, responseData = Load(dataStore, 3) + if response ~= "Success" then Unlock(dataStore, 3) for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end return end + dataStore.__public.State = true + if dataStore.TaskManager:FindLast(CloseTask) == nil and dataStore.TaskManager:FindLast(DestroyTask) == nil then + StartSaveTimer(proxy) + StartLockTimer(proxy) + end + for thread, template in runningTask:Iterate() do + if dataStore.__public.Value == nil then + dataStore.__public.Value = Clone(template) + elseif type(dataStore.__public.Value) == "table" and type(template) == "table" then + Reconcile(dataStore.__public.Value, template) + end + task.defer(thread, response) + end + if dataStore.ProcessingQueue == false and dataStore.__public.ProcessQueue.Connections > 0 then task.defer(ProcessQueue, proxy) end + dataStore.__public.StateChanged:Fire(true, proxy) +end + +ReadTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + if dataStore.__public.State == true then for thread in runningTask:Iterate() do task.defer(thread, "State", "Open") end return end + local response, responseData = Load(dataStore, 3) + if response ~= "Success" then for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end return end + for thread, template in runningTask:Iterate() do + if dataStore.__public.Value == nil then + dataStore.__public.Value = Clone(template) + elseif type(dataStore.__public.Value) == "table" and type(template) == "table" then + Reconcile(dataStore.__public.Value, template) + end + task.defer(thread, response) + end +end + +LockTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + local attemptsRemaining = dataStore.__public.AttemptsRemaining + local response, responseData = Lock(dataStore, 3) + if response ~= "Success" then dataStore.__public.AttemptsRemaining -= 1 end + if dataStore.__public.AttemptsRemaining ~= attemptsRemaining then dataStore.__public.AttemptsChanged:Fire(dataStore.__public.AttemptsRemaining, proxy) end + if dataStore.__public.AttemptsRemaining > 0 then + if dataStore.TaskManager:FindLast(CloseTask) == nil and dataStore.TaskManager:FindLast(DestroyTask) == nil then StartLockTimer(proxy) end + else + dataStore.__public.State = false + StopLockTimer(dataStore) + StopSaveTimer(dataStore) + if dataStore.__public.SaveOnClose == true then Save(proxy, 3) end + Unlock(dataStore, 3) + dataStore.__public.StateChanged:Fire(false, proxy) + end + for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end +end + +SaveTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + if dataStore.__public.State == false then for thread in runningTask:Iterate() do task.defer(thread, "State", "Closed") end return end + StopSaveTimer(dataStore) + runningTask:End() + local response, responseData = Save(proxy, 3) + if dataStore.TaskManager:FindLast(CloseTask) == nil and dataStore.TaskManager:FindLast(DestroyTask) == nil then StartSaveTimer(proxy) end + for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end +end + +CloseTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + if dataStore.__public.State == false then for thread in runningTask:Iterate() do task.defer(thread, "Success") end return end + dataStore.__public.State = false + local response, responseData = nil, nil + if dataStore.__public.SaveOnClose == true then response, responseData = Save(proxy, 3) end + Unlock(dataStore, 3) + dataStore.__public.StateChanged:Fire(false, proxy) + if response == "Saved" then + for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end + else + for thread in runningTask:Iterate() do task.defer(thread, "Success") end + end +end + +DestroyTask = function(runningTask, proxy) + local dataStore = getmetatable(proxy) + local response, responseData = nil, nil + if dataStore.__public.State == false then + dataStore.__public.State = nil + else + dataStore.__public.State = nil + if dataStore.__public.SaveOnClose == true then response, responseData = Save(proxy, 3) end + Unlock(dataStore, 3) + end + dataStore.__public.StateChanged:Fire(nil, proxy) + dataStore.__public.StateChanged:DisconnectAll() + dataStore.__public.Saving:DisconnectAll() + dataStore.__public.Saved:DisconnectAll() + dataStore.__public.AttemptsChanged:DisconnectAll() + dataStore.__public.ProcessQueue:DisconnectAll() + bindToClose[dataStore.__public.UniqueId] = nil + if response == "Saved" then + for thread in runningTask:Iterate() do task.defer(thread, response, responseData) end + else + for thread in runningTask:Iterate() do task.defer(thread, "Success") end + end +end + +Lock = function(dataStore, attempts) + local success, value, id, lockTime, lockInterval, lockAttempts = nil, nil, nil, nil, dataStore.__public.LockInterval, dataStore.__public.LockAttempts + for i = 1, attempts do + if i > 1 then task.wait(1) end + lockTime = os.clock() + success, value = pcall(dataStore.MemoryStore.UpdateAsync, dataStore.MemoryStore, "Id", function(value) id = value return if id == nil or id == dataStore.__public.UniqueId then dataStore.__public.UniqueId else nil end, lockInterval * lockAttempts + 30) + if success == true then break end + end + if success == false then return "Error", value end + if value == nil then return "Locked", id end + dataStore.LockTime = lockTime + lockInterval * lockAttempts + dataStore.ActiveLockInterval = lockInterval + dataStore.__public.AttemptsRemaining = lockAttempts + return "Success" +end + +Unlock = function(dataStore, attempts) + local success, value, id = nil, nil, nil + for i = 1, attempts do + if i > 1 then task.wait(1) end + success, value = pcall(dataStore.MemoryStore.UpdateAsync, dataStore.MemoryStore, "Id", function(value) id = value return if id == dataStore.__public.UniqueId then dataStore.__public.UniqueId else nil end, 0) + if success == true then break end + end + if success == false then return "Error", value end + if value == nil and id ~= nil then return "Locked", id end + return "Success" +end + +Load = function(dataStore, attempts) + local success, value, info = nil, nil, nil + for i = 1, attempts do + if i > 1 then task.wait(1) end + success, value, info = pcall(dataStore.DataStore.GetAsync, dataStore.DataStore, dataStore.__public.Key) + if success == true then break end + end + if success == false then return "Error", value end + if info == nil then + dataStore.__public.Metadata, dataStore.__public.UserIds, dataStore.__public.CreatedTime, dataStore.__public.UpdatedTime, dataStore.__public.Version = {}, {}, 0, 0, "" + else + dataStore.__public.Metadata, dataStore.__public.UserIds, dataStore.__public.CreatedTime, dataStore.__public.UpdatedTime, dataStore.__public.Version = info:GetMetadata(), info:GetUserIds(), info.CreatedTime, info.UpdatedTime, info.Version + end + if type(dataStore.__public.Metadata.Compress) ~= "table" then + dataStore.__public.Value = value + else + dataStore.__public.CompressedValue = value + local decimals = 10 ^ (dataStore.__public.Metadata.Compress.Decimals or 3) + dataStore.__public.Value = Decompress(dataStore.__public.CompressedValue, decimals) + end + return "Success" +end + +Save = function(proxy, attempts) + local dataStore = getmetatable(proxy) + local deltaTime = os.clock() - dataStore.SaveTime + if deltaTime < dataStore.__public.SaveDelay then task.wait(dataStore.__public.SaveDelay - deltaTime) end + dataStore.__public.Saving:Fire(dataStore.__public.Value, proxy) + local success, value, info = nil, nil, nil + if dataStore.__public.Value == nil then + for i = 1, attempts do + if i > 1 then task.wait(1) end + success, value, info = pcall(dataStore.DataStore.RemoveAsync, dataStore.DataStore, dataStore.__public.Key) + if success == true then break end + end + if success == false then dataStore.__public.Saved:Fire("Error", value, proxy) return "Error", value end + dataStore.__public.Metadata, dataStore.__public.UserIds, dataStore.__public.CreatedTime, dataStore.__public.UpdatedTime, dataStore.__public.Version = {}, {}, 0, 0, "" + elseif type(dataStore.__public.Metadata.Compress) ~= "table" then + dataStore.Options:SetMetadata(dataStore.__public.Metadata) + for i = 1, attempts do + if i > 1 then task.wait(1) end + success, value = pcall(dataStore.DataStore.SetAsync, dataStore.DataStore, dataStore.__public.Key, dataStore.__public.Value, dataStore.__public.UserIds, dataStore.Options) + if success == true then break end + end + if success == false then dataStore.__public.Saved:Fire("Error", value, proxy) return "Error", value end + dataStore.__public.Version = value + else + local level = dataStore.__public.Metadata.Compress.Level or 2 + local decimals = 10 ^ (dataStore.__public.Metadata.Compress.Decimals or 3) + local safety = if dataStore.__public.Metadata.Compress.Safety == nil then true else dataStore.__public.Metadata.Compress.Safety + dataStore.__public.CompressedValue = Compress(dataStore.__public.Value, level, decimals, safety) + dataStore.Options:SetMetadata(dataStore.__public.Metadata) + for i = 1, attempts do + if i > 1 then task.wait(1) end + success, value = pcall(dataStore.DataStore.SetAsync, dataStore.DataStore, dataStore.__public.Key, dataStore.__public.CompressedValue, dataStore.__public.UserIds, dataStore.Options) + if success == true then break end + end + if success == false then dataStore.__public.Saved:Fire("Error", value, proxy) return "Error", value end + dataStore.Version = value + end + dataStore.SaveTime = os.clock() + dataStore.__public.Saved:Fire("Saved", dataStore.__public.Value, proxy) + return "Saved", dataStore.__public.Value +end + +StartSaveTimer = function(proxy) + local dataStore = getmetatable(proxy) + if dataStore.SaveThread ~= nil then task.cancel(dataStore.SaveThread) end + if dataStore.__public.SaveInterval == 0 then return end + dataStore.SaveThread = task.delay(dataStore.__public.SaveInterval, SaveTimerEnded, proxy) +end + +StopSaveTimer = function(dataStore) + if dataStore.SaveThread == nil then return end + task.cancel(dataStore.SaveThread) + dataStore.SaveThread = nil +end + +SaveTimerEnded = function(proxy) + local dataStore = getmetatable(proxy) + dataStore.SaveThread = nil + if dataStore.TaskManager:FindLast(SaveTask) ~= nil then return end + dataStore.TaskManager:InsertBack(SaveTask, proxy) +end + +StartLockTimer = function(proxy) + local dataStore = getmetatable(proxy) + if dataStore.LockThread ~= nil then task.cancel(dataStore.LockThread) end + local startTime = dataStore.LockTime - dataStore.__public.AttemptsRemaining * dataStore.ActiveLockInterval + dataStore.LockThread = task.delay(startTime - os.clock() + dataStore.ActiveLockInterval, LockTimerEnded, proxy) +end + +StopLockTimer = function(dataStore) + if dataStore.LockThread == nil then return end + task.cancel(dataStore.LockThread) + dataStore.LockThread = nil +end + +LockTimerEnded = function(proxy) + local dataStore = getmetatable(proxy) + dataStore.LockThread = nil + if dataStore.TaskManager:FindFirst(LockTask) ~= nil then return end + dataStore.TaskManager:InsertBack(LockTask, proxy) +end + +ProcessQueue = function(proxy) + local dataStore = getmetatable(proxy) + if dataStore.__public.State ~= true then return end + if dataStore.__public.ProcessQueue.Connections == 0 then return end + if dataStore.ProcessingQueue == true then return end + dataStore.ProcessingQueue = true + while true do + local success, values, id = pcall(dataStore.Queue.ReadAsync, dataStore.Queue, 100, false, 30) + if dataStore.__public.State ~= true then break end + if dataStore.__public.ProcessQueue.Connections == 0 then break end + if success == true and id ~= nil then dataStore.__public.ProcessQueue:Fire(id, values, proxy) end + end + dataStore.ProcessingQueue = false +end + +SignalConnected = function(connected, signal) + if connected == false then return end + ProcessQueue(signal.DataStore) +end + +Clone = function(original) + if type(original) ~= "table" then return original end + local clone = {} + for index, value in original do clone[index] = Clone(value) end + return clone +end + +Reconcile = function(target, template) + for index, value in template do + if type(index) == "number" then continue end + if target[index] == nil then + target[index] = Clone(value) + elseif type(target[index]) == "table" and type(value) == "table" then + Reconcile(target[index], value) + end + end +end + +Compress = function(value, level, decimals, safety) + local data = {} + if type(value) == "boolean" then + table.insert(data, if value == false then "-" else "+") + elseif type(value) == "number" then + if value % 1 == 0 then + table.insert(data, if value < 0 then "<" .. Encode(-value) else ">" .. Encode(value)) + else + table.insert(data, if value < 0 then "(" .. Encode(math.round(-value * decimals)) else ")" .. Encode(math.round(value * decimals))) + end + elseif type(value) == "string" then + if safety == true then value = value:gsub("", " ") end + table.insert(data, "#" .. value .. "") + elseif type(value) == "table" then + if #value > 0 and level == 2 then + table.insert(data, "|") + for i = 1, #value do table.insert(data, Compress(value[i], level, decimals, safety)) end + table.insert(data, "") + else + table.insert(data, "*") + for key, tableValue in value do table.insert(data, Compress(key, level, decimals, safety)) table.insert(data, Compress(tableValue, level, decimals, safety)) end + table.insert(data, "") + end + end + return table.concat(data) +end + +Decompress = function(value, decimals, index) + local i1, i2, dataType, data = value:find("([-+<>()#|*])", index or 1) + if dataType == "-" then + return false, i2 + elseif dataType == "+" then + return true, i2 + elseif dataType == "<" then + i1, i2, data = value:find("([^-+<>()#|*]*)", i2 + 1) + return -Decode(data), i2 + elseif dataType == ">" then + i1, i2, data = value:find("([^-+<>()#|*]*)", i2 + 1) + return Decode(data), i2 + elseif dataType == "(" then + i1, i2, data = value:find("([^-+<>()#|*]*)", i2 + 1) + return -Decode(data) / decimals, i2 + elseif dataType == ")" then + i1, i2, data = value:find("([^-+<>()#|*]*)", i2 + 1) + return Decode(data) / decimals, i2 + elseif dataType == "#" then + i1, i2, data = value:find("(.-)", i2 + 1) + return data, i2 + elseif dataType == "|" then + local array = {} + while true do + data, i2 = Decompress(value, decimals, i2 + 1) + if data == nil then break end + table.insert(array, data) + end + return array, i2 + elseif dataType == "*" then + local dictionary, key = {}, nil + while true do + key, i2 = Decompress(value, decimals, i2 + 1) + if key == nil then break end + data, i2 = Decompress(value, decimals, i2 + 1) + dictionary[key] = data + end + return dictionary, i2 + end + return nil, i2 +end + +Encode = function(value) + if value == 0 then return "0" end + local data = {} + while value > 0 do + table.insert(data, characters[value % base]) + value = math.floor(value / base) + end + return table.concat(data) +end + +Decode = function(value) + local number, power, data = 0, 1, {string.byte(value, 1, #value)} + for i, code in data do + number += bytes[code] * power + power *= base + end + return number +end + +BindToClose = function() + active = false + for uniqueId, proxy in bindToClose do + local dataStore = getmetatable(proxy) + if dataStore.__public.State == nil then continue end + dataStores[dataStore.__public.Id] = nil + StopLockTimer(dataStore) + StopSaveTimer(dataStore) + if dataStore.TaskManager:FindFirst(DestroyTask) == nil then dataStore.TaskManager:InsertBack(DestroyTask, proxy) end + end + while next(bindToClose) ~= nil do task.wait() end +end + + + + +-- Events +game:BindToClose(BindToClose) + + + + +return table.freeze(Constructor) :: Constructor \ No newline at end of file diff --git a/src/Chemical/Packages/Datastore/init.meta.json b/src/Chemical/Packages/Datastore/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Packages/Datastore/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Packages/JECS.lua b/src/Chemical/Packages/JECS.lua new file mode 100644 index 0000000..9342294 --- /dev/null +++ b/src/Chemical/Packages/JECS.lua @@ -0,0 +1,2716 @@ + +--!optimize 2 +--!native +--!strict +--draft 4 + +type i53 = number +type i24 = number + +type Ty = { i53 } +type ArchetypeId = number + +type Column = { any } + +type Map = { [K]: V } + +type ecs_archetype_t = { + id: number, + types: Ty, + type: string, + entities: { number }, + columns: { Column }, + records: { [i53]: number }, + counts: { [i53]: number }, +} + +export type Archetype = { + id: number, + types: Ty, + type: string, + entities: { number }, + columns: { Column }, + records: { [Id]: number }, + counts: { [Id]: number }, +} + +type ecs_record_t = { + archetype: ecs_archetype_t, + row: number, + dense: i24, +} + +type ecs_id_record_t = { + cache: { number }, + counts: { number }, + flags: number, + size: number, + hooks: { + on_add: ((entity: i53, id: i53, data: any?) -> ())?, + on_change: ((entity: i53, id: i53, data: any) -> ())?, + on_remove: ((entity: i53, id: i53) -> ())?, + }, +} + +type ecs_id_index_t = Map + +type ecs_archetypes_map_t = { [string]: ecs_archetype_t } + +type ecs_archetypes_t = { ecs_archetype_t } + +type ecs_entity_index_t = { + dense_array: Map, + sparse_array: Map, + alive_count: number, + max_id: number, + range_begin: number?, + range_end: number? +} + +type ecs_query_data_t = { + compatible_archetypes: { ecs_archetype_t }, + ids: { i53 }, + filter_with: { i53 }, + filter_without: { i53 }, + next: () -> (number, ...any), + world: ecs_world_t, +} + +type ecs_observer_t = { + callback: (archetype: ecs_archetype_t) -> (), + query: ecs_query_data_t, +} + +type ecs_observable_t = Map> + +type ecs_world_t = { + archetype_edges: Map>, + entity_index: ecs_entity_index_t, + component_index: ecs_id_index_t, + archetypes: ecs_archetypes_t, + archetype_index: ecs_archetypes_map_t, + max_archetype_id: number, + max_component_id: number, + ROOT_ARCHETYPE: ecs_archetype_t, + observable: Map>, +} + +local HI_COMPONENT_ID = _G.__JECS_HI_COMPONENT_ID or 256 +-- stylua: ignore start +local EcsOnAdd = HI_COMPONENT_ID + 1 +local EcsOnRemove = HI_COMPONENT_ID + 2 +local EcsOnChange = HI_COMPONENT_ID + 3 +local EcsWildcard = HI_COMPONENT_ID + 4 +local EcsChildOf = HI_COMPONENT_ID + 5 +local EcsComponent = HI_COMPONENT_ID + 6 +local EcsOnDelete = HI_COMPONENT_ID + 7 +local EcsOnDeleteTarget = HI_COMPONENT_ID + 8 +local EcsDelete = HI_COMPONENT_ID + 9 +local EcsRemove = HI_COMPONENT_ID + 10 +local EcsName = HI_COMPONENT_ID + 11 +local EcsOnArchetypeCreate = HI_COMPONENT_ID + 12 +local EcsOnArchetypeDelete = HI_COMPONENT_ID + 13 +local EcsRest = HI_COMPONENT_ID + 14 + +local ECS_ID_DELETE = 0b01 +local ECS_ID_IS_TAG = 0b10 +local ECS_ID_MASK = 0b00 + +local ECS_ENTITY_MASK = bit32.lshift(1, 24) +local ECS_GENERATION_MASK = bit32.lshift(1, 16) +local ECS_PAIR_OFFSET = 2^48 + +local NULL_ARRAY = table.freeze({}) :: Column +local NULL = newproxy(false) + +local ECS_INTERNAL_ERROR = [[ + This is an internal error, please file a bug report via the following link: + + https://github.com/Ukendio/jecs/issues/new?template=BUG-REPORT.md +]] + +local function ecs_assert(condition, msg: string?) + if not condition then + error(msg) + end +end + +local ecs_metadata: Map> = {} +local ecs_max_component_id = 0 +local ecs_max_tag_id = EcsRest + +local function ECS_COMPONENT() + ecs_max_component_id += 1 + if ecs_max_component_id > HI_COMPONENT_ID then + error("Too many components") + end + return ecs_max_component_id +end + +local function ECS_TAG() + ecs_max_tag_id += 1 + return ecs_max_tag_id +end + +local function ECS_META(id: i53, ty: i53, value: any?) + local bundle = ecs_metadata[id] + if bundle == nil then + bundle = {} + ecs_metadata[id] = bundle + end + bundle[ty] = if value == nil then NULL else value +end + +local function ECS_META_RESET() + ecs_metadata = {} + ecs_max_component_id = 0 + ecs_max_tag_id = EcsRest +end + +local function ECS_COMBINE(id: number, generation: number): i53 + return id + (generation * ECS_ENTITY_MASK) +end + +local function ECS_IS_PAIR(e: number): boolean + return e > ECS_PAIR_OFFSET +end + +local function ECS_GENERATION_INC(e: i53): i53 + if e > ECS_ENTITY_MASK then + local id = e % ECS_ENTITY_MASK + local generation = e // ECS_ENTITY_MASK + + local next_gen = generation + 1 + if next_gen >= ECS_GENERATION_MASK then + return id + end + + return ECS_COMBINE(id, next_gen) + end + return ECS_COMBINE(e, 1) +end + +local function ECS_ENTITY_T_LO(e: i53): i24 + return e % ECS_ENTITY_MASK +end + +local function ECS_ID(e: i53) + return e % ECS_ENTITY_MASK +end + +local function ECS_GENERATION(e: i53) + return e // ECS_ENTITY_MASK +end + +local function ECS_ENTITY_T_HI(e: i53): i24 + return e // ECS_ENTITY_MASK +end + +local function ECS_PAIR(pred: i53, obj: i53): i53 + pred %= ECS_ENTITY_MASK + obj %= ECS_ENTITY_MASK + + return obj + (pred * ECS_ENTITY_MASK) + ECS_PAIR_OFFSET +end + +local function ECS_PAIR_FIRST(e: i53): i24 + return (e - ECS_PAIR_OFFSET) // ECS_ENTITY_MASK +end + +local function ECS_PAIR_SECOND(e: i53): i24 + return (e - ECS_PAIR_OFFSET) % ECS_ENTITY_MASK +end + +local function entity_index_try_get_any( + entity_index: ecs_entity_index_t, + entity: number +): ecs_record_t? + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + + if not r or r.dense == 0 then + return nil + end + + return r +end + +local function entity_index_try_get(entity_index: ecs_entity_index_t, entity: number): ecs_record_t? + local r = entity_index_try_get_any(entity_index, entity) + if r then + local r_dense = r.dense + if r_dense > entity_index.alive_count then + return nil + end + if entity_index.dense_array[r_dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_try_get_fast(entity_index: ecs_entity_index_t, entity: number): ecs_record_t? + local r = entity_index.sparse_array[ECS_ENTITY_T_LO(entity)] + if r then + if entity_index.dense_array[r.dense] ~= entity then + return nil + end + end + return r +end + +local function entity_index_is_alive(entity_index: ecs_entity_index_t, entity: i53) + return entity_index_try_get(entity_index, entity) ~= nil +end + +local function entity_index_get_alive(entity_index: ecs_entity_index_t, entity: i53): i53? + local r = entity_index_try_get_any(entity_index, entity) + if r then + return entity_index.dense_array[r.dense] + end + return nil +end + +local function ecs_get_alive(world, entity) + if entity == 0 then + return 0 + end + + local eindex = world.entity_index + + if entity_index_is_alive(eindex, entity) then + return entity + end + + if entity > ECS_ENTITY_MASK then + return 0 + end + + local current = entity_index_get_alive(eindex, entity) + if not current or not entity_index_is_alive(eindex, current) then + return 0 + end + + return current +end + +local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range" + +local function entity_index_new_id(entity_index: ecs_entity_index_t): i53 + local dense_array = entity_index.dense_array + local alive_count = entity_index.alive_count + local sparse_array = entity_index.sparse_array + local max_id = entity_index.max_id + + if alive_count < max_id then + alive_count += 1 + entity_index.alive_count = alive_count + local id = dense_array[alive_count] + return id + end + + local id = max_id + 1 + local range_end = entity_index.range_end + ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY) + + entity_index.max_id = id + alive_count += 1 + entity_index.alive_count = alive_count + dense_array[alive_count] = id + sparse_array[id] = { dense = alive_count } :: ecs_record_t + + return id +end + +local function ecs_pair_first(world: ecs_world_t, e: i53) + local pred = ECS_PAIR_FIRST(e) + return ecs_get_alive(world, pred) +end + +local function ecs_pair_second(world: ecs_world_t, e: i53) + local obj = ECS_PAIR_SECOND(e) + return ecs_get_alive(world, obj) +end + +local function query_match(query: ecs_query_data_t, + archetype: ecs_archetype_t) + local records = archetype.records + local with = query.filter_with + + for _, id in with do + if not records[id] then + return false + end + end + + local without = query.filter_without + if without then + for _, id in without do + if records[id] then + return false + end + end + end + + return true +end + +local function find_observers(world: ecs_world_t, event: i53, + component: i53): { ecs_observer_t }? + local cache = world.observable[event] + if not cache then + return nil + end + return cache[component] :: any +end + +local function archetype_move( + entity_index: ecs_entity_index_t, + to: ecs_archetype_t, + dst_row: i24, + from: ecs_archetype_t, + src_row: i24 +) + local src_columns = from.columns + local dst_columns = to.columns + local dst_entities = to.entities + local src_entities = from.entities + + local last = #src_entities + local id_types = from.types + local records = to.records + + for i, column in src_columns do + if column == NULL_ARRAY then + continue + end + -- Retrieves the new column index from the source archetype's record from each component + -- We have to do this because the columns are tightly packed and indexes may not correspond to each other. + local tr = records[id_types[i]] + + -- Sometimes target column may not exist, e.g. when you remove a component. + if tr then + dst_columns[tr][dst_row] = column[src_row] + end + + -- If the entity is the last row in the archetype then swapping it would be meaningless. + if src_row ~= last then + -- Swap rempves columns to ensure there are no holes in the archetype. + column[src_row] = column[last] + end + column[last] = nil + end + + local moved = #src_entities + + -- Move the entity from the source to the destination archetype. + -- Because we have swapped columns we now have to update the records + -- corresponding to the entities' rows that were swapped. + local e1 = src_entities[src_row] + local e2 = src_entities[moved] + + if src_row ~= moved then + src_entities[src_row] = e2 + end + + src_entities[moved] = nil :: any + dst_entities[dst_row] = e1 + + local sparse_array = entity_index.sparse_array + + local record1 = sparse_array[ECS_ENTITY_T_LO(e1)] + local record2 = sparse_array[ECS_ENTITY_T_LO(e2)] + record1.row = dst_row + record2.row = src_row +end + +local function archetype_append( + entity: i53, + archetype: ecs_archetype_t +): number + local entities = archetype.entities + local length = #entities + 1 + entities[length] = entity + return length +end + +local function new_entity( + entity: i53, + record: ecs_record_t, + archetype: ecs_archetype_t +): ecs_record_t + local row = archetype_append(entity, archetype) + record.archetype = archetype + record.row = row + return record +end + +local function entity_move( + entity_index: ecs_entity_index_t, + entity: i53, + record: ecs_record_t, + to: ecs_archetype_t +) + local sourceRow = record.row + local from = record.archetype + local dst_row = archetype_append(entity, to) + archetype_move(entity_index, to, dst_row, from, sourceRow) + record.archetype = to + record.row = dst_row +end + +local function hash(arr: { number }): string + return table.concat(arr, "_") +end + +local function fetch(id: i53, records: { number }, + columns: { Column }, row: number): any + local tr = records[id] + + if not tr then + return nil + end + + return columns[tr][row] +end + +local function world_get(world: ecs_world_t, entity: i53, + a: i53, b: i53?, c: i53?, d: i53?, e: i53?): ...any + local record = entity_index_try_get_fast(world.entity_index, entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local records = archetype.records + local columns = archetype.columns + local row = record.row + + local va = fetch(a, records, columns, row) + + if not b then + return va + elseif not c then + return va, fetch(b, records, columns, row) + elseif not d then + return va, fetch(b, records, columns, row), fetch(c, records, columns, row) + elseif not e then + return va, fetch(b, records, columns, row), fetch(c, records, columns, row), fetch(d, records, columns, row) + else + error("args exceeded") + end +end + +local function world_has_one_inline(world: ecs_world_t, entity: i53, id: i53): boolean + local record = entity_index_try_get_fast(world.entity_index, entity) + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + local records = archetype.records + + return records[id] ~= nil +end + +local function ecs_is_tag(world: ecs_world_t, entity: i53): boolean + local idr = world.component_index[entity] + if idr then + return bit32.band(idr.flags, ECS_ID_IS_TAG) ~= 0 + end + return not world_has_one_inline(world, entity, EcsComponent) +end + +local function world_has(world: ecs_world_t, entity: i53, + a: i53, b: i53?, c: i53?, d: i53?, e: i53?): boolean + + local record = entity_index_try_get_fast(world.entity_index, entity) + if not record then + return false + end + + local archetype = record.archetype + if not archetype then + return false + end + + local records = archetype.records + + return records[a] ~= nil and + (b == nil or records[b] ~= nil) and + (c == nil or records[c] ~= nil) and + (d == nil or records[d] ~= nil) and + (e == nil or error("args exceeded")) +end + +local function world_target(world: ecs_world_t, entity: i53, relation: i24, index: number?): i24? + local nth = index or 0 + local record = entity_index_try_get_fast(world.entity_index, entity) + if not record then + return nil + end + + local archetype = record.archetype + if not archetype then + return nil + end + + local r = ECS_PAIR(relation, EcsWildcard) + + local count = archetype.counts[r] + if not count then + return nil + end + + if nth >= count then + nth = nth + count + 1 + end + + nth = archetype.types[nth + archetype.records[r]] + if not nth then + return nil + end + + return entity_index_get_alive(world.entity_index, + ECS_PAIR_SECOND(nth)) +end + +local function ECS_ID_IS_WILDCARD(e: i53): boolean + local first = ECS_ENTITY_T_HI(e) + local second = ECS_ENTITY_T_LO(e) + return first == EcsWildcard or second == EcsWildcard +end + +local function id_record_ensure(world: ecs_world_t, id: number): ecs_id_record_t + local component_index = world.component_index + local entity_index = world.entity_index + local idr: ecs_id_record_t? = component_index[id] + + if idr then + return idr + end + + local flags = ECS_ID_MASK + local relation = id + local target = 0 + local is_pair = ECS_IS_PAIR(id) + if is_pair then + relation = entity_index_get_alive(entity_index, ECS_PAIR_FIRST(id)) :: i53 + ecs_assert(relation and entity_index_is_alive( + entity_index, relation), ECS_INTERNAL_ERROR) + target = entity_index_get_alive(entity_index, ECS_PAIR_SECOND(id)) :: i53 + ecs_assert(target and entity_index_is_alive( + entity_index, target), ECS_INTERNAL_ERROR) + end + + local cleanup_policy = world_target(world, relation, EcsOnDelete, 0) + local cleanup_policy_target = world_target(world, relation, EcsOnDeleteTarget, 0) + + local has_delete = false + + if cleanup_policy == EcsDelete or cleanup_policy_target == EcsDelete then + has_delete = true + end + + local on_add, on_change, on_remove = world_get(world, + relation, EcsOnAdd, EcsOnChange, EcsOnRemove) + + local is_tag = not world_has_one_inline(world, + relation, EcsComponent) + + if is_tag and is_pair then + is_tag = not world_has_one_inline(world, target, EcsComponent) + end + + flags = bit32.bor( + flags, + if has_delete then ECS_ID_DELETE else 0, + if is_tag then ECS_ID_IS_TAG else 0 + ) + + idr = { + size = 0, + cache = {}, + counts = {}, + flags = flags, + hooks = { + on_add = on_add, + on_change = on_change, + on_remove = on_remove, + }, + } :: ecs_id_record_t + + component_index[id] = idr + + return idr +end + +local function archetype_append_to_records( + idr: ecs_id_record_t, + archetype: ecs_archetype_t, + id: i53, + index: number +) + local archetype_id = archetype.id + local archetype_records = archetype.records + local archetype_counts = archetype.counts + local idr_columns = idr.cache + local idr_counts = idr.counts + local tr = idr_columns[archetype_id] + if not tr then + idr_columns[archetype_id] = index + idr_counts[archetype_id] = 1 + + archetype_records[id] = index + archetype_counts[id] = 1 + else + local max_count = idr_counts[archetype_id] + 1 + idr_counts[archetype_id] = max_count + archetype_counts[id] = max_count + end +end + +local function archetype_create(world: ecs_world_t, id_types: { i24 }, ty, prev: i53?): ecs_archetype_t + local archetype_id = (world.max_archetype_id :: number) + 1 + world.max_archetype_id = archetype_id + + local length = #id_types + local columns = (table.create(length) :: any) :: { Column } + + local records: { number } = {} + local counts: { number } = {} + + local archetype: ecs_archetype_t = { + columns = columns, + entities = {}, + id = archetype_id, + records = records, + counts = counts, + type = ty, + types = id_types, + } + + for i, component_id in id_types do + local idr = id_record_ensure(world, component_id) + archetype_append_to_records(idr, archetype, component_id, i) + + if ECS_IS_PAIR(component_id) then + local relation = ECS_PAIR_FIRST(component_id) + local object = ECS_PAIR_SECOND(component_id) + local r = ECS_PAIR(relation, EcsWildcard) + local idr_r = id_record_ensure(world, r) + archetype_append_to_records(idr_r, archetype, r, i) + + local t = ECS_PAIR(EcsWildcard, object) + local idr_t = id_record_ensure(world, t) + archetype_append_to_records(idr_t, archetype, t, i) + end + + if bit32.band(idr.flags, ECS_ID_IS_TAG) == 0 then + columns[i] = {} + else + columns[i] = NULL_ARRAY + end + end + + for id in records do + local observer_list = find_observers(world, EcsOnArchetypeCreate, id) + if not observer_list then + continue + end + for _, observer in observer_list do + if query_match(observer.query, archetype) then + observer.callback(archetype) + end + end + end + + world.archetype_index[ty] = archetype + world.archetypes[archetype_id] = archetype + world.archetype_edges[archetype.id] = {} :: Map + + return archetype +end + +local function world_range(world: ecs_world_t, range_begin: number, range_end: number?) + local entity_index = world.entity_index + + entity_index.range_begin = range_begin + entity_index.range_end = range_end + + local max_id = entity_index.max_id + + if range_begin > max_id then + local dense_array = entity_index.dense_array + local sparse_array = entity_index.sparse_array + + for i = max_id + 1, range_begin do + dense_array[i] = i + sparse_array[i] = { + dense = 0 + } :: ecs_record_t + end + entity_index.max_id = range_begin - 1 + entity_index.alive_count = range_begin - 1 + end +end + +local function world_entity(world: ecs_world_t, entity: i53?): i53 + local entity_index = world.entity_index + if entity then + local index = ECS_ID(entity) + local max_id = entity_index.max_id + local sparse_array = entity_index.sparse_array + local dense_array = entity_index.dense_array + local alive_count = entity_index.alive_count + local r = sparse_array[index] + if r then + local dense = r.dense + + if not dense or r.dense == 0 then + r.dense = index + dense = index + end + + local any = dense_array[dense] + if dense <= alive_count then + if any ~= entity then + error("Entity ID is already in use with a different generation") + else + return entity + end + end + + local e_swap = dense_array[dense] + local r_swap = entity_index_try_get_any(entity_index, e_swap) :: ecs_record_t + alive_count += 1 + entity_index.alive_count = alive_count + r_swap.dense = dense + r.dense = alive_count + dense_array[dense] = e_swap + dense_array[alive_count] = entity + + return entity + else + for i = max_id + 1, index do + sparse_array[i] = { dense = i } :: ecs_record_t + dense_array[i] = i + end + entity_index.max_id = index + + local e_swap = dense_array[alive_count] + local r_swap = sparse_array[alive_count] + r_swap.dense = index + + alive_count += 1 + entity_index.alive_count = alive_count + + r = sparse_array[index] + + r.dense = alive_count + + sparse_array[index] = r + + dense_array[index] = e_swap + dense_array[alive_count] = entity + + + return entity + end + end + return entity_index_new_id(entity_index, entity) +end + +local function world_parent(world: ecs_world_t, entity: i53) + return world_target(world, entity, EcsChildOf, 0) +end + +local function archetype_ensure(world: ecs_world_t, id_types): ecs_archetype_t + if #id_types < 1 then + return world.ROOT_ARCHETYPE + end + + local ty = hash(id_types) + local archetype = world.archetype_index[ty] + if archetype then + return archetype + end + + return archetype_create(world, id_types, ty) +end + +local function find_insert(id_types: { i53 }, toAdd: i53): number + for i, id in id_types do + if id == toAdd then + error("Duplicate component id") + return -1 + end + if id > toAdd then + return i + end + end + return #id_types + 1 +end + +local function find_archetype_without( + world: ecs_world_t, + node: ecs_archetype_t, + id: i53 +): ecs_archetype_t + local id_types = node.types + local at = table.find(id_types, id) + + local dst = table.clone(id_types) + table.remove(dst, at) + + return archetype_ensure(world, dst) +end + + +local function create_edge_for_remove( + world: ecs_world_t, + node: ecs_archetype_t, + edge: Map, + id: i53 +): ecs_archetype_t + local to = find_archetype_without(world, node, id) + local edges = world.archetype_edges + local archetype_id = node.id + edges[archetype_id][id] = to + edges[to.id][id] = node + return to +end + +local function archetype_traverse_remove( + world: ecs_world_t, + id: i53, + from: ecs_archetype_t +): ecs_archetype_t + local edges = world.archetype_edges + local edge = edges[from.id] + + local to: ecs_archetype_t = edge[id] + if to == nil then + to = find_archetype_without(world, from, id) + edge[id] = to + edges[to.id][id] = from + end + + return to +end + +local function find_archetype_with(world, id, from): ecs_archetype_t + local id_types = from.types + + local at = find_insert(id_types, id) + local dst = table.clone(id_types) :: { i53 } + table.insert(dst, at, id) + + return archetype_ensure(world, dst) +end + +local function archetype_traverse_add(world, id, from: ecs_archetype_t): ecs_archetype_t + from = from or world.ROOT_ARCHETYPE + if from.records[id] then + return from + end + local edges = world.archetype_edges + local edge = edges[from.id] + + local to = edge[id] + if not to then + to = find_archetype_with(world, id, from) + edge[id] = to + edges[to.id][id] = from + end + + return to +end + +local function world_add( + world: ecs_world_t, + entity: i53, + id: i53 +): () + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end + + local from = record.archetype + local to = archetype_traverse_add(world, id, from) + if from == to then + return + end + if from then + entity_move(entity_index, entity, record, to) + else + if #to.types > 0 then + new_entity(entity, record, to) + end + end + + local idr = world.component_index[id] + local on_add = idr.hooks.on_add + + if on_add then + on_add(entity, id) + end +end + +local function world_set(world: ecs_world_t, entity: i53, id: i53, data: unknown): () + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end + + local from: ecs_archetype_t = record.archetype + local to: ecs_archetype_t = archetype_traverse_add(world, id, from) + local idr = world.component_index[id] + local idr_hooks = idr.hooks + + if from == to then + local tr = (to :: ecs_archetype_t).records[id] + local column = from.columns[tr] + column[record.row] = data + + -- If the archetypes are the same it can avoid moving the entity + -- and just set the data directly. + local on_change = idr_hooks.on_change + if on_change then + on_change(entity, id, data) + end + + return + end + + if from then + -- If there was a previous archetype, then the entity needs to move the archetype + entity_move(entity_index, entity, record, to) + else + if #to.types > 0 then + -- When there is no previous archetype it should create the archetype + new_entity(entity, record, to) + end + end + + local tr = to.records[id] + local column = to.columns[tr] + + column[record.row] = data + + local on_add = idr_hooks.on_add + if on_add then + on_add(entity, id, data) + end +end + +local function world_component(world: World): i53 + local id = (world.max_component_id :: number) + 1 + if id > HI_COMPONENT_ID then + -- IDs are partitioned into ranges because component IDs are not nominal, + -- so it needs to error when IDs intersect into the entity range. + error("Too many components, consider using world:entity() instead to create components.") + end + world.max_component_id = id + + return id +end + +local function world_remove(world: ecs_world_t, entity: i53, id: i53) + local entity_index = world.entity_index + local record = entity_index_try_get_fast(entity_index, entity) + if not record then + return + end + local from = record.archetype + + if not from then + return + end + + if from.records[id] then + local idr = world.component_index[id] + local on_remove = idr.hooks.on_remove + if on_remove then + on_remove(entity, id) + end + + local to = archetype_traverse_remove(world, id, record.archetype) + + entity_move(entity_index, entity, record, to) + end +end + +local function archetype_fast_delete_last(columns: { Column }, column_count: number, types: { i53 }, entity: i53) + for i, column in columns do + if column ~= NULL_ARRAY then + column[column_count] = nil + end + end +end + +local function archetype_fast_delete(columns: { Column }, column_count: number, row, types, entity) + for i, column in columns do + if column ~= NULL_ARRAY then + column[row] = column[column_count] + column[column_count] = nil + end + end +end + +local function archetype_delete(world: ecs_world_t, archetype: ecs_archetype_t, row: number) + local entity_index = world.entity_index + local component_index = world.component_index + local columns = archetype.columns + local id_types = archetype.types + local entities = archetype.entities + local column_count = #entities + local last = #entities + local move = entities[last] + -- We assume first that the entity is the last in the archetype + local delete = move + + if row ~= last then + local record_to_move = entity_index_try_get_any(entity_index, move) + if record_to_move then + record_to_move.row = row + end + + delete = entities[row] + entities[row] = move + end + + for _, id in id_types do + local idr = component_index[id] + local on_remove = idr.hooks.on_remove + if on_remove then + on_remove(delete, id) + end + end + + entities[last] = nil :: any + + if row == last then + archetype_fast_delete_last(columns, column_count, id_types, delete) + else + archetype_fast_delete(columns, column_count, row, id_types, delete) + end +end + +local function world_clear(world: ecs_world_t, entity: i53) + local entity_index = world.entity_index + local component_index = world.component_index + local archetypes = world.archetypes + local tgt = ECS_PAIR(EcsWildcard, entity) + local idr_t = component_index[tgt] + local idr = component_index[entity] + local rel = ECS_PAIR(entity, EcsWildcard) + local idr_r = component_index[rel] + + if idr then + local count = 0 + local queue = {} + for archetype_id in idr.cache do + local idr_archetype = archetypes[archetype_id] + local entities = idr_archetype.entities + local n = #entities + count += n + table.move(entities, 1, n, #queue + 1, queue) + end + for _, e in queue do + world_remove(world, e, entity) + end + end + + if idr_t then + local queue: { i53 } + local ids: Map + + local count = 0 + local archetype_ids = idr_t.cache + for archetype_id in archetype_ids do + local idr_t_archetype = archetypes[archetype_id] + local idr_t_types = idr_t_archetype.types + local entities = idr_t_archetype.entities + local removal_queued = false + + for _, id in idr_t_types do + if not ECS_IS_PAIR(id) then + continue + end + local object = entity_index_get_alive( + entity_index, ECS_PAIR_SECOND(id)) + if object ~= entity then + continue + end + if not ids then + ids = {} :: { [i53]: boolean } + end + ids[id] = true + removal_queued = true + end + + if not removal_queued then + continue + end + + if not queue then + queue = {} :: { i53 } + end + + local n = #entities + table.move(entities, 1, n, count + 1, queue) + count += n + end + + for id in ids do + for _, child in queue do + world_remove(world, child, id) + end + end + end + + if idr_r then + local count = 0 + local archetype_ids = idr_r.cache + local ids = {} + local queue = {} + for archetype_id in archetype_ids do + local idr_r_archetype = archetypes[archetype_id] + local entities = idr_r_archetype.entities + local tr = idr_r_archetype.records[rel] + local tr_count = idr_r_archetype.counts[rel] + local types = idr_r_archetype.types + for i = tr, tr + tr_count - 1 do + ids[types[i]] = true + end + local n = #entities + table.move(entities, 1, n, count + 1, queue) + count += n + end + + for _, e in queue do + for id in ids do + world_remove(world, e, id) + end + end + end +end + +local function archetype_destroy(world: ecs_world_t, archetype: ecs_archetype_t) + if archetype == world.ROOT_ARCHETYPE then + return + end + + local component_index = world.component_index + local archetype_edges = world.archetype_edges + + for id, edge in archetype_edges[archetype.id] do + archetype_edges[edge.id][id] = nil + end + + local archetype_id = archetype.id + world.archetypes[archetype_id] = nil :: any + world.archetype_index[archetype.type] = nil :: any + local records = archetype.records + + for id in records do + local observer_list = find_observers(world, EcsOnArchetypeDelete, id) + if not observer_list then + continue + end + for _, observer in observer_list do + if query_match(observer.query, archetype) then + observer.callback(archetype) + end + end + end + + for id in records do + local idr = component_index[id] + idr.cache[archetype_id] = nil :: any + idr.counts[archetype_id] = nil + idr.size -= 1 + records[id] = nil :: any + if idr.size == 0 then + component_index[id] = nil :: any + end + end +end + +local function world_cleanup(world: ecs_world_t) + local archetypes = world.archetypes + + for _, archetype in archetypes do + if #archetype.entities == 0 then + archetype_destroy(world, archetype) + end + end + + local new_archetypes = table.create(#archetypes) :: { ecs_archetype_t } + local new_archetype_map = {} + + for index, archetype in archetypes do + new_archetypes[index] = archetype + new_archetype_map[archetype.type] = archetype + end + + world.archetypes = new_archetypes + world.archetype_index = new_archetype_map +end + +local function world_delete(world: ecs_world_t, entity: i53) + local entity_index = world.entity_index + local record = entity_index_try_get(entity_index, entity) + if not record then + return + end + + local archetype = record.archetype + local row = record.row + + if archetype then + -- In the future should have a destruct mode for + -- deleting archetypes themselves. Maybe requires recycling + archetype_delete(world, archetype, row) + end + + local delete = entity + local component_index = world.component_index + local archetypes = world.archetypes + local tgt = ECS_PAIR(EcsWildcard, delete) + local rel = ECS_PAIR(delete, EcsWildcard) + + local idr_t = component_index[tgt] + local idr = component_index[delete] + local idr_r = component_index[rel] + + if idr then + local flags = idr.flags + if bit32.band(flags, ECS_ID_DELETE) ~= 0 then + for archetype_id in idr.cache do + local idr_archetype = archetypes[archetype_id] + + local entities = idr_archetype.entities + local n = #entities + for i = n, 1, -1 do + world_delete(world, entities[i]) + end + + archetype_destroy(world, idr_archetype) + end + else + for archetype_id in idr.cache do + local idr_archetype = archetypes[archetype_id] + local entities = idr_archetype.entities + local n = #entities + for i = n, 1, -1 do + world_remove(world, entities[i], delete) + end + + archetype_destroy(world, idr_archetype) + end + end + end + + if idr_t then + local children: { i53 } + local ids: Map + + local count = 0 + local archetype_ids = idr_t.cache + for archetype_id in archetype_ids do + local idr_t_archetype = archetypes[archetype_id] + local idr_t_types = idr_t_archetype.types + local entities = idr_t_archetype.entities + local removal_queued = false + + for _, id in idr_t_types do + if not ECS_IS_PAIR(id) then + continue + end + local object = entity_index_get_alive( + entity_index, ECS_PAIR_SECOND(id)) + if object ~= delete then + continue + end + local id_record = component_index[id] + local flags = id_record.flags + local flags_delete_mask: number = bit32.band(flags, ECS_ID_DELETE) + if flags_delete_mask ~= 0 then + for i = #entities, 1, -1 do + local child = entities[i] + world_delete(world, child) + end + break + else + if not ids then + ids = {} :: { [i53]: boolean } + end + ids[id] = true + removal_queued = true + end + end + + if not removal_queued then + continue + end + if not children then + children = {} :: { i53 } + end + local n = #entities + table.move(entities, 1, n, count + 1, children) + count += n + end + + if ids then + for _, child in children do + for id in ids do + world_remove(world, child, id) + end + end + end + + for archetype_id in archetype_ids do + archetype_destroy(world, archetypes[archetype_id]) + end + end + + if idr_r then + local archetype_ids = idr_r.cache + local flags = idr_r.flags + if (bit32.band(flags, ECS_ID_DELETE) :: number) ~= 0 then + for archetype_id in archetype_ids do + local idr_r_archetype = archetypes[archetype_id] + local entities = idr_r_archetype.entities + local n = #entities + for i = n, 1, -1 do + world_delete(world, entities[i]) + end + archetype_destroy(world, idr_r_archetype) + end + else + local children = {} + local count = 0 + local ids = {} + for archetype_id in archetype_ids do + local idr_r_archetype = archetypes[archetype_id] + local entities = idr_r_archetype.entities + local tr = idr_r_archetype.records[rel] + local tr_count = idr_r_archetype.counts[rel] + local types = idr_r_archetype.types + for i = tr, tr_count do + ids[types[tr]] = true + end + local n = #entities + table.move(entities, 1, n, count + 1, children) + count += n + end + + for _, child in children do + for id in ids do + world_remove(world, child, id) + end + end + + for archetype_id in archetype_ids do + archetype_destroy(world, archetypes[archetype_id]) + end + end + end + + local dense_array = entity_index.dense_array + local dense = record.dense + local i_swap = entity_index.alive_count + entity_index.alive_count = i_swap - 1 + + local e_swap = dense_array[i_swap] + local r_swap = entity_index_try_get_any(entity_index, e_swap) :: ecs_record_t + + r_swap.dense = dense + record.archetype = nil :: any + record.row = nil :: any + record.dense = i_swap + + dense_array[dense] = e_swap + dense_array[i_swap] = ECS_GENERATION_INC(entity) +end + +local function world_exists(world: ecs_world_t, entity): boolean + return entity_index_try_get_any(world.entity_index, entity) ~= nil +end + +local function world_contains(world: ecs_world_t, entity): boolean + return entity_index_is_alive(world.entity_index, entity) +end + +local function NOOP() end + +export type QueryInner = { + compatible_archetypes: { Archetype }, + ids: { i53 }, + filter_with: { i53 }, + filter_without: { i53 }, + next: () -> (number, ...any), + world: World, +} + +local function query_iter_init(query: ecs_query_data_t): () -> (number, ...any) + local world_query_iter_next + + local compatible_archetypes = query.compatible_archetypes + local lastArchetype = 1 + local archetype = compatible_archetypes[1] + if not archetype then + return NOOP :: () -> (number, ...any) + end + local columns = archetype.columns + local entities = archetype.entities + local i = #entities + local records = archetype.records + + local ids = query.ids + local A, B, C, D, E, F, G, H, I = unpack(ids) + local a: Column, b: Column, c: Column, d: Column + local e: Column, f: Column, g: Column, h: Column + + if not B then + a = columns[records[A]] + elseif not C then + a = columns[records[A]] + b = columns[records[B]] + elseif not D then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + elseif not E then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + elseif not F then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + elseif not G then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + elseif not H then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + elseif not I then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + h = columns[records[H]] + end + + if not B then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + end + + local row = i + i -= 1 + + return entity, a[row] + end + elseif not C then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row] + end + elseif not D then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row] + end + elseif not E then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row] + end + elseif not F then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row] + end + elseif not G then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row] + end + elseif not H then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row] + end + elseif not I then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + h = columns[records[H]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row], h[row] + end + else + local output = {} + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + end + + local row = i + i -= 1 + + for j, id in ids do + output[j] = columns[records[id]][row] + end + + return entity, unpack(output) + end + end + + query.next = world_query_iter_next + return world_query_iter_next +end + +local function query_iter(query): () -> (number, ...any) + local query_next = query.next + if not query_next then + query_next = query_iter_init(query) + end + return query_next +end + +local function query_without(query: ecs_query_data_t, ...: i53) + local without = { ... } + query.filter_without = without + local compatible_archetypes = query.compatible_archetypes + for i = #compatible_archetypes, 1, -1 do + local archetype = compatible_archetypes[i] + local records = archetype.records + local matches = true + + for _, id in without do + if records[id] then + matches = false + break + end + end + + if matches then + continue + end + + local last = #compatible_archetypes + if last ~= i then + compatible_archetypes[i] = compatible_archetypes[last] + end + compatible_archetypes[last] = nil :: any + end + + return query :: any +end + +local function query_with(query: ecs_query_data_t, ...: i53) + local compatible_archetypes = query.compatible_archetypes + local with = { ... } + query.filter_with = with + + for i = #compatible_archetypes, 1, -1 do + local archetype = compatible_archetypes[i] + local records = archetype.records + local matches = true + + for _, id in with do + if not records[id] then + matches = false + break + end + end + + if matches then + continue + end + + local last = #compatible_archetypes + if last ~= i then + compatible_archetypes[i] = compatible_archetypes[last] + end + compatible_archetypes[last] = nil :: any + end + + return query :: any +end + +-- Meant for directly iterating over archetypes to minimize +-- function call overhead. Should not be used unless iterating over +-- hundreds of thousands of entities in bulk. +local function query_archetypes(query) + return query.compatible_archetypes +end + +local function query_cached(query: ecs_query_data_t) + local with = query.filter_with + local ids = query.ids + if with then + table.move(ids, 1, #ids, #with + 1, with) + else + query.filter_with = ids + end + + local compatible_archetypes = query.compatible_archetypes + local lastArchetype = 1 + + local A, B, C, D, E, F, G, H, I = unpack(ids) + local a: Column, b: Column, c: Column, d: Column + local e: Column, f: Column, g: Column, h: Column + + local world_query_iter_next + local columns: { Column } + local entities: { number } + local i: number + local archetype: ecs_archetype_t + local records: { number } + local archetypes = query.compatible_archetypes + + local world = query.world :: { observable: ecs_observable_t } + -- Only need one observer for EcsArchetypeCreate and EcsArchetypeDelete respectively + -- because the event will be emitted for all components of that Archetype. + local observable = world.observable :: ecs_observable_t + local on_create_action = observable[EcsOnArchetypeCreate] + if not on_create_action then + on_create_action = {} :: Map + observable[EcsOnArchetypeCreate] = on_create_action + end + local query_cache_on_create = on_create_action[A] + if not query_cache_on_create then + query_cache_on_create = {} + on_create_action[A] = query_cache_on_create + end + + local on_delete_action = observable[EcsOnArchetypeDelete] + if not on_delete_action then + on_delete_action = {} :: Map + observable[EcsOnArchetypeDelete] = on_delete_action + end + local query_cache_on_delete = on_delete_action[A] + if not query_cache_on_delete then + query_cache_on_delete = {} + on_delete_action[A] = query_cache_on_delete + end + + local function on_create_callback(archetype) + table.insert(archetypes, archetype) + end + + local function on_delete_callback(archetype) + local i = table.find(archetypes, archetype) :: number + if i == nil then + return + end + local n = #archetypes + archetypes[i] = archetypes[n] + archetypes[n] = nil + end + + local observer_for_create = { query = query, callback = on_create_callback } + local observer_for_delete = { query = query, callback = on_delete_callback } + + table.insert(query_cache_on_create, observer_for_create) + table.insert(query_cache_on_delete, observer_for_delete) + + local function cached_query_iter() + lastArchetype = 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return NOOP + end + entities = archetype.entities + i = #entities + records = archetype.records + columns = archetype.columns + if not B then + a = columns[records[A]] + elseif not C then + a = columns[records[A]] + b = columns[records[B]] + elseif not D then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + elseif not E then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + elseif not F then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + elseif not G then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + elseif not H then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + elseif not I then + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + h = columns[records[H]] + end + + return world_query_iter_next + end + + if not B then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + end + + local row = i + i -= 1 + + return entity, a[row] + end + elseif not C then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row] + end + elseif not D then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row] + end + elseif not E then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row] + end + elseif not F then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row] + end + elseif not G then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row] + end + elseif not H then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row] + end + elseif not I then + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + a = columns[records[A]] + b = columns[records[B]] + c = columns[records[C]] + d = columns[records[D]] + e = columns[records[E]] + f = columns[records[F]] + g = columns[records[G]] + h = columns[records[H]] + end + + local row = i + i -= 1 + + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row], h[row] + end + else + local queryOutput = {} + function world_query_iter_next(): any + local entity = entities[i] + while entity == nil do + lastArchetype += 1 + archetype = compatible_archetypes[lastArchetype] + if not archetype then + return nil + end + + entities = archetype.entities + i = #entities + if i == 0 then + continue + end + entity = entities[i] + columns = archetype.columns + records = archetype.records + end + + local row = i + i -= 1 + + if not F then + return entity, a[row], b[row], c[row], d[row], e[row] + elseif not G then + return entity, a[row], b[row], c[row], d[row], e[row], f[row] + elseif not H then + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row] + elseif not I then + return entity, a[row], b[row], c[row], d[row], e[row], f[row], g[row], h[row] + end + + for j, id in ids do + queryOutput[j] = columns[records[id]][row] + end + + return entity, unpack(queryOutput) + end + end + + local cached_query = query :: any + cached_query.archetypes = query_archetypes + cached_query.__iter = cached_query_iter + cached_query.iter = cached_query_iter + setmetatable(cached_query, cached_query) + return cached_query +end + +local Query = {} +Query.__index = Query +Query.__iter = query_iter +Query.iter = query_iter_init +Query.without = query_without +Query.with = query_with +Query.archetypes = query_archetypes +Query.cached = query_cached + +local function world_query(world: ecs_world_t, ...) + local compatible_archetypes = {} + local length = 0 + + local ids = { ... } + + local archetypes = world.archetypes + + local idr: ecs_id_record_t? + local component_index = world.component_index + + local q = setmetatable({ + ids = ids, + compatible_archetypes = compatible_archetypes, + world = world, + }, Query) + + for _, id in ids do + local map = component_index[id] + if not map then + return q + end + + if idr == nil or (map.size :: number) < (idr.size :: number) then + idr = map + end + end + + if idr == nil then + return q + end + + for archetype_id in idr.cache do + local compatibleArchetype = archetypes[archetype_id] + if #compatibleArchetype.entities == 0 then + continue + end + local records = compatibleArchetype.records + + local skip = false + + for i, id in ids do + local tr = records[id] + if not tr then + skip = true + break + end + end + + if skip then + continue + end + + length += 1 + compatible_archetypes[length] = compatibleArchetype + end + + return q +end + +local function world_each(world: ecs_world_t, id: i53): () -> () + local idr = world.component_index[id] + if not idr then + return NOOP + end + + local idr_cache = idr.cache + local archetypes = world.archetypes + local archetype_id = next(idr_cache, nil) :: number + local archetype = archetypes[archetype_id] + if not archetype then + return NOOP + end + + local entities = archetype.entities + local row = #entities + + return function(): any + local entity = entities[row] + while not entity do + archetype_id = next(idr_cache, archetype_id) :: number + if not archetype_id then + return + end + archetype = archetypes[archetype_id] + entities = archetype.entities + row = #entities + entity = entities[row] + end + row -= 1 + return entity + end +end + +local function world_children(world: ecs_world_t, parent: i53) + return world_each(world, ECS_PAIR(EcsChildOf, parent)) +end + +export type Record = { + archetype: Archetype, + row: number, + dense: i24, +} +export type ComponentRecord = { + cache: { [Id]: number }, + counts: { [Id]: number }, + flags: number, + size: number, + hooks: { + on_add: ((entity: Entity, id: Entity, value: T) -> ())?, + on_change: ((entity: Entity, id: Entity, value: T) -> ())?, + on_remove: ((entity: Entity, id: Entity) -> ())?, + }, +} +export type ComponentIndex = Map +export type Archetypes = { [Id]: Archetype } + +export type EntityIndex = { + dense_array: Map, + sparse_array: Map, + alive_count: number, + max_id: number, + range_begin: number?, + range_end: number? +} + +local World = {} +World.__index = World + +World.entity = world_entity +World.query = world_query +World.remove = world_remove +World.clear = world_clear +World.delete = world_delete +World.component = world_component +World.add = world_add +World.set = world_set +World.get = world_get +World.has = world_has +World.target = world_target +World.parent = world_parent +World.contains = world_contains +World.exists = world_exists +World.cleanup = world_cleanup +World.each = world_each +World.children = world_children +World.range = world_range + +local function world_new() + local entity_index = { + dense_array = {}, + sparse_array = {}, + alive_count = 0, + max_id = 0, + } :: ecs_entity_index_t + local self = setmetatable({ + archetype_edges = {}, + + archetype_index = {} :: { [string]: Archetype }, + archetypes = {} :: Archetypes, + component_index = {} :: ComponentIndex, + entity_index = entity_index, + ROOT_ARCHETYPE = (nil :: any) :: Archetype, + + max_archetype_id = 0, + max_component_id = ecs_max_component_id, + + observable = {} :: Observable, + }, World) :: any + + self.ROOT_ARCHETYPE = archetype_create(self, {}, "") + + for i = 1, HI_COMPONENT_ID do + local e = entity_index_new_id(entity_index) + world_add(self, e, EcsComponent) + end + + for i = HI_COMPONENT_ID + 1, EcsRest do + -- Initialize built-in components + entity_index_new_id(entity_index) + end + + world_add(self, EcsName, EcsComponent) + world_add(self, EcsOnChange, EcsComponent) + world_add(self, EcsOnAdd, EcsComponent) + world_add(self, EcsOnRemove, EcsComponent) + world_add(self, EcsWildcard, EcsComponent) + world_add(self, EcsRest, EcsComponent) + + world_set(self, EcsOnAdd, EcsName, "jecs.OnAdd") + world_set(self, EcsOnRemove, EcsName, "jecs.OnRemove") + world_set(self, EcsOnChange, EcsName, "jecs.OnChange") + world_set(self, EcsWildcard, EcsName, "jecs.Wildcard") + world_set(self, EcsChildOf, EcsName, "jecs.ChildOf") + world_set(self, EcsComponent, EcsName, "jecs.Component") + world_set(self, EcsOnDelete, EcsName, "jecs.OnDelete") + world_set(self, EcsOnDeleteTarget, EcsName, "jecs.OnDeleteTarget") + world_set(self, EcsDelete, EcsName, "jecs.Delete") + world_set(self, EcsRemove, EcsName, "jecs.Remove") + world_set(self, EcsName, EcsName, "jecs.Name") + world_set(self, EcsRest, EcsRest, "jecs.Rest") + + world_add(self, EcsChildOf, ECS_PAIR(EcsOnDeleteTarget, EcsDelete)) + + for i = EcsRest + 1, ecs_max_tag_id do + entity_index_new_id(entity_index) + end + + for i, bundle in ecs_metadata do + for ty, value in bundle do + if value == NULL then + world_add(self, i, ty) + else + world_set(self, i, ty, value) + end + end + end + + return self +end + +World.new = world_new + +export type Entity = { __T: T } +export type Id = { __T: T } +export type Pair = Id

+type ecs_id_t = Id | Pair | Pair<"Tag", T> +export type Item = (self: Query) -> (Entity, T...) +export type Iter = (query: Query) -> () -> (Entity, T...) + +export type Query = typeof(setmetatable( + {} :: { + iter: Iter, + with: (self: Query, ...Id) -> Query, + without: (self: Query, ...Id) -> Query, + archetypes: (self: Query) -> { Archetype }, + cached: (self: Query) -> Query, + }, + {} :: { + __iter: Iter + } +)) + +export type Observer = { + callback: (archetype: Archetype) -> (), + query: QueryInner, +} + +export type Observable = { + [Id]: { + [Id]: { + { Observer } + } + } +} + +export type World = { + archetype_index: { [string]: Archetype }, + archetypes: Archetypes, + component_index: ComponentIndex, + entity_index: EntityIndex, + ROOT_ARCHETYPE: Archetype, + + max_component_id: number, + max_archetype_id: number, + + observable: any, + + --- Enforce a check on entities to be created within desired range + range: (self: World, range_begin: number, range_end: number?) -> (), + + --- Creates a new entity + entity: (self: World, id: Entity?) -> Entity, + --- Creates a new entity located in the first 256 ids. + --- These should be used for static components for fast access. + component: (self: World) -> Entity, + --- Gets the target of an relationship. For example, when a user calls + --- `world:target(id, ChildOf(parent), 0)`, you will obtain the parent entity. + target: (self: World, id: Entity, relation: Id, index: number?) -> Entity?, + --- Deletes an entity and all it's related components and relationships. + delete: (self: World, id: Entity) -> (), + + --- Adds a component to the entity with no value + add: (self: World, id: Entity, component: Id) -> (), + --- Assigns a value to a component on the given entity + set: (self: World, id: Entity, component: Id, data: a) -> (), + + cleanup: (self: World) -> (), + -- Clears an entity from the world + clear: (self: World, id: Id) -> (), + --- Removes a component from the given entity + remove: (self: World, id: Entity, component: Id) -> (), + --- Retrieves the value of up to 4 components. These values may be nil. + get: & ((World, Entity, Id) -> a?) + & ((World, Entity, Id, Id) -> (a?, b?)) + & ((World, Entity, Id, Id, Id) -> (a?, b?, c?)) + & ((World, Entity, Id, Id, Id, Id) -> (a?, b?, c?, d?)), + + --- Returns whether the entity has the ID. + has: ((World, Entity, Id) -> boolean) + & ((World, Entity, Id, Id) -> boolean) + & ((World, Entity, Id, Id, Id) -> boolean) + & (World, Entity, Id, Id, Id, Id) -> boolean, + + --- Get parent (target of ChildOf relationship) for entity. If there is no ChildOf relationship pair, it will return nil. + parent: (self: World, entity: Entity) -> Entity, + + --- Checks if the world contains the given entity + contains: (self: World, entity: Entity) -> boolean, + + --- Checks if the entity exists + exists: (self: World, entity: Entity) -> boolean, + + each: (self: World, id: Id) -> () -> Entity, + + children: (self: World, id: Id) -> () -> Entity, + + --- Searches the world for entities that match a given query + query: ((World, Id) -> Query) + & ((World, Id, Id) -> Query) + & ((World, Id, Id, Id) -> Query) + & ((World, Id, Id, Id, Id) -> Query) + & ((World, Id, Id, Id, Id, Id) -> Query) + & ((World, Id, Id, Id, Id, Id, Id) -> Query) + & ((World, Id, Id, Id, Id, Id, Id, Id) -> Query) + & ((World, Id, Id, Id, Id, Id, Id, Id, Id, ...Id) -> Query) +} +-- type function ecs_id_t(entity) +-- local ty = entity:components()[2] +-- local __T = ty:readproperty(types.singleton("__T")) +-- if not __T then +-- return ty:readproperty(types.singleton("__jecs_pair_value")) +-- end +-- return __T +-- end + +-- type function ecs_pair_t(first, second) +-- if ecs_id_t(first):is("nil") then +-- return second +-- else +-- return first +-- end +-- end +-- + +return { + World = World :: { new: () -> World }, + world = world_new :: () -> World, + component = (ECS_COMPONENT :: any) :: () -> Entity, + tag = (ECS_TAG :: any) :: () -> Entity, + meta = (ECS_META :: any) :: (id: Entity, id: Id, value: T) -> Entity, + is_tag = (ecs_is_tag :: any) :: (World, Id) -> boolean, + + OnAdd = EcsOnAdd :: Entity<(entity: Entity, id: Id, data: T) -> ()>, + OnRemove = EcsOnRemove :: Entity<(entity: Entity, id: Id) -> ()>, + OnChange = EcsOnChange :: Entity<(entity: Entity, id: Id, data: T) -> ()>, + ChildOf = EcsChildOf :: Entity, + Component = EcsComponent :: Entity, + Wildcard = EcsWildcard :: Entity, + w = EcsWildcard :: Entity, + OnDelete = EcsOnDelete :: Entity, + OnDeleteTarget = EcsOnDeleteTarget :: Entity, + Delete = EcsDelete :: Entity, + Remove = EcsRemove :: Entity, + Name = EcsName :: Entity, + Rest = EcsRest :: Entity, + + pair = (ECS_PAIR :: any) :: (first: Id

, second: Id) -> Pair, + + -- Inwards facing API for testing + ECS_ID = ECS_ENTITY_T_LO, + ECS_GENERATION_INC = ECS_GENERATION_INC, + ECS_GENERATION = ECS_GENERATION, + ECS_ID_IS_WILDCARD = ECS_ID_IS_WILDCARD, + ECS_ID_DELETE = ECS_ID_DELETE, + ECS_META_RESET = ECS_META_RESET, + + IS_PAIR = (ECS_IS_PAIR :: any) :: (pair: Pair) -> boolean, + ECS_PAIR_FIRST = ECS_PAIR_FIRST :: (pair: Pair) -> Id

, + ECS_PAIR_SECOND = ECS_PAIR_SECOND :: (pair: Pair) -> Id, + pair_first = (ecs_pair_first :: any) :: (world: World, pair: Pair) -> Id

, + pair_second = (ecs_pair_second :: any) :: (world: World, pair: Pair) -> Id, + entity_index_get_alive = entity_index_get_alive, + + archetype_append_to_records = archetype_append_to_records, + id_record_ensure = id_record_ensure, + archetype_create = archetype_create, + archetype_ensure = archetype_ensure, + find_insert = find_insert, + find_archetype_with = find_archetype_with, + find_archetype_without = find_archetype_without, + create_edge_for_remove = create_edge_for_remove, + archetype_traverse_add = archetype_traverse_add, + archetype_traverse_remove = archetype_traverse_remove, + + entity_move = entity_move, + + entity_index_try_get = entity_index_try_get, + entity_index_try_get_any = entity_index_try_get_any, + entity_index_try_get_fast = entity_index_try_get_fast, + entity_index_is_alive = entity_index_is_alive, + entity_index_new_id = entity_index_new_id, + + query_iter = query_iter, + query_iter_init = query_iter_init, + query_with = query_with, + query_without = query_without, + query_archetypes = query_archetypes, + query_match = query_match, + + find_observers = find_observers, +} \ No newline at end of file diff --git a/src/Chemical/Packages/LinkedList.lua b/src/Chemical/Packages/LinkedList.lua new file mode 100644 index 0000000..cbe1f60 --- /dev/null +++ b/src/Chemical/Packages/LinkedList.lua @@ -0,0 +1,160 @@ +local listClass, linkClass = {}, {} +listClass.__index, linkClass.__index = listClass, linkClass + +export type List = typeof(listClass.new((1 :: any) :: T)) + +function listClass.new() + local self = setmetatable({}, listClass) + self.List = self + self.Links = {} + self.Length = 0 + self.Next = self + self.Previous = self + return self +end + +function listClass:InsertFront(value) + assert(self.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self, Previous = self, Next = self.Next} + self.Links[value] = link + self.Length += 1 + self.Next.Previous = link + self.Next = link + return setmetatable(link, linkClass) +end + +function listClass:InsertAfter(value) + assert(self.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self, Previous = self, Next = self.Next} + self.Links[value] = link + self.Length += 1 + self.Next.Previous = link + self.Next = link + return setmetatable(link, linkClass) +end + +function listClass:InsertBack(value) + assert(self.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self, Next = self, Previous = self.Previous} + self.Links[value] = link + self.Length += 1 + self.Previous.Next = link + self.Previous = link + return setmetatable(link, linkClass) +end + +function listClass:InsertBefore(value) + assert(self.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self, Next = self, Previous = self.Previous} + self.Links[value] = link + self.Length += 1 + self.Previous.Next = link + self.Previous = link + return setmetatable(link, linkClass) +end + +function listClass:GetNext(link) + link = (link or self).Next + if link ~= self then return link, link.Value end +end + +function listClass:GetPrevious(link) + link = (link or self).Previous + if link ~= self then return link, link.Value end +end + +function listClass:IterateForward(link) + return listClass.GetNext, self, link +end + +function listClass:IterateBackward(link) + return listClass.GetPrevious, self, link +end + +function listClass:Remove(value, clean: boolean?) + local link = self.Links[value] + if link ~= nil then + self.Links[value] = nil + self.Length -= 1 + link.List = nil + link.Previous.Next = link.Next + link.Next.Previous = link.Previous + + if clean then setmetatable(link, nil) return end + return link + end +end + +function listClass:RemoveFirst() + local link = self.Next + if link ~= self then + self.Links[link.Value] = nil + self.Length -= 1 + link.List = nil + link.Previous.Next = link.Next + link.Next.Previous = link.Previous + return link.Value, link + end +end + +function listClass:RemoveLast() + local link = self.Previous + if link ~= self then + self.Links[link.Value] = nil + self.Length -= 1 + link.List = nil + link.Previous.Next = link.Next + link.Next.Previous = link.Previous + return link.Value, link + end +end + +function listClass:Destroy() + local l = self.List.Length + for link, value in self:IterateForward() do + self:Remove(value, true) + end + setmetatable(self, nil) + table.clear(self) +end + +function linkClass:InsertAfter(value) + assert(self.List.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self.List, Previous = self, Next = self.Next} + self.List.Links[value] = link + self.List.Length += 1 + self.Next.Previous = link + self.Next = link + return setmetatable(link, linkClass) +end + +function linkClass:InsertBefore(value) + assert(self.List.Links[value] == nil, "Value already exists") + local link = {Value = value, List = self.List, Next = self, Previous = self.Previous} + self.List.Links[value] = link + self.List.Length += 1 + self.Previous.Next = link + self.Previous = link + return setmetatable(link, linkClass) +end + +function linkClass:GetNext() + local link = self.Next + if link ~= link.List then return link end +end + +function linkClass:GetPrevious() + local link = self.Previous + if link ~= link.List then return link end +end + +function linkClass:Remove() + assert(self.List ~= nil, "Link is not in a list") + self.List.Links[self.Value] = nil + self.List.Length -= 1 + self.List = nil + self.Previous.Next = self.Next + self.Next.Previous = self.Previous +end + +return listClass \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/Signal.lua b/src/Chemical/Packages/Packet/Signal.lua new file mode 100644 index 0000000..c582c2b --- /dev/null +++ b/src/Chemical/Packages/Packet/Signal.lua @@ -0,0 +1,101 @@ +--!strict + + +-- Requires +local Task = require(script.Parent.Task) + + +-- Types +export type Signal = { + Type: "Signal", + Previous: Connection, + Next: Connection, + Fire: (self: Signal, A...) -> (), + Connect: (self: Signal, func: (A...) -> ()) -> Connection, + Once: (self: Signal, func: (A...) -> ()) -> Connection, + Wait: (self: Signal) -> A..., +} + +export type Connection = { + Type: "Connection", + Previous: Connection, + Next: Connection, + Once: boolean, + Function: (player: Player, A...) -> (), + Thread: thread, + Disconnect: (self: Connection) -> (), +} + + +-- Varables +local Signal = {} :: Signal<...any> +local Connection = {} :: Connection<...any> + + +-- Constructor +local function Constructor() + local signal = (setmetatable({}, Signal) :: any) :: Signal + signal.Previous = signal :: any + signal.Next = signal :: any + return signal +end + + +-- Signal +Signal["__index"] = Signal +Signal.Type = "Signal" + +function Signal:Connect(func) + local connection = (setmetatable({}, Connection) :: any) :: Connection + connection.Previous = self.Previous + connection.Next = self :: any + connection.Once = false + connection.Function = func + self.Previous.Next = connection + self.Previous = connection + return connection +end + +function Signal:Once(func) + local connection = (setmetatable({}, Connection) :: any) :: Connection + connection.Previous = self.Previous + connection.Next = self :: any + connection.Once = true + connection.Function = func + self.Previous.Next = connection + self.Previous = connection + return connection +end + +function Signal:Wait() + local connection = (setmetatable({}, Connection) :: any) :: Connection + connection.Previous = self.Previous + connection.Next = self :: any + connection.Once = true + connection.Thread = coroutine.running() + self.Previous.Next = connection + self.Previous = connection + return coroutine.yield() +end + +function Signal:Fire(...) + local connection = self.Next + while connection.Type == "Connection" do + if connection.Function then Task:Defer(connection.Function, ...) else task.defer(connection.Thread, ...) end + if connection.Once then connection.Previous.Next = connection.Next connection.Next.Previous = connection.Previous end + connection = connection.Next + end +end + + +-- Connection +Connection["__index"] = Connection +Connection.Type = "Connection" + +function Connection:Disconnect() + self.Previous.Next = self.Next + self.Next.Previous = self.Previous +end + + +return Constructor \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/Task.lua b/src/Chemical/Packages/Packet/Task.lua new file mode 100644 index 0000000..5731b1a --- /dev/null +++ b/src/Chemical/Packages/Packet/Task.lua @@ -0,0 +1,46 @@ +--!strict + + +-- Types +export type Task = { + Type: "Task", + Spawn: (self: Task, func: (...any) -> (), ...any) -> thread, + Defer: (self: Task, func: (...any) -> (), ...any) -> thread, + Delay: (self: Task, duration: number, func: (...any) -> (), ...any) -> thread, +} + + +-- Varables +local Call, Thread +local Task = {} :: Task +local threads = {} :: {thread} + + +-- Task +Task.Type = "Task" + +function Task:Spawn(func, ...) + return task.spawn(table.remove(threads) or task.spawn(Thread), func, ...) +end + +function Task:Defer(func, ...) + return task.defer(table.remove(threads) or task.spawn(Thread), func, ...) +end + +function Task:Delay(duration, func, ...) + return task.delay(duration, table.remove(threads) or task.spawn(Thread), func, ...) +end + + +-- Functions +function Call(func: (...any) -> (), ...) + func(...) + table.insert(threads, coroutine.running()) +end + +function Thread() + while true do Call(coroutine.yield()) end +end + + +return Task \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/Types/Characters.lua b/src/Chemical/Packages/Packet/Types/Characters.lua new file mode 100644 index 0000000..6e67cad --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/Characters.lua @@ -0,0 +1,6 @@ +return {[0] = -- Recommended character array lengths: 2, 4, 8, 16, 32, 64, 128, 256 + " ", ".", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", + "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", +} \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/Types/Enums.lua b/src/Chemical/Packages/Packet/Types/Enums.lua new file mode 100644 index 0000000..92b7973 --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/Enums.lua @@ -0,0 +1,12 @@ +return { -- Add any enum [Max: 255] + Enum.AccessoryType, + Enum.Axis, + Enum.BodyPart, + Enum.BodyPartR15, + Enum.EasingDirection, + Enum.EasingStyle, + Enum.KeyCode, + Enum.Material, + Enum.NormalId, + Enum.HumanoidStateType +} \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/Types/Static1.lua b/src/Chemical/Packages/Packet/Types/Static1.lua new file mode 100644 index 0000000..9e1a06b --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/Static1.lua @@ -0,0 +1,8 @@ +return { + "DataStore Failed To Load", + "Another Static String", + math.pi, + 123456789, + Vector3.new(1, 2, 3), + "You can have upto 255 static values of any type" +} diff --git a/src/Chemical/Packages/Packet/Types/Static2.lua b/src/Chemical/Packages/Packet/Types/Static2.lua new file mode 100644 index 0000000..9e1a06b --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/Static2.lua @@ -0,0 +1,8 @@ +return { + "DataStore Failed To Load", + "Another Static String", + math.pi, + 123456789, + Vector3.new(1, 2, 3), + "You can have upto 255 static values of any type" +} diff --git a/src/Chemical/Packages/Packet/Types/Static3.lua b/src/Chemical/Packages/Packet/Types/Static3.lua new file mode 100644 index 0000000..9e1a06b --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/Static3.lua @@ -0,0 +1,8 @@ +return { + "DataStore Failed To Load", + "Another Static String", + math.pi, + 123456789, + Vector3.new(1, 2, 3), + "You can have upto 255 static values of any type" +} diff --git a/src/Chemical/Packages/Packet/Types/init.lua b/src/Chemical/Packages/Packet/Types/init.lua new file mode 100644 index 0000000..4ba7c66 --- /dev/null +++ b/src/Chemical/Packages/Packet/Types/init.lua @@ -0,0 +1,705 @@ +--!strict +--!optimize 2 + +--[[ + S8 Minimum: -128 Maximum: 127 + S16 Minimum: -32768 Maximum: 32767 + S24 Minimum: -8388608 Maximum: 8388607 + S32 Minimum: -2147483648 Maximum: 2147483647 + + U8 Minimum: 0 Maximum: 255 + U16 Minimum: 0 Maximum: 65535 + U24 Minimum: 0 Maximum: 16777215 + U32 Minimum: 0 Maximum: 4294967295 + + F16 ±2048 [65520] + F24 ±262144 [4294959104] + F32 ±16777216 [170141183460469231731687303715884105728] + F64 ±9007199254740992 [huge] +]] + + +-- Types +export type Cursor = { + Buffer: buffer, + BufferLength: number, + BufferOffset: number, + Instances: {Instance}, + InstancesOffset: number, +} + + +-- Varables +local activeCursor : Cursor +local activeBuffer : buffer +local bufferLength : number +local bufferOffset : number +local instances : {Instance} +local instancesOffset : number +local types = {} +local reads = {} +local writes = {} +local anyReads = {} :: {[any]: () -> any} +local anyWrites = {} :: {[any]: (any) -> ()} + + +-- Functions +local function Allocate(bytes: number) + local targetLength = bufferOffset + bytes + if bufferLength < targetLength then + while bufferLength < targetLength do bufferLength *= 2 end + local newBuffer = buffer.create(bufferLength) + buffer.copy(newBuffer, 0, activeBuffer, 0, bufferOffset) + activeCursor.Buffer = newBuffer + activeBuffer = newBuffer + end +end + +local function ReadS8(): number local value = buffer.readi8(activeBuffer, bufferOffset) bufferOffset += 1 return value end +local function WriteS8(value: number) buffer.writei8(activeBuffer, bufferOffset, value) bufferOffset += 1 end +local function ReadS16(): number local value = buffer.readi16(activeBuffer, bufferOffset) bufferOffset += 2 return value end +local function WriteS16(value: number) buffer.writei16(activeBuffer, bufferOffset, value) bufferOffset += 2 end +local function ReadS24(): number local value = buffer.readbits(activeBuffer, bufferOffset * 8, 24) - 8388608 bufferOffset += 3 return value end +local function WriteS24(value: number) buffer.writebits(activeBuffer, bufferOffset * 8, 24, value + 8388608) bufferOffset += 3 end +local function ReadS32(): number local value = buffer.readi32(activeBuffer, bufferOffset) bufferOffset += 4 return value end +local function WriteS32(value: number) buffer.writei32(activeBuffer, bufferOffset, value) bufferOffset += 4 end +local function ReadU8(): number local value = buffer.readu8(activeBuffer, bufferOffset) bufferOffset += 1 return value end +local function WriteU8(value: number) buffer.writeu8(activeBuffer, bufferOffset, value) bufferOffset += 1 end +local function ReadU16(): number local value = buffer.readu16(activeBuffer, bufferOffset) bufferOffset += 2 return value end +local function WriteU16(value: number) buffer.writeu16(activeBuffer, bufferOffset, value) bufferOffset += 2 end +local function ReadU24(): number local value = buffer.readbits(activeBuffer, bufferOffset * 8, 24) bufferOffset += 3 return value end +local function WriteU24(value: number) buffer.writebits(activeBuffer, bufferOffset * 8, 24, value) bufferOffset += 3 end +local function ReadU32(): number local value = buffer.readu32(activeBuffer, bufferOffset) bufferOffset += 4 return value end +local function WriteU32(value: number) buffer.writeu32(activeBuffer, bufferOffset, value) bufferOffset += 4 end +local function ReadF32(): number local value = buffer.readf32(activeBuffer, bufferOffset) bufferOffset += 4 return value end +local function WriteF32(value: number) buffer.writef32(activeBuffer, bufferOffset, value) bufferOffset += 4 end +local function ReadF64(): number local value = buffer.readf64(activeBuffer, bufferOffset) bufferOffset += 8 return value end +local function WriteF64(value: number) buffer.writef64(activeBuffer, bufferOffset, value) bufferOffset += 8 end +local function ReadString(length: number) local value = buffer.readstring(activeBuffer, bufferOffset, length) bufferOffset += length return value end +local function WriteString(value: string) buffer.writestring(activeBuffer, bufferOffset, value) bufferOffset += #value end +local function ReadBuffer(length: number) local value = buffer.create(length) buffer.copy(value, 0, activeBuffer, bufferOffset, length) bufferOffset += length return value end +local function WriteBuffer(value: buffer) buffer.copy(activeBuffer, bufferOffset, value) bufferOffset += buffer.len(value) end +local function ReadInstance() instancesOffset += 1 return instances[instancesOffset] end +local function WriteInstance(value) instancesOffset += 1 instances[instancesOffset] = value end + +local function ReadF16(): number + local bitOffset = bufferOffset * 8 + bufferOffset += 2 + local mantissa = buffer.readbits(activeBuffer, bitOffset + 0, 10) + local exponent = buffer.readbits(activeBuffer, bitOffset + 10, 5) + local sign = buffer.readbits(activeBuffer, bitOffset + 15, 1) + if mantissa == 0b0000000000 then + if exponent == 0b00000 then return 0 end + if exponent == 0b11111 then return if sign == 0 then math.huge else -math.huge end + elseif exponent == 0b11111 then return 0/0 end + if sign == 0 then + return (mantissa / 1024 + 1) * 2 ^ (exponent - 15) + else + return -(mantissa / 1024 + 1) * 2 ^ (exponent - 15) + end +end +local function WriteF16(value: number) + local bitOffset = bufferOffset * 8 + bufferOffset += 2 + if value == 0 then + buffer.writebits(activeBuffer, bitOffset, 16, 0b0_00000_0000000000) + elseif value >= 65520 then + buffer.writebits(activeBuffer, bitOffset, 16, 0b0_11111_0000000000) + elseif value <= -65520 then + buffer.writebits(activeBuffer, bitOffset, 16, 0b1_11111_0000000000) + elseif value ~= value then + buffer.writebits(activeBuffer, bitOffset, 16, 0b0_11111_0000000001) + else + local sign = 0 + if value < 0 then sign = 1 value = -value end + local mantissa, exponent = math.frexp(value) + buffer.writebits(activeBuffer, bitOffset + 0, 10, mantissa * 2048 - 1023.5) + buffer.writebits(activeBuffer, bitOffset + 10, 5, exponent + 14) + buffer.writebits(activeBuffer, bitOffset + 15, 1, sign) + end +end + +local function ReadF24(): number + local bitOffset = bufferOffset * 8 + bufferOffset += 3 + local mantissa = buffer.readbits(activeBuffer, bitOffset + 0, 17) + local exponent = buffer.readbits(activeBuffer, bitOffset + 17, 6) + local sign = buffer.readbits(activeBuffer, bitOffset + 23, 1) + if mantissa == 0b00000000000000000 then + if exponent == 0b000000 then return 0 end + if exponent == 0b111111 then return if sign == 0 then math.huge else -math.huge end + elseif exponent == 0b111111 then return 0/0 end + if sign == 0 then + return (mantissa / 131072 + 1) * 2 ^ (exponent - 31) + else + return -(mantissa / 131072 + 1) * 2 ^ (exponent - 31) + end +end +local function WriteF24(value: number) + local bitOffset = bufferOffset * 8 + bufferOffset += 3 + if value == 0 then + buffer.writebits(activeBuffer, bitOffset, 24, 0b0_000000_00000000000000000) + elseif value >= 4294959104 then + buffer.writebits(activeBuffer, bitOffset, 24, 0b0_111111_00000000000000000) + elseif value <= -4294959104 then + buffer.writebits(activeBuffer, bitOffset, 24, 0b1_111111_00000000000000000) + elseif value ~= value then + buffer.writebits(activeBuffer, bitOffset, 24, 0b0_111111_00000000000000001) + else + local sign = 0 + if value < 0 then sign = 1 value = -value end + local mantissa, exponent = math.frexp(value) + buffer.writebits(activeBuffer, bitOffset + 0, 17, mantissa * 262144 - 131071.5) + buffer.writebits(activeBuffer, bitOffset + 17, 6, exponent + 30) + buffer.writebits(activeBuffer, bitOffset + 23, 1, sign) + end +end + + +-- Types +types.Any = "Any" :: any +reads.Any = function() return anyReads[ReadU8()]() end +writes.Any = function(value: any) anyWrites[typeof(value)](value) end + +types.Nil = ("Nil" :: any) :: nil +reads.Nil = function() return nil end +writes.Nil = function(value: nil) end + +types.NumberS8 = ("NumberS8" :: any) :: number +reads.NumberS8 = function() return ReadS8() end +writes.NumberS8 = function(value: number) Allocate(1) WriteS8(value) end + +types.NumberS16 = ("NumberS16" :: any) :: number +reads.NumberS16 = function() return ReadS16() end +writes.NumberS16 = function(value: number) Allocate(2) WriteS16(value) end + +types.NumberS24 = ("NumberS24" :: any) :: number +reads.NumberS24 = function() return ReadS24() end +writes.NumberS24 = function(value: number) Allocate(3) WriteS24(value) end + +types.NumberS32 = ("NumberS32" :: any) :: number +reads.NumberS32 = function() return ReadS32() end +writes.NumberS32 = function(value: number) Allocate(4) WriteS32(value) end + +types.NumberU8 = ("NumberU8" :: any) :: number +reads.NumberU8 = function() return ReadU8() end +writes.NumberU8 = function(value: number) Allocate(1) WriteU8(value) end + +types.NumberU16 = ("NumberU16" :: any) :: number +reads.NumberU16 = function() return ReadU16() end +writes.NumberU16 = function(value: number) Allocate(2) WriteU16(value) end + +types.NumberU24 = ("NumberU24" :: any) :: number +reads.NumberU24 = function() return ReadU24() end +writes.NumberU24 = function(value: number) Allocate(3) WriteU24(value) end + +types.NumberU32 = ("NumberU32" :: any) :: number +reads.NumberU32 = function() return ReadU32() end +writes.NumberU32 = function(value: number) Allocate(4) WriteU32(value) end + +types.NumberF16 = ("NumberF16" :: any) :: number +reads.NumberF16 = function() return ReadF16() end +writes.NumberF16 = function(value: number) Allocate(2) WriteF16(value) end + +types.NumberF24 = ("NumberF24" :: any) :: number +reads.NumberF24 = function() return ReadF24() end +writes.NumberF24 = function(value: number) Allocate(3) WriteF24(value) end + +types.NumberF32 = ("NumberF32" :: any) :: number +reads.NumberF32 = function() return ReadF32() end +writes.NumberF32 = function(value: number) Allocate(4) WriteF32(value) end + +types.NumberF64 = ("NumberF64" :: any) :: number +reads.NumberF64 = function() return ReadF64() end +writes.NumberF64 = function(value: number) Allocate(8) WriteF64(value) end + +types.String = ("String" :: any) :: string +reads.String = function() return ReadString(ReadU8()) end +writes.String = function(value: string) local length = #value Allocate(1 + length) WriteU8(length) WriteString(value) end + +types.StringLong = ("StringLong" :: any) :: string +reads.StringLong = function() return ReadString(ReadU16()) end +writes.StringLong = function(value: string) local length = #value Allocate(2 + length) WriteU16(length) WriteString(value) end + +types.Buffer = ("Buffer" :: any) :: buffer +reads.Buffer = function() return ReadBuffer(ReadU8()) end +writes.Buffer = function(value: buffer) local length = buffer.len(value) Allocate(1 + length) WriteU8(length) WriteBuffer(value) end + +types.BufferLong = ("BufferLong" :: any) :: buffer +reads.BufferLong = function() return ReadBuffer(ReadU16()) end +writes.BufferLong = function(value: buffer) local length = buffer.len(value) Allocate(2 + length) WriteU16(length) WriteBuffer(value) end + +types.Instance = ("Instance" :: any) :: Instance +reads.Instance = function() return ReadInstance() end +writes.Instance = function(value: Instance) WriteInstance(value) end + +types.Boolean8 = ("Boolean8" :: any) :: boolean +reads.Boolean8 = function() return ReadU8() == 1 end +writes.Boolean8 = function(value: boolean) Allocate(1) WriteU8(if value then 1 else 0) end + +types.NumberRange = ("NumberRange" :: any) :: NumberRange +reads.NumberRange = function() return NumberRange.new(ReadF32(), ReadF32()) end +writes.NumberRange = function(value: NumberRange) Allocate(8) WriteF32(value.Min) WriteF32(value.Max) end + +types.BrickColor = ("BrickColor" :: any) :: BrickColor +reads.BrickColor = function() return BrickColor.new(ReadU16()) end +writes.BrickColor = function(value: BrickColor) Allocate(2) WriteU16(value.Number) end + +types.Color3 = ("Color3" :: any) :: Color3 +reads.Color3 = function() return Color3.fromRGB(ReadU8(), ReadU8(), ReadU8()) end +writes.Color3 = function(value: Color3) Allocate(3) WriteU8(value.R * 255 + 0.5) WriteU8(value.G * 255 + 0.5) WriteU8(value.B * 255 + 0.5) end + +types.UDim = ("UDim" :: any) :: UDim +reads.UDim = function() return UDim.new(ReadS16() / 1000, ReadS16()) end +writes.UDim = function(value: UDim) Allocate(4) WriteS16(value.Scale * 1000) WriteS16(value.Offset) end + +types.UDim2 = ("UDim2" :: any) :: UDim2 +reads.UDim2 = function() return UDim2.new(ReadS16() / 1000, ReadS16(), ReadS16() / 1000, ReadS16()) end +writes.UDim2 = function(value: UDim2) Allocate(8) WriteS16(value.X.Scale * 1000) WriteS16(value.X.Offset) WriteS16(value.Y.Scale * 1000) WriteS16(value.Y.Offset) end + +types.Rect = ("Rect" :: any) :: Rect +reads.Rect = function() return Rect.new(ReadF32(), ReadF32(), ReadF32(), ReadF32()) end +writes.Rect = function(value: Rect) Allocate(16) WriteF32(value.Min.X) WriteF32(value.Min.Y) WriteF32(value.Max.X) WriteF32(value.Max.Y) end + +types.Vector2S16 = ("Vector2S16" :: any) :: Vector2 +reads.Vector2S16 = function() return Vector2.new(ReadS16(), ReadS16()) end +writes.Vector2S16 = function(value: Vector2) Allocate(4) WriteS16(value.X) WriteS16(value.Y) end + +types.Vector2F24 = ("Vector2F24" :: any) :: Vector2 +reads.Vector2F24 = function() return Vector2.new(ReadF24(), ReadF24()) end +writes.Vector2F24 = function(value: Vector2) Allocate(6) WriteF24(value.X) WriteF24(value.Y) end + +types.Vector2F32 = ("Vector2F32" :: any) :: Vector2 +reads.Vector2F32 = function() return Vector2.new(ReadF32(), ReadF32()) end +writes.Vector2F32 = function(value: Vector2) Allocate(8) WriteF32(value.X) WriteF32(value.Y) end + +types.Vector3S16 = ("Vector3S16" :: any) :: Vector3 +reads.Vector3S16 = function() return Vector3.new(ReadS16(), ReadS16(), ReadS16()) end +writes.Vector3S16 = function(value: Vector3) Allocate(6) WriteS16(value.X) WriteS16(value.Y) WriteS16(value.Z) end + +types.Vector3F24 = ("Vector3F24" :: any) :: Vector3 +reads.Vector3F24 = function() return Vector3.new(ReadF24(), ReadF24(), ReadF24()) end +writes.Vector3F24 = function(value: Vector3) Allocate(9) WriteF24(value.X) WriteF24(value.Y) WriteF24(value.Z) end + +types.Vector3F32 = ("Vector3F32" :: any) :: Vector3 +reads.Vector3F32 = function() return Vector3.new(ReadF32(), ReadF32(), ReadF32()) end +writes.Vector3F32 = function(value: Vector3) Allocate(12) WriteF32(value.X) WriteF32(value.Y) WriteF32(value.Z) end + +types.NumberU4 = ("NumberU4" :: any) :: {number} +reads.NumberU4 = function() + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + return { + buffer.readbits(activeBuffer, bitOffset + 0, 4), + buffer.readbits(activeBuffer, bitOffset + 4, 4) + } +end +writes.NumberU4 = function(value: {number}) + Allocate(1) + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + buffer.writebits(activeBuffer, bitOffset + 0, 4, value[1]) + buffer.writebits(activeBuffer, bitOffset + 4, 4, value[2]) +end + +types.BooleanNumber = ("BooleanNumber" :: any) :: {Boolean: boolean, Number: number} +reads.BooleanNumber = function() + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + return { + Boolean = buffer.readbits(activeBuffer, bitOffset + 0, 1) == 1, + Number = buffer.readbits(activeBuffer, bitOffset + 1, 7), + } +end +writes.BooleanNumber = function(value: {Boolean: boolean, Number: number}) + Allocate(1) + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + buffer.writebits(activeBuffer, bitOffset + 0, 1, if value.Boolean then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 1, 7, value.Number) +end + +types.Boolean1 = ("Boolean1" :: any) :: {boolean} +reads.Boolean1 = function() + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + return { + buffer.readbits(activeBuffer, bitOffset + 0, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 1, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 2, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 3, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 4, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 5, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 6, 1) == 1, + buffer.readbits(activeBuffer, bitOffset + 7, 1) == 1, + } +end +writes.Boolean1 = function(value: {boolean}) + Allocate(1) + local bitOffset = bufferOffset * 8 + bufferOffset += 1 + buffer.writebits(activeBuffer, bitOffset + 0, 1, if value[1] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 1, 1, if value[2] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 2, 1, if value[3] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 3, 1, if value[4] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 4, 1, if value[5] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 5, 1, if value[6] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 6, 1, if value[7] then 1 else 0) + buffer.writebits(activeBuffer, bitOffset + 7, 1, if value[8] then 1 else 0) +end + +types.CFrameF24U8 = ("CFrameF24U8" :: any) :: CFrame +reads.CFrameF24U8 = function() + return CFrame.fromEulerAnglesXYZ(ReadU8() / 40.58451048843331, ReadU8() / 40.58451048843331, ReadU8() / 40.58451048843331) + + Vector3.new(ReadF24(), ReadF24(), ReadF24()) +end +writes.CFrameF24U8 = function(value: CFrame) + local rx, ry, rz = value:ToEulerAnglesXYZ() + Allocate(12) + WriteU8(rx * 40.58451048843331 + 0.5) WriteU8(ry * 40.58451048843331 + 0.5) WriteU8(rz * 40.58451048843331 + 0.5) + WriteF24(value.X) WriteF24(value.Y) WriteF24(value.Z) +end + +types.CFrameF32U8 = ("CFrameF32U8" :: any) :: CFrame +reads.CFrameF32U8 = function() + return CFrame.fromEulerAnglesXYZ(ReadU8() / 40.58451048843331, ReadU8() / 40.58451048843331, ReadU8() / 40.58451048843331) + + Vector3.new(ReadF32(), ReadF32(), ReadF32()) +end +writes.CFrameF32U8 = function(value: CFrame) + local rx, ry, rz = value:ToEulerAnglesXYZ() + Allocate(15) + WriteU8(rx * 40.58451048843331 + 0.5) WriteU8(ry * 40.58451048843331 + 0.5) WriteU8(rz * 40.58451048843331 + 0.5) + WriteF32(value.X) WriteF32(value.Y) WriteF32(value.Z) +end + +types.CFrameF32U16 = ("CFrameF32U16" :: any) :: CFrame +reads.CFrameF32U16 = function() + return CFrame.fromEulerAnglesXYZ(ReadU16() / 10430.219195527361, ReadU16() / 10430.219195527361, ReadU16() / 10430.219195527361) + + Vector3.new(ReadF32(), ReadF32(), ReadF32()) +end +writes.CFrameF32U16 = function(value: CFrame) + local rx, ry, rz = value:ToEulerAnglesXYZ() + Allocate(18) + WriteU16(rx * 10430.219195527361 + 0.5) WriteU16(ry * 10430.219195527361 + 0.5) WriteU16(rz * 10430.219195527361 + 0.5) + WriteF32(value.X) WriteF32(value.Y) WriteF32(value.Z) +end + +types.Region3 = ("Region3" :: any) :: Region3 +reads.Region3 = function() + return Region3.new( + Vector3.new(ReadF32(), ReadF32(), ReadF32()), + Vector3.new(ReadF32(), ReadF32(), ReadF32()) + ) +end +writes.Region3 = function(value: Region3) + local halfSize = value.Size / 2 + local minimum = value.CFrame.Position - halfSize + local maximum = value.CFrame.Position + halfSize + Allocate(24) + WriteF32(minimum.X) WriteF32(minimum.Y) WriteF32(minimum.Z) + WriteF32(maximum.X) WriteF32(maximum.Y) WriteF32(maximum.Z) +end + +types.NumberSequence = ("NumberSequence" :: any) :: NumberSequence +reads.NumberSequence = function() + local length = ReadU8() + local keypoints = table.create(length) + for index = 1, length do + table.insert(keypoints, NumberSequenceKeypoint.new(ReadU8() / 255, ReadU8() / 255, ReadU8() / 255)) + end + return NumberSequence.new(keypoints) +end +writes.NumberSequence = function(value: NumberSequence) + local length = #value.Keypoints + Allocate(1 + length * 3) + WriteU8(length) + for index, keypoint in value.Keypoints do + WriteU8(keypoint.Time * 255 + 0.5) WriteU8(keypoint.Value * 255 + 0.5) WriteU8(keypoint.Envelope * 255 + 0.5) + end +end + +types.ColorSequence = ("ColorSequence" :: any) :: ColorSequence +reads.ColorSequence = function() + local length = ReadU8() + local keypoints = table.create(length) + for index = 1, length do + table.insert(keypoints, ColorSequenceKeypoint.new(ReadU8() / 255, Color3.fromRGB(ReadU8(), ReadU8(), ReadU8()))) + end + return ColorSequence.new(keypoints) +end +writes.ColorSequence = function(value: ColorSequence) + local length = #value.Keypoints + Allocate(1 + length * 4) + WriteU8(length) + for index, keypoint in value.Keypoints do + WriteU8(keypoint.Time * 255 + 0.5) + WriteU8(keypoint.Value.R * 255 + 0.5) WriteU8(keypoint.Value.G * 255 + 0.5) WriteU8(keypoint.Value.B * 255 + 0.5) + end +end + +local characterIndices = {} +local characters = require(script.Characters) +for index, value in characters do characterIndices[value] = index end +local characterBits = math.ceil(math.log(#characters + 1, 2)) +local characterBytes = characterBits / 8 +types.Characters = ("Characters" :: any) :: string +reads.Characters = function() + local length = ReadU8() + local characterArray = table.create(length) + local bitOffset = bufferOffset * 8 + bufferOffset += math.ceil(length * characterBytes) + for index = 1, length do + table.insert(characterArray, characters[buffer.readbits(activeBuffer, bitOffset, characterBits)]) + bitOffset += characterBits + end + return table.concat(characterArray) +end +writes.Characters = function(value: string) + local length = #value + local bytes = math.ceil(length * characterBytes) + Allocate(1 + bytes) + WriteU8(length) + local bitOffset = bufferOffset * 8 + for index = 1, length do + buffer.writebits(activeBuffer, bitOffset, characterBits, characterIndices[value:sub(index, index)]) + bitOffset += characterBits + end + bufferOffset += bytes +end + +local enumIndices = {} +local enums = require(script.Enums) +for index, static in enums do enumIndices[static] = index end +types.EnumItem = ("EnumItem" :: any) :: EnumItem +reads.EnumItem = function() return enums[ReadU8()]:FromValue(ReadU16()) end +writes.EnumItem = function(value: EnumItem) Allocate(3) WriteU8(enumIndices[value.EnumType]) WriteU16(value.Value) end + +local staticIndices = {} +local statics = require(script.Static1) +for index, static in statics do staticIndices[static] = index end +types.Static1 = ("Static1" :: any) :: any +reads.Static1 = function() return statics[ReadU8()] end +writes.Static1 = function(value: any) Allocate(1) WriteU8(staticIndices[value] or 0) end + +local staticIndices = {} +local statics = require(script.Static2) +for index, static in statics do staticIndices[static] = index end +types.Static2 = ("Static2" :: any) :: any +reads.Static2 = function() return statics[ReadU8()] end +writes.Static2 = function(value: any) Allocate(1) WriteU8(staticIndices[value] or 0) end + +local staticIndices = {} +local statics = require(script.Static3) +for index, static in statics do staticIndices[static] = index end +types.Static3 = ("Static3" :: any) :: any +reads.Static3 = function() return statics[ReadU8()] end +writes.Static3 = function(value: any) Allocate(1) WriteU8(staticIndices[value] or 0) end + + +-- Any Types +anyReads[0] = function() return nil end +anyWrites["nil"] = function(value: nil) Allocate(1) WriteU8(0) end + +anyReads[1] = function() return -ReadU8() end +anyReads[2] = function() return -ReadU16() end +anyReads[3] = function() return -ReadU24() end +anyReads[4] = function() return -ReadU32() end +anyReads[5] = function() return ReadU8() end +anyReads[6] = function() return ReadU16() end +anyReads[7] = function() return ReadU24() end +anyReads[8] = function() return ReadU32() end +anyReads[9] = function() return ReadF32() end +anyReads[10] = function() return ReadF64() end +anyWrites.number = function(value: number) + if value % 1 == 0 then + if value < 0 then + if value > -256 then + Allocate(2) WriteU8(1) WriteU8(-value) + elseif value > -65536 then + Allocate(3) WriteU8(2) WriteU16(-value) + elseif value > -16777216 then + Allocate(4) WriteU8(3) WriteU24(-value) + elseif value > -4294967296 then + Allocate(5) WriteU8(4) WriteU32(-value) + else + Allocate(9) WriteU8(10) WriteF64(value) + end + else + if value < 256 then + Allocate(2) WriteU8(5) WriteU8(value) + elseif value < 65536 then + Allocate(3) WriteU8(6) WriteU16(value) + elseif value < 16777216 then + Allocate(4) WriteU8(7) WriteU24(value) + elseif value < 4294967296 then + Allocate(5) WriteU8(8) WriteU32(value) + else + Allocate(9) WriteU8(10) WriteF64(value) + end + end + elseif value > -1048576 and value < 1048576 then + Allocate(5) WriteU8(9) WriteF32(value) + else + Allocate(9) WriteU8(10) WriteF64(value) + end +end + +anyReads[11] = function() return ReadString(ReadU8()) end +anyWrites.string = function(value: string) local length = #value Allocate(2 + length) WriteU8(11) WriteU8(length) WriteString(value) end + +anyReads[12] = function() return ReadBuffer(ReadU8()) end +anyWrites.buffer = function(value: buffer) local length = buffer.len(value) Allocate(2 + length) WriteU8(12) WriteU8(length) WriteBuffer(value) end + +anyReads[13] = function() return ReadInstance() end +anyWrites.Instance = function(value: Instance) Allocate(1) WriteU8(13) WriteInstance(value) end + +anyReads[14] = function() return ReadU8() == 1 end +anyWrites.boolean = function(value: boolean) Allocate(2) WriteU8(14) WriteU8(if value then 1 else 0) end + +anyReads[15] = function() return NumberRange.new(ReadF32(), ReadF32()) end +anyWrites.NumberRange = function(value: NumberRange) Allocate(9) WriteU8(15) WriteF32(value.Min) WriteF32(value.Max) end + +anyReads[16] = function() return BrickColor.new(ReadU16()) end +anyWrites.BrickColor = function(value: BrickColor) Allocate(3) WriteU8(16) WriteU16(value.Number) end + +anyReads[17] = function() return Color3.fromRGB(ReadU8(), ReadU8(), ReadU8()) end +anyWrites.Color3 = function(value: Color3) Allocate(4) WriteU8(17) WriteU8(value.R * 255 + 0.5) WriteU8(value.G * 255 + 0.5) WriteU8(value.B * 255 + 0.5) end + +anyReads[18] = function() return UDim.new(ReadS16() / 1000, ReadS16()) end +anyWrites.UDim = function(value: UDim) Allocate(5) WriteU8(18) WriteS16(value.Scale * 1000) WriteS16(value.Offset) end + +anyReads[19] = function() return UDim2.new(ReadS16() / 1000, ReadS16(), ReadS16() / 1000, ReadS16()) end +anyWrites.UDim2 = function(value: UDim2) Allocate(9) WriteU8(19) WriteS16(value.X.Scale * 1000) WriteS16(value.X.Offset) WriteS16(value.Y.Scale * 1000) WriteS16(value.Y.Offset) end + +anyReads[20] = function() return Rect.new(ReadF32(), ReadF32(), ReadF32(), ReadF32()) end +anyWrites.Rect = function(value: Rect) Allocate(17) WriteU8(20) WriteF32(value.Min.X) WriteF32(value.Min.Y) WriteF32(value.Max.X) WriteF32(value.Max.Y) end + +anyReads[21] = function() return Vector2.new(ReadF32(), ReadF32()) end +anyWrites.Vector2 = function(value: Vector2) Allocate(9) WriteU8(21) WriteF32(value.X) WriteF32(value.Y) end + +anyReads[22] = function() return Vector3.new(ReadF32(), ReadF32(), ReadF32()) end +anyWrites.Vector3 = function(value: Vector3) Allocate(13) WriteU8(22) WriteF32(value.X) WriteF32(value.Y) WriteF32(value.Z) end + +anyReads[23] = function() + return CFrame.fromEulerAnglesXYZ(ReadU16() / 10430.219195527361, ReadU16() / 10430.219195527361, ReadU16() / 10430.219195527361) + + Vector3.new(ReadF32(), ReadF32(), ReadF32()) +end +anyWrites.CFrame = function(value: CFrame) + local rx, ry, rz = value:ToEulerAnglesXYZ() + Allocate(19) + WriteU8(23) + WriteU16(rx * 10430.219195527361 + 0.5) WriteU16(ry * 10430.219195527361 + 0.5) WriteU16(rz * 10430.219195527361 + 0.5) + WriteF32(value.X) WriteF32(value.Y) WriteF32(value.Z) +end + +anyReads[24] = function() + return Region3.new( + Vector3.new(ReadF32(), ReadF32(), ReadF32()), + Vector3.new(ReadF32(), ReadF32(), ReadF32()) + ) +end +anyWrites.Region3 = function(value: Region3) + local halfSize = value.Size / 2 + local minimum = value.CFrame.Position - halfSize + local maximum = value.CFrame.Position + halfSize + Allocate(25) + WriteU8(24) + WriteF32(minimum.X) WriteF32(minimum.Y) WriteF32(minimum.Z) + WriteF32(maximum.X) WriteF32(maximum.Y) WriteF32(maximum.Z) +end + +anyReads[25] = function() + local length = ReadU8() + local keypoints = table.create(length) + for index = 1, length do + table.insert(keypoints, NumberSequenceKeypoint.new(ReadU8() / 255, ReadU8() / 255, ReadU8() / 255)) + end + return NumberSequence.new(keypoints) +end +anyWrites.NumberSequence = function(value: NumberSequence) + local length = #value.Keypoints + Allocate(2 + length * 3) + WriteU8(25) + WriteU8(length) + for index, keypoint in value.Keypoints do + WriteU8(keypoint.Time * 255 + 0.5) WriteU8(keypoint.Value * 255 + 0.5) WriteU8(keypoint.Envelope * 255 + 0.5) + end +end + +anyReads[26] = function() + local length = ReadU8() + local keypoints = table.create(length) + for index = 1, length do + table.insert(keypoints, ColorSequenceKeypoint.new(ReadU8() / 255, Color3.fromRGB(ReadU8(), ReadU8(), ReadU8()))) + end + return ColorSequence.new(keypoints) +end +anyWrites.ColorSequence = function(value: ColorSequence) + local length = #value.Keypoints + Allocate(2 + length * 4) + WriteU8(26) + WriteU8(length) + for index, keypoint in value.Keypoints do + WriteU8(keypoint.Time * 255 + 0.5) + WriteU8(keypoint.Value.R * 255 + 0.5) WriteU8(keypoint.Value.G * 255 + 0.5) WriteU8(keypoint.Value.B * 255 + 0.5) + end +end + +anyReads[27] = function() + return enums[ReadU8()]:FromValue(ReadU16()) +end +anyWrites.EnumItem = function(value: EnumItem) + Allocate(4) + WriteU8(27) + WriteU8(enumIndices[value.EnumType]) + WriteU16(value.Value) +end + +anyReads[28] = function() + local value = {} + while true do + local typeId = ReadU8() + if typeId == 0 then return value else value[anyReads[typeId]()] = anyReads[ReadU8()]() end + end +end +anyWrites.table = function(value: {[any]: any}) + Allocate(1) + WriteU8(28) + for index, value in value do anyWrites[typeof(index)](index) anyWrites[typeof(value)](value) end + Allocate(1) + WriteU8(0) +end + + +return { + Import = function(cursor: Cursor) + activeCursor = cursor + activeBuffer = cursor.Buffer + bufferLength = cursor.BufferLength + bufferOffset = cursor.BufferOffset + instances = cursor.Instances + instancesOffset = cursor.InstancesOffset + end, + + Export = function() + activeCursor.BufferLength = bufferLength + activeCursor.BufferOffset = bufferOffset + activeCursor.InstancesOffset = instancesOffset + return activeCursor + end, + + Truncate = function() + local truncatedBuffer = buffer.create(bufferOffset) + buffer.copy(truncatedBuffer, 0, activeBuffer, 0, bufferOffset) + if instancesOffset == 0 then return truncatedBuffer else return truncatedBuffer, instances end + end, + + Ended = function() + return bufferOffset >= bufferLength + end, + + Types = types, + Reads = reads, + Writes = writes, +} \ No newline at end of file diff --git a/src/Chemical/Packages/Packet/init.lua b/src/Chemical/Packages/Packet/init.lua new file mode 100644 index 0000000..2bf707d --- /dev/null +++ b/src/Chemical/Packages/Packet/init.lua @@ -0,0 +1,368 @@ +--!strict + + +-- Requires +local Signal = require(script.Signal) +local Task = require(script.Task) +local Types = require(script.Types) + + +-- Types +export type Packet = { + Type: "Packet", + Id: number, + Name: string, + Reads: {() -> any}, + Writes: {(any) -> ()}, + ResponseTimeout: number, + ResponseTimeoutValue: any, + ResponseReads: {() -> any}, + ResponseWrites: {(any) -> ()}, + OnServerEvent: Signal.Signal<(Player, A...)>, + OnClientEvent: Signal.Signal, + OnServerInvoke: nil | (player: Player, A...) -> B..., + OnClientInvoke: nil | (A...) -> B..., + Response: (self: Packet, B...) -> Packet, + Fire: (self: Packet, A...) -> B..., + FireClient: (self: Packet, player: Player, A...) -> B..., + Serialize: (self: Packet, A...) -> (buffer, {Instance}?), + Deserialize: (self: Packet, serializeBuffer: buffer, instances: {Instance}?) -> A..., +} + + +-- Varables +local ParametersToFunctions, TableToFunctions, ReadParameters, WriteParameters, Timeout +local RunService = game:GetService("RunService") +local PlayersService = game:GetService("Players") +local reads, writes, Import, Export, Truncate, Ended = Types.Reads, Types.Writes, Types.Import, Types.Export, Types.Truncate, Types.Ended +local ReadU8, WriteU8, ReadU16, WriteU16 = reads.NumberU8, writes.NumberU8, reads.NumberU16, writes.NumberU16 +local Packet = {} :: Packet<...any, ...any> +local packets = {} :: {[string | number]: Packet<...any, ...any>} +local playerCursors : {[Player]: Types.Cursor} +local playerThreads : {[Player]: {[number]: {Yielded: thread, Timeout: thread}, Index: number}} +local threads : {[number]: {Yielded: thread, Timeout: thread}, Index: number} +local remoteEvent : RemoteEvent +local packetCounter : number +local cursor = {Buffer = buffer.create(128), BufferLength = 128, BufferOffset = 0, Instances = {}, InstancesOffset = 0} + + +-- Constructor +local function Constructor(_, name: string, ...: A...) + local packet = packets[name] :: Packet + if packet then return packet end + local packet = (setmetatable({}, Packet) :: any) :: Packet + packet.Name = name + if RunService:IsServer() then + packet.Id = packetCounter + packet.OnServerEvent = Signal() :: Signal.Signal<(Player, A...)> + remoteEvent:SetAttribute(name, packetCounter) + packets[packetCounter] = packet + packetCounter += 1 + else + packet.Id = remoteEvent:GetAttribute(name) + packet.OnClientEvent = Signal() :: Signal.Signal + if packet.Id then packets[packet.Id] = packet end + end + packet.Reads, packet.Writes = ParametersToFunctions(table.pack(...)) + packets[packet.Name] = packet + return packet +end + + +-- Packet +Packet["__index"] = Packet +Packet.Type = "Packet" + +function Packet:Response(...) + self.ResponseTimeout = self.ResponseTimeout or 10 + self.ResponseReads, self.ResponseWrites = ParametersToFunctions(table.pack(...)) + return self +end + +function Packet:Fire(...) + Import(cursor) + WriteU8(self.Id) + if self.ResponseReads then + WriteU8(threads.Index) + threads[threads.Index] = {Yielded = coroutine.running(), Timeout = Task:Delay(self.ResponseTimeout, Timeout, coroutine.running(), self.ResponseTimeoutValue)} + threads.Index = (threads.Index + 1) % 128 + WriteParameters(self.Writes, {...}) + cursor = Export() + return coroutine.yield() + else + WriteParameters(self.Writes, {...}) + cursor = Export() + end +end + +function Packet:FireClient(player, ...) + if player.Parent == nil then return end + Import(playerCursors[player] or {Buffer = buffer.create(128), BufferLength = 128, BufferOffset = 0, Instances = {}, InstancesOffset = 0}) + WriteU8(self.Id) + if self.ResponseReads then + local threads = playerThreads[player] + if threads == nil then threads = {Index = 0} playerThreads[player] = threads end + WriteU8(threads.Index) + threads[threads.Index] = {Yielded = coroutine.running(), Timeout = Task:Delay(self.ResponseTimeout, Timeout, coroutine.running(), self.ResponseTimeoutValue)} + threads.Index = (threads.Index + 1) % 128 + WriteParameters(self.Writes, {...}) + playerCursors[player] = Export() + return coroutine.yield() + else + WriteParameters(self.Writes, {...}) + playerCursors[player] = Export() + end +end + +function Packet:Serialize(...) + Import({Buffer = buffer.create(128), BufferLength = 128, BufferOffset = 0, Instances = {}, InstancesOffset = 0}) + WriteParameters(self.Writes, {...}) + return Truncate() +end + +function Packet:Deserialize(serializeBuffer, instances) + Import({Buffer = serializeBuffer, BufferLength = buffer.len(serializeBuffer), BufferOffset = 0, Instances = instances or {}, InstancesOffset = 0}) + return ReadParameters(self.Reads) +end + + +-- Functions +function ParametersToFunctions(parameters: {any}) + local readFunctions, writeFunctions = table.create(#parameters), table.create(#parameters) + for index, parameter in ipairs(parameters) do + if type(parameter) == "table" then + readFunctions[index], writeFunctions[index] = TableToFunctions(parameter) + else + readFunctions[index], writeFunctions[index] = reads[parameter], writes[parameter] + end + end + return readFunctions, writeFunctions +end + +function TableToFunctions(parameters: {any}) + if #parameters == 1 then + local parameter = parameters[1] + local ReadFunction, WriteFunction + if type(parameter) == "table" then + ReadFunction, WriteFunction = TableToFunctions(parameter) + else + ReadFunction, WriteFunction = reads[parameter], writes[parameter] + end + local Read = function() + local length = ReadU16() + local values = table.create(length) + for index = 1, length do values[index] = ReadFunction() end + return values + end + local Write = function(values: {any}) + WriteU16(#values) + for index, value in values do WriteFunction(value) end + end + return Read, Write + else + local keys = {} for key, value in parameters do table.insert(keys, key) end table.sort(keys) + local readFunctions, writeFunctions = table.create(#keys), table.create(#keys) + for index, key in keys do + local parameter = parameters[key] + if type(parameter) == "table" then + readFunctions[index], writeFunctions[index] = TableToFunctions(parameter) + else + readFunctions[index], writeFunctions[index] = reads[parameter], writes[parameter] + end + end + local Read = function() + local values = {} + for index, ReadFunction in readFunctions do values[keys[index]] = ReadFunction() end + return values + end + local Write = function(values: {[any]: any}) + for index, WriteFunction in writeFunctions do WriteFunction(values[keys[index]]) end + end + return Read, Write + end +end + +function ReadParameters(reads: {() -> any}) + local values = table.create(#reads) + for index, func in reads do values[index] = func() end + return table.unpack(values) +end + +function WriteParameters(writes: {(any) -> ()}, values: {any}) + for index, func in writes do func(values[index]) end +end + +function Timeout(thread: thread, value: any) + task.defer(thread, value) +end + + +-- Initialize +if RunService:IsServer() then + playerCursors = {} + playerThreads = {} + packetCounter = 0 + remoteEvent = Instance.new("RemoteEvent", script) + + local playerBytes = {} + + local thread = task.spawn(function() + while true do + coroutine.yield() + if cursor.BufferOffset > 0 then + local truncatedBuffer = buffer.create(cursor.BufferOffset) + buffer.copy(truncatedBuffer, 0, cursor.Buffer, 0, cursor.BufferOffset) + if cursor.InstancesOffset == 0 then + remoteEvent:FireAllClients(truncatedBuffer) + else + remoteEvent:FireAllClients(truncatedBuffer, cursor.Instances) + cursor.InstancesOffset = 0 + table.clear(cursor.Instances) + end + cursor.BufferOffset = 0 + end + for player, cursor in playerCursors do + local truncatedBuffer = buffer.create(cursor.BufferOffset) + buffer.copy(truncatedBuffer, 0, cursor.Buffer, 0, cursor.BufferOffset) + if cursor.InstancesOffset == 0 then + remoteEvent:FireClient(player, truncatedBuffer) + else + remoteEvent:FireClient(player, truncatedBuffer, cursor.Instances) + end + end + table.clear(playerCursors) + table.clear(playerBytes) + end + end) + + local respond = function(packet: Packet, player: Player, threadIndex: number, ...) + if packet.OnServerInvoke == nil then if RunService:IsStudio() then warn("OnServerInvoke not found for packet:", packet.Name, "discarding event:", ...) end return end + local values = {packet.OnServerInvoke(player, ...)} + if player.Parent == nil then return end + Import(playerCursors[player] or {Buffer = buffer.create(128), BufferLength = 128, BufferOffset = 0, Instances = {}, InstancesOffset = 0}) + WriteU8(packet.Id) + WriteU8(threadIndex + 128) + WriteParameters(packet.ResponseWrites, values) + playerCursors[player] = Export() + end + + local onServerEvent = function(player: Player, receivedBuffer: buffer, instances: {Instance}?) + local bytes = (playerBytes[player] or 0) + math.max(buffer.len(receivedBuffer), 800) + if bytes > 8_000 then if RunService:IsStudio() then warn(player.Name, "is exceeding the data/rate limit; some events may be dropped") end return end + playerBytes[player] = bytes + Import({Buffer = receivedBuffer, BufferLength = buffer.len(receivedBuffer), BufferOffset = 0, Instances = instances or {}, InstancesOffset = 0}) + while Ended() == false do + local packet = packets[ReadU8()] + if packet.ResponseReads then + local threadIndex = ReadU8() + if threadIndex < 128 then + Task:Defer(respond, packet, player, threadIndex, ReadParameters(packet.Reads)) + else + threadIndex -= 128 + local threads = playerThreads[player][threadIndex] + if threads then + task.cancel(threads.Timeout) + task.defer(threads.Yielded, ReadParameters(packet.ResponseReads)) + playerThreads[player][threadIndex] = nil + elseif RunService:IsStudio() then + warn("Response thread not found for packet:", packet.Name, "discarding response:", ReadParameters(packet.ResponseReads)) + else + ReadParameters(packet.ResponseReads) + end + end + else + packet.OnServerEvent:Fire(player, ReadParameters(packet.Reads)) + end + end + end + + remoteEvent.OnServerEvent:Connect(function(player: Player, ...) + local success, errorMessage: string? = pcall(onServerEvent, player, ...) + if errorMessage and RunService:IsStudio() then warn(player.Name, errorMessage) end + end) + + PlayersService.PlayerRemoving:Connect(function(player) + playerCursors[player] = nil + playerThreads[player] = nil + playerBytes[player] = nil + end) + + RunService.Heartbeat:Connect(function(deltaTime) task.defer(thread) end) +else + threads = {Index = 0} + remoteEvent = script:WaitForChild("RemoteEvent") + local totalTime = 0 + + local thread = task.spawn(function() + while true do + coroutine.yield() + if cursor.BufferOffset > 0 then + local truncatedBuffer = buffer.create(cursor.BufferOffset) + buffer.copy(truncatedBuffer, 0, cursor.Buffer, 0, cursor.BufferOffset) + if cursor.InstancesOffset == 0 then + remoteEvent:FireServer(truncatedBuffer) + else + remoteEvent:FireServer(truncatedBuffer, cursor.Instances) + cursor.InstancesOffset = 0 + table.clear(cursor.Instances) + end + cursor.BufferOffset = 0 + end + end + end) + + local respond = function(packet: Packet, threadIndex: number, ...) + if packet.OnClientInvoke == nil then warn("OnClientInvoke not found for packet:", packet.Name, "discarding event:", ...) return end + local values = {packet.OnClientInvoke(...)} + Import(cursor) + WriteU8(packet.Id) + WriteU8(threadIndex + 128) + WriteParameters(packet.ResponseWrites, values) + cursor = Export() + end + + remoteEvent.OnClientEvent:Connect(function(receivedBuffer: buffer, instances: {Instance}?) + Import({Buffer = receivedBuffer, BufferLength = buffer.len(receivedBuffer), BufferOffset = 0, Instances = instances or {}, InstancesOffset = 0}) + while Ended() == false do + local packet = packets[ReadU8()] + if packet.ResponseReads then + local threadIndex = ReadU8() + if threadIndex < 128 then + Task:Defer(respond, packet, threadIndex, ReadParameters(packet.Reads)) + else + threadIndex -= 128 + local threads = threads[threadIndex] + if threads then + task.cancel(threads.Timeout) + task.defer(threads.Yielded, ReadParameters(packet.ResponseReads)) + threads[threadIndex] = nil + else + warn("Response thread not found for packet:", packet.Name, "discarding response:", ReadParameters(packet.ResponseReads)) + end + end + else + packet.OnClientEvent:Fire(ReadParameters(packet.Reads)) + end + end + end) + + remoteEvent.AttributeChanged:Connect(function(name) + local packet = packets[name] + if packet then + if packet.Id then packets[packet.Id] = nil end + packet.Id = remoteEvent:GetAttribute(name) + if packet.Id then packets[packet.Id] = packet end + end + end) + + RunService.Heartbeat:Connect(function(deltaTime) + totalTime += deltaTime + if totalTime > 0.016666666666666666 then + totalTime %= 0.016666666666666666 + task.defer(thread) + end + end) +end + + +return setmetatable(Types.Types, {__call = Constructor}) \ No newline at end of file diff --git a/src/Chemical/Packages/Promise.lua b/src/Chemical/Packages/Promise.lua new file mode 100644 index 0000000..69ddf89 --- /dev/null +++ b/src/Chemical/Packages/Promise.lua @@ -0,0 +1,2203 @@ +--!nonstrict +--[[ + An implementation of Promises similar to Promise/A+ + + Original library by Eryn L. K. + Edit by Amber Grace (@DataBrain) to include custom type annotations & extensions + Forked from github repository: evaera/roblox-lua-promise + Forked from release V4.0.0 (Mar 2nd 2022). + + I do not intend to maintain + this forked version of the library unless a major bug or exploit is + found with the original library that needs to be patched here. + + Licensed under MIT License (see nested module for full license) +]] + +export type Status = "Started" | "Resolved" | "Rejected" | "Cancelled" +export type Executor = ( + resolve: (...any) -> (), + reject: (...any) -> (), + onCancel: (abortHandler: (() -> ())?) -> boolean +) -> () +export type Promise = { + timeout: (self: Promise, seconds: number, rejectionValue: any?) -> Promise, + getStatus: (self: Promise) -> Status, + andThen: ( + self: Promise, + successHandler: (V...) -> ...any, + failureHandler: ((V...) -> ...any)? + ) -> Promise, + catch: ( + self: Promise, + failureHandler: (V...) -> ...any + ) -> Promise, + tap: ( + self: Promise, + tapHandler: (V...) -> ...any + ) -> Promise, + andThenCall: ( + self: Promise, + callback: (T...) -> any, + T... + ) -> Promise, + andThenReturn: ( + self: Promise, + ...any + ) -> Promise, + cancel: (self: Promise) -> (), + finally: ( + self: Promise, + finallyHandler: (status: Status) -> ...any + ) -> Promise, + finallyCall: ( + self: Promise, + callback: (T...) -> any, + T... + ) -> Promise, + finallyReturn: ( + self: Promise, + ...any + ) -> Promise, + awaitStatus: ( + self: Promise + ) -> (Status, V...), + await: (self: Promise) -> (boolean, V...), + expect: (self: Promise) -> V..., + now: (self: Promise, rejectionValue: any?) -> Promise, +} +export type ErrorKind = "ExecutionError" | "AlreadyCancelled" | "NotResolvedInTime" | "TimedOut" +export type ErrorOptions = { + error: string?, + trace: string?, + context: string?, + kind: ErrorKind +} +export type Error = { + kind: ErrorKind, + trace: string?, + context: string?, + parent: Error?, + error: string, + createdTick: number, + createdTrace: string, + + extend: (self: Error, options: ErrorOptions) -> Error, + getErrorChain: (self: Error) -> {Error}, +} +type PromiseLib = { + new: (executor: Executor) -> Promise, + defer: (executor: Executor) -> Promise, + resolve: (...any) -> Promise, + reject: (...any) -> Promise, + try: ( + callback: (T...) -> ...any, + T... + ) -> Promise, + all: (promises: {Promise}) -> Promise, + fold: ( + list: {any}, + reducer: (accumulator: any, value: any, index: number) -> any, + initialValue: any + ) -> (), + some: (promises: {Promise}, count: number) -> Promise, + any: (promises: {Promise}) -> Promise, + allSettled: (promises: {Promise}) -> Promise, + race: (promises: {Promise}) -> Promise, + each: ( + list: {any}, + predicate: (value: any, index: number) -> any + ) -> Promise, + is: (object: any) -> boolean, + promisify: ( + callback: (T...) -> ...any + ) -> ((T...) -> Promise), + delay: (seconds: number) -> Promise, + retry: ( + callback: (P...) -> Promise, + times: number, + P... + ) -> Promise, + retryWithDelay: ( + callback: (P...) -> Promise, + times: number, + seconds: number, + P... + ) -> Promise, + fromEvent: ( + event: RBXScriptSignal | {Connect: any}, + predicate: ((...any) -> boolean)? + ) -> Promise, + onUnhandledRejection: ( + callback: (Promise, ...any) -> () + ) -> (() -> ()) +} + +local ERROR_NON_PROMISE_IN_LIST = "Non-promise value passed into %s at index %s" +local ERROR_NON_LIST = "Please pass a list of promises to %s" +local ERROR_NON_FUNCTION = "Please pass a handler function to %s!" +local MODE_KEY_METATABLE: any = { __mode = "k" } + +local function isCallable(value: any) + if type(value) == "function" then + return true + end + + if type(value) == "table" then + local metatable = getmetatable(value) + if metatable and type(rawget(metatable, "__call")) == "function" then + return true + end + end + + return false +end + +--[[ + Creates an enum dictionary with some metamethods to prevent common mistakes. +]] +local function makeEnum(enumName: any, members: any) + local enum = {} + + for _, memberName in ipairs(members) do + enum[memberName] = memberName + end + + return setmetatable(enum, { + __index = function(_, k) + error(string.format("%s is not in %s!", k, enumName), 2) + end, + __newindex = function() + error(string.format("Creating new members in %s is not allowed!", enumName), 2) + end, + }) +end + +--[=[ + An object to represent runtime errors that occur during execution. + Promises that experience an error like this will be rejected with + an instance of this object. + + @class Error +]=] +local Error: any +do + Error = { + Kind = makeEnum("Promise.Error.Kind", { + "ExecutionError", + "AlreadyCancelled", + "NotResolvedInTime", + "TimedOut", + }), + } + Error.__index = Error + + function Error.new(options: any, parent: any) + options = options or {} + return setmetatable({ + error = tostring(options.error) or "[This error has no error text.]", + trace = options.trace, + context = options.context, + kind = options.kind, + parent = parent, + createdTick = os.clock(), + createdTrace = debug.traceback(), + }, Error) + end + + function Error.is(anything: any) + if type(anything) == "table" then + local metatable = getmetatable(anything) + + if type(metatable) == "table" then + return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function" + end + end + + return false + end + + function Error.isKind(anything: any, kind: any) + assert(kind ~= nil, "Argument #2 to Promise.Error.isKind must not be nil") + + return Error.is(anything) and anything.kind == kind + end + + function Error:extend(options: any) + options = options or {} + + options.kind = options.kind or self.kind + + return Error.new(options, self) + end + + function Error:getErrorChain() + local runtimeErrors = { self } + + while runtimeErrors[#runtimeErrors].parent do + table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent) + end + + return runtimeErrors + end + + function Error:__tostring() + local errorStrings = { + string.format("-- Promise.Error(%s) --", self.kind or "?"), + } + + for _, runtimeError in ipairs(self:getErrorChain()) do + table.insert( + errorStrings, + table.concat({ + runtimeError.trace or runtimeError.error, + runtimeError.context, + }, "\n") + ) + end + + return table.concat(errorStrings, "\n") + end +end + +--[[ + Packs a number of arguments into a table and returns its length. + + Used to cajole varargs without dropping sparse values. +]] +local function pack(...: any) + return select("#", ...), { ... } +end + +--[[ + Returns first value (success), and packs all following values. +]] +local function packResult(success: any, ...: any) + return success, select("#", ...), { ... } +end + +local function makeErrorHandler(traceback: any) + assert(traceback ~= nil, "traceback is nil") + + return function(err: any) + -- If the error object is already a table, forward it directly. + -- Should we extend the error here and add our own trace? + + if type(err) == "table" then + return err + end + + return Error.new({ + error = err, + kind = Error.Kind.ExecutionError, + trace = debug.traceback(tostring(err), 2), + context = "Promise created at:\n\n" .. traceback, + }) + end +end + +--[[ + Calls a Promise executor with error handling. +]] +local function runExecutor(traceback: any, callback: any, ...: any) + return packResult(xpcall(callback, makeErrorHandler(traceback), ...)) +end + +--[[ + Creates a function that invokes a callback with correct error handling and + resolution mechanisms. +]] +local function createAdvancer(traceback: any, callback: any, resolve: any, reject: any) + return function(...: any) + local ok, resultLength, result = runExecutor(traceback, callback, ...) + + if ok then + resolve(unpack(result, 1, resultLength)) + else + reject(result[1]) + end + end +end + +local function isEmpty(t: any) + return next(t) == nil +end + +--[=[ + An enum value used to represent the Promise's status. + @interface Status + @tag enum + @within Promise + .Started "Started" -- The Promise is executing, and not settled yet. + .Resolved "Resolved" -- The Promise finished successfully. + .Rejected "Rejected" -- The Promise was rejected. + .Cancelled "Cancelled" -- The Promise was cancelled before it finished. +]=] +--[=[ + @prop Status Status + @within Promise + @readonly + @tag enums + A table containing all members of the `Status` enum, e.g., `Promise.Status.Resolved`. +]=] +--[=[ + A Promise is an object that represents a value that will exist in the future, but doesn't right now. + Promises allow you to then attach callbacks that can run once the value becomes available (known as *resolving*), + or if an error has occurred (known as *rejecting*). + + @class Promise + @__index prototype +]=] +local Promise: any = { + Error = Error, + Status = makeEnum("Promise.Status", { "Started", "Resolved", "Rejected", "Cancelled" }), + _getTime = os.clock, + _timeEvent = game:GetService("RunService").Heartbeat, + _unhandledRejectionCallbacks = {}, + TEST = nil :: boolean?, +} +Promise.prototype = {} +Promise.__index = Promise.prototype + +function Promise._new(traceback: any, callback: any, parent: any) + if parent ~= nil and not Promise.is(parent) then + error("Argument #2 to Promise.new must be a promise or nil", 2) + end + + local self: any = { + -- The executor thread. + _thread = nil, + + -- Used to locate where a promise was created + _source = traceback, + + _status = Promise.Status.Started, + + -- A table containing a list of all results, whether success or failure. + -- Only valid if _status is set to something besides Started + _values = nil, + + -- Lua doesn't like sparse arrays very much, so we explicitly store the + -- length of _values to handle middle nils. + _valuesLength = -1, + + -- Tracks if this Promise has no error observers.. + _unhandledRejection = true, + + -- Queues representing functions we should invoke when we update! + _queuedResolve = {}, + _queuedReject = {}, + _queuedFinally = {}, + + -- The function to run when/if this promise is cancelled. + _cancellationHook = nil, + + -- The "parent" of this promise in a promise chain. Required for + -- cancellation propagation upstream. + _parent = parent, + + -- Consumers are Promises that have chained onto this one. + -- We track them for cancellation propagation downstream. + _consumers = setmetatable({}, MODE_KEY_METATABLE), + } + + if parent and parent._status == Promise.Status.Started then + parent._consumers[self] = true + end + + setmetatable(self, Promise) + + local function resolve(...: any) + self:_resolve(...) + end + + local function reject(...: any) + self:_reject(...) + end + + local function onCancel(cancellationHook: any) + if cancellationHook then + if self._status == Promise.Status.Cancelled then + cancellationHook() + else + self._cancellationHook = cancellationHook + end + end + + return self._status == Promise.Status.Cancelled + end + + self._thread = coroutine.create(function() + local ok, _, result = runExecutor(self._source, callback, resolve, reject, onCancel) + + if not ok then + reject(result[1]) + end + end) + + task.spawn(self._thread) + + return self +end + +--[=[ + Construct a new Promise that will be resolved or rejected with the given callbacks. + + If you `resolve` with a Promise, it will be chained onto. + + You can safely yield within the executor function and it will not block the creating thread. + + ```lua + local myFunction() + return Promise.new(function(resolve, reject, onCancel) + wait(1) + resolve("Hello world!") + end) + end + + myFunction():andThen(print) + ``` + + You do not need to use `pcall` within a Promise. Errors that occur during execution will be caught and turned into a rejection automatically. If `error()` is called with a table, that table will be the rejection value. Otherwise, string errors will be converted into `Promise.Error(Promise.Error.Kind.ExecutionError)` objects for tracking debug information. + + You may register an optional cancellation hook by using the `onCancel` argument: + + * This should be used to abort any ongoing operations leading up to the promise being settled. + * Call the `onCancel` function with a function callback as its only argument to set a hook which will in turn be called when/if the promise is cancelled. + * `onCancel` returns `true` if the Promise was already cancelled when you called `onCancel`. + * Calling `onCancel` with no argument will not override a previously set cancellation hook, but it will still return `true` if the Promise is currently cancelled. + * You can set the cancellation hook at any time before resolving. + * When a promise is cancelled, calls to `resolve` or `reject` will be ignored, regardless of if you set a cancellation hook or not. + + :::caution + If the Promise is cancelled, the `executor` thread is closed with `coroutine.close` after the cancellation hook is called. + + You must perform any cleanup code in the cancellation hook: any time your executor yields, it **may never resume**. + ::: + + @param executor (resolve: (...: any) -> (), reject: (...: any) -> (), onCancel: (abortHandler?: () -> ()) -> boolean) -> () + @return Promise +]=] +function Promise.new(executor: any) + return Promise._new(debug.traceback(nil, 2), executor) +end + +function Promise:__tostring() + return string.format("Promise(%s)", self._status) +end + +--[=[ + The same as [Promise.new](/api/Promise#new), except execution begins after the next `Heartbeat` event. + + This is a spiritual replacement for `spawn`, but it does not suffer from the same [issues](https://eryn.io/gist/3db84579866c099cdd5bb2ff37947cec) as `spawn`. + + ```lua + local function waitForChild(instance, childName, timeout) + return Promise.defer(function(resolve, reject) + local child = instance:WaitForChild(childName, timeout) + + ;(child and resolve or reject)(child) + end) + end + ``` + + @param executor (resolve: (...: any) -> (), reject: (...: any) -> (), onCancel: (abortHandler?: () -> ()) -> boolean) -> () + @return Promise +]=] +function Promise.defer(executor: any) + local traceback = debug.traceback(nil, 2) + local promise + promise = Promise._new(traceback, function(resolve, reject, onCancel) + local connection + connection = Promise._timeEvent:Connect(function() + connection:Disconnect() + local ok, _, result = runExecutor(traceback, executor, resolve, reject, onCancel) + + if not ok then + reject(result[1]) + end + end) + end) + + return promise +end + +-- Backwards compatibility +Promise.async = Promise.defer + +--[=[ + Creates an immediately resolved Promise with the given value. + + ```lua + -- Example using Promise.resolve to deliver cached values: + function getSomething(name) + if cache[name] then + return Promise.resolve(cache[name]) + else + return Promise.new(function(resolve, reject) + local thing = getTheThing() + cache[name] = thing + + resolve(thing) + end) + end + end + ``` + + @param ... any + @return Promise<...any> +]=] +function Promise.resolve(...: any) + local length, values = pack(...) + return Promise._new(debug.traceback(nil, 2), function(resolve) + resolve(unpack(values, 1, length)) + end) +end + +--[=[ + Creates an immediately rejected Promise with the given value. + + :::caution + Something needs to consume this rejection (i.e. `:catch()` it), otherwise it will emit an unhandled Promise rejection warning on the next frame. Thus, you should not create and store rejected Promises for later use. Only create them on-demand as needed. + ::: + + @param ... any + @return Promise<...any> +]=] +function Promise.reject(...: any) + local length, values = pack(...) + return Promise._new(debug.traceback(nil, 2), function(_, reject) + reject(unpack(values, 1, length)) + end) +end + +--[[ + Runs a non-promise-returning function as a Promise with the + given arguments. +]] +function Promise._try(traceback: any, callback: any, ...: any) + local valuesLength, values = pack(...) + + return Promise._new(traceback, function(resolve) + resolve(callback(unpack(values, 1, valuesLength))) + end) +end + +--[=[ + Begins a Promise chain, calling a function and returning a Promise resolving with its return value. If the function errors, the returned Promise will be rejected with the error. You can safely yield within the Promise.try callback. + + :::info + `Promise.try` is similar to [Promise.promisify](#promisify), except the callback is invoked immediately instead of returning a new function. + ::: + + ```lua + Promise.try(function() + return math.random(1, 2) == 1 and "ok" or error("Oh an error!") + end) + :andThen(function(text) + print(text) + end) + :catch(function(err) + warn("Something went wrong") + end) + ``` + + @param callback (...: T...) -> ...any + @param ... T... -- Additional arguments passed to `callback` + @return Promise +]=] +function Promise.try(callback: any, ...: any) + return Promise._try(debug.traceback(nil, 2), callback, ...) +end + +--[[ + Returns a new promise that: + * is resolved when all input promises resolve + * is rejected if ANY input promises reject +]] +function Promise._all(traceback: any, promises: any, amount: any) + if type(promises) ~= "table" then + error(string.format(ERROR_NON_LIST, "Promise.all"), 3) + end + + -- We need to check that each value is a promise here so that we can produce + -- a proper error rather than a rejected promise with our error. + for i, promise in pairs(promises) do + if not Promise.is(promise) then + error(string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.all", tostring(i)), 3) + end + end + + -- If there are no values then return an already resolved promise. + if #promises == 0 or amount == 0 then + return Promise.resolve({}) + end + + return Promise._new(traceback, function(resolve, reject, onCancel) + -- An array to contain our resolved values from the given promises. + local resolvedValues = {} + local newPromises = {} + + -- Keep a count of resolved promises because just checking the resolved + -- values length wouldn't account for promises that resolve with nil. + local resolvedCount = 0 + local rejectedCount = 0 + local done = false + + local function cancel() + for _, promise in ipairs(newPromises) do + promise:cancel() + end + end + + -- Called when a single value is resolved and resolves if all are done. + local function resolveOne(i, ...) + if done then + return + end + + resolvedCount = resolvedCount + 1 + + if amount == nil then + resolvedValues[i] = ... + else + resolvedValues[resolvedCount] = ... + end + + if resolvedCount >= (amount or #promises) then + done = true + resolve(resolvedValues) + cancel() + end + end + + onCancel(cancel) + + -- We can assume the values inside `promises` are all promises since we + -- checked above. + for i, promise in ipairs(promises) do + newPromises[i] = promise:andThen(function(...) + resolveOne(i, ...) + end, function(...) + rejectedCount = rejectedCount + 1 + + if amount == nil or #promises - rejectedCount < amount then + cancel() + done = true + + reject(...) + end + end) + end + + if done then + cancel() + end + end) +end + +--[=[ + Accepts an array of Promises and returns a new promise that: + * is resolved after all input promises resolve. + * is rejected if *any* input promises reject. + + :::info + Only the first return value from each promise will be present in the resulting array. + ::: + + After any input Promise rejects, all other input Promises that are still pending will be cancelled if they have no other consumers. + + ```lua + local promises = { + returnsAPromise("example 1"), + returnsAPromise("example 2"), + returnsAPromise("example 3"), + } + + return Promise.all(promises) + ``` + + @param promises {Promise} + @return Promise<{T}> +]=] +function Promise.all(promises: any) + return Promise._all(debug.traceback(nil, 2), promises) +end + +--[=[ + Folds an array of values or promises into a single value. The array is traversed sequentially. + + The reducer function can return a promise or value directly. Each iteration receives the resolved value from the previous, and the first receives your defined initial value. + + The folding will stop at the first rejection encountered. + ```lua + local basket = {"blueberry", "melon", "pear", "melon"} + Promise.fold(basket, function(cost, fruit) + if fruit == "blueberry" then + return cost -- blueberries are free! + else + -- call a function that returns a promise with the fruit price + return fetchPrice(fruit):andThen(function(fruitCost) + return cost + fruitCost + end) + end + end, 0) + ``` + + @since v3.1.0 + @param list {T | Promise} + @param reducer (accumulator: U, value: T, index: number) -> U | Promise + @param initialValue U +]=] +function Promise.fold(list: any, reducer: any, initialValue: any) + assert(type(list) == "table", "Bad argument #1 to Promise.fold: must be a table") + assert(isCallable(reducer), "Bad argument #2 to Promise.fold: must be a function") + + local accumulator = Promise.resolve(initialValue) + return Promise.each(list, function(resolvedElement, i) + accumulator = accumulator:andThen(function(previousValueResolved) + return reducer(previousValueResolved, resolvedElement, i) + end) + end):andThen(function() + return accumulator + end) +end + +--[=[ + Accepts an array of Promises and returns a Promise that is resolved as soon as `count` Promises are resolved from the input array. The resolved array values are in the order that the Promises resolved in. When this Promise resolves, all other pending Promises are cancelled if they have no other consumers. + + `count` 0 results in an empty array. The resultant array will never have more than `count` elements. + + ```lua + local promises = { + returnsAPromise("example 1"), + returnsAPromise("example 2"), + returnsAPromise("example 3"), + } + + return Promise.some(promises, 2) -- Only resolves with first 2 promises to resolve + ``` + + @param promises {Promise} + @param count number + @return Promise<{T}> +]=] +function Promise.some(promises: any, count: any) + assert(type(count) == "number", "Bad argument #2 to Promise.some: must be a number") + + return Promise._all(debug.traceback(nil, 2), promises, count) +end + +--[=[ + Accepts an array of Promises and returns a Promise that is resolved as soon as *any* of the input Promises resolves. It will reject only if *all* input Promises reject. As soon as one Promises resolves, all other pending Promises are cancelled if they have no other consumers. + + Resolves directly with the value of the first resolved Promise. This is essentially [[Promise.some]] with `1` count, except the Promise resolves with the value directly instead of an array with one element. + + ```lua + local promises = { + returnsAPromise("example 1"), + returnsAPromise("example 2"), + returnsAPromise("example 3"), + } + + return Promise.any(promises) -- Resolves with first value to resolve (only rejects if all 3 rejected) + ``` + + @param promises {Promise} + @return Promise +]=] +function Promise.any(promises: {any}) + return Promise._all(debug.traceback(nil, 2), promises, 1):andThen(function(values) + return values[1] + end) +end + +--[=[ + Accepts an array of Promises and returns a new Promise that resolves with an array of in-place Statuses when all input Promises have settled. This is equivalent to mapping `promise:finally` over the array of Promises. + + ```lua + local promises = { + returnsAPromise("example 1"), + returnsAPromise("example 2"), + returnsAPromise("example 3"), + } + + return Promise.allSettled(promises) + ``` + + @param promises {Promise} + @return Promise<{Status}> +]=] +function Promise.allSettled(promises: any) + if type(promises) ~= "table" then + error(string.format(ERROR_NON_LIST, "Promise.allSettled"), 2) + end + + -- We need to check that each value is a promise here so that we can produce + -- a proper error rather than a rejected promise with our error. + for i, promise in pairs(promises) do + if not Promise.is(promise) then + error(string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.allSettled", tostring(i)), 2) + end + end + + -- If there are no values then return an already resolved promise. + if #promises == 0 then + return Promise.resolve({}) + end + + return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel) + -- An array to contain our resolved values from the given promises. + local fates = {} + local newPromises = {} + + -- Keep a count of resolved promises because just checking the resolved + -- values length wouldn't account for promises that resolve with nil. + local finishedCount = 0 + + -- Called when a single value is resolved and resolves if all are done. + local function resolveOne(i, ...) + finishedCount = finishedCount + 1 + + fates[i] = ... + + if finishedCount >= #promises then + resolve(fates) + end + end + + onCancel(function() + for _, promise in ipairs(newPromises) do + promise:cancel() + end + end) + + -- We can assume the values inside `promises` are all promises since we + -- checked above. + for i, promise in ipairs(promises) do + newPromises[i] = promise:finally(function(...) + resolveOne(i, ...) + end) + end + end) +end + +--[=[ + Accepts an array of Promises and returns a new promise that is resolved or rejected as soon as any Promise in the array resolves or rejects. + + :::warning + If the first Promise to settle from the array settles with a rejection, the resulting Promise from `race` will reject. + + If you instead want to tolerate rejections, and only care about at least one Promise resolving, you should use [Promise.any](#any) or [Promise.some](#some) instead. + ::: + + All other Promises that don't win the race will be cancelled if they have no other consumers. + + ```lua + local promises = { + returnsAPromise("example 1"), + returnsAPromise("example 2"), + returnsAPromise("example 3"), + } + + return Promise.race(promises) -- Only returns 1st value to resolve or reject + ``` + + @param promises {Promise} + @return Promise +]=] +function Promise.race(promises: any) + assert(type(promises) == "table", string.format(ERROR_NON_LIST, "Promise.race")) + + for i, promise in pairs(promises) do + assert(Promise.is(promise), string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.race", tostring(i))) + end + + return Promise._new(debug.traceback(nil, 2), function(resolve, reject, onCancel) + local newPromises = {} + local finished = false + + local function cancel() + for _, promise in ipairs(newPromises) do + promise:cancel() + end + end + + local function finalize(callback) + return function(...) + cancel() + finished = true + return callback(...) + end + end + + if onCancel(finalize(reject)) then + return + end + + for i, promise in ipairs(promises) do + newPromises[i] = promise:andThen(finalize(resolve), finalize(reject)) + end + + if finished then + cancel() + end + end) +end + +--[=[ + Iterates serially over the given an array of values, calling the predicate callback on each value before continuing. + + If the predicate returns a Promise, we wait for that Promise to resolve before moving on to the next item + in the array. + + :::info + `Promise.each` is similar to `Promise.all`, except the Promises are ran in order instead of all at once. + + But because Promises are eager, by the time they are created, they're already running. Thus, we need a way to defer creation of each Promise until a later time. + + The predicate function exists as a way for us to operate on our data instead of creating a new closure for each Promise. If you would prefer, you can pass in an array of functions, and in the predicate, call the function and return its return value. + ::: + + ```lua + Promise.each({ + "foo", + "bar", + "baz", + "qux" + }, function(value, index) + return Promise.delay(1):andThen(function() + print(("%d) Got %s!"):format(index, value)) + end) + end) + + --[[ + (1 second passes) + > 1) Got foo! + (1 second passes) + > 2) Got bar! + (1 second passes) + > 3) Got baz! + (1 second passes) + > 4) Got qux! + ]] + ``` + + If the Promise a predicate returns rejects, the Promise from `Promise.each` is also rejected with the same value. + + If the array of values contains a Promise, when we get to that point in the list, we wait for the Promise to resolve before calling the predicate with the value. + + If a Promise in the array of values is already Rejected when `Promise.each` is called, `Promise.each` rejects with that value immediately (the predicate callback will never be called even once). If a Promise in the list is already Cancelled when `Promise.each` is called, `Promise.each` rejects with `Promise.Error(Promise.Error.Kind.AlreadyCancelled`). If a Promise in the array of values is Started at first, but later rejects, `Promise.each` will reject with that value and iteration will not continue once iteration encounters that value. + + Returns a Promise containing an array of the returned/resolved values from the predicate for each item in the array of values. + + If this Promise returned from `Promise.each` rejects or is cancelled for any reason, the following are true: + - Iteration will not continue. + - Any Promises within the array of values will now be cancelled if they have no other consumers. + - The Promise returned from the currently active predicate will be cancelled if it hasn't resolved yet. + + @since 3.0.0 + @param list {T | Promise} + @param predicate (value: T, index: number) -> U | Promise + @return Promise<{U}> +]=] +function Promise.each(list: any, predicate: any) + assert(type(list) == "table", string.format(ERROR_NON_LIST, "Promise.each")) + assert(isCallable(predicate), string.format(ERROR_NON_FUNCTION, "Promise.each")) + + return Promise._new(debug.traceback(nil, 2), function(resolve, reject, onCancel) + local results = {} + local promisesToCancel = {} + + local cancelled = false + + local function cancel() + for _, promiseToCancel in ipairs(promisesToCancel) do + promiseToCancel:cancel() + end + end + + onCancel(function() + cancelled = true + + cancel() + end) + + -- We need to preprocess the list of values and look for Promises. + -- If we find some, we must register our andThen calls now, so that those Promises have a consumer + -- from us registered. If we don't do this, those Promises might get cancelled by something else + -- before we get to them in the series because it's not possible to tell that we plan to use it + -- unless we indicate it here. + + local preprocessedList = {} + + for index, value in ipairs(list) do + if Promise.is(value) then + if value:getStatus() == Promise.Status.Cancelled then + cancel() + return reject(Error.new({ + error = "Promise is cancelled", + kind = Error.Kind.AlreadyCancelled, + context = string.format( + "The Promise that was part of the array at index %d passed into Promise.each was already cancelled when Promise.each began.\n\nThat Promise was created at:\n\n%s", + index, + value._source + ), + })) + elseif value:getStatus() == Promise.Status.Rejected then + cancel() + return reject(select(2, value:await())) + end + + -- Chain a new Promise from this one so we only cancel ours + local ourPromise = value:andThen(function(...) + return ... + end) + + table.insert(promisesToCancel, ourPromise) + preprocessedList[index] = ourPromise + else + preprocessedList[index] = value + end + end + + for index, value in ipairs(preprocessedList) do + if Promise.is(value) then + local success + success, value = value:await() + + if not success then + cancel() + return reject(value) + end + end + + if cancelled then + return + end + + local predicatePromise = Promise.resolve(predicate(value, index)) + + table.insert(promisesToCancel, predicatePromise) + + local success, result = predicatePromise:await() + + if not success then + cancel() + return reject(result) + end + + results[index] = result + end + + resolve(results) + return + end) +end + +--[=[ + Checks whether the given object is a Promise via duck typing. This only checks if the object is a table and has an `andThen` method. + + @param object any + @return boolean -- `true` if the given `object` is a Promise. +]=] +function Promise.is(object: any) + if type(object) ~= "table" then + return false + end + + local objectMetatable = getmetatable(object) + + if objectMetatable == Promise then + -- The Promise came from this library. + return true + elseif objectMetatable == nil then + -- No metatable, but we should still chain onto tables with andThen methods + return isCallable(object.andThen) + elseif + type(objectMetatable) == "table" + and type(rawget(objectMetatable, "__index")) == "table" + and isCallable(rawget(rawget(objectMetatable, "__index"), "andThen")) + then + -- Maybe this came from a different or older Promise library. + return true + end + + return false +end + +--[=[ + Wraps a function that yields into one that returns a Promise. + + Any errors that occur while executing the function will be turned into rejections. + + :::info + `Promise.promisify` is similar to [Promise.try](#try), except the callback is returned as a callable function instead of being invoked immediately. + ::: + + ```lua + local sleep = Promise.promisify(wait) + + sleep(1):andThen(print) + ``` + + ```lua + local isPlayerInGroup = Promise.promisify(function(player, groupId) + return player:IsInGroup(groupId) + end) + ``` + + @param callback (...: any) -> ...any + @return (...: any) -> Promise +]=] +function Promise.promisify(callback: any) + return function(...: any) + return Promise._try(debug.traceback(nil, 2), callback, ...) + end +end + +--[=[ + Returns a Promise that resolves after `seconds` seconds have passed. The Promise resolves with the actual amount of time that was waited. + + This function is **not** a wrapper around `wait`. `Promise.delay` uses a custom scheduler which provides more accurate timing. As an optimization, cancelling this Promise instantly removes the task from the scheduler. + + :::warning + Passing `NaN`, infinity, or a number less than 1/60 is equivalent to passing 1/60. + ::: + + ```lua + Promise.delay(5):andThenCall(print, "This prints after 5 seconds") + ``` + + @function delay + @within Promise + @param seconds number + @return Promise +]=] +do + -- uses a sorted doubly linked list (queue) to achieve O(1) remove operations and O(n) for insert + + -- the initial node in the linked list + local first + local connection + + function Promise.delay(seconds: any) + assert(type(seconds) == "number", "Bad argument #1 to Promise.delay, must be a number.") + -- If seconds is -INF, INF, NaN, or less than 1 / 60, assume seconds is 1 / 60. + -- This mirrors the behavior of wait() + if not (seconds >= 1 / 60) or seconds == math.huge then + seconds = 1 / 60 + end + + return Promise._new(debug.traceback(nil, 2), function(resolve: any, _: any, onCancel: any) + local startTime = Promise._getTime() + local endTime = startTime + seconds + + local node = { + resolve = resolve, + startTime = startTime, + endTime = endTime, + } + + if connection == nil then -- first is nil when connection is nil + first = node + connection = Promise._timeEvent:Connect(function() + local threadStart = Promise._getTime() + + while first ~= nil and first.endTime < threadStart do + local current = first + first = current.next + + if first == nil then + connection:Disconnect() + connection = nil + else + first.previous = nil + end + + current.resolve(Promise._getTime() - current.startTime) + end + end) + else -- first is non-nil + if first.endTime < endTime then -- if `node` should be placed after `first` + -- we will insert `node` between `current` and `next` + -- (i.e. after `current` if `next` is nil) + local current = first + local next = current.next + + while next ~= nil and next.endTime < endTime do + current = next + next = current.next + end + + -- `current` must be non-nil, but `next` could be `nil` (i.e. last item in list) + current.next = node + node.previous = current + + if next ~= nil then + node.next = next + next.previous = node + end + else + -- set `node` to `first` + node.next = first + first.previous = node + first = node + end + end + + onCancel(function() + -- remove node from queue + local next = node.next + + if first == node then + if next == nil then -- if `node` is the first and last + connection:Disconnect() + connection = nil + else -- if `node` is `first` and not the last + next.previous = nil + end + first = next + else + local previous = node.previous + -- since `node` is not `first`, then we know `previous` is non-nil + previous.next = next + + if next ~= nil then + next.previous = previous + end + end + end) + end) + end +end + +--[=[ + Returns a new Promise that resolves if the chained Promise resolves within `seconds` seconds, or rejects if execution time exceeds `seconds`. The chained Promise will be cancelled if the timeout is reached. + + Rejects with `rejectionValue` if it is non-nil. If a `rejectionValue` is not given, it will reject with a `Promise.Error(Promise.Error.Kind.TimedOut)`. This can be checked with [[Error.isKind]]. + + ```lua + getSomething():timeout(5):andThen(function(something) + -- got something and it only took at max 5 seconds + end):catch(function(e) + -- Either getting something failed or the time was exceeded. + + if Promise.Error.isKind(e, Promise.Error.Kind.TimedOut) then + warn("Operation timed out!") + else + warn("Operation encountered an error!") + end + end) + ``` + + Sugar for: + + ```lua + Promise.race({ + Promise.delay(seconds):andThen(function() + return Promise.reject( + rejectionValue == nil + and Promise.Error.new({ kind = Promise.Error.Kind.TimedOut }) + or rejectionValue + ) + end), + promise + }) + ``` + + @param seconds number + @param rejectionValue? any -- The value to reject with if the timeout is reached + @return Promise +]=] +function Promise.prototype:timeout(seconds: any, rejectionValue: any) + local traceback = debug.traceback(nil, 2) + + return Promise.race({ + Promise.delay(seconds):andThen(function() + return Promise.reject(rejectionValue == nil and Error.new({ + kind = Error.Kind.TimedOut, + error = "Timed out", + context = string.format( + "Timeout of %d seconds exceeded.\n:timeout() called at:\n\n%s", + seconds, + traceback + ), + }) or rejectionValue) + end), + self, + }) +end + +--[=[ + Returns the current Promise status. + + @return Status +]=] +function Promise.prototype:getStatus() + return self._status +end + +--[[ + Creates a new promise that receives the result of this promise. + + The given callbacks are invoked depending on that result. +]] +function Promise.prototype:_andThen(traceback: any, successHandler: any, failureHandler: any) + self._unhandledRejection = false + + -- If we are already cancelled, we return a cancelled Promise + if self._status == Promise.Status.Cancelled then + local promise = Promise.new(function() end) + promise:cancel() + + return promise + end + + -- Create a new promise to follow this part of the chain + return Promise._new(traceback, function(resolve: any, reject: any, onCancel: any) + -- Our default callbacks just pass values onto the next promise. + -- This lets success and failure cascade correctly! + + local successCallback = resolve + if successHandler then + successCallback = createAdvancer(traceback, successHandler, resolve, reject) + end + + local failureCallback = reject + if failureHandler then + failureCallback = createAdvancer(traceback, failureHandler, resolve, reject) + end + + if self._status == Promise.Status.Started then + -- If we haven't resolved yet, put ourselves into the queue + table.insert(self._queuedResolve, successCallback) + table.insert(self._queuedReject, failureCallback) + + onCancel(function() + -- These are guaranteed to exist because the cancellation handler is guaranteed to only + -- be called at most once + if self._status == Promise.Status.Started then + table.remove(self._queuedResolve, table.find(self._queuedResolve, successCallback)) + table.remove(self._queuedReject, table.find(self._queuedReject, failureCallback)) + end + end) + elseif self._status == Promise.Status.Resolved then + -- This promise has already resolved! Trigger success immediately. + successCallback(unpack(self._values, 1, self._valuesLength)) + elseif self._status == Promise.Status.Rejected then + -- This promise died a terrible death! Trigger failure immediately. + failureCallback(unpack(self._values, 1, self._valuesLength)) + end + end, self) +end + +--[=[ + Chains onto an existing Promise and returns a new Promise. + + :::warning + Within the failure handler, you should never assume that the rejection value is a string. Some rejections within the Promise library are represented by [[Error]] objects. If you want to treat it as a string for debugging, you should call `tostring` on it first. + ::: + + You can return a Promise from the success or failure handler and it will be chained onto. + + Calling `andThen` on a cancelled Promise returns a cancelled Promise. + + :::tip + If the Promise returned by `andThen` is cancelled, `successHandler` and `failureHandler` will not run. + + To run code no matter what, use [Promise:finally]. + ::: + + @param successHandler (...: any) -> ...any + @param failureHandler? (...: any) -> ...any + @return Promise<...any> +]=] +function Promise.prototype:andThen(successHandler: any, failureHandler: any) + assert(successHandler == nil or isCallable(successHandler), string.format(ERROR_NON_FUNCTION, "Promise:andThen")) + assert(failureHandler == nil or isCallable(failureHandler), string.format(ERROR_NON_FUNCTION, "Promise:andThen")) + + return self:_andThen(debug.traceback(nil, 2), successHandler, failureHandler) +end + +--[=[ + Shorthand for `Promise:andThen(nil, failureHandler)`. + + Returns a Promise that resolves if the `failureHandler` worked without encountering an additional error. + + :::warning + Within the failure handler, you should never assume that the rejection value is a string. Some rejections within the Promise library are represented by [[Error]] objects. If you want to treat it as a string for debugging, you should call `tostring` on it first. + ::: + + Calling `catch` on a cancelled Promise returns a cancelled Promise. + + :::tip + If the Promise returned by `catch` is cancelled, `failureHandler` will not run. + + To run code no matter what, use [Promise:finally]. + ::: + + @param failureHandler (...: any) -> ...any + @return Promise<...any> +]=] +function Promise.prototype:catch(failureHandler: any) + assert(failureHandler == nil or isCallable(failureHandler), string.format(ERROR_NON_FUNCTION, "Promise:catch")) + return self:_andThen(debug.traceback(nil, 2), nil, failureHandler) +end + +--[=[ + Similar to [Promise.andThen](#andThen), except the return value is the same as the value passed to the handler. In other words, you can insert a `:tap` into a Promise chain without affecting the value that downstream Promises receive. + + ```lua + getTheValue() + :tap(print) + :andThen(function(theValue) + print("Got", theValue, "even though print returns nil!") + end) + ``` + + If you return a Promise from the tap handler callback, its value will be discarded but `tap` will still wait until it resolves before passing the original value through. + + @param tapHandler (...: any) -> ...any + @return Promise<...any> +]=] +function Promise.prototype:tap(tapHandler: any) + assert(isCallable(tapHandler), string.format(ERROR_NON_FUNCTION, "Promise:tap")) + return self:_andThen(debug.traceback(nil, 2), function(...: any) + local callbackReturn = tapHandler(...) + + if Promise.is(callbackReturn) then + local length, values = pack(...) + return callbackReturn:andThen(function() + return unpack(values, 1, length) + end) + end + + return ... + end) +end + +--[=[ + Attaches an `andThen` handler to this Promise that calls the given callback with the predefined arguments. The resolved value is discarded. + + ```lua + promise:andThenCall(someFunction, "some", "arguments") + ``` + + This is sugar for + + ```lua + promise:andThen(function() + return someFunction("some", "arguments") + end) + ``` + + @param callback (...: any) -> any + @param ...? any -- Additional arguments which will be passed to `callback` + @return Promise +]=] +function Promise.prototype:andThenCall(callback: any, ...: any) + assert(isCallable(callback), string.format(ERROR_NON_FUNCTION, "Promise:andThenCall")) + local length, values = pack(...) + return self:_andThen(debug.traceback(nil, 2), function() + return callback(unpack(values, 1, length)) + end) +end + +--[=[ + Attaches an `andThen` handler to this Promise that discards the resolved value and returns the given value from it. + + ```lua + promise:andThenReturn("some", "values") + ``` + + This is sugar for + + ```lua + promise:andThen(function() + return "some", "values" + end) + ``` + + :::caution + Promises are eager, so if you pass a Promise to `andThenReturn`, it will begin executing before `andThenReturn` is reached in the chain. Likewise, if you pass a Promise created from [[Promise.reject]] into `andThenReturn`, it's possible that this will trigger the unhandled rejection warning. If you need to return a Promise, it's usually best practice to use [[Promise.andThen]]. + ::: + + @param ... any -- Values to return from the function + @return Promise +]=] +function Promise.prototype:andThenReturn(...: any) + local length, values = pack(...) + return self:_andThen(debug.traceback(nil, 2), function() + return unpack(values, 1, length) + end) +end + +--[=[ + Cancels this promise, preventing the promise from resolving or rejecting. Does not do anything if the promise is already settled. + + Cancellations will propagate upwards and downwards through chained promises. + + Promises will only be cancelled if all of their consumers are also cancelled. This is to say that if you call `andThen` twice on the same promise, and you cancel only one of the child promises, it will not cancel the parent promise until the other child promise is also cancelled. + + ```lua + promise:cancel() + ``` +]=] +function Promise.prototype:cancel() + if self._status ~= Promise.Status.Started then + return + end + + self._status = Promise.Status.Cancelled + + if self._cancellationHook then + self._cancellationHook() + end + + coroutine.close(self._thread) + + if self._parent then + self._parent:_consumerCancelled(self) + end + + for child in pairs(self._consumers) do + child:cancel() + end + + self:_finalize() +end + +--[[ + Used to decrease the number of consumers by 1, and if there are no more, + cancel this promise. +]] +function Promise.prototype:_consumerCancelled(consumer: any) + if self._status ~= Promise.Status.Started then + return + end + + self._consumers[consumer] = nil + + if next(self._consumers) == nil then + self:cancel() + end +end + +--[[ + Used to set a handler for when the promise resolves, rejects, or is + cancelled. +]] +function Promise.prototype:_finally(traceback: any, finallyHandler: any) + self._unhandledRejection = false + + local promise = Promise._new(traceback, function(resolve: any, reject: any, onCancel: any) + local handlerPromise + + onCancel(function() + -- The finally Promise is not a proper consumer of self. We don't care about the resolved value. + -- All we care about is running at the end. Therefore, if self has no other consumers, it's safe to + -- cancel. We don't need to hold out cancelling just because there's a finally handler. + self:_consumerCancelled(self) + + if handlerPromise then + handlerPromise:cancel() + end + end) + + local finallyCallback = resolve + if finallyHandler then + finallyCallback = function(...: any) + local callbackReturn = finallyHandler(...) + + if Promise.is(callbackReturn) then + handlerPromise = callbackReturn + + callbackReturn + :finally(function(status) + if status ~= Promise.Status.Rejected then + resolve(self) + end + end) + :catch(function(...: any) + reject(...) + end) + else + resolve(self) + end + end + end + + if self._status == Promise.Status.Started then + -- The promise is not settled, so queue this. + table.insert(self._queuedFinally, finallyCallback) + else + -- The promise already settled or was cancelled, run the callback now. + finallyCallback(self._status) + end + end) + + return promise +end + +--[=[ + Set a handler that will be called regardless of the promise's fate. The handler is called when the promise is + resolved, rejected, *or* cancelled. + + Returns a new Promise that: + - resolves with the same values that this Promise resolves with. + - rejects with the same values that this Promise rejects with. + - is cancelled if this Promise is cancelled. + + If the value you return from the handler is a Promise: + - We wait for the Promise to resolve, but we ultimately discard the resolved value. + - If the returned Promise rejects, the Promise returned from `finally` will reject with the rejected value from the + *returned* promise. + - If the `finally` Promise is cancelled, and you returned a Promise from the handler, we cancel that Promise too. + + Otherwise, the return value from the `finally` handler is entirely discarded. + + :::note Cancellation + As of Promise v4, `Promise:finally` does not count as a consumer of the parent Promise for cancellation purposes. + This means that if all of a Promise's consumers are cancelled and the only remaining callbacks are finally handlers, + the Promise is cancelled and the finally callbacks run then and there. + + Cancellation still propagates through the `finally` Promise though: if you cancel the `finally` Promise, it can cancel + its parent Promise if it had no other consumers. Likewise, if the parent Promise is cancelled, the `finally` Promise + will also be cancelled. + ::: + + ```lua + local thing = createSomething() + + doSomethingWith(thing) + :andThen(function() + print("It worked!") + -- do something.. + end) + :catch(function() + warn("Oh no it failed!") + end) + :finally(function() + -- either way, destroy thing + + thing:Destroy() + end) + + ``` + + @param finallyHandler (status: Status) -> ...any + @return Promise<...any> +]=] +function Promise.prototype:finally(finallyHandler: any) + assert(finallyHandler == nil or isCallable(finallyHandler), string.format(ERROR_NON_FUNCTION, "Promise:finally")) + return self:_finally(debug.traceback(nil, 2), finallyHandler) +end + +--[=[ + Same as `andThenCall`, except for `finally`. + + Attaches a `finally` handler to this Promise that calls the given callback with the predefined arguments. + + @param callback (...: any) -> any + @param ...? any -- Additional arguments which will be passed to `callback` + @return Promise +]=] +function Promise.prototype:finallyCall(callback: any, ...: any) + assert(isCallable(callback), string.format(ERROR_NON_FUNCTION, "Promise:finallyCall")) + local length, values = pack(...) + return self:_finally(debug.traceback(nil, 2), function() + return callback(unpack(values, 1, length)) + end) +end + +--[=[ + Attaches a `finally` handler to this Promise that discards the resolved value and returns the given value from it. + + ```lua + promise:finallyReturn("some", "values") + ``` + + This is sugar for + + ```lua + promise:finally(function() + return "some", "values" + end) + ``` + + @param ... any -- Values to return from the function + @return Promise +]=] +function Promise.prototype:finallyReturn(...: any) + local length, values = pack(...) + return self:_finally(debug.traceback(nil, 2), function() + return unpack(values, 1, length) + end) +end + +--[=[ + Yields the current thread until the given Promise completes. Returns the Promise's status, followed by the values that the promise resolved or rejected with. + + @yields + @return Status -- The Status representing the fate of the Promise + @return ...any -- The values the Promise resolved or rejected with. +]=] +function Promise.prototype:awaitStatus() + self._unhandledRejection = false + + if self._status == Promise.Status.Started then + local thread = coroutine.running() + + self + :finally(function() + task.spawn(thread) + end) + -- The finally promise can propagate rejections, so we attach a catch handler to prevent the unhandled + -- rejection warning from appearing + :catch( + function() end + ) + + coroutine.yield() + end + + if self._status == Promise.Status.Resolved then + return self._status, unpack(self._values, 1, self._valuesLength) + elseif self._status == Promise.Status.Rejected then + return self._status, unpack(self._values, 1, self._valuesLength) + end + + return self._status +end + +local function awaitHelper(status: any, ...: any) + return status == Promise.Status.Resolved, ... +end + +--[=[ + Yields the current thread until the given Promise completes. Returns true if the Promise resolved, followed by the values that the promise resolved or rejected with. + + :::caution + If the Promise gets cancelled, this function will return `false`, which is indistinguishable from a rejection. If you need to differentiate, you should use [[Promise.awaitStatus]] instead. + ::: + + ```lua + local worked, value = getTheValue():await() + + if worked then + print("got", value) + else + warn("it failed") + end + ``` + + @yields + @return boolean -- `true` if the Promise successfully resolved + @return ...any -- The values the Promise resolved or rejected with. +]=] +function Promise.prototype:await() + return awaitHelper(self:awaitStatus()) +end + +local function expectHelper(status: any, ...: any) + if status ~= Promise.Status.Resolved then + error((...) == nil and "Expected Promise rejected with no value." or (...), 3) + end + + return ... +end + +--[=[ + Yields the current thread until the given Promise completes. Returns the values that the promise resolved with. + + ```lua + local worked = pcall(function() + print("got", getTheValue():expect()) + end) + + if not worked then + warn("it failed") + end + ``` + + This is essentially sugar for: + + ```lua + select(2, assert(promise:await())) + ``` + + **Errors** if the Promise rejects or gets cancelled. + + @error any -- Errors with the rejection value if this Promise rejects or gets cancelled. + @yields + @return ...any -- The values the Promise resolved with. +]=] +function Promise.prototype:expect() + return expectHelper(self:awaitStatus()) +end + +-- Backwards compatibility +Promise.prototype.awaitValue = Promise.prototype.expect + +--[[ + Intended for use in tests. + + Similar to await(), but instead of yielding if the promise is unresolved, + _unwrap will throw. This indicates an assumption that a promise has + resolved. +]] +function Promise.prototype:_unwrap() + if self._status == Promise.Status.Started then + error("Promise has not resolved or rejected.", 2) + end + + local success = self._status == Promise.Status.Resolved + + return success, unpack(self._values, 1, self._valuesLength) +end + +function Promise.prototype:_resolve(...: any) + if self._status ~= Promise.Status.Started then + if Promise.is((...)) then + (...):_consumerCancelled(self) + end + return + end + + -- If the resolved value was a Promise, we chain onto it! + if Promise.is((...)) then + -- Without this warning, arguments sometimes mysteriously disappear + if select("#", ...) > 1 then + local message = string.format( + "When returning a Promise from andThen, extra arguments are " .. "discarded! See:\n\n%s", + self._source + ) + warn(message) + end + + local chainedPromise = ... + + local promise = chainedPromise:andThen(function(...: any) + self:_resolve(...) + end, function(...: any) + local maybeRuntimeError = chainedPromise._values[1] + + -- Backwards compatibility < v2 + if chainedPromise._error then + maybeRuntimeError = Error.new({ + error = chainedPromise._error, + kind = Error.Kind.ExecutionError, + context = "[No stack trace available as this Promise originated from an older version of the Promise library (< v2)]", + }) + end + + if Error.isKind(maybeRuntimeError, Error.Kind.ExecutionError) then + return self:_reject(maybeRuntimeError:extend({ + error = "This Promise was chained to a Promise that errored.", + trace = "", + context = string.format( + "The Promise at:\n\n%s\n...Rejected because it was chained to the following Promise, which encountered an error:\n", + self._source + ), + })) + end + + self:_reject(...) + return + end) + + if promise._status == Promise.Status.Cancelled then + self:cancel() + elseif promise._status == Promise.Status.Started then + -- Adopt ourselves into promise for cancellation propagation. + self._parent = promise + promise._consumers[self] = true + end + + return + end + + self._status = Promise.Status.Resolved + self._valuesLength, self._values = pack(...) + + -- We assume that these callbacks will not throw errors. + for _, callback in ipairs(self._queuedResolve) do + coroutine.wrap(callback)(...) + end + + self:_finalize() +end + +function Promise.prototype:_reject(...: any) + if self._status ~= Promise.Status.Started then + return + end + + self._status = Promise.Status.Rejected + self._valuesLength, self._values = pack(...) + + -- If there are any rejection handlers, call those! + if not isEmpty(self._queuedReject) then + -- We assume that these callbacks will not throw errors. + for _, callback in ipairs(self._queuedReject) do + coroutine.wrap(callback)(...) + end + else + -- At this point, no one was able to observe the error. + -- An error handler might still be attached if the error occurred + -- synchronously. We'll wait one tick, and if there are still no + -- observers, then we should put a message in the console. + + local err = tostring((...)) + + coroutine.wrap(function() + Promise._timeEvent:Wait() + + -- Someone observed the error, hooray! + if not self._unhandledRejection then + return + end + + -- Build a reasonable message + local message = string.format("Unhandled Promise rejection:\n\n%s\n\n%s", err, self._source) + + for _, callback in ipairs(Promise._unhandledRejectionCallbacks) do + task.spawn(callback, self, unpack(self._values, 1, self._valuesLength)) + end + + if Promise.TEST then + -- Don't spam output when we're running tests. + return + end + + warn(message) + end)() + end + + self:_finalize() +end + +--[[ + Calls any :finally handlers. We need this to be a separate method and + queue because we must call all of the finally callbacks upon a success, + failure, *and* cancellation. +]] +function Promise.prototype:_finalize() + for _, callback in ipairs(self._queuedFinally) do + -- Purposefully not passing values to callbacks here, as it could be the + -- resolved values, or rejected errors. If the developer needs the values, + -- they should use :andThen or :catch explicitly. + coroutine.wrap(callback)(self._status) + end + + self._queuedFinally = nil + self._queuedReject = nil + self._queuedResolve = nil + + -- Clear references to other Promises to allow gc + if not Promise.TEST then + self._parent = nil + self._consumers = nil + end + + task.defer(coroutine.close, self._thread) +end + +--[=[ + Chains a Promise from this one that is resolved if this Promise is already resolved, and rejected if it is not resolved at the time of calling `:now()`. This can be used to ensure your `andThen` handler occurs on the same frame as the root Promise execution. + + ```lua + doSomething() + :now() + :andThen(function(value) + print("Got", value, "synchronously.") + end) + ``` + + If this Promise is still running, Rejected, or Cancelled, the Promise returned from `:now()` will reject with the `rejectionValue` if passed, otherwise with a `Promise.Error(Promise.Error.Kind.NotResolvedInTime)`. This can be checked with [[Error.isKind]]. + + @param rejectionValue? any -- The value to reject with if the Promise isn't resolved + @return Promise +]=] +function Promise.prototype:now(rejectionValue: any) + local traceback = debug.traceback(nil, 2) + if self._status == Promise.Status.Resolved then + return self:_andThen(traceback, function(...: any) + return ... + end) + else + return Promise.reject(rejectionValue == nil and Error.new({ + kind = Error.Kind.NotResolvedInTime, + error = "This Promise was not resolved in time for :now()", + context = ":now() was called at:\n\n" .. traceback, + }) or rejectionValue) + end +end + +--[=[ + Repeatedly calls a Promise-returning function up to `times` number of times, until the returned Promise resolves. + + If the amount of retries is exceeded, the function will return the latest rejected Promise. + + ```lua + local function canFail(a, b, c) + return Promise.new(function(resolve, reject) + -- do something that can fail + + local failed, thing = doSomethingThatCanFail(a, b, c) + + if failed then + reject("it failed") + else + resolve(thing) + end + end) + end + + local MAX_RETRIES = 10 + local value = Promise.retry(canFail, MAX_RETRIES, "foo", "bar", "baz") -- args to send to canFail + ``` + + @since 3.0.0 + @param callback (...: P) -> Promise + @param times number + @param ...? P + @return Promise +]=] +function Promise.retry(callback: any, times: any, ...: any) + assert(isCallable(callback), "Parameter #1 to Promise.retry must be a function") + assert(type(times) == "number", "Parameter #2 to Promise.retry must be a number") + + local args, length = { ... }, select("#", ...) + + return Promise.resolve(callback(...)):catch(function(...: any) + if times > 0 then + return Promise.retry(callback, times - 1, unpack(args, 1, length)) + else + return Promise.reject(...) + end + end) +end + +--[=[ + Repeatedly calls a Promise-returning function up to `times` number of times, waiting `seconds` seconds between each + retry, until the returned Promise resolves. + + If the amount of retries is exceeded, the function will return the latest rejected Promise. + + @since v3.2.0 + @param callback (...: P) -> Promise + @param times number + @param seconds number + @param ...? P + @return Promise +]=] +function Promise.retryWithDelay(callback: any, times: any, seconds: any, ...: any) + assert(isCallable(callback), "Parameter #1 to Promise.retry must be a function") + assert(type(times) == "number", "Parameter #2 (times) to Promise.retry must be a number") + assert(type(seconds) == "number", "Parameter #3 (seconds) to Promise.retry must be a number") + + local args, length = { ... }, select("#", ...) + + return Promise.resolve(callback(...)):catch(function(...: any) + if times > 0 then + Promise.delay(seconds):await() + + return Promise.retryWithDelay(callback, times - 1, seconds, unpack(args, 1, length)) + else + return Promise.reject(...) + end + end) +end + +--[=[ + Converts an event into a Promise which resolves the next time the event fires. + + The optional `predicate` callback, if passed, will receive the event arguments and should return `true` or `false`, based on if this fired event should resolve the Promise or not. If `true`, the Promise resolves. If `false`, nothing happens and the predicate will be rerun the next time the event fires. + + The Promise will resolve with the event arguments. + + :::tip + This function will work given any object with a `Connect` method. This includes all Roblox events. + ::: + + ```lua + -- Creates a Promise which only resolves when `somePart` is touched + -- by a part named `"Something specific"`. + return Promise.fromEvent(somePart.Touched, function(part) + return part.Name == "Something specific" + end) + ``` + + @since 3.0.0 + @param event Event -- Any object with a `Connect` method. This includes all Roblox events. + @param predicate? (...: P) -> boolean -- A function which determines if the Promise should resolve with the given value, or wait for the next event to check again. + @return Promise

+]=] +function Promise.fromEvent(event: any, predicate: any) + predicate = predicate or function() + return true + end + + return Promise._new(debug.traceback(nil, 2), function(resolve: any, _: any, onCancel: any) + local connection + local shouldDisconnect = false + + local function disconnect() + connection:Disconnect() + connection = nil + end + + -- We use shouldDisconnect because if the callback given to Connect is called before + -- Connect returns, connection will still be nil. This happens with events that queue up + -- events when there's nothing connected, such as RemoteEvents + + connection = event:Connect(function(...: any) + local callbackValue = (predicate :: any)(...) + + if callbackValue == true then + resolve(...) + + if connection then + disconnect() + else + shouldDisconnect = true + end + elseif type(callbackValue) ~= "boolean" then + error("Promise.fromEvent predicate should always return a boolean") + end + end) + + if shouldDisconnect and connection then + return disconnect() + end + + onCancel(disconnect) + return + end) +end + +--[=[ + Registers a callback that runs when an unhandled rejection happens. An unhandled rejection happens when a Promise + is rejected, and the rejection is not observed with `:catch`. + + The callback is called with the actual promise that rejected, followed by the rejection values. + + @since v3.2.0 + @param callback (promise: Promise, ...: any) -- A callback that runs when an unhandled rejection happens. + @return () -> () -- Function that unregisters the `callback` when called +]=] +function Promise.onUnhandledRejection(callback: any) + table.insert(Promise._unhandledRejectionCallbacks, callback) + + return function() + local index = table.find(Promise._unhandledRejectionCallbacks, callback) + + if index then + table.remove(Promise._unhandledRejectionCallbacks, index) + end + end +end + +return (Promise :: any) :: PromiseLib \ No newline at end of file diff --git a/src/Chemical/Packages/Queue.lua b/src/Chemical/Packages/Queue.lua new file mode 100644 index 0000000..9d5f4c9 --- /dev/null +++ b/src/Chemical/Packages/Queue.lua @@ -0,0 +1,53 @@ +--!strict + +--From Roblox docs + +local Queue = {} +Queue.__index = Queue + +export type Queue = typeof(setmetatable( + {} :: { + _first: number, + _last: number, + _queue: { T }, + }, + Queue + )) + +function Queue.new(): Queue + local self = setmetatable({ + _first = 0, + _last = -1, + _queue = {}, + }, Queue) + + return self +end + +-- Check if the queue is empty +function Queue.isEmpty(self: Queue) + return self._first > self._last +end + +-- Add a value to the queue +function Queue.enqueue(self: Queue, value: T) + local last = self._last + 1 + self._last = last + self._queue[last] = value +end + +-- Remove a value from the queue +function Queue.dequeue(self: Queue): T? + if self:isEmpty() then + return nil + end + + local first = self._first + local value = self._queue[first] + self._queue[first] = nil + self._first = first + 1 + + return value +end + +return Queue \ No newline at end of file diff --git a/src/Chemical/Packages/Signals.lua b/src/Chemical/Packages/Signals.lua new file mode 100644 index 0000000..4014314 --- /dev/null +++ b/src/Chemical/Packages/Signals.lua @@ -0,0 +1,113 @@ +--- Lua-side duplication of the API of events on Roblox objects. +-- Signals are needed for to ensure that for local events objects are passed by +-- reference rather than by value where possible, as the BindableEvent objects +-- always pass signal arguments by value, meaning tables will be deep copied. +-- Roblox's deep copy method parses to a non-lua table compatable format. +-- @classmod Signal + +local HttpService = game:GetService("HttpService") + +local ENABLE_TRACEBACK = false + +local Signal = {} +Signal.__index = Signal +Signal.ClassName = "Signal" + +--- Constructs a new signal. +-- @constructor Signal.new() +-- @treturn Signal +function Signal.new() + local self = setmetatable({}, Signal) + + self._bindableEvent = Instance.new("BindableEvent") + self._argMap = {} + self._source = ENABLE_TRACEBACK and debug.traceback() or "" + + -- Events in Roblox execute in reverse order as they are stored in a linked list and + -- new connections are added at the head. This event will be at the tail of the list to + -- clean up memory. + self._bindableEvent.Event:Connect(function(key) + self._argMap[key] = nil + + -- We've been destroyed here and there's nothing left in flight. + -- Let's remove the argmap too. + -- This code may be slower than leaving this table allocated. + if (not self._bindableEvent) and (not next(self._argMap)) then + self._argMap = nil + end + end) + + return self +end + +--- Fire the event with the given arguments. All handlers will be invoked. Handlers follow +-- Roblox signal conventions. +-- @param ... Variable arguments to pass to handler +-- @treturn nil +function Signal:Fire(...) + if not self._bindableEvent then + warn(("Signal is already destroyed. %s"):format(self._source)) + return + end + + local args = table.pack(...) + + -- TODO: Replace with a less memory/computationally expensive key generation scheme + local key = HttpService:GenerateGUID(false) + self._argMap[key] = args + + -- Queues each handler onto the queue. + self._bindableEvent:Fire(key) +end + +--- Connect a new handler to the event. Returns a connection object that can be disconnected. +-- @tparam function handler Function handler called with arguments passed when `:Fire(...)` is called +-- @treturn Connection Connection object that can be disconnected +function Signal:Connect(handler) + if not (type(handler) == "function") then + error(("connect(%s)"):format(typeof(handler)), 2) + end + + return self._bindableEvent.Event:Connect(function(key) + -- note we could queue multiple events here, but we'll do this just as Roblox events expect + -- to behave. + + local args = self._argMap[key] + if args then + handler(table.unpack(args, 1, args.n)) + else + error("Missing arg data, probably due to reentrance.") + end + end) +end + +--- Wait for fire to be called, and return the arguments it was given. +-- @treturn ... Variable arguments from connection +function Signal:Wait() + local key = self._bindableEvent.Event:Wait() + local args = self._argMap[key] + if args then + return table.unpack(args, 1, args.n) + else + error("Missing arg data, probably due to reentrance.") + return nil + end +end + +--- Disconnects all connected events to the signal. Voids the signal as unusable. +-- @treturn nil +function Signal:Destroy() + if self._bindableEvent then + -- This should disconnect all events, but in-flight events should still be + -- executed. + + self._bindableEvent:Destroy() + self._bindableEvent = nil + end + + -- Do not remove the argmap. It will be cleaned up by the cleanup connection. + + setmetatable(self, nil) +end + +return Signal \ No newline at end of file diff --git a/src/Chemical/Packages/Trees.lua b/src/Chemical/Packages/Trees.lua new file mode 100644 index 0000000..88e6c4c --- /dev/null +++ b/src/Chemical/Packages/Trees.lua @@ -0,0 +1,237 @@ +-- Tree Library == Vibe coded by Sovereignty + +export type Node = { + Key: string, + Value: value, + Children: {Node}, + Parent: Node?, + FullPath: string, + + new: (key: string, value: any?) -> Node, + AddChild: (self: Node, key: string, value: any?) -> Node, + GetChild: (self: Node, key: string) -> Node?, + GetChildren: (self: Node) -> {Node}, + GetAllDescendants: (self: Node) -> {Node}, + GetPath: (self: Node) -> {string}, + SetValue: (self: Node, value: any?) -> (), + TraverseDFS: (self: Node, callback: (node: Node) -> ()) -> (), + TraverseBFS: (self: Node, callback: (node: Node) -> ()) -> (), +} + +export type Tree = { + Root: Node, + + new: (rootKey: string?, rootValue: any?) -> (Tree), + AddNode: (self: Tree, pathParts: {string}, value: any?) -> Node, + GetNode: (self: Tree, pathParts: {string}) -> Node?, + GetNodeChildrenByPath: (self: Tree, pathParts: {string}) -> {Node}, + GetDescendantsByPath: (self: Tree, pathParts: {string}) -> {Node}, + FindNode: (self: Tree, predicate: (node: Node) -> boolean) -> Node?, + RemoveNode: (self: Tree, pathParts: {string}) -> boolean, + UpdateNode: (self: Tree, pathParts: {string}, newValue: any) -> boolean, + GetPathString: (self: Tree, pathParts: {string}) -> string, + Traverse: (self: Tree, method: "DFS" | "BFS", callback: (node: Node) -> ()) -> (), + Print: (self: Tree) -> (), +} + +local Node = {} :: Node +Node.__index = Node + +function Node.new(key: string, value: any?): Node + return setmetatable({ + Key = key, + Value = value, + Children = {} :: {Node}, + Parent = nil :: Node?, + FullPath = "" + }, Node) :: any +end + +function Node:AddChild(key: string, value: any?): Node + local child = Node.new(key, value) + child.Parent = self + + if self.FullPath == "/" then + child.FullPath = "/" .. key + else + child.FullPath = self.FullPath .. "/" .. key + end + + table.insert(self.Children, child) + return child +end + +function Node:GetChild(key: string): Node? + for _, child in ipairs(self.Children) do + if child.Key == key then + return child + end + end + return nil +end + +function Node:GetChildren(): {Node} + return self.Children +end + +function Node:GetAllDescendants(): {Node} + local descendants = {} :: {Node} + local function traverse(node: Node) + for _, child in ipairs(node.Children) do + table.insert(descendants, child) + traverse(child) + end + end + traverse(self) + return descendants +end + +function Node:GetPath(): {string} + local parts = {} :: {string} + local current: Node? = self + while current do + table.insert(parts, 1, current.Key) + current = current.Parent + end + return parts +end + +function Node:SetValue(value: any?) + self.Value = value +end + +function Node:TraverseDFS(callback: (node: Node) -> ()) + callback(self) + for _, child in ipairs(self.Children) do + child:TraverseDFS(callback) + end +end + +function Node:TraverseBFS(callback: (node: Node) -> ()) + local queue = {self} :: {Node} + while #queue > 0 do + local current = table.remove(queue, 1) + callback(current) + for _, child in ipairs(current.Children) do + table.insert(queue, child) + end + end +end + +local Tree = {} :: Tree +Tree.__index = Tree + +function Tree.new(rootKey: string?, rootValue: any?): Tree + local rootKey = rootKey or "/" + local root = Node.new(rootKey, rootValue) + + root.FullPath = rootKey == "/" and "/" or "/" .. rootKey + + return setmetatable({ + Root = root + }, Tree) :: Tree +end + +function Tree:AddNode(pathParts: {string}, value: any?): Node + local current: Node = self.Root + + for _, part in ipairs(pathParts) do + local child = current:GetChild(part) + + if not child then + child = current:AddChild(part, nil) + end + + current = child + end + + current.Value = value + return current +end + +function Tree:GetNode(pathParts: {string}): Node? + local current: Node? = self.Root + for _, part in ipairs(pathParts) do + local nextNode = current:GetChild(part) + if not nextNode then break end + current = nextNode + end + return current ~= self.Root and current +end + +function Tree:GetNodeChildrenByPath(pathParts: {string}): {Node} + local node = self:GetNode(pathParts) + return node and node:GetChildren() or {} +end + +function Tree:GetDescendantsByPath(pathParts: {string}): {Node} + local node = self:GetNode(pathParts) + return node and node:GetAllDescendants() or {} +end + +function Tree:FindNode(predicate: (node: Node) -> boolean): Node? + local found: Node? = nil + self.Root:TraverseDFS(function(node) + if predicate(node) then + found = node + end + end) + return found +end + +function Tree:RemoveNode(pathParts: {string}): boolean + local node = self:GetNode(pathParts) + if not node or node == self.Root then return false end + + local parent = node.Parent + if not parent then return false end + + for i, child in ipairs(parent.Children) do + if child == node then + table.remove(parent.Children, i) + return true + end + end + + return false +end + +function Tree:UpdateNode(pathParts: {string}, newValue: any): boolean + local node = self:GetNode(pathParts) + if node then + node.Value = newValue + return true + end + return false +end + +function Tree:GetPathString(pathParts: {string}): string + return "/" .. table.concat(pathParts, "/") +end + +function Tree:Traverse(method: "DFS" | "BFS", callback: (node: Node) -> ()) + if method == "DFS" then + self.Root:TraverseDFS(callback) + elseif method == "BFS" then + self.Root:TraverseBFS(callback) + else + error("Invalid traversal method. Use 'DFS' or 'BFS'") + end +end + +function Tree:Print() + print("Tree Structure:") + self.Root:TraverseDFS(function(node) + local indent = string.rep(" ", #node:GetPath() - 1) + + -- Fix: Format root path correctly + local displayPath = node.FullPath + if node == self.Root and node.Key == "/" then + displayPath = "/" + end + + print(indent .. node.Key .. " (" .. displayPath .. ")") + end) +end + +return Tree \ No newline at end of file diff --git a/src/Chemical/Packages/init.meta.json b/src/Chemical/Packages/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Packages/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Singletons/Reactor.lua b/src/Chemical/Singletons/Reactor.lua new file mode 100644 index 0000000..eb093e2 --- /dev/null +++ b/src/Chemical/Singletons/Reactor.lua @@ -0,0 +1,323 @@ +--[[ + @module Reactor + + This module facilitates the creation and replication of stateful objects, called "Reactions," + from the server to clients. It is designed to be used in an ECS (Entity-Component System) + environment. + + - A "Reactor" is a factory for creating "Reactions" of a specific type. + - A "Reaction" is a state object identified by a name and a unique key. + - State changes within a Reaction are automatically replicated to the appropriate clients. + - It uses a tokenization system to minimize network bandwidth for property names and paths. + + Yes, the documentation was generated. +]] + + +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +local RootFolder = script.Parent.Parent + + +local Packet = require(RootFolder.Packages.Packet) +local Signal = require(RootFolder.Packages.Signals) +local Promise = require(RootFolder.Packages.Promise) +local Queue = require(RootFolder.Packages.Queue) + + +local ECS = require(RootFolder.ECS) +local Cache = require(RootFolder.Cache) +local Symbols = require(RootFolder.Symbols) +local Effect = require(RootFolder.Factories.Effect) +local Reaction = require(RootFolder.Factories.Reaction) +local Array = require(RootFolder.Functions.Array) +local Blueprint = require(RootFolder.Functions.Blueprint) +local Is = require(RootFolder.Functions.Is) + + +type Player = { UserId: number } +type Symbol = Symbols.Symbol +type Reaction = Reaction.Reaction & { To: { Player } | Symbol, Name: string, Key: string } + + +local network = { + Create = Packet("C__Create", Packet.String, Packet.String, { Packet.NumberU8 }, Packet.Any, Packet.Any), + Update = Packet("C__Update", { Packet.NumberU8 }, { Packet.NumberU8 }, Packet.Any), + UpdateChanges = Packet("C__UpdateChange", { Packet.NumberU8 }, { Packet.NumberU8 }, Packet.Any), -- TODO + Destroy = Packet("C__Destroy", { Packet.NumberU8 }), +} + +local Tokens = Cache.Tokens.new() +local Reactions: { [string]: { [string]: Reaction } } = {} +local ReactionQueues: { [string]: { [string]: Queue.Queue<{ { number } | any }> } } = {} +local OnReactionCreated = Signal.new() + + + +--- Recursively walks a table structure and creates a map of string keys to numerical tokens. +-- This is used to prepare a state object for replication, ensuring the client can reconstruct it. +local function createPathTokenMap( + snapshotTable: { [any]: any }, + originalTable: { [any]: any } +): { [string]: number } + local result = {} + + for key, snapshotValue in pairs(snapshotTable) do + local originalValue = originalTable[key] + + + if typeof(key) ~= "string" then + continue + end + + + result[key] = Tokens:ToToken(key) + + + if Is.Array(snapshotValue) and Is.Array(originalValue) then + for k, token in createPathTokenMap(snapshotValue, originalValue) do + result[k] = token + end + end + end + + return result +end + +--- Sends a network packet to a specified target (all players or a list of players). +local AllPlayers = Symbols.All("Players") +local function sendToTarget(target: { Player } | Symbol, packet: any, ...: any) + if target == AllPlayers then + packet:Fire(...) + else + for _, player in target :: { Player } do + packet:FireClient(player, ...) + end + end +end + +--- Handles the server-side logic for replicating a Reaction to clients. +local function replicate(reaction: Reaction) + local name = reaction.Name + local key = reaction.Key + local target = reaction.To + + + local nameToken = Tokens:ToToken(name) + local keyToken = Tokens:ToToken(key) + local reactionIdTokens = { nameToken, keyToken } + + local blueprint = reaction:blueprint() + local pathTokenMap = createPathTokenMap(reaction:snapshot(), reaction:get()) + + + sendToTarget(target, network.Create, name, key, reactionIdTokens, pathTokenMap, blueprint) + + + Array.Walk(reaction:get(), function(path: { string }, value: any) + if Is.Stateful(value) then + local pathTokens = Tokens:ToTokenPath(path) + + local eff = Effect(function() + sendToTarget(target, network.Update, reactionIdTokens, pathTokens, value:get()) + end) + + + ECS.World:add(eff.entity, ECS.JECS.pair(ECS.Tags.InScope, reaction.entity)) + end + end) + + -- Patch the reaction's cleanup to notify clients of its destruction. + ECS.World:set(reaction.entity, ECS.Components.CleanupFn, function() + if Reactions[name] and Reactions[name][key] then + Reactions[name][key] = nil + end + sendToTarget(target, network.Destroy, reactionIdTokens) + end) +end + + +if RunService:IsClient() then + --- Reconstructs a Reaction on the client based on data from the server. + local function reconstruct(name: string, key: string, reactionIdTokens: { number }, pathTokenMap: { [string]: number }, blueprint: { string: {T: number, V: any} }): () + if Reactions[name] and Reactions[name][key] then + return + end + + -- Map the incoming tokens so we can translate them back to strings later. + Tokens:Map({ [name] = reactionIdTokens[1], [key] = reactionIdTokens[2] }) + Tokens:Map(pathTokenMap) + + -- Create the local version of the Reaction + local reaction = Reaction(name, key, Blueprint:Read(blueprint :: any)) + reaction.Name = name + reaction.Key = key + + if not Reactions[name] then + Reactions[name] = {} + end + Reactions[name][key] = reaction + + -- Process any queued updates that arrived before this creation packet. + if ReactionQueues[name] and ReactionQueues[name][key] then + local queue = ReactionQueues[name][key] + while not queue:isEmpty() do + local args = queue:dequeue() + local pathTokens, value = table.unpack(args) + local path = Tokens:FromPath(pathTokens) + local statefulValue = Array.FindOnPath(reaction:get(), path) + if statefulValue and statefulValue.set then + statefulValue:set(value) + end + end + ReactionQueues[name][key] = nil -- Clear the queue + end + + OnReactionCreated:Fire(name, key, reaction) + end + + --- Applies a state update from the server to a local Reaction. + local function update(reactionIdTokens: { number }, pathTokens: { number }, value: any) + local name = Tokens:From(reactionIdTokens[1]) + local key = Tokens:From(reactionIdTokens[2]) + local path = Tokens:FromPath(pathTokens) + + if not name or not key then return end + + local reaction = Reactions[name] and Reactions[name][key] + + -- If the reaction doesn't exist yet, queue the update. + if not reaction then + if not ReactionQueues[name] then ReactionQueues[name] = {} end + if not ReactionQueues[name][key] then ReactionQueues[name][key] = Queue.new() end + + ReactionQueues[name][key]:enqueue({ pathTokens, value }) + return + end + + if reaction.__destroyed then return end + + -- Apply the update + local container = reaction:get() + local statefulValue = Array.FindOnPath(container, path) + if statefulValue and statefulValue.set then + statefulValue:set(value) + end + end + + --- Destroys a local Reaction when notified by the server. + local function destroy(reactionIdTokens: { number }) + local name = Tokens:From(reactionIdTokens[1]) + local key = Tokens:From(reactionIdTokens[2]) + + if not name or not key then return end + + local reaction = Reactions[name] and Reactions[name][key] + + if not reaction or not reaction.entity then return end + + reaction:destroy() + Reactions[name][key] = nil + end + + -- Connect client network events to their handler functions + network.Create.OnClientEvent:Connect(reconstruct) + network.Update.OnClientEvent:Connect(update) + network.Destroy.OnClientEvent:Connect(destroy) +else + Players.PlayerAdded:Connect(function(player: Player) + for _, keyedReactions in Reactions do + for _, reaction in keyedReactions do + if reaction.To == Symbols.All("Players") or table.find(reaction.To, player) then + replicate(reaction) + end + end + end + end) +end + +--// Public API +local api = {} + +--- Awaits the creation of a specific Reaction on the client. +-- @param name The name of the Reactor that creates the Reaction. +-- @param key The unique key of the Reaction. +-- @return A promise that resolves with the Reaction once it's created. +function api.await(name: string, key: string): Reaction + if Reactions[name] and Reactions[name][key] then + return Reactions[name][key] + end + + return Promise.fromEvent(OnReactionCreated, function(n: string, k: string, _) + return name == n and key == k + end):andThen(function(...) + return select(3, ...) -- Return the reaction object + end):expect() +end + +--- Listens for the creation of any Reaction from a specific Reactor. +-- @param name The name of the Reactor. +-- @param callback A function to call with the key and Reaction object. +-- @return A connection object with a :Disconnect() method. +function api.onCreate(name: string, callback: (key: string, reaction: Reaction) -> ()) + return OnReactionCreated:Connect(function(n: string, k: string, reaction: Reaction) + if n == name then + task.spawn(callback, k, reaction) + end + end) +end + +--- Creates a "Reactor" factory. +-- @param config A configuration table with a 'Name' and optional 'Subjects' (players). +-- @param constructor A function that returns the initial state for a new Reaction. +-- @return A Reactor object with `create` and `await` methods. +return function( + config: { Name: string, Subjects: { Player } | Symbol? }, + constructor: (key: string, U...) -> T +) + local name = config.Name + assert(name, "Reactor config must include a 'Name'.") + + local to = config.Subjects or Symbols.All("Players") + local reactor = {} + + --- Creates and replicates a new Reaction. [SERVER-ONLY] + -- @param self The reactor object. + -- @param key A unique key for this Reaction. + -- @param ... Additional arguments to be passed to the constructor. + -- @return The created Reaction instance. + function reactor:create(key: string, ...: U...): Reaction + assert(not RunService:IsClient(), "Reactions can only be created on the server.") + if Reactions[name] and Reactions[name][key] then + warn(string.format("Reactor '%s' is overwriting an existing reaction with key '%s'", name, key)) + end + + local reaction = Reaction(name, key, constructor(key, ...)) + reaction.To = to + reaction.Name = name + reaction.Key = key + + if not Reactions[name] then + Reactions[name] = {} + end + Reactions[name][key] = reaction + + -- The new, encapsulated replicate function handles all server-side logic. + replicate(reaction) + + return reaction + end + + --- Awaits a specific Reaction from this Reactor. [CLIENT-ONLY] + function reactor:await(key: string): Reaction + return api.await(name, key) + end + + --- Listens for new Reactions created by this Reactor. [CLIENT-ONLY] + function reactor:onCreate(callback: (key: string, reaction: Reaction) -> ()) + return api.onCreate(name, callback) + end + + return reactor +end \ No newline at end of file diff --git a/src/Chemical/Singletons/Scheduler.lua b/src/Chemical/Singletons/Scheduler.lua new file mode 100644 index 0000000..55f05b1 --- /dev/null +++ b/src/Chemical/Singletons/Scheduler.lua @@ -0,0 +1,95 @@ +--!native +--!optimize 2 + +local RunService = game:GetService("RunService") + +local RootFolder = script.Parent.Parent + +local ECS = require(RootFolder.ECS) + +local Components = ECS.Components +local Tags = ECS.Tags +local JECS = ECS.JECS +local World = ECS.World + +local MAX_COMPUTATION_DEPTH = 100 + +local dirtySourceQuery = World:query(Components.Object) + :with(Tags.IsStateful, Tags.IsDirty) + :without(Tags.IsEffect, Tags.IsComputed) + :cached() + +local dirtyComputedsQuery = World:query(Components.Object) + :with(Tags.IsComputed, Tags.IsDirty) + :cached() + +local dirtyEffectsQuery = World:query(Components.Object) + :with(Tags.IsEffect, Tags.IsDirty) + :cached() + + +local GetSubscribers = require(RootFolder.Functions.GetSubscribers) + + +local Scheduler = {} + +function Scheduler:Update() + -- PROPAGATE DIRTINESS + for sourceEntity, _ in dirtySourceQuery:iter() do + local subscribers = GetSubscribers(sourceEntity) + + for _, subscriberEntity in ipairs(subscribers) do + if not World:has(subscriberEntity, Tags.IsDirty) then + World:add(subscriberEntity, Tags.IsDirty) + end + end + + World:remove(sourceEntity, Tags.IsDirty) + end + + -- RE-RUN COMPUTED VALUES + for i = 1, MAX_COMPUTATION_DEPTH do + local computedsToProcess = {} + + for entity, computable in dirtyComputedsQuery:iter() do + table.insert(computedsToProcess, computable) + end + + if #computedsToProcess == 0 then + break + end + + for _, computable in ipairs(computedsToProcess) do + + computable:compute() + World:remove(computable.entity, Tags.IsDirty) + + for _, subscriber in ipairs(GetSubscribers(computable.entity)) do + World:add(subscriber, Tags.IsDirty) + end + end + + if i == MAX_COMPUTATION_DEPTH then + warn("Chemical: Max computation depth exceeded. Check for a circular dependency in your Computed values.") + end + end + + -- RUN EFFECTS & OBSERVERS + for _, runnable in dirtyEffectsQuery:iter() do + runnable:run() + World:remove(runnable.entity, Tags.IsDirty) + end +end + +if RunService:IsServer() then + RunService.Heartbeat:Connect(function() + Scheduler:Update() + end) +else + RunService.RenderStepped:Connect(function() + Scheduler:Update() + end) +end + + +return Scheduler \ No newline at end of file diff --git a/src/Chemical/Singletons/init.meta.json b/src/Chemical/Singletons/init.meta.json new file mode 100644 index 0000000..1025b06 --- /dev/null +++ b/src/Chemical/Singletons/init.meta.json @@ -0,0 +1,3 @@ +{ + "ignoreUnknownInstances": true +} \ No newline at end of file diff --git a/src/Chemical/Symbols.lua b/src/Chemical/Symbols.lua new file mode 100644 index 0000000..f2991ad --- /dev/null +++ b/src/Chemical/Symbols.lua @@ -0,0 +1,26 @@ +local RootFolder = script.Parent + +local GuiTypes = require(RootFolder.Types.Gui) +local GetSymbol = require(RootFolder.Functions.GetSymbol) + +export type Symbol = GetSymbol.Symbol + +local module = {} + +module.OnEvent = function(eventName: GuiTypes.EventNames) + return GetSymbol(eventName, "Event") +end + +module.OnChange = function(eventName: GuiTypes.PropertyNames) + return GetSymbol(eventName, "Change") +end + +module.Children = GetSymbol("All", "Children") + +module.All = function(subjects: S) + return GetSymbol(subjects, "All") :: Symbol +end + +--OnEvent symbols are handled by the OnEvent function. + +return module diff --git a/src/Chemical/Types/Gui.lua b/src/Chemical/Types/Gui.lua new file mode 100644 index 0000000..99bb9c8 --- /dev/null +++ b/src/Chemical/Types/Gui.lua @@ -0,0 +1,360 @@ +type Stateful = { set: (T) -> (), get: () -> (T), __entity: number } + +export type GuiBaseProperties = { + Name: (Stateful | string)?, + Visible: (Stateful | boolean)?, + Active: (Stateful | boolean)?, + AnchorPoint: (Stateful | Vector2)?, + Position: (Stateful | UDim2)?, + Size: (Stateful | UDim2)?, + Rotation: (Stateful | number)?, + ZIndex: (Stateful | number)?, + LayoutOrder: (Stateful | number)?, + BackgroundTransparency: (Stateful | number)?, + BackgroundColor3: (Stateful | Color3)?, + BorderSizePixel: (Stateful | number)?, + BorderColor3: (Stateful | Color3)?, + ClipsDescendants: (Stateful | boolean)?, + Selectable: (Stateful | boolean)?, + Parent: GuiObject?, + Children: { [number]: Instance | Stateful }, + + [any]: (() -> ())? +} + +type GuiBaseEvents = { + InputBegan: (input: InputObject, gameProcessed: boolean) -> (), + InputEnded: (input: InputObject, gameProcessed: boolean) -> (), + InputChanged: (input: InputObject, gameProcessed: boolean) -> (), + + -- Mouse Events + MouseEnter: () -> (), + MouseLeave: () -> (), + MouseMoved: (deltaX: number, deltaY: number) -> (), + MouseWheelForward: (scrollDelta: number) -> (), + MouseWheelBackward: (scrollDelta: number) -> (), + + -- Touch Events + TouchTap: (touchPositions: {Vector2}, state: Enum.UserInputState) -> (), + TouchPinch: (scale: number, velocity: number, state: Enum.UserInputState) -> (), + TouchPan: (pan: Vector2, velocity: Vector2, state: Enum.UserInputState) -> (), + TouchSwipe: (direction: Enum.SwipeDirection, touches: number) -> (), + TouchRotate: (rotation: number, velocity: number, state: Enum.UserInputState) -> (), + TouchLongPress: (duration: number) -> (), + + -- Console/Selection Events + SelectionGained: () -> (), + SelectionLost: () -> (), + SelectionChanged: (newSelection: Instance) -> (), +} + +type ImageGuiProperties = GuiBaseProperties & { + Image: (Stateful | string)?, + ImageColor3: (Stateful | Color3)?, + ImageTransparency: (Stateful | number)?, + ScaleType: (Stateful | Enum.ScaleType)?, + SliceCenter: (Stateful | Rect)?, + TileSize: (Stateful | UDim2)?, + ResampleMode: (Stateful | Enum.ResamplerMode)?, +} + +type TextGuiProperties = GuiBaseProperties & { + Text: (Stateful | string)?, + TextColor3: (Stateful | Color3)?, + TextTransparency: (Stateful | number)?, + TextStrokeColor3: (Stateful | Color3)?, + TextStrokeTransparency: (Stateful | number)?, + TextScaled: (Stateful | boolean)?, + TextSize: (Stateful | number)?, + TextWrapped: (Stateful | boolean)?, + FontFace: (Stateful | Font)?, + LineHeight: (Stateful | number)?, + RichText: (Stateful | boolean)?, + TextXAlignment: (Stateful | Enum.TextXAlignment)?, + TextYAlignment: (Stateful | Enum.TextYAlignment)?, + TextTruncate: (Stateful | Enum.TextTruncate)?, + [any]: (() -> ())?, +} + +export type FrameProperties = GuiBaseProperties +export type TextLabelProperties = TextGuiProperties +export type ImageLabelProperties = ImageGuiProperties + +-- Interactive Elements +type ButtonEvents = GuiBaseEvents & { + Activated: (inputType: Enum.UserInputType?) -> (), + MouseButton1Click: () -> (), + MouseButton2Click: () -> (), + MouseButton2Down: () -> (), + MouseButton2Up: () -> (), + + MouseWheelForward: nil, + MouseWheelBackward: nil, +} + +export type ButtonProperties = { + AutoButtonColor: (Stateful | boolean)?, + Modal: (Stateful | boolean)?, + Selected: (Stateful | boolean)?, + + ButtonHoverStyle: (Stateful | Enum.ButtonStyle)?, + ButtonPressStyle: (Stateful | Enum.ButtonStyle)?, + ActivationBehavior: (Stateful | Enum.ActivationBehavior)?, + + SelectionGroup: (Stateful | number)?, + SelectionBehaviorUp: (Stateful | Enum.SelectionBehavior)?, + SelectionBehaviorDown: (Stateful | Enum.SelectionBehavior)?, + SelectionBehaviorLeft: (Stateful | Enum.SelectionBehavior)?, + SelectionBehaviorRight: (Stateful | Enum.SelectionBehavior)?, + GamepadPriority: (Stateful | number)?, +} + + +export type TextButtonProperties = TextGuiProperties & ButtonProperties +export type ImageButtonProperties = ImageGuiProperties & ButtonProperties + +type TextBoxEvents = GuiBaseEvents & { + FocusLost: (enterPressed: boolean) -> (), + FocusGained: () -> (), + TextChanged: (text: string) -> (), +} + +export type TextBoxProperties = TextGuiProperties & { + ClearTextOnFocus: (Stateful | boolean)?, + MultiLine: (Stateful | boolean)?, + PlaceholderText: (Stateful | string)?, + PlaceholderColor3: (Stateful | Color3)?, + CursorPosition: (Stateful | number)?, + SelectionStart: (Stateful | number)?, + ShowNativeInput: (Stateful | boolean)?, + TextInputType: (Stateful | Enum.TextInputType)?, +} + + +-- Containers +type ScrollingFrameEvents = GuiBaseEvents & { + Scrolled: (scrollVelocity: Vector2) -> (), +} + +export type ScrollingFrameProperties = FrameProperties & { + ScrollBarImageColor3: (Stateful | Color3)?, + ScrollBarThickness: (Stateful | number)?, + ScrollingDirection: (Stateful | Enum.ScrollingDirection)?, + CanvasSize: (Stateful | UDim2)?, + CanvasPosition: (Stateful | Vector2)?, + AutomaticCanvasSize: (Stateful | Enum.AutomaticSize)?, + VerticalScrollBarInset: (Stateful | Enum.ScrollBarInset)?, + HorizontalScrollBarInset: (Stateful | Enum.ScrollBarInset)?, + ScrollBarImageTransparency: (Stateful | number)?, + ElasticBehavior: (Stateful | Enum.ElasticBehavior)?, + VerticalScrollBarPosition: (Stateful | Enum.VerticalScrollBarPosition)?, +} + +type ViewportFrameEvents = GuiBaseEvents & { + ViewportResized: (newSize: Vector2) -> (), + CameraChanged: (newCamera: Camera) -> (), +} + +export type ViewportFrameProperties = FrameProperties & { + CurrentCamera: (Stateful | Camera)?, + ImageColor3: (Stateful | Color3)?, + LightColor: (Stateful | Color3)?, + LightDirection: (Stateful | Vector3)?, + Ambient: (Stateful | Color3)?, + LightAngularInfluence: (Stateful | number)?, +} + +-- Layouts +export type UIListLayoutProperties = { + Padding: (Stateful | UDim)?, + FillDirection: (Stateful | Enum.FillDirection)?, + HorizontalAlignment: (Stateful | Enum.HorizontalAlignment)?, + VerticalAlignment: (Stateful | Enum.VerticalAlignment)?, + SortOrder: (Stateful | Enum.SortOrder)?, + Appearance: (Stateful | Enum.Appearance)?, +} + +export type UIGridLayoutProperties = { + CellSize: (Stateful | UDim2)?, + CellPadding: (Stateful | UDim2)?, + StartCorner: (Stateful | Enum.StartCorner)?, + FillDirection: (Stateful | Enum.FillDirection)?, + HorizontalAlignment: (Stateful | Enum.HorizontalAlignment)?, + VerticalAlignment: (Stateful | Enum.VerticalAlignment)?, + SortOrder: (Stateful | Enum.SortOrder)?, +} + +-- Style Elements +export type UICornerProperties = { + CornerRadius: (Stateful | UDim)?, +} + +export type UIStrokeProperties = { + Color: (Stateful | Color3)?, + Thickness: (Stateful | number)?, + Transparency: (Stateful | number)?, + Enabled: (Stateful | boolean)?, + ApplyStrokeMode: (Stateful | Enum.ApplyStrokeMode)?, + LineJoinMode: (Stateful | Enum.LineJoinMode)?, +} + +export type UIGradientProperties = { + Color: (Stateful | ColorSequence)?, + Transparency: (Stateful | NumberSequence)?, + Offset: (Stateful | Vector2)?, + Rotation: (Stateful | number)?, + Enabled: (Stateful | boolean)?, +} + +export type UIPaddingProperties = { + PaddingTop: (Stateful | UDim)?, + PaddingBottom: (Stateful | UDim)?, + PaddingLeft: (Stateful | UDim)?, + PaddingRight: (Stateful | UDim)?, +} + +export type UIScaleProperties = { + Scale: (Stateful | number)?, +} + + +type CanvasMouseEvents = GuiBaseEvents & { + MouseWheel: (direction: Enum.MouseWheelDirection, delta: number) -> (), +} + +export type CanvasGroupProperties = { + GroupTransparency: (Stateful | number)?, + GroupColor3: (Stateful | Color3)?, +} & CanvasMouseEvents + +-- Constraints +export type UIAspectRatioConstraintProperties = { + AspectRatio: (Stateful | number)?, + AspectType: (Stateful | Enum.AspectType)?, + DominantAxis: (Stateful | Enum.DominantAxis)?, +} + +export type UISizeConstraintProperties = { + MinSize: (Stateful | Vector2)?, + MaxSize: (Stateful | Vector2)?, +} + +-- Specialized +export type BillboardGuiProperties = GuiBaseProperties & { + Active: (Stateful | boolean)?, + AlwaysOnTop: (Stateful | boolean)?, + LightInfluence: (Stateful | number)?, + MaxDistance: (Stateful | number)?, + SizeOffset: (Stateful | Vector2)?, + StudsOffset: (Stateful | Vector3)?, + ExtentsOffset: (Stateful | Vector3)?, +} + +export type SurfaceGuiProperties = GuiBaseProperties & { + Active: (Stateful | boolean)?, + AlwaysOnTop: (Stateful | boolean)?, + Brightness: (Stateful | number)?, + CanvasSize: (Stateful | Vector2)?, + Face: (Stateful | Enum.NormalId)?, + LightInfluence: (Stateful | number)?, + PixelsPerStud: (Stateful | number)?, + SizingMode: (Stateful | Enum.SurfaceGuiSizingMode)?, + ToolPunchThroughDistance: (Stateful | number)?, +} + +export type ScreenGuiProperties = GuiBaseProperties & { + Active: (Stateful | boolean)?, + AlwaysOnTop: (Stateful | boolean)?, + Brightness: (Stateful | number)?, + DisplayOrder: (Stateful | number)?, + IgnoreGuiInset: (Stateful | boolean)?, + OnTopOfCoreBlur: (Stateful | boolean)?, + ScreenInsets: (Stateful | Enum.ScreenInsets)?, + ZIndexBehavior: (Stateful | Enum.ZIndexBehavior)?, +} + +export type EventNames = ( + "InputBegan" | "InputEnded" | "InputChanged" | + "MouseEnter" | "MouseLeave" | "MouseMoved" | + "MouseButton1Down" | "MouseButton1Up" | + "MouseWheelForward" | "MouseWheelBackward" | + + "TouchTap" | "TouchPinch" | "TouchPan" | + "TouchSwipe" | "TouchRotate" | "TouchLongPress" | + + "SelectionGained" | "SelectionLost" | "SelectionChanged" | + + "Activated" | "MouseButton1Click" | "MouseButton2Click" | + "MouseButton2Down" | "MouseButton2Up" | + + "FocusLost" | "FocusGained" | "TextChanged" | + + "Scrolled" | + + "ViewportResized" | "CameraChanged" | + + "BillboardTransformed" | + + "SurfaceChanged" | + + "GroupTransparencyChanged" | + + "StrokeUpdated" | + + "GradientOffsetChanged" | + + "ChildAdded" | "ChildRemoved" | "AncestryChanged" +) + +export type PropertyNames = ( + "Name" | "Visible" | "Active" | "AnchorPoint" | "Position" | "Size" | + "Rotation" | "ZIndex" | "LayoutOrder" | "BackgroundTransparency" | + "BackgroundColor3" | "BorderSizePixel" | "BorderColor3" | + "ClipsDescendants" | "Selectable" | + + "Image" | "ImageColor3" | "ImageTransparency" | "ScaleType" | + "SliceCenter" | "TileSize" | "ResampleMode" | + + "Text" | "TextColor3" | "TextTransparency" | "TextStrokeColor3" | + "TextStrokeTransparency" | "TextScaled" | "TextSize" | "TextWrapped" | + "FontFace" | "LineHeight" | "RichText" | "TextXAlignment" | + "TextYAlignment" | "TextTruncate" | + + "AutoButtonColor" | "Modal" | "Selected" | "ButtonHoverStyle" | + "ButtonPressStyle" | "ActivationBehavior" | "SelectionGroup" | + "SelectionBehaviorUp" | "SelectionBehaviorDown" | + "SelectionBehaviorLeft" | "SelectionBehaviorRight" | "GamepadPriority" | + + "ClearTextOnFocus" | "MultiLine" | "PlaceholderText" | + "PlaceholderColor3" | "CursorPosition" | "SelectionStart" | + "ShowNativeInput" | "TextInputType" | + + "ScrollBarImageColor3" | "ScrollBarThickness" | "ScrollingDirection" | + "CanvasSize" | "CanvasPosition" | "AutomaticCanvasSize" | + "VerticalScrollBarInset" | "HorizontalScrollBarInset" | + "ScrollBarImageTransparency" | "ElasticBehavior" | "VerticalScrollBarPosition" | + + "CurrentCamera" | "LightColor" | "LightDirection" | "Ambient" | + "LightAngularInfluence" | + + "Padding" | "FillDirection" | "HorizontalAlignment" | "VerticalAlignment" | + "SortOrder" | "Appearance" | "CellSize" | "CellPadding" | "StartCorner" | + + "CornerRadius" | "Color" | "Thickness" | "Transparency" | "Enabled" | + "ApplyStrokeMode" | "LineJoinMode" | "Offset" | "Rotation" | + "PaddingTop" | "PaddingBottom" | "PaddingLeft" | "PaddingRight" | "Scale" | + + "GroupTransparency" | "GroupColor3" | + + "AspectRatio" | "AspectType" | "DominantAxis" | "MinSize" | "MaxSize" | + + "AlwaysOnTop" | "LightInfluence" | "MaxDistance" | "SizeOffset" | + "StudsOffset" | "ExtentsOffset" | + + "Brightness" | "Face" | "PixelsPerStud" | "SizingMode" | "ToolPunchThroughDistance" | + + "Parent" | "Children" +) + + +return {} \ No newline at end of file diff --git a/src/Chemical/Types/Overrides.lua b/src/Chemical/Types/Overrides.lua new file mode 100644 index 0000000..e4ae643 --- /dev/null +++ b/src/Chemical/Types/Overrides.lua @@ -0,0 +1,66 @@ +--!strict +local Gui = require(script.Parent.Gui) + +-- Define the custom method we're adding. +type CompositionHandle = { + Destroy: (self: CompositionHandle) -> () +} + +-- The factory function now returns an intersection type. +-- It tells Luau "this object has all the properties of P AND all the properties of CompositionHandle". +type ComposerFactory

= (blueprint: P) -> (P & CompositionHandle) + +-- The overloads remain the same, but their return type is now more powerful. +export type ComposeFunction = ( + -- Overloads for creating new instances via class name strings + ((target: "Frame") -> ComposerFactory) & + ((target: "TextLabel") -> ComposerFactory) & + ((target: "ImageLabel") -> ComposerFactory) & + ((target: "TextButton") -> ComposerFactory) & + ((target: "ImageButton") -> ComposerFactory) & + ((target: "TextBox") -> ComposerFactory) & + ((target: "ScrollingFrame") -> ComposerFactory) & + ((target: "ViewportFrame") -> ComposerFactory) & + ((target: "CanvasGroup") -> ComposerFactory) & + ((target: "UIListLayout") -> ComposerFactory) & + ((target: "UIGridLayout") -> ComposerFactory) & + ((target: "UICorner") -> ComposerFactory) & + ((target: "UIStroke") -> ComposerFactory) & + ((target: "UIGradient") -> ComposerFactory) & + ((target: "UIPadding") -> ComposerFactory) & + ((target: "UIScale") -> ComposerFactory) & + ((target: "UIAspectRatioConstraint") -> ComposerFactory) & + ((target: "UISizeConstraint") -> ComposerFactory) & + ((target: "BillboardGui") -> ComposerFactory) & + ((target: "SurfaceGui") -> ComposerFactory) & + ((target: "ScreenGui") -> ComposerFactory) & + + -- Overloads for adopting existing instances + ((target: Frame) -> ComposerFactory) & + ((target: TextLabel) -> ComposerFactory) & + ((target: ImageLabel) -> ComposerFactory) & + ((target: TextButton) -> ComposerFactory) & + ((target: ImageButton) -> ComposerFactory) & + ((target: TextBox) -> ComposerFactory) & + ((target: ScrollingFrame) -> ComposerFactory) & + ((target: ViewportFrame) -> ComposerFactory) & + ((target: CanvasGroup) -> ComposerFactory) & + ((target: UIListLayout) -> ComposerFactory) & + ((target: UIGridLayout) -> ComposerFactory) & + ((target: UICorner) -> ComposerFactory) & + ((target: UIStroke) -> ComposerFactory) & + ((target: UIGradient) -> ComposerFactory) & + ((target: UIPadding) -> ComposerFactory) & + ((target: UIScale) -> ComposerFactory) & + ((target: UIAspectRatioConstraint) -> ComposerFactory) & + ((target: UISizeConstraint) -> ComposerFactory) & + ((target: BillboardGui) -> ComposerFactory) & + ((target: SurfaceGui) -> ComposerFactory) & + ((target: ScreenGui) -> ComposerFactory) & + + -- Fallback overloads for generic/unspecified types + ((target: string) -> ComposerFactory) & + ((target: GuiObject) -> ComposerFactory) +) + +return {} \ No newline at end of file diff --git a/src/Chemical/Types/init.lua b/src/Chemical/Types/init.lua new file mode 100644 index 0000000..2bc3ec3 --- /dev/null +++ b/src/Chemical/Types/init.lua @@ -0,0 +1,17 @@ +local ECS = require(script.Parent.Packages.JECS) + +local module = {} + +export type HasEntity = { + entity: ECS.Entity +} + +export type MaybeDestroyable = { + __internalDestroy: (MaybeDestroyable) -> (), +} + +export type MaybeCleanable = { + clean: (MaybeCleanable) -> () +} + +return module diff --git a/src/Chemical/init.lua b/src/Chemical/init.lua new file mode 100644 index 0000000..f15cd10 --- /dev/null +++ b/src/Chemical/init.lua @@ -0,0 +1,66 @@ +--!strict + +--version 0.2.5 == Sovereignty + +--TODO: + -- Reactors and Reactions: Tables and Maps change networking, rather than full value networking on change. + -- Export Types + -- Improve file organization + -- Templating + -- Entity recycling - if possible + +local ECS = require(script.ECS) +local Overrides = require(script.Types.Overrides) + +local Value = require(script.Factories.Value) +local Table = require(script.Factories.Table) +local Map = require(script.Factories.Map) +local Computed = require(script.Factories.Computed) +local Observer = require(script.Factories.Observer) +local Watch = require(script.Factories.Watch) +local Effect = require(script.Factories.Effect) +local Reaction = require(script.Factories.Reaction) + + +local Compose = require(script.Functions.Compose) + + +local Is = require(script.Functions.Is) +local Peek = require(script.Functions.Peek) +local Array = require(script.Functions.Array) +local Alive = require(script.Functions.Alive) +local Destroy = require(script.Functions:FindFirstChild("Destroy")) +local Blueprint = require(script.Functions.Blueprint) + +local Symbols = require(script.Symbols) + +local Scheduler = require(script.Singletons.Scheduler) +local Reactor = require(script.Singletons.Reactor) + + +return { + Value = (Value :: any) :: Value.ValueFactory, + Table = Table, + Map = Map, + Computed = Computed, + Observer = Observer, + Watch = Watch, + Effect = Effect, + Reaction = Reaction, + + + Compose = (Compose :: any) :: Overrides.ComposeFunction, + Reactor = Reactor, + + Is = Is, + Peek = Peek, + Array = Array, + Alive = Alive, + Destroy = Destroy, + Blueprint = Blueprint, + + + OnEvent = Symbols.OnEvent, + OnChange = Symbols.OnChange, + Children = Symbols.Children +} \ No newline at end of file