├── selene.toml ├── .gitattributes ├── src ├── Tags │ ├── init.lua │ ├── TagWatcher.spec.lua │ └── TagWatcher.lua ├── Shared │ ├── Symbol.lua │ ├── ClientTestRPC.lua │ └── Util.lua ├── init.lua ├── Replication │ ├── getOrCreate.lua │ ├── init.lua │ ├── Util.lua │ ├── Replicator.lua │ ├── Transmitter.spec │ │ ├── TransmitterClient.spec.lua │ │ └── init.lua │ ├── ClientTransmitter.lua │ └── ServerTransmitter.lua ├── Fabric │ ├── makeEnum.lua │ ├── isAllowedOnRef.lua │ ├── Operators │ │ ├── Comparators.lua │ │ ├── Reducers.spec.lua │ │ ├── Comparators.spec.lua │ │ └── Reducers.lua │ ├── BuiltInSerializers.lua │ ├── Types.lua │ ├── HotReloader.lua │ ├── Serializer.lua │ ├── Reactor.lua │ ├── init.spec.lua │ ├── UnitCollection.lua │ ├── init.lua │ ├── Unit.lua │ └── Unit.spec.lua ├── ServiceUnits │ └── init.lua ├── Batching │ ├── SinglePromiseEvent.lua │ ├── init.spec.lua │ └── init.lua └── Roact │ ├── init.lua │ └── FabricComponent.lua ├── .editorconfig ├── testRunner └── runTests.server.lua ├── example ├── server │ ├── init.server.lua │ └── units │ │ └── Door.lua └── client │ ├── clientRun.client.lua │ └── units │ └── Door.lua ├── rotriever.toml ├── clientTests └── init.client.lua ├── .gitignore ├── rotriever.lock ├── testez.toml ├── LICENSE ├── default.project.json └── README.md /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox+testez" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/Tags/init.lua: -------------------------------------------------------------------------------- 1 | local TagWatcher = require(script.TagWatcher) 2 | 3 | return function (fabric) 4 | fabric.tags = TagWatcher.new(fabric) 5 | end 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.lua] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false -------------------------------------------------------------------------------- /testRunner/runTests.server.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | require(ReplicatedStorage.Packages.TestEZ).TestBootstrap:run({ 4 | ReplicatedStorage.Packages.Fabric 5 | }) -------------------------------------------------------------------------------- /src/Shared/Symbol.lua: -------------------------------------------------------------------------------- 1 | local Symbol = { 2 | _symbols = {} 3 | } 4 | 5 | function Symbol.named(name) 6 | Symbol._symbols[name] = Symbol._symbols[name] or {} 7 | 8 | return Symbol._symbols[name] 9 | end 10 | 11 | return Symbol -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | return { 2 | Fabric = require(script.Fabric); 3 | useTags = require(script.Tags); 4 | useReplication = require(script.Replication); 5 | useRoact = require(script.Roact); 6 | useServiceUnits = require(script.ServiceUnits); 7 | useBatching = require(script.Batching); 8 | } -------------------------------------------------------------------------------- /example/server/init.server.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local FabricLib = require(ReplicatedStorage.Packages.Fabric) 4 | 5 | local fabric = FabricLib.Fabric.new("example") 6 | FabricLib.useReplication(fabric) 7 | FabricLib.useTags(fabric) 8 | 9 | fabric:registerUnitsIn(script.units) -------------------------------------------------------------------------------- /rotriever.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "evaera/fabric" 3 | version = "0.0.0" 4 | author = "evaera" 5 | content_root = "src" 6 | license = "MIT" 7 | 8 | [dependencies] 9 | t = { git = "https://github.com/Roblox/t" } 10 | TestEZ = { git = "https://github.com/Roblox/testez" } 11 | Promise = { git = "https://github.com/evaera/roblox-lua-promise" } -------------------------------------------------------------------------------- /src/Replication/getOrCreate.lua: -------------------------------------------------------------------------------- 1 | local function getOrCreate(parent, name, class) 2 | local instance = parent:FindFirstChild(name) 3 | 4 | if not instance then 5 | instance = Instance.new(class) 6 | instance.Name = name 7 | instance.Parent = parent 8 | end 9 | 10 | return instance 11 | end 12 | 13 | return { 14 | getOrCreate = getOrCreate 15 | } -------------------------------------------------------------------------------- /clientTests/init.client.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local tests = {} 3 | 4 | function tests.invoke_test(...) 5 | require(ReplicatedStorage.Packages.TestEZ).TestBootstrap:run(...) 6 | return 7 | end 8 | 9 | ReplicatedStorage:WaitForChild("__TestRPC").OnClientInvoke = function(name, ...) 10 | return tests[name](...) 11 | end -------------------------------------------------------------------------------- /example/client/clientRun.client.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local FabricLib = require(ReplicatedStorage.Packages.Fabric) 4 | 5 | local fabric = FabricLib.Fabric.new("example") 6 | FabricLib.useReplication(fabric) 7 | FabricLib.useTags(fabric) 8 | FabricLib.useBatching(fabric) 9 | 10 | fabric.DEBUG = false 11 | 12 | fabric:registerUnitsIn(ReplicatedStorage.Packages.exampleClientUnits) -------------------------------------------------------------------------------- /src/Replication/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local ServerReplicator = require(script.ServerTransmitter) 4 | local ClientReplicator = require(script.ClientTransmitter) 5 | local registerReplicator = require(script.Replicator) 6 | 7 | return function (fabric) 8 | registerReplicator(fabric) 9 | 10 | fabric.transmitter = (RunService:IsServer() and ServerReplicator or ClientReplicator).new(fabric) 11 | 12 | end 13 | -------------------------------------------------------------------------------- /src/Shared/ClientTestRPC.lua: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local __TestRPC = Instance.new("RemoteFunction") 5 | __TestRPC.Name = "__TestRPC" 6 | __TestRPC.Parent = ReplicatedStorage 7 | 8 | return function(name, ...) 9 | local plr = Players:GetPlayers()[1] 10 | while not plr do 11 | wait() 12 | plr = Players:GetPlayers()[1] 13 | end 14 | return __TestRPC:InvokeClient(plr, name, ...) 15 | end -------------------------------------------------------------------------------- /src/Fabric/makeEnum.lua: -------------------------------------------------------------------------------- 1 | local function makeEnum(enumName, members) 2 | local enum = {} 3 | 4 | for _, memberName in ipairs(members) do 5 | enum[memberName] = memberName 6 | end 7 | 8 | return setmetatable(enum, { 9 | __index = function(_, k) 10 | error(string.format("%s is not in %s!", k, enumName), 2) 11 | end, 12 | __newindex = function() 13 | error(string.format("Creating new members in %s is not allowed!", enumName), 2) 14 | end, 15 | }) 16 | end 17 | 18 | return { 19 | makeEnum = makeEnum; 20 | } -------------------------------------------------------------------------------- /example/server/units/Door.lua: -------------------------------------------------------------------------------- 1 | return { 2 | name = "Door"; 3 | tag = "Door"; 4 | 5 | units = { 6 | Replicated = {}; 7 | }; 8 | 9 | defaults = { 10 | transparency = 0; 11 | }; 12 | 13 | onInitialize = function(self) 14 | 15 | end; 16 | 17 | onClientSetTransparency = function(self, _player, amount) 18 | if math.random() > 0.2 then 19 | self:addLayer(self, { 20 | transparency = amount 21 | }) 22 | else 23 | print("Ignore") 24 | end 25 | end; 26 | 27 | onUpdated = function(self) 28 | print(self.ref.Name, "updated") 29 | end; 30 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | .vscode 43 | build 44 | Packages 45 | roblox.toml -------------------------------------------------------------------------------- /src/Replication/Util.lua: -------------------------------------------------------------------------------- 1 | local Util = {} 2 | 3 | function Util.clipMask(data, mask) 4 | assert(type(data) == "table", "Attempt to mask on a non-table!") 5 | 6 | local clippedData = {} 7 | 8 | for k, v in pairs(data) do 9 | if mask[k] == true then 10 | clippedData[k] = v 11 | elseif type(mask[k]) == "table" then 12 | clippedData[k] = Util.clipMask(data[k], mask[k]) 13 | end 14 | end 15 | 16 | return clippedData 17 | end 18 | 19 | function Util.find(array, value) 20 | for i, v in ipairs(array) do 21 | if v == value then 22 | return i 23 | end 24 | end 25 | end 26 | 27 | return Util 28 | -------------------------------------------------------------------------------- /src/Fabric/isAllowedOnRef.lua: -------------------------------------------------------------------------------- 1 | local function makeClassCheckFromArray(array) 2 | return function(ref) 3 | for _, className in ipairs(array) do 4 | if ref:IsA(className) then 5 | return true 6 | end 7 | end 8 | 9 | return 10 | false, 11 | ("Ref type %q is not allowed to have this unit!") 12 | :format(tostring(ref)) 13 | end 14 | end 15 | 16 | local function isAllowedOnRef(staticUnit, ref) 17 | if staticUnit.refCheck == nil then 18 | return true 19 | end 20 | 21 | if type(staticUnit.refCheck) == "table" then 22 | staticUnit.refCheck = makeClassCheckFromArray(staticUnit.refCheck) 23 | end 24 | 25 | return staticUnit.refCheck(ref) 26 | end 27 | 28 | return { 29 | isAllowedOnRef = isAllowedOnRef; 30 | } -------------------------------------------------------------------------------- /src/ServiceUnits/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | return function(fabric) 4 | local deferredCreation 5 | 6 | fabric:on("unitRegistered", function(staticUnit) 7 | if staticUnit.isService == true then 8 | if deferredCreation == nil then 9 | deferredCreation = {} 10 | 11 | local connection 12 | 13 | connection = RunService.Heartbeat:Connect(function() 14 | connection:Disconnect() 15 | 16 | for _, staticUnitToCreate in ipairs(deferredCreation) do 17 | fabric:getOrCreateUnitByRef(staticUnitToCreate, game):mergeBaseLayer({}) 18 | end 19 | 20 | deferredCreation = nil 21 | end) 22 | end 23 | 24 | table.insert(deferredCreation, staticUnit) 25 | end 26 | end) 27 | end -------------------------------------------------------------------------------- /src/Fabric/Operators/Comparators.lua: -------------------------------------------------------------------------------- 1 | local Util = require(script.Parent.Parent.Parent.Shared.Util) 2 | 3 | local COMPARATOR_NEAR_DEFAULT = 0.001 4 | 5 | local Comparators = {} 6 | 7 | function Comparators.reference(a, b) 8 | return a ~= b 9 | end 10 | 11 | function Comparators.value(a, b) 12 | return not Util.deepEquals(a, b) 13 | end 14 | 15 | function Comparators.within(epsilon) 16 | return function(a, b) 17 | return math.abs(a - b) > epsilon 18 | end 19 | end 20 | 21 | function Comparators.structure(propertyComparators) 22 | return function (a, b) 23 | for _, item in ipairs({a, b}) do 24 | for key in pairs(item) do 25 | if (propertyComparators[key] or Comparators.default)(a and a[key], b and b[key]) then 26 | return true 27 | end 28 | end 29 | end 30 | 31 | return false 32 | end 33 | end 34 | 35 | Comparators.near = Comparators.within(COMPARATOR_NEAR_DEFAULT) 36 | 37 | Comparators.default = Comparators.reference 38 | 39 | return Comparators 40 | -------------------------------------------------------------------------------- /src/Batching/SinglePromiseEvent.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Parent.Promise) 2 | 3 | local SinglePromiseEvent = {} 4 | SinglePromiseEvent.__index = SinglePromiseEvent 5 | 6 | function SinglePromiseEvent.new(executor) 7 | local self = setmetatable({ 8 | _listener = nil, 9 | }, SinglePromiseEvent) 10 | 11 | local function fire() 12 | if self._listener then 13 | coroutine.wrap(self._listener)() 14 | end 15 | end 16 | 17 | self._promise = Promise.defer(function(resolve) 18 | resolve(Promise.new(executor(fire)):andThen(function() 19 | self._listener = nil 20 | end)) 21 | end) 22 | 23 | return self 24 | end 25 | 26 | function SinglePromiseEvent:Connect(callback) 27 | assert(self._listener == nil, "SinglePromiseEvent is already used up") 28 | assert(self._promise:getStatus() == "Started", "SinglePromiseEvent is already used up") 29 | 30 | self._listener = callback 31 | return { 32 | Disconnect = function() 33 | self._promise:cancel() 34 | self._listener = nil 35 | end 36 | } 37 | end 38 | 39 | return SinglePromiseEvent -------------------------------------------------------------------------------- /rotriever.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by rotriever. 2 | # It is not intended for manual editing. 3 | lockfile_format_version = 3 4 | 5 | [[package]] 6 | name = "evaera/fabric" 7 | version = "0.0.0" 8 | dependencies = [ 9 | "Promise evaera/roblox-lua-promise 33736a32 git+https://github.com/evaera/roblox-lua-promise#master", 10 | "TestEZ roblox/testez 0a816380 git+https://github.com/roblox/testez#master", 11 | "t osyris/t d4294924 git+https://github.com/roblox/t#master", 12 | ] 13 | 14 | [[package]] 15 | name = "evaera/roblox-lua-promise" 16 | version = "3.0.1" 17 | source = "git+https://github.com/evaera/roblox-lua-promise#master" 18 | commit = "33736a32545408c7e35f27fae56c8312aab0c4d5" 19 | 20 | [[package]] 21 | name = "osyris/t" 22 | version = "1.2.5" 23 | source = "git+https://github.com/roblox/t#master" 24 | commit = "d42949248d50c13bf71188649695b6bb5493d6a5" 25 | 26 | [[package]] 27 | name = "roblox/testez" 28 | version = "0.3.2" 29 | source = "git+https://github.com/roblox/testez#master" 30 | commit = "0a8163801a0842ecc24301457ea167ed1209fedc" 31 | -------------------------------------------------------------------------------- /src/Fabric/BuiltInSerializers.lua: -------------------------------------------------------------------------------- 1 | local Unit = require(script.Parent.Unit) 2 | 3 | return { 4 | serializers = { 5 | [Unit] = function(unit, fabric) 6 | 7 | return { 8 | type = "_unit"; 9 | name = unit.name; 10 | ref = fabric.serializer:serialize(unit.ref); 11 | } 12 | end 13 | }; 14 | 15 | deserializers = { 16 | _unit = function(data, fabric, failMode) 17 | local ref = fabric.serializer:deserialize(data.ref) 18 | 19 | if failMode == fabric.serializer.FailMode.Error then 20 | assert(ref ~= nil, ("Attempt to deserialize a %q unit on a ref that's not present in this realm."):format( 21 | tostring(data.name) 22 | )) 23 | end 24 | 25 | local unit = fabric._collection:getUnitByRef(data.name, ref) 26 | 27 | if unit == nil and failMode == fabric.serializer.FailMode.Error then 28 | error( 29 | ("Attempt to deserialize unit %q on %q, but it does not exist in this realm."):format( 30 | tostring(data.name), 31 | tostring(ref) 32 | ) 33 | ) 34 | end 35 | 36 | return unit 37 | end 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /testez.toml: -------------------------------------------------------------------------------- 1 | # TestEZ 2 | [[afterAll.args]] 3 | type = "function" 4 | 5 | [[afterEach.args]] 6 | type = "function" 7 | 8 | [[beforeAll.args]] 9 | type = "function" 10 | 11 | [[beforeEach.args]] 12 | type = "function" 13 | 14 | [[describe.args]] 15 | type = "string" 16 | 17 | [[describe.args]] 18 | type = "function" 19 | 20 | [[describeFOCUS.args]] 21 | type = "string" 22 | 23 | [[describeFOCUS.args]] 24 | type = "function" 25 | 26 | [[describeSKIP.args]] 27 | type = "string" 28 | 29 | [[describeSKIP.args]] 30 | type = "function" 31 | 32 | [[expect.args]] 33 | type = "any" 34 | 35 | [[FIXME.args]] 36 | type = "string" 37 | required = false 38 | 39 | [FOCUS] 40 | args = [] 41 | 42 | [[it.args]] 43 | type = "string" 44 | 45 | [[it.args]] 46 | type = "function" 47 | 48 | [[itFIXME.args]] 49 | type = "string" 50 | 51 | [[itFIXME.args]] 52 | type = "function" 53 | 54 | [[itFOCUS.args]] 55 | type = "string" 56 | 57 | [[itFOCUS.args]] 58 | type = "function" 59 | 60 | [[itSKIP.args]] 61 | type = "string" 62 | 63 | [[itSKIP.args]] 64 | type = "function" 65 | 66 | [SKIP] 67 | args = [] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Eryn Lynn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Fabric/Types.lua: -------------------------------------------------------------------------------- 1 | local t = require(script.Parent.Parent.Parent.t) 2 | 3 | local Types = {} 4 | 5 | Types.UnitDefinition = t.interface({ 6 | -- User implementations 7 | name = t.string; 8 | reducer = t.optional(t.callback); 9 | schema = t.optional(t.callback); 10 | defaults = t.optional(t.map(t.string, t.any)); 11 | units = t.optional(t.map(t.string, t.any)); 12 | refCheck = t.optional(t.union(t.array(t.string), t.callback)); 13 | shouldUpdate = t.optional(t.callback); 14 | 15 | -- Reserved Properties 16 | data = t.none; 17 | lastData = t.none; 18 | fabric = t.none; 19 | fire = t.none; 20 | on = t.none; 21 | ref = t.none; 22 | isLoaded = t.none; 23 | setIsLoading = t.none; 24 | get = t.none; 25 | 26 | -- Events 27 | onLoaded = t.optional(t.callback); 28 | onUpdated = t.optional(t.callback); 29 | initialize = t.optional(t.callback); 30 | destroy = t.optional(t.callback); 31 | render = t.optional(t.callback); 32 | 33 | effects = t.optional(t.map(t.any, t.callback)); 34 | 35 | -- Extensions 36 | tag = t.optional(t.string); 37 | chainingEvents = t.optional(t.array(t.string)); 38 | isService = t.optional(t.boolean); 39 | }) 40 | 41 | return Types -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fabric", 3 | "servePort": 12345, 4 | "tree": { 5 | "$className": "DataModel", 6 | "Workspace": { 7 | "$className": "Workspace", 8 | "Baseplate": { 9 | "$className": "Part", 10 | "$properties": { 11 | "Size": [ 12 | 400, 13 | 5, 14 | 400 15 | ], 16 | "Anchored": true 17 | } 18 | } 19 | }, 20 | "ReplicatedStorage": { 21 | "$className": "ReplicatedStorage", 22 | "Packages": { 23 | "Fabric": { 24 | "$path": "src" 25 | }, 26 | "$path": "Packages", 27 | "exampleClientUnits": { 28 | "$path": "./example/client/units" 29 | } 30 | } 31 | }, 32 | "ServerScriptService": { 33 | "$className": "ServerScriptService", 34 | "run": { 35 | "$path": "./testRunner/runTests.server.lua" 36 | }, 37 | "example": { 38 | "$path": "./example/server" 39 | } 40 | }, 41 | "StarterPlayer": { 42 | "$className": "StarterPlayer", 43 | "StarterPlayerScripts": { 44 | "$className": "StarterPlayerScripts", 45 | "clientTests": { 46 | "$path": "./clientTests" 47 | }, 48 | "example-client": { 49 | "$path": "./example/client/clientRun.client.lua" 50 | } 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /example/client/units/Door.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | return { 4 | name = "Door"; 5 | tag = "Door"; 6 | 7 | units = { 8 | Replicated = {}; 9 | }; 10 | 11 | onInitialize = function(self) 12 | self.cd = Instance.new("ClickDetector") 13 | self.cd.Parent = self.ref 14 | 15 | self.cd.MouseClick:Connect(function() 16 | local amount = math.random() 17 | self:getUnit("Transmitter"):sendWithPredictiveLayer({ 18 | transparency = amount 19 | }, "setTransparency", amount) 20 | end) 21 | 22 | end; 23 | 24 | batch = function(on) 25 | return { 26 | on.spreadInterval(5, function() 27 | local color = BrickColor.random() 28 | return function(unit) 29 | unit:addLayer("e", { 30 | color = color 31 | }) 32 | end 33 | end), 34 | } 35 | end, 36 | 37 | onUpdated = function(self) 38 | 39 | end, 40 | 41 | effects = { 42 | -- Each effect only runs if the key it accesses with :get actually changes 43 | function(self) 44 | self.ref.Transparency = self:get("transparency") or 0 45 | 46 | self.x = (self.x or 0) + 1 47 | 48 | end, 49 | function(self) 50 | self.ref.BrickColor = self:get("color") or BrickColor.new("Really red") 51 | end, 52 | } 53 | } -------------------------------------------------------------------------------- /src/Replication/Replicator.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local Util = require(script.Parent.Parent.Shared.Util) 3 | local Symbol = require(script.Parent.Parent.Shared.Symbol) 4 | 5 | return function (fabric) 6 | fabric:registerUnit(Util.assign( 7 | {}, 8 | { 9 | name = "Replicated"; 10 | reducer = fabric.reducers.structure({}); 11 | schema = function(value) 12 | return type(value) == "table" 13 | end; 14 | onInitialize = function(self) 15 | self.transmitter = self.ref:getOrCreateUnit("Transmitter") 16 | self.initialBroadcastSent = false 17 | 18 | self:on("destroy", self.transmitter:on("subscriberAdded", function(player) 19 | if self.initialBroadcastSent then 20 | self.transmitter:sendTo(player, "replicate", { 21 | data = self.ref.data 22 | }) 23 | end 24 | end)) 25 | 26 | if RunService:IsClient() then 27 | self.ref:setIsLoading() 28 | 29 | self.transmitter:on("serverReplicate", function(payload) 30 | self.ref:_addLayer(Symbol.named("remote"), payload.data) 31 | end) 32 | end 33 | end; 34 | }, 35 | RunService:IsServer() and { 36 | onLoaded = function(self) 37 | self:on("destroy", self.ref:on("updated", function() 38 | self.initialBroadcastSent = true 39 | 40 | self.transmitter:broadcast("replicate", { 41 | data = self.ref.data; 42 | }) 43 | end)) 44 | end; 45 | } or {} 46 | )) 47 | end -------------------------------------------------------------------------------- /src/Fabric/Operators/Reducers.spec.lua: -------------------------------------------------------------------------------- 1 | local Reducers = require(script.Parent.Reducers) 2 | 3 | return function() 4 | describe("structure", function() 5 | it("should reduce normally", function() 6 | local value = Reducers.structure({ 7 | add = Reducers.add, 8 | multiply = Reducers.multiply, 9 | })({ 10 | { add = 3, multiply = 3, default = 1 }, 11 | { add = 2, multiply = 2, default = 2 }, 12 | }) 13 | 14 | expect(value.add).to.equal(5) 15 | expect(value.multiply).to.equal(6) 16 | expect(value.default).to.equal(2) 17 | end) 18 | 19 | it("should pass through with one value with no params", function() 20 | local layer = { add = 3, multiply = 3, default = 1 } 21 | 22 | local value = Reducers.structure()({ 23 | layer, 24 | }) 25 | 26 | expect(value.add).to.equal(3) 27 | expect(value.multiply).to.equal(3) 28 | expect(value.default).to.equal(1) 29 | 30 | expect(value).to.equal(layer) 31 | end) 32 | 33 | it("should assign with two value", function() 34 | local layer1 = { add = 3, multiply = 3, default = 1 } 35 | local layer2 = { add = 2, default = 2 } 36 | 37 | local value = Reducers.structure()({ 38 | layer1, 39 | layer2 40 | }) 41 | 42 | expect(value.add).to.equal(2) 43 | expect(value.multiply).to.equal(3) 44 | expect(value.default).to.equal(2) 45 | 46 | expect(value).to.never.equal(layer1) 47 | expect(value).to.never.equal(layer2) 48 | end) 49 | end) 50 | end -------------------------------------------------------------------------------- /src/Tags/TagWatcher.spec.lua: -------------------------------------------------------------------------------- 1 | local CollectionService = game:GetService("CollectionService") 2 | local Workspace = game:GetService("Workspace") 3 | local FabricLib = require(script.Parent.Parent) 4 | local Fabric = FabricLib.Fabric 5 | 6 | return function() 7 | local fabric, testInstance, invokeHeartbeat 8 | 9 | beforeEach(function() 10 | fabric = Fabric.new("tag watcher") 11 | FabricLib.useTags(fabric) 12 | do 13 | -- monkey patch heartbeat 14 | local heartbeatBindableEvent = Instance.new("BindableEvent") 15 | fabric.Heartbeat = heartbeatBindableEvent.Event 16 | invokeHeartbeat = function(...) 17 | heartbeatBindableEvent:Fire(...) 18 | end 19 | end 20 | 21 | testInstance = Instance.new("Part") 22 | testInstance.Parent = Workspace 23 | end) 24 | 25 | afterEach(function() 26 | testInstance:Destroy() 27 | end) 28 | 29 | it("shouldn't listen before registering", function() 30 | local newUnit = { 31 | name = "Test2", 32 | tag = "Test2", 33 | } 34 | local newUnit2 = { 35 | name = "Test3", 36 | tag = "Test3", 37 | units = { 38 | Test2 = {} 39 | }, 40 | } 41 | fabric:registerUnit(newUnit2) 42 | CollectionService:AddTag(testInstance, "Test3") 43 | expect(CollectionService:HasTag(testInstance, "Test3")).to.equal(true) 44 | fabric:registerUnit(newUnit) 45 | invokeHeartbeat() 46 | local unit = fabric:getUnitByRef("Test3", testInstance) 47 | expect(unit).to.be.ok() 48 | expect(fabric:getUnitByRef("Test2", unit)).to.be.ok() 49 | end) 50 | end -------------------------------------------------------------------------------- /src/Fabric/HotReloader.lua: -------------------------------------------------------------------------------- 1 | local HotReloader = {} 2 | HotReloader.__index = HotReloader 3 | 4 | function HotReloader.new(fabric) 5 | return setmetatable({ 6 | fabric = fabric; 7 | staticUnits = {}; 8 | }, HotReloader) 9 | end 10 | 11 | function HotReloader:giveModule(module, initialValue) 12 | self.staticUnits[module] = initialValue 13 | 14 | module.Changed:Connect(function() 15 | local newStaticUnit = require(module:Clone()) 16 | local oldStaticUnit = self.staticUnits[module] 17 | 18 | if newStaticUnit.name == nil then 19 | newStaticUnit.name = module.Name 20 | end 21 | 22 | self.fabric._collection:register(newStaticUnit, true) 23 | self.fabric:fire("unitHotReloaded", newStaticUnit) 24 | 25 | local count = 0 26 | for _, unitMap in pairs(self.fabric._collection._refUnits) do 27 | if unitMap[oldStaticUnit] then 28 | unitMap[oldStaticUnit]:_cleanUpEffects() 29 | 30 | unitMap[newStaticUnit] = unitMap[oldStaticUnit] 31 | unitMap[oldStaticUnit] = nil 32 | 33 | setmetatable(unitMap[newStaticUnit], newStaticUnit) 34 | unitMap[newStaticUnit]:fire("hotReloaded") 35 | 36 | local ok, errorValue = xpcall(function() 37 | unitMap[newStaticUnit]:_runEffects() 38 | end, function(innerErrorValue) 39 | return debug.traceback(innerErrorValue) 40 | end) 41 | 42 | if not ok then 43 | warn(("Effects of %s encountered an error during hot reloading:\n\n%s"):format( 44 | tostring(unitMap[newStaticUnit]), 45 | tostring(errorValue) 46 | )) 47 | end 48 | 49 | count += 1 50 | end 51 | end 52 | 53 | self.staticUnits[module] = newStaticUnit 54 | 55 | self.fabric:debug("[Hot Reload]", module.Name, "->", count, "units") 56 | end) 57 | end 58 | 59 | return HotReloader 60 | -------------------------------------------------------------------------------- /src/Roact/init.lua: -------------------------------------------------------------------------------- 1 | local Unit = require(script.Parent.Fabric.Unit) 2 | 3 | return function(fabric, roact) 4 | local roactUnit = require(script.FabricComponent)(fabric, roact) 5 | 6 | local function createElement(instance, props, children) 7 | local componentProps = {} 8 | 9 | if props then 10 | for key, value in pairs(props) do 11 | if getmetatable(key) == Unit then 12 | componentProps[key] = value 13 | props[key] = nil 14 | end 15 | end 16 | end 17 | 18 | if next(componentProps) then 19 | -- we create a roactUnit to attach fabric units to the instance 20 | return roact.createElement(roactUnit, { 21 | units = componentProps, 22 | createRef = function(root) 23 | return root(instance, props, children) 24 | end 25 | }) 26 | else 27 | return roact.createElement(instance, props, children) 28 | end 29 | end 30 | 31 | local function setupRender(staticUnit) 32 | if staticUnit.render then 33 | staticUnit.effects = staticUnit.effects or {} 34 | staticUnit.effects._roactRender = function(self) 35 | local rootElement = staticUnit.render(self, createElement) 36 | 37 | if rootElement == nil and self._roactHandle then 38 | roact.unmount(self._roactHandle) 39 | self._roactHandle = nil 40 | return 41 | end 42 | 43 | if self._roactHandle == nil then 44 | self._roactHandle = roact.mount(rootElement, self.ref) 45 | 46 | self:on("destroy", function() 47 | roact.unmount(self._roactHandle) 48 | self._roactHandle = nil 49 | end) 50 | else 51 | roact.update(self._roactHandle, rootElement) 52 | end 53 | end 54 | end 55 | end 56 | 57 | fabric:on("unitRegistered", setupRender) 58 | fabric:on("unitHotReloaded", setupRender) 59 | end -------------------------------------------------------------------------------- /src/Roact/FabricComponent.lua: -------------------------------------------------------------------------------- 1 | return function(fabric, roact) 2 | local FabricComponent = roact.Component:extend("FabricComponent") 3 | 4 | function FabricComponent:init() 5 | self.ref = roact.createRef() 6 | end 7 | 8 | function FabricComponent:render() 9 | local child = roact.oneChild(self.props[roact.Children]) 10 | assert(not child, "FabricComponent cannot have any children!") 11 | assert(type(self.props.createRef) == "function", "FabricComponent requires a 'createRef' callback as a prop!") 12 | assert(self.props.units, "FabricComponent requires a 'units' table as a prop mapping component name -> base layer.") 13 | 14 | local rootCalled = false 15 | local createRoot = function(component, props, children) 16 | assert(rootCalled == false, "createRoot can only be called once!") 17 | props = props or {} 18 | rootCalled = true 19 | props[roact.Ref] = self.ref 20 | return roact.createElement(component, props, children) 21 | end 22 | 23 | local root = self.props.createRef(createRoot) 24 | 25 | return root 26 | end 27 | 28 | function FabricComponent:didMount() 29 | local ref = self.ref:getValue() 30 | assert(ref, "You must call createRoot in the passed createRef function!") 31 | for name, baseLayer in pairs(self.props.units) do 32 | local unit = fabric:getOrCreateUnitByRef(name, ref) 33 | unit:mergeBaseLayer(baseLayer) 34 | end 35 | end 36 | 37 | function FabricComponent:didUpdate() 38 | local ref = self.ref:getValue() 39 | assert(ref, "You must call createRoot in the passed createRef function!") 40 | for name, baseLayer in pairs(self.props.units) do 41 | local unit = fabric:getOrCreateUnitByRef(name, ref) 42 | unit:mergeBaseLayer(baseLayer) 43 | end 44 | end 45 | 46 | return FabricComponent 47 | end -------------------------------------------------------------------------------- /src/Fabric/Serializer.lua: -------------------------------------------------------------------------------- 1 | local BuiltInSerializers = require(script.Parent.BuiltInSerializers) 2 | local makeEnum = require(script.Parent.makeEnum).makeEnum 3 | 4 | local Serializer = { 5 | FailMode = makeEnum("FailMode", {"Error", "Ignore"}); 6 | } 7 | Serializer.__index = Serializer 8 | 9 | function Serializer.new(fabric) 10 | return setmetatable({ 11 | _serializers = setmetatable({}, {__index = BuiltInSerializers.serializers}); 12 | _deserializers = setmetatable({}, {__index = BuiltInSerializers.deserializers}); 13 | fabric = fabric; 14 | }, Serializer) 15 | end 16 | 17 | function Serializer:deserialize(serializedTarget, failMode) 18 | if type(serializedTarget) ~= "table" then 19 | return serializedTarget 20 | end 21 | 22 | local deserializer = self._deserializers[serializedTarget.type] 23 | if not deserializer then 24 | error("Unable to deserialize object") -- TODO: Dump inspect of object 25 | end 26 | 27 | local object = deserializer(serializedTarget, self.fabric) 28 | 29 | if object == nil and failMode == Serializer.FailMode.Error then 30 | error("Deserialization failed for object and no error was emitted by the deserializer. This is a bug in your deserializer!") 31 | end 32 | 33 | return object 34 | end 35 | 36 | function Serializer:serialize(object) 37 | if type(object) ~= "table" then 38 | return object 39 | end 40 | 41 | local serializer = self:findSerializer(object) 42 | 43 | return 44 | serializer and serializer(object, self.fabric) 45 | or error(("Unable to serialize replicated unit %s"):format(tostring(object))) 46 | end 47 | 48 | function Serializer:registerSerializer(class, callback) 49 | self._serializers[class] = callback 50 | end 51 | 52 | function Serializer:registerDeserializer(name, callback) 53 | assert(type(name) == "string", "Deserializer type must be a string") 54 | self._deserializers[name] = callback 55 | end 56 | 57 | local function find(class, map) 58 | if map[class] then 59 | return map[class] 60 | end 61 | 62 | local metatable = getmetatable(class) 63 | 64 | if metatable then 65 | return find(metatable, map) 66 | end 67 | end 68 | 69 | function Serializer:findSerializer(class) 70 | return find(class, self._serializers) 71 | end 72 | 73 | function Serializer:findDeserializer(name) 74 | return self._deserializers[name] 75 | end 76 | 77 | return Serializer -------------------------------------------------------------------------------- /src/Fabric/Operators/Comparators.spec.lua: -------------------------------------------------------------------------------- 1 | local Comparators = require(script.Parent.Comparators) 2 | local Util = require(script.Parent.Parent.Parent.Shared.Util) 3 | 4 | local COMPARATOR_NEAR_DEFAULT = 0.001 5 | 6 | return function () 7 | describe("Comparators", function() 8 | it("should compare by reference", function() 9 | expect(Comparators.reference(1, 1)).to.equal(false) 10 | expect(Comparators.reference(1, 2)).to.equal(true) 11 | expect(Comparators.reference({}, {})).to.equal(true) 12 | 13 | local a = {} 14 | expect(Comparators.reference(a, a)).to.equal(false) 15 | end) 16 | 17 | it("should compare by value", function() 18 | expect(Comparators.value(1, 1)).to.equal(false) 19 | expect(Comparators.value(1, 2)).to.equal(true) 20 | expect(Comparators.value({}, {})).to.equal(false) 21 | expect(Comparators.value({1}, {1})).to.equal(false) 22 | expect(Comparators.value({1}, {2})).to.equal(true) 23 | 24 | local a = {} 25 | expect(Comparators.value(a, a)).to.equal(false) 26 | 27 | expect(Comparators.value({key = 1}, {key = 1})).to.equal(false) 28 | expect(Comparators.value({key = 1}, {key = 2})).to.equal(true) 29 | 30 | local nested = {key = 1, sub = {subkey = "hello"}} 31 | local nestedCopy = Util.deepCopy(nested) 32 | expect(Comparators.value(nested, nestedCopy)).to.equal(false) 33 | end) 34 | 35 | it("should compare within a range", function() 36 | expect(Comparators.within(1)(1, 5)).to.equal(true) 37 | expect(Comparators.within(1)(1, 1.5)).to.equal(false) 38 | 39 | expect(COMPARATOR_NEAR_DEFAULT > 0).to.equal(true) 40 | expect(Comparators.near(0, COMPARATOR_NEAR_DEFAULT/2)).to.equal(false) 41 | expect(Comparators.near(0, COMPARATOR_NEAR_DEFAULT*2)).to.equal(true) 42 | end) 43 | end) 44 | 45 | describe("Comparator utilities", function() 46 | it("should compare by property", function() 47 | local compare = Comparators.structure({ 48 | reference = Comparators.reference; 49 | value = Comparators.value; 50 | near = Comparators.near; 51 | }) 52 | 53 | local t = { x = 1 } 54 | 55 | expect(compare({ 56 | reference = t; 57 | value = t; 58 | near = 0; 59 | }, { 60 | reference = t; 61 | value = { x = 1 }; 62 | near = 0.0001; 63 | })).to.equal(false) 64 | 65 | expect(compare({ 66 | reference = t; 67 | new = 5; 68 | }, { 69 | reference = t; 70 | })).to.equal(true) 71 | end) 72 | end) 73 | end 74 | -------------------------------------------------------------------------------- /src/Fabric/Reactor.lua: -------------------------------------------------------------------------------- 1 | local ROOT = {} 2 | 3 | local Reactor = {} 4 | Reactor.__index = Reactor 5 | 6 | function Reactor.new(fabric) 7 | return setmetatable({ 8 | fabric = fabric; 9 | _unitStack = {}; 10 | _effectStack = {}; 11 | }, Reactor) 12 | end 13 | 14 | function Reactor:push(unit, effectKey) 15 | table.insert(self._unitStack, unit) 16 | table.insert(self._effectStack, effectKey) 17 | end 18 | 19 | function Reactor:pop() 20 | table.remove(self._effectStack, #self._effectStack) 21 | table.remove(self._unitStack, #self._unitStack) 22 | end 23 | 24 | function Reactor:peek() 25 | return self._unitStack[#self._unitStack], self._effectStack[#self._effectStack] 26 | end 27 | 28 | function Reactor._getCallback(unit, interestedUnit) 29 | return function(data, lastData) 30 | for interestedEffectKey, interestedKeys in pairs(interestedUnit._reactsTo[unit]) do 31 | local needsUpdate = false 32 | 33 | for interestedKey in pairs(interestedKeys) do 34 | if 35 | interestedKey == ROOT 36 | or data == nil 37 | or lastData == nil 38 | or data[interestedKey] ~= lastData[interestedKey] 39 | then 40 | interestedUnit.fabric:debug( 41 | "[Reactor Push]", 42 | unit, 43 | interestedKey == ROOT and "root" or interestedKey, 44 | "->", 45 | interestedUnit 46 | ) 47 | needsUpdate = true 48 | break 49 | end 50 | end 51 | 52 | if needsUpdate then 53 | interestedUnit:_runEffect(interestedEffectKey) 54 | end 55 | end 56 | end 57 | end 58 | 59 | function Reactor:react(unit, key) 60 | if unit:isDestroyed() then 61 | -- unit:get can still be called if the unit is destroyed. 62 | return 63 | end 64 | 65 | local interestedUnit, interestedEffectKey = self:peek() 66 | 67 | if interestedUnit == nil then 68 | return 69 | end 70 | 71 | assert(key == nil or type(key) == "string", "Effects API does not work with nested keys yet") 72 | 73 | if key == nil then 74 | key = ROOT 75 | end 76 | 77 | if interestedUnit._reactsTo[unit] == nil then 78 | self.fabric:debug("[Reactor Interest]", unit, "->", interestedUnit) 79 | 80 | interestedUnit._reactsTo[unit] = {} 81 | 82 | local cleanupCallback = unit:on("updated", self._getCallback(unit, interestedUnit)) 83 | interestedUnit:on("destroy", cleanupCallback) 84 | interestedUnit:on("hotReloaded", cleanupCallback) 85 | interestedUnit:on("hotReloaded", function() 86 | interestedUnit._reactsTo[unit] = nil 87 | end) 88 | end 89 | 90 | if interestedUnit._reactsTo[unit][interestedEffectKey] == nil then 91 | interestedUnit._reactsTo[unit][interestedEffectKey] = {} 92 | end 93 | 94 | interestedUnit._reactsTo[unit][interestedEffectKey][key] = true 95 | end 96 | 97 | return Reactor -------------------------------------------------------------------------------- /src/Shared/Util.lua: -------------------------------------------------------------------------------- 1 | local Util = {} 2 | 3 | function Util.assign(toObj, ...) 4 | for _, fromObj in ipairs({...}) do 5 | for key, value in pairs(fromObj) do 6 | toObj[key] = value 7 | end 8 | end 9 | 10 | return toObj 11 | end 12 | 13 | function Util.makeToString(staticName) 14 | return function(self) 15 | return ("%s(%s)"):format(staticName, getmetatable(self).name) 16 | end 17 | end 18 | 19 | function Util.concat(list, ...) 20 | local args = { ... } 21 | local result = {} 22 | for i = 1, #list do 23 | result[i] = list[i] 24 | end 25 | for i = 1, #args do 26 | local value = args[i] 27 | for j = 1, #value do 28 | result[#result + 1] = value[j] 29 | end 30 | end 31 | return result 32 | end 33 | 34 | function Util.callCounter() 35 | return setmetatable({ 36 | call = function(self, key) 37 | self[key] += 1 38 | end 39 | }, { 40 | __index = function(self, key) 41 | self[key] = 0 42 | return 0 43 | end 44 | }) 45 | end 46 | 47 | function Util.deepCopy(t) 48 | if type(t) == "table" then 49 | local n = {} 50 | for i,v in pairs(t) do 51 | n[i] = Util.deepCopy(v) 52 | end 53 | return n 54 | else 55 | return t 56 | end 57 | end 58 | 59 | function Util.deepEquals(a, b) 60 | if type(a) ~= "table" or type(b) ~= "table" then 61 | return a == b 62 | end 63 | 64 | for k in pairs(a) do 65 | local av = a[k] 66 | local bv = b[k] 67 | if type(av) == "table" and type(bv) == "table" then 68 | local result = Util.deepEquals(av, bv) 69 | if not result then 70 | return false 71 | end 72 | elseif av ~= bv then 73 | return false 74 | end 75 | end 76 | 77 | -- extra keys in b 78 | for k in pairs(b) do 79 | if a[k] == nil then 80 | return false 81 | end 82 | end 83 | 84 | return true 85 | end 86 | 87 | function Util.requireAllInAnd(instance, callback, self) 88 | for _, object in ipairs(instance:GetChildren()) do 89 | if object:IsA("ModuleScript") then 90 | callback(self, require(object)) 91 | else 92 | Util.requireAllInAnd(object, callback, self) 93 | end 94 | end 95 | end 96 | 97 | --- Maps values of an array through a callback and returns an array of mapped values 98 | function Util.mapArray(array, callback) 99 | local results = {} 100 | 101 | for i, v in ipairs(array) do 102 | results[i] = callback(v, i) 103 | end 104 | 105 | return results 106 | end 107 | 108 | --- Maps arguments #2-n through callback and returns values as tuple 109 | function Util.mapTuple(callback, ...) 110 | local results = {} 111 | for i, value in ipairs({...}) do 112 | results[i] = callback(value) 113 | end 114 | return unpack(results) 115 | end 116 | 117 | return Util 118 | -------------------------------------------------------------------------------- /src/Tags/TagWatcher.lua: -------------------------------------------------------------------------------- 1 | local CollectionService = game:GetService("CollectionService") 2 | local RunService = game:GetService("RunService") 3 | 4 | local TagWatcher = {} 5 | TagWatcher.__index = TagWatcher 6 | 7 | function TagWatcher.new(fabric) 8 | local self = setmetatable({ 9 | fabric = fabric; 10 | _tags = {}; 11 | _deferredCreation = nil; 12 | _deferredRegistration = nil; 13 | }, TagWatcher) 14 | 15 | fabric:on("unitRegistered", function(staticUnit) 16 | if staticUnit.tag then 17 | if self._deferredRegistration == nil then 18 | self._deferredRegistration = {} 19 | 20 | local connection 21 | connection = self.fabric.Heartbeat:Connect(function() 22 | connection:Disconnect() 23 | 24 | for _, item in ipairs(self._deferredRegistration) do 25 | self:listenForTag(item.tag, item) 26 | end 27 | 28 | self._deferredRegistration = nil 29 | end) 30 | end 31 | 32 | table.insert(self._deferredRegistration, staticUnit) 33 | end 34 | end) 35 | 36 | return self 37 | end 38 | 39 | function TagWatcher:_deferCreation(staticUnit, instance, data) 40 | if self._deferredCreation == nil then 41 | self._deferredCreation = {} 42 | 43 | local connection 44 | connection = self.fabric.Heartbeat:Connect(function() 45 | connection:Disconnect() 46 | 47 | for _, item in ipairs(self._deferredCreation) do 48 | self.fabric:getOrCreateUnitByRef(item.staticUnit, item.instance):mergeBaseLayer(item.data) 49 | end 50 | 51 | self._deferredCreation = nil 52 | end) 53 | end 54 | 55 | table.insert(self._deferredCreation, { 56 | staticUnit = staticUnit; 57 | instance = instance; 58 | data = data; 59 | }) 60 | end 61 | 62 | function TagWatcher:listenForTag(tag, staticUnit) 63 | self.fabric:debug("Creating units for tag", tag) 64 | assert(self._tags[tag] == nil, ("Tag %q is already in use!"):format(tag)) 65 | self._tags[tag] = true 66 | 67 | local function addFromTag(instance) 68 | local data = {} 69 | 70 | if 71 | RunService:IsClient() 72 | and staticUnit.units 73 | and staticUnit.units.Replicated 74 | then 75 | -- Create unit and let Replicated unit subscribe 76 | self.fabric._collection:getOrCreateUnitByRef(staticUnit, instance) 77 | else 78 | if 79 | instance:FindFirstChild(staticUnit.name) 80 | and instance[staticUnit.name].ClassName == "ModuleScript" 81 | then 82 | data = require(instance[staticUnit.name]) 83 | end 84 | 85 | self.fabric._collection:getOrCreateUnitByRef(staticUnit, instance) 86 | self:_deferCreation(staticUnit, instance, data) 87 | end 88 | end 89 | 90 | local function removeFromTag(instance) 91 | local unit = self.fabric:getUnitByRef(staticUnit, instance) 92 | 93 | if unit then 94 | unit:fire("destroy") 95 | end 96 | end 97 | 98 | CollectionService:GetInstanceRemovedSignal(tag):Connect(removeFromTag) 99 | CollectionService:GetInstanceAddedSignal(tag):Connect(addFromTag) 100 | for _, instance in ipairs(CollectionService:GetTagged(tag)) do 101 | addFromTag(instance) 102 | end 103 | end 104 | 105 | return TagWatcher 106 | -------------------------------------------------------------------------------- /src/Fabric/init.spec.lua: -------------------------------------------------------------------------------- 1 | local Fabric = require(script.Parent) 2 | 3 | return function() 4 | describe("Fabric.new", function() 5 | it("should return a fabric", function() 6 | local fabric = Fabric.new("the namespace") 7 | 8 | expect(fabric.namespace).to.equal("the namespace") 9 | end) 10 | end) 11 | 12 | describe("Fabric:registerUnit", function() 13 | it("should register units", function() 14 | local unitDef = { 15 | name = "Test"; 16 | } 17 | local fabric = Fabric.new() 18 | 19 | local eventCount = 0 20 | 21 | fabric:on("unitRegistered", function() 22 | eventCount += 1 23 | end) 24 | 25 | fabric:registerUnit(unitDef) 26 | 27 | expect(fabric.Unit.Test).to.be.ok() 28 | expect(eventCount).to.equal(1) 29 | end) 30 | 31 | it("shouldn't register duplicate units", function() 32 | local unitDef = { 33 | name = "Test"; 34 | } 35 | local fabric = Fabric.new() 36 | 37 | fabric:registerUnit(unitDef) 38 | 39 | local unitDef2 = { 40 | name = "Test"; 41 | } 42 | local stat, err = pcall(function() 43 | fabric:registerUnit(unitDef2) 44 | end) 45 | 46 | expect(stat).to.equal(false) 47 | expect(err:match("A unit with this name is already registered!")).to.be.ok() 48 | end) 49 | end) 50 | 51 | describe("Fabric:registerUnitsIn", function() 52 | 53 | end) 54 | 55 | describe("Fabric:getUnitByRef and Fabric:getOrCreateUnitByRef", function() 56 | it("should create and get a unit on ref", function() 57 | local unitDef = { 58 | name = "Test"; 59 | } 60 | local fabric = Fabric.new() 61 | 62 | fabric:registerUnit(unitDef) 63 | 64 | local testRef = {} 65 | 66 | expect(fabric:getUnitByRef("Test", testRef)).to.never.be.ok() 67 | 68 | fabric:getOrCreateUnitByRef(unitDef, testRef) 69 | expect(fabric:getUnitByRef("Test", testRef)).to.be.ok() 70 | end) 71 | end) 72 | 73 | describe("Fabric:removeAllUnitsWithRef", function() 74 | it("should remove all units with a ref", function() 75 | local unitDef = { 76 | name = "Test"; 77 | } 78 | local unitDef2 = { 79 | name = "Test2"; 80 | } 81 | local fabric = Fabric.new() 82 | 83 | fabric:registerUnit(unitDef) 84 | fabric:registerUnit(unitDef2) 85 | 86 | local testRef = {} 87 | 88 | fabric:getOrCreateUnitByRef(unitDef, testRef) 89 | fabric:getOrCreateUnitByRef(unitDef2, testRef) 90 | 91 | expect(fabric:getUnitByRef("Test", testRef)).to.be.ok() 92 | expect(fabric:getUnitByRef("Test2", testRef)).to.be.ok() 93 | 94 | fabric:removeAllUnitsWithRef(testRef) 95 | 96 | expect(fabric:getUnitByRef("Test", testRef)).to.never.be.ok() 97 | expect(fabric:getUnitByRef("Test2", testRef)).to.never.be.ok() 98 | end) 99 | end) 100 | 101 | describe("Fabric:fire and Fabric:on", function() 102 | it("should fire events", function() 103 | local fabric = Fabric.new() 104 | 105 | local callCount = 0 106 | fabric:on("testEvent", function() 107 | callCount += 1 108 | end) 109 | 110 | expect(callCount).to.equal(0) 111 | 112 | fabric:fire("testEvent") 113 | 114 | expect(callCount).to.equal(1) 115 | 116 | fabric:fire("doesn't exist") 117 | 118 | expect(callCount).to.equal(1) 119 | end) 120 | end) 121 | end -------------------------------------------------------------------------------- /src/Replication/Transmitter.spec/TransmitterClient.spec.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local Promise = require(script.Parent.Parent.Parent.Parent.Promise) 4 | local FabricLib = require(script.Parent.Parent.Parent) 5 | local Fabric = FabricLib.Fabric 6 | 7 | 8 | return function() 9 | if not RunService:IsClient() then 10 | return 11 | end 12 | 13 | local testUnit, testRef 14 | 15 | 16 | -- TODO: since fabrics with the same namesapce share event listeners, 17 | -- we must use a unique namespace for each test 18 | local function makeFabric(namespace) 19 | local fabric = Fabric.new(namespace) 20 | FabricLib.useTags(fabric) 21 | FabricLib.useReplication(fabric) 22 | fabric:registerUnit(testUnit) 23 | return fabric 24 | end 25 | beforeEach(function() 26 | testUnit = { 27 | name = "TestTransmitter", 28 | units = { 29 | Replicated = {} 30 | }, 31 | } 32 | testRef = script.Parent:WaitForChild("TEST_REF") 33 | end) 34 | 35 | describe("Transmitter", function() 36 | it("should transmit from client", function() 37 | local fabric = makeFabric("receive") 38 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", testRef) 39 | expect(unit).to.be.ok() 40 | unit:getUnit("Transmitter"):send("TestEvent", "this is a test arg") 41 | end) 42 | 43 | it("should receive from server", function() 44 | local fabric = makeFabric("transmit") 45 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", testRef) 46 | 47 | local done = false 48 | Promise.new(function(resolve) 49 | unit:on("serverTestEvent", function(test_arg) 50 | resolve(test_arg == "this is a test arg") 51 | end) 52 | end):andThen(function(clientDone) 53 | done = clientDone 54 | end):timeout(2):await() 55 | expect(done).to.equal(true) 56 | end) 57 | 58 | it("should send with predictive layers", function() 59 | local fabric = makeFabric("predictive") 60 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", testRef) 61 | expect(unit).to.be.ok() 62 | unit:mergeBaseLayer({ 63 | someOtherData = true 64 | }) 65 | unit:getUnit("Transmitter"):sendWithPredictiveLayer({ 66 | testData = true 67 | }, "TestEvent", "this is a test arg") 68 | -- on same frame, check if predictive data set 69 | expect(unit:get("testData")).to.equal(true) 70 | local onRejectData = true 71 | Promise.new(function(resolve) 72 | unit:getUnit("Transmitter"):on("rejectNetworkPrediction", function() 73 | onRejectData = unit:get("testData") 74 | resolve() 75 | end) 76 | end):timeout(2):await() 77 | expect(onRejectData).to.never.be.ok() 78 | end) 79 | 80 | it("should send with valid predictive layers", function() 81 | local fabric = makeFabric("validPredictive") 82 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", testRef) 83 | expect(unit).to.be.ok() 84 | unit:mergeBaseLayer({ 85 | someOtherData = true 86 | }) 87 | unit:getUnit("Transmitter"):sendWithPredictiveLayer({ 88 | testData = 1 89 | }, "TestEvent", "this is a test arg") 90 | -- on same frame, check if predictive data set 91 | expect(unit:get("testData")).to.equal(1) 92 | Promise.new(function(resolve) 93 | unit:on("serverResponse", function() 94 | resolve() 95 | end) 96 | end):timeout(5):await() 97 | expect(unit:get("testData")).to.equal(2) 98 | end) 99 | end) 100 | end -------------------------------------------------------------------------------- /src/Batching/init.spec.lua: -------------------------------------------------------------------------------- 1 | local Promise = require(script.Parent.Parent.Parent.Promise) 2 | local FabricLib = require(script.Parent.Parent) 3 | local Fabric = FabricLib.Fabric 4 | 5 | return function() 6 | 7 | local fabric, event 8 | 9 | local function makeUnitDefinition(callback) 10 | return { 11 | name = "test"; 12 | batch = function(on) 13 | return { 14 | on.event(event.Event, callback) 15 | } 16 | end 17 | } 18 | end 19 | 20 | beforeEach(function() 21 | fabric = Fabric.new("batching") 22 | FabricLib.useBatching(fabric) 23 | event = Instance.new("BindableEvent") 24 | end) 25 | 26 | describe("Batching", function() 27 | it("should loop over everything", function() 28 | local refs = {} 29 | for i = 1, 10 do 30 | refs[i] = {} 31 | end 32 | 33 | local hasRan = false 34 | local staticUnit = makeUnitDefinition(function(list) 35 | expect(#list).to.equal(#refs) 36 | hasRan = true 37 | for _, unit in ipairs(list) do 38 | unit:mergeBaseLayer({ 39 | testValue = true 40 | }) 41 | end 42 | end) 43 | fabric:registerUnit(staticUnit) 44 | 45 | for _, ref in ipairs(refs) do 46 | fabric:getOrCreateUnitByRef(staticUnit, ref):mergeBaseLayer({}) 47 | end 48 | 49 | event:Fire() -- ☠️🏴 50 | expect(hasRan).to.equal(true) 51 | 52 | for _, ref in ipairs(refs) do 53 | expect(fabric:getUnitByRef(staticUnit, ref):get("testValue")).to.be.ok() 54 | end 55 | end) 56 | 57 | it("should remove refs from batch on destruction", function() 58 | local refs = {} 59 | for i = 1, 10 do 60 | refs[i] = {} 61 | end 62 | 63 | local hasRan = false 64 | local staticUnit = makeUnitDefinition(function(list) 65 | expect(#list).to.equal(#refs - 1) 66 | hasRan = true 67 | for _, unit in ipairs(list) do 68 | unit:mergeBaseLayer({ 69 | testValue = true 70 | }) 71 | end 72 | end) 73 | fabric:registerUnit(staticUnit) 74 | 75 | for _, ref in ipairs(refs) do 76 | fabric:getOrCreateUnitByRef(staticUnit, ref):mergeBaseLayer({}) 77 | end 78 | fabric:removeAllUnitsWithRef(refs[1]) 79 | 80 | event:Fire() -- ☠️🏴 81 | expect(hasRan).to.equal(true) 82 | end) 83 | 84 | it("should fire every interval", function() 85 | local times = {} 86 | local intervalUnit = { 87 | name = "test"; 88 | batch = function(on) 89 | return { 90 | on.interval(1/6, function(list) 91 | list[1]:mergeBaseLayer({ 92 | testValue = list[1]:get("testValue") + 1 93 | }) 94 | table.insert(times, os.clock()) 95 | end) 96 | } 97 | end 98 | } 99 | fabric:registerUnit(intervalUnit) 100 | 101 | local ref = {} 102 | local unit = fabric:getOrCreateUnitByRef(intervalUnit, ref) 103 | unit:mergeBaseLayer({ 104 | testValue = 0 105 | }) 106 | Promise.delay(5.2/6):await() 107 | 108 | expect(math.abs(unit:get("testValue") - 5) <= 1).to.equal(true) 109 | fabric:removeAllUnitsWithRef(ref) 110 | end) 111 | 112 | it("should statically retrieve all units", function() 113 | local refs = {} 114 | for i = 1, 10 do 115 | refs[i] = {} 116 | end 117 | 118 | local staticUnit = makeUnitDefinition(function() end) 119 | fabric:registerUnit(staticUnit) 120 | 121 | for _, ref in ipairs(refs) do 122 | fabric:getOrCreateUnitByRef(staticUnit, ref):mergeBaseLayer({}) 123 | end 124 | 125 | expect(#staticUnit.getAllUnits()).to.equal(#refs) 126 | end) 127 | end) 128 | 129 | end -------------------------------------------------------------------------------- /src/Replication/ClientTransmitter.lua: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | 4 | local EVENT_NAME = "fabricEvent" 5 | 6 | local ClientTransmitter = { 7 | Remote = {}; 8 | } 9 | ClientTransmitter.__index = ClientTransmitter 10 | 11 | function ClientTransmitter.new(fabric) 12 | local self = { 13 | fabric = fabric; 14 | } 15 | 16 | self._event = ReplicatedStorage:WaitForChild(EVENT_NAME) 17 | 18 | self._unit = fabric:registerUnit({ 19 | name = "Transmitter"; 20 | onInitialize = function(unit) 21 | self:subscribe(unit.ref) 22 | end; 23 | onDestroy = function(unit) 24 | assert(unit.ref ~= nil, "unit.ref is nil") 25 | self:unsubscribe(unit.ref) 26 | end; 27 | send = function(unit, transmitEvent, transmitData) 28 | self:_send( 29 | "event", 30 | self.fabric.serializer:serialize(unit.ref), 31 | transmitEvent, 32 | transmitData 33 | ) 34 | end; 35 | -- Returns true if the layer is created, false if not. 36 | sendWithPredictiveLayer = function(unit, layerData, transmitEvent, transmitData) 37 | 38 | if unit.ref.data == nil then 39 | -- use regular send if it is loading 40 | unit:send(transmitEvent, transmitData) 41 | 42 | return false 43 | end 44 | 45 | local predictionGUID = "NetworkPredictionLayer-" .. HttpService:GenerateGUID(false) 46 | 47 | self:_send( 48 | "event", 49 | self.fabric.serializer:serialize(unit.ref), 50 | transmitEvent, 51 | transmitData, 52 | predictionGUID 53 | ) 54 | 55 | unit.ref:addLayer(predictionGUID, layerData) 56 | return true 57 | end; 58 | }) 59 | 60 | self._event.OnClientEvent:Connect(function(namespace, serializedUnit, predictionGUIDs, eventName, ...) 61 | if namespace ~= self.fabric.namespace then 62 | return 63 | end 64 | 65 | local unit = self.fabric.serializer:deserialize(serializedUnit) 66 | assert(unit ~= nil, "unit is nil") 67 | 68 | if predictionGUIDs then 69 | for _, predictionGUID in ipairs(predictionGUIDs) do 70 | unit:removeLayer(predictionGUID) 71 | end 72 | end 73 | 74 | if ClientTransmitter.Remote[eventName] then 75 | ClientTransmitter.Remote[eventName](self, unit, ...) 76 | end 77 | end) 78 | 79 | return setmetatable(self, ClientTransmitter) 80 | end 81 | 82 | function ClientTransmitter:subscribe(unit) 83 | self.fabric:debug("Subscribing to", unit.name) 84 | self:_send("subscribe", self.fabric.serializer:serialize(unit)) 85 | end 86 | 87 | function ClientTransmitter:unsubscribe(unit) 88 | self.fabric:debug("Unsubscribing from", unit.name) 89 | self:_send("unsubscribe", self.fabric.serializer:serialize(unit)) 90 | end 91 | 92 | function ClientTransmitter:_send(eventName, serializedUnit, ...) 93 | self._event:FireServer(self.fabric.namespace, eventName, serializedUnit, ...) 94 | end 95 | 96 | function ClientTransmitter.Remote:event(unit, transmitEvent, transmitData) 97 | local transmitter = unit:getUnit(self._unit) 98 | 99 | assert(transmitter ~= nil, "unit doesn't have a transmitter") 100 | 101 | local transmitStr = "server" .. transmitEvent:sub(1, 1):upper() .. transmitEvent:sub(2) 102 | transmitter:fire( 103 | transmitStr, 104 | transmitData 105 | ) 106 | 107 | transmitter.ref:fire( 108 | transmitStr, 109 | transmitData 110 | ) 111 | end 112 | 113 | function ClientTransmitter.Remote:rejectNetworkPrediction(unit) 114 | self.fabric:debug(("Network prediction rejected for %q"):format(tostring(unit))) 115 | unit:getUnit(self._unit):fire("rejectNetworkPrediction") 116 | end 117 | 118 | return ClientTransmitter 119 | -------------------------------------------------------------------------------- /src/Fabric/UnitCollection.lua: -------------------------------------------------------------------------------- 1 | local Unit = require(script.Parent.Unit) 2 | local Types = require(script.Parent.Types) 3 | local isAllowedOnRef = require(script.Parent.isAllowedOnRef).isAllowedOnRef 4 | 5 | local WEAK_KEYS_METATABLE = { 6 | __mode = "k" 7 | } 8 | 9 | local UnitCollection = {} 10 | UnitCollection.__index = UnitCollection 11 | 12 | function UnitCollection.new(fabric) 13 | return setmetatable({ 14 | fabric = fabric; 15 | _unitsByName = {}; 16 | _unitsByRef = {}; 17 | _refUnits = {}; 18 | }, UnitCollection) 19 | end 20 | 21 | function UnitCollection:register(unitDefinition, isHotReload) 22 | assert(Types.UnitDefinition(unitDefinition)) 23 | 24 | if not isHotReload then 25 | assert(self._unitsByName[unitDefinition.name] == nil, "A unit with this name is already registered!") 26 | end 27 | 28 | self.fabric.Unit[unitDefinition.name] = unitDefinition 29 | 30 | setmetatable(unitDefinition, Unit) 31 | unitDefinition.__index = unitDefinition 32 | unitDefinition.__tostring = Unit.__tostring 33 | unitDefinition.fabric = self.fabric 34 | 35 | unitDefinition.new = function() 36 | return setmetatable({}, unitDefinition) 37 | end 38 | 39 | self._unitsByName[unitDefinition.name] = unitDefinition 40 | self._unitsByRef[unitDefinition] = unitDefinition 41 | end 42 | 43 | function UnitCollection:resolve(unitResolvable) 44 | return self._unitsByRef[unitResolvable] 45 | or self._unitsByName[unitResolvable] 46 | end 47 | 48 | function UnitCollection:resolveOrError(unitResolvable) 49 | return self:resolve(unitResolvable) or error( 50 | ("Cannot resolve unit %s"):format(tostring(unitResolvable)) 51 | ) 52 | end 53 | 54 | function UnitCollection:constructUnit(staticUnit, ref) 55 | assert(isAllowedOnRef(staticUnit, ref)) 56 | 57 | local unit = staticUnit.new() 58 | 59 | assert( 60 | getmetatable(unit) == staticUnit, 61 | "Metatable of newly constructed unit must be its static counterpart" 62 | ) 63 | 64 | unit.private = {} 65 | unit._layers = {} 66 | unit._layerOrder = {} 67 | unit._reactsTo = setmetatable({}, WEAK_KEYS_METATABLE) 68 | unit._unitScopeLayers = {} 69 | unit._listeners = {} 70 | unit._effectCleanUps = {} 71 | unit.ref = ref 72 | unit.fabric = self.fabric 73 | unit._loading = false 74 | unit._loaded = false 75 | 76 | self._refUnits[ref] = self._refUnits[ref] or {} 77 | self._refUnits[ref][staticUnit] = unit 78 | 79 | unit:on("destroy", function() 80 | self:deconstructUnit(unit) 81 | end) 82 | 83 | if staticUnit.units then 84 | for name, data in pairs(staticUnit.units) do 85 | unit:getOrCreateUnit(name):mergeBaseLayer(data) 86 | end 87 | end 88 | 89 | unit:fire("initialize") 90 | 91 | return unit 92 | end 93 | 94 | -- Need a way to hook into that and make sure units being removed is 95 | -- identical to unit having all data set to nil 96 | -- Perhaps a unit:destroy() method is necessary after all 97 | function UnitCollection:deconstructUnit(unit) 98 | local staticUnit = getmetatable(unit) 99 | 100 | self._refUnits[unit.ref][staticUnit] = nil 101 | 102 | if next(self._refUnits[unit.ref]) == nil then 103 | self._refUnits[unit.ref] = nil 104 | end 105 | 106 | self:removeAllUnitsWithRef(unit) 107 | 108 | unit:_cleanUpEffects() 109 | 110 | unit._listeners = nil 111 | unit.ref = nil 112 | unit._destroyed = true 113 | unit._layers = nil 114 | unit._layerOrder = nil 115 | unit._reactsTo = nil 116 | unit._effectCleanUps = nil 117 | 118 | for _, disconnect in pairs(unit._unitScopeLayers) do 119 | disconnect() 120 | end 121 | 122 | unit._unitScopeLayers = nil 123 | end 124 | 125 | function UnitCollection:getUnitByRef(unitResolvable, ref) 126 | local staticUnit = self:resolveOrError(unitResolvable) 127 | 128 | return self._refUnits[ref] and self._refUnits[ref][staticUnit] 129 | end 130 | 131 | function UnitCollection:getOrCreateUnitByRef(unitResolvable, ref) 132 | local unit = self:getUnitByRef(unitResolvable, ref) 133 | 134 | if not unit then 135 | unit = self:constructUnit(self:resolveOrError(unitResolvable), ref) 136 | end 137 | 138 | return unit 139 | end 140 | 141 | function UnitCollection:removeAllUnitsWithRef(ref) 142 | if self._refUnits[ref] then 143 | for _staticUnit, unit in pairs(self._refUnits[ref]) do 144 | unit:fire("destroy") 145 | end 146 | end 147 | end 148 | 149 | return UnitCollection 150 | -------------------------------------------------------------------------------- /src/Replication/Transmitter.spec/init.lua: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local FabricLib = require(script.Parent.Parent) 3 | local Fabric = FabricLib.Fabric 4 | local Promise = require(script.Parent.Parent.Parent.Promise) 5 | local invokeClientRPC = require(script.Parent.Parent.Shared.ClientTestRPC) 6 | -- TEST_REF must be visible to clients 7 | local TEST_REF = Instance.new("Folder") 8 | TEST_REF.Name = "TEST_REF" 9 | TEST_REF.Parent = script 10 | 11 | return function() 12 | 13 | -- HACK: Detect if play solo (devhub big dumb dumb https://developer.roblox.com/en-us/api-reference/function/RunService/IsRunMode) 14 | wait(1) 15 | if #Players:GetPlayers() == 0 then 16 | return 17 | end 18 | 19 | 20 | local testUnit 21 | 22 | -- TODO: since fabrics with the same namesapce share event listeners, 23 | -- we must use a unique namespace for each test 24 | local function makeFabric(namespace) 25 | local fabric = Fabric.new(namespace) 26 | FabricLib.useTags(fabric) 27 | FabricLib.useReplication(fabric) 28 | fabric:registerUnit(testUnit) 29 | return fabric 30 | end 31 | beforeEach(function() 32 | testUnit = { 33 | name = "TestTransmitter", 34 | units = { 35 | Replicated = {} 36 | }, 37 | } 38 | end) 39 | 40 | describe("Transmitter", function() 41 | it("should receive from client", function() 42 | local fabric = makeFabric("receive") 43 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", TEST_REF) 44 | expect(unit).to.be.ok() 45 | 46 | local done = false 47 | local promise = Promise.new(function(resolve) 48 | unit:on("clientTestEvent", function(_, test_arg) 49 | resolve(test_arg == "this is a test arg") 50 | end) 51 | end):andThen(function(serverDone) 52 | done = serverDone 53 | end) 54 | 55 | invokeClientRPC("invoke_test", {script["TransmitterClient.spec"]}, nil, {testNamePattern = "should transmit from client"}) 56 | 57 | promise:timeout(2):await() 58 | expect(done).to.equal(true) 59 | end) 60 | 61 | it("should transmit to client", function() 62 | local fabric = makeFabric("transmit") 63 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", TEST_REF) 64 | expect(unit).to.be.ok() 65 | 66 | -- wait for client to sub before pub 67 | Promise.all({ 68 | Promise.new(function(resolve) 69 | resolve(invokeClientRPC("invoke_test", {script["TransmitterClient.spec"]}, nil, {testNamePattern = "should receive from server"})) 70 | end), 71 | Promise.new(function(resolve) 72 | unit:getUnit("Transmitter"):on("subscriberAdded", function() 73 | resolve(unit:getUnit("Transmitter"):broadcast("TestEvent", "this is a test arg")) 74 | end) 75 | end) 76 | }):timeout(2):await() 77 | end) 78 | 79 | it("should reject invalid predictive layers", function() 80 | local fabric = makeFabric("predictive") 81 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", TEST_REF) 82 | expect(unit).to.be.ok() 83 | local done = false 84 | local promise = Promise.new(function(resolve) 85 | unit:on("clientTestEvent", function(_, test_arg) 86 | resolve(test_arg == "this is a test arg") 87 | end) 88 | end):andThen(function(serverDone) 89 | done = serverDone 90 | end) 91 | 92 | invokeClientRPC("invoke_test", {script["TransmitterClient.spec"]}, nil, {testNamePattern = "should send with predictive layers"}) 93 | 94 | promise:timeout(2):await() 95 | expect(done).to.equal(true) 96 | end) 97 | 98 | it("should respond to valid predictive layer", function() 99 | local fabric = makeFabric("validPredictive") 100 | local unit = fabric:getOrCreateUnitByRef("TestTransmitter", TEST_REF) 101 | expect(unit).to.be.ok() 102 | 103 | local done = false 104 | Promise.all({ 105 | Promise.new(function(resolve) 106 | unit:on("clientTestEvent", function(_, test_arg) 107 | resolve(test_arg == "this is a test arg") 108 | end) 109 | end):andThen(function(serverDone) 110 | done = serverDone 111 | unit:mergeBaseLayer({ 112 | testData = 2 113 | }) 114 | unit:getUnit("Transmitter"):broadcast("Response") 115 | end), 116 | Promise.new(function(resolve) 117 | invokeClientRPC("invoke_test", {script["TransmitterClient.spec"]}, nil, {testNamePattern = "should send with valid predictive layers"}) 118 | resolve() 119 | end) 120 | }):timeout(2):await() 121 | 122 | expect(done).to.equal(true) 123 | end) 124 | end) 125 | end -------------------------------------------------------------------------------- /src/Fabric/Operators/Reducers.lua: -------------------------------------------------------------------------------- 1 | local Util = require(script.Parent.Parent.Parent.Shared.Util) 2 | 3 | local Reducers = {} 4 | 5 | function Reducers.last(values) 6 | return values[#values] 7 | end 8 | 9 | function Reducers.first(values) 10 | return values[1] 11 | end 12 | 13 | function Reducers.truthy(values) 14 | for _, value in ipairs(values) do 15 | if value then 16 | return value 17 | end 18 | end 19 | end 20 | 21 | function Reducers.falsy(values) 22 | for _, value in ipairs(values) do 23 | if not value then 24 | return value 25 | end 26 | end 27 | end 28 | 29 | function Reducers.add(values) 30 | local reducedValue = 0 31 | 32 | for _, value in ipairs(values) do 33 | reducedValue = reducedValue + value 34 | end 35 | 36 | return reducedValue 37 | end 38 | 39 | function Reducers.multiply(values) 40 | local reducedValue = 1 41 | 42 | for _, value in ipairs(values) do 43 | reducedValue = reducedValue * value 44 | end 45 | 46 | return reducedValue 47 | end 48 | 49 | function Reducers.concatArray(values) 50 | return Util.concat(unpack(values)) 51 | end 52 | 53 | function Reducers.collect(values) 54 | return values 55 | end 56 | 57 | function Reducers.lowest(values) 58 | if #values == 0 then 59 | return 60 | end 61 | 62 | return math.min(unpack(values)) 63 | end 64 | 65 | function Reducers.highest(values) 66 | if #values == 0 then 67 | return 68 | end 69 | 70 | return math.max(unpack(values)) 71 | end 72 | 73 | function Reducers.mergeTable(values) 74 | return Util.assign({}, unpack(values)) 75 | end 76 | 77 | -- Utilities 78 | 79 | function Reducers.concatString(delim) 80 | return function (values) 81 | return table.concat(values, delim or "") 82 | end 83 | end 84 | 85 | function Reducers.priorityValue(reducer) 86 | reducer = reducer or Reducers.last 87 | 88 | return function (values) 89 | 90 | local highestPriority = -math.huge 91 | local highestPriorityValues = {} 92 | 93 | for _, struct in ipairs(values) do 94 | if struct.priority > highestPriority then 95 | highestPriorityValues = {struct.value} 96 | elseif struct.priority == highestPriority then 97 | table.insert(highestPriorityValues, struct.value) 98 | end 99 | end 100 | 101 | return reducer(highestPriorityValues) 102 | end 103 | end 104 | 105 | function Reducers.structure(reducers, default) 106 | local passthrough = reducers == nil and default == nil 107 | 108 | if default == nil then 109 | default = Reducers.last 110 | end 111 | 112 | return function(values) 113 | if passthrough then 114 | if #values == 1 then 115 | return values[1] 116 | else 117 | return Util.assign({}, unpack(values)) 118 | end 119 | end 120 | 121 | local properties = {} 122 | 123 | for _, value in ipairs(values) do 124 | for propName, propValue in pairs(value) do 125 | if properties[propName] == nil then 126 | properties[propName] = {} 127 | end 128 | 129 | table.insert(properties[propName], propValue) 130 | end 131 | end 132 | 133 | local reducedValue = {} 134 | 135 | for propName, propValues in pairs(properties) do 136 | reducedValue[propName] = 137 | (reducers[propName] or default)(propValues, properties) 138 | end 139 | 140 | return reducedValue 141 | end 142 | end 143 | 144 | -- TODO: structure with unknown fields using one 145 | function Reducers.map(reducer, ...) 146 | return Reducers.structure({}, reducer, ...) 147 | end 148 | 149 | function Reducers.exactly(value) 150 | return function () 151 | return value 152 | end 153 | end 154 | 155 | function Reducers.try(...) 156 | local reducers = {...} 157 | 158 | return function (values) 159 | for _, reducer in ipairs(reducers) do 160 | local result = reducer(values) 161 | 162 | if result ~= nil then 163 | return result 164 | end 165 | end 166 | 167 | return nil 168 | end 169 | end 170 | 171 | function Reducers.compose(...) 172 | local reducers = {...} 173 | 174 | return function (values) 175 | for _, reducer in ipairs(reducers) do 176 | values = reducer(values) 177 | end 178 | 179 | return values 180 | end 181 | end 182 | 183 | --? Should this be removed in favor of Reducers.try? 184 | function Reducers.thisOr(reducer, defaultValue) 185 | return function(values) 186 | local result = reducer(values) 187 | 188 | if result == nil then 189 | return defaultValue 190 | else 191 | return result 192 | end 193 | end 194 | end 195 | 196 | local function makeOr(func) 197 | return function (defaultValue) 198 | return Reducers.thisOr(func, defaultValue) 199 | end 200 | end 201 | 202 | Reducers.truthyOr = makeOr(Reducers.truthy) 203 | Reducers.falsyOr = makeOr(Reducers.falsy) 204 | Reducers.lastOr = makeOr(Reducers.last) 205 | Reducers.default = Reducers.structure() 206 | 207 | return Reducers 208 | -------------------------------------------------------------------------------- /src/Replication/ServerTransmitter.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local getOrCreate = require(script.Parent.getOrCreate).getOrCreate 3 | local FailMode = require(script.Parent.Parent.Fabric.Serializer).FailMode 4 | 5 | local EVENT_NAME = "fabricEvent" 6 | 7 | local EVENT_FAIL_MODES = { 8 | unsubscribe = FailMode.Ignore; 9 | } 10 | 11 | local ServerTransmitter = { 12 | Remote = {} 13 | } 14 | ServerTransmitter.__index = ServerTransmitter 15 | 16 | function ServerTransmitter.new(fabric) 17 | local self = { 18 | fabric = fabric; 19 | } 20 | 21 | self._event = getOrCreate( 22 | ReplicatedStorage, 23 | EVENT_NAME, 24 | "RemoteEvent" 25 | ) 26 | 27 | self._unit = fabric:registerUnit({ 28 | name = "Transmitter"; 29 | reducer = fabric.reducers.structure({}); 30 | schema = function(value) 31 | return type(value) == "table" 32 | end; 33 | onInitialize = function(unit) 34 | unit.subscribers = {} 35 | end; 36 | broadcast = function(unit, transmitEvent, transmitData) 37 | for _, player in ipairs(unit.subscribers) do 38 | self:_send( 39 | unit, 40 | player, 41 | "event", 42 | transmitEvent, 43 | transmitData 44 | ) 45 | end 46 | end; 47 | sendTo = function(unit, player, transmitEvent, transmitData) 48 | self:_send( 49 | unit, 50 | player, 51 | "event", 52 | transmitEvent, 53 | transmitData 54 | ) 55 | end; 56 | }) 57 | 58 | self._event.OnServerEvent:Connect(function(player, namespace, eventName, serializedUnit, ...) 59 | if namespace ~= self.fabric.namespace then 60 | return 61 | end 62 | 63 | if ServerTransmitter.Remote[eventName] then 64 | local transmitter = self:_getTransmitterFromSerializedUnit( 65 | serializedUnit, 66 | EVENT_FAIL_MODES[eventName] or FailMode.Error 67 | ) 68 | 69 | ServerTransmitter.Remote[eventName](self, player, transmitter, ...) 70 | end 71 | end) 72 | 73 | return setmetatable(self, ServerTransmitter) 74 | end 75 | 76 | function ServerTransmitter:_getTransmitterFromSerializedUnit(serializedUnit, failMode) 77 | local unit = self.fabric.serializer:deserialize(serializedUnit, failMode) 78 | 79 | if not unit then 80 | self.fabric:debug(("Client wants communicate with unit %q on %q, but that doesn't exist on the server. This could be normal if the attached Instance was removed."):format( 81 | tostring(serializedUnit.name), 82 | tostring(serializedUnit.ref) 83 | )) 84 | return 85 | end 86 | 87 | local transmitter = unit:getUnit(self._unit) 88 | 89 | if not transmitter then 90 | self.fabric:debug(("%s does not have a Transmitter attached, but received a message."):format(unit)) 91 | end 92 | 93 | return transmitter 94 | end 95 | 96 | function ServerTransmitter.Remote:subscribe(player, transmitter) 97 | table.insert(transmitter.subscribers, player) 98 | 99 | transmitter:fire("subscriberAdded", player) 100 | end 101 | 102 | -- TODO: Make sure players who leave the game get removed from subscribers 103 | function ServerTransmitter.Remote:unsubscribe(player, transmitter) 104 | if transmitter == nil then 105 | return 106 | end 107 | 108 | for i, listPlayer in ipairs(transmitter.subscribers) do 109 | if player == listPlayer then 110 | table.remove(transmitter.subscribers, i) 111 | 112 | transmitter:fire("subscriberRemoved", player) 113 | 114 | break 115 | end 116 | end 117 | end 118 | 119 | function ServerTransmitter.Remote:event(player, transmitter, transmitEvent, transmitData, predictionGUID) 120 | if type(predictionGUID) == "string" then 121 | if transmitter.predictionGUIDBuffer == nil then 122 | transmitter.predictionGUIDBuffer = {} 123 | 124 | local connection 125 | connection = transmitter.fabric.Heartbeat:Connect(function() 126 | connection:Disconnect() 127 | 128 | if #transmitter.predictionGUIDBuffer > 0 then 129 | self:_send(transmitter, player, "rejectNetworkPrediction") 130 | end 131 | 132 | transmitter.predictionGUIDBuffer = nil 133 | end) 134 | end 135 | 136 | table.insert(transmitter.predictionGUIDBuffer, predictionGUID) 137 | end 138 | 139 | local transmitStr = "client" .. transmitEvent:sub(1, 1):upper() .. transmitEvent:sub(2) 140 | transmitter:fire( 141 | transmitStr, 142 | player, 143 | transmitData 144 | ) 145 | 146 | transmitter.ref:fire( 147 | transmitStr, 148 | player, 149 | transmitData 150 | ) 151 | end 152 | 153 | function ServerTransmitter:_send(transmitter, player, eventName, ...) 154 | self._event:FireClient( 155 | player, 156 | self.fabric.namespace, 157 | self.fabric.serializer:serialize(transmitter.ref), 158 | transmitter.predictionGUIDBuffer, 159 | eventName, 160 | ... 161 | ) 162 | 163 | if transmitter.predictionGUIDBuffer then 164 | transmitter.predictionGUIDBuffer = {} 165 | end 166 | end 167 | 168 | return ServerTransmitter 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
7 | 8 | Fabric provides infrastructure for representing the state of *things* in your game. 9 | 10 | Fabric is currently experimental, and has not seen an initial release yet. Getting closer to that point every day! 11 | 12 | ## Features 13 | - Safely encapsulate state and behaviors with "units" 14 | - Easy cross-unit communication 15 | - Only make changes to the world when relevant data changes 16 | - [Hot reloading](https://i.eryn.io/2045/4sPsRGdA.mp4): make changes to your code and see them update immediately in game, no need to stop and replay. 17 | - Automatic replication and networked events 18 | - Client prediction of server state changes 19 | - Create new instances declaratively with Roact ([Fabric + Roact = ❤️](https://i.eryn.io/2045/pYNXQain.png)) 20 | - Easy integration with CollectionService tags 21 | - Built-in support for distribution of batched updates ([before](https://i.eryn.io/2047/2020-11-22_01-34-54.mp4) and [after](https://i.eryn.io/2047/2020-11-22_01-33-23.mp4)) 22 | ## Principles 23 | 24 | - Provide infrastructure for representing game state (things, not services). 25 | - State is represented by lots of small units which can be attached to *anything*. 26 | - Facilitate state sharing in a generic and consistent way that eliminates the need for many small manager services. Services can directly affect the state of the things they need to change without needing to go through an ancillary service that manages that state. 27 | - For example: Instead of two services needing to talk to the WalkSpeedManager service, they can instead both provide the same unit on the player which knows how to manage the walk speed. 28 | - Units have layers. 29 | - Imagine each unit as a stack of transparent sheets of paper. Each page only contains the data that its author cares about. Then, to read the data in the unit, you look down at the stack. Newer layers override old data, but if the author of that page left that section blank, it falls through to the page below, all the way to the bottom page, which could contain default data. 30 | - This is how units work in Fabric. Each unit can have multiple layers at a time which when combined by Fabric form the final data. You can then add a callback to do something whenever there's new data on each unit. 31 | - This gives us multiple advantages: 32 | - It's now impossible to run into this scenario: Service A adds a unit to a thing, then Service B adds the same unit to that thing, which effectively did nothing. But now Service B removes that unit, but Service A still wants that unit there. With Fabric, Service B would have just removed its *layer*, not the entire unit. Which means that the unit still exists so long as one layer does. 33 | - Units are more than just data. They can have their own behavior and can emit events. This is a natural way to think about and model problems that are primarily reacting to state changes. 34 | - Fabric isn't an ECS. Units provide functionality to react to state changes and facilitate updating on fixed intervals or to events, but is organized in a way that is distinct from the ECS pattern. 35 | - The same unit can exist on the server and the client. Fabric can handle replicating data from the server to the client in order to keep things in sync. 36 | - Fabric supports replicated network events per-unit. 37 | - Fabric (will) also support only replicating a subset of the unit's data to the client by optionally white-listing keys of the unit data. 38 | - Streaming first. Fabric only sends the client data that it needs. The client can request to subscribe to and unsubscribe from server-side units. 39 | - Units can create other units. Fabric (will) provide an easy way to, based on the data of this unit, create another unit by transforming the data of this unit. 40 | - For example, this allows us to create many small, focused units, and then create higher-level units that could provide many units. 41 | - Think about this: maybe you have a unit to track a damage-over-time effect on the player which deals some amount of damage to their health bar every few seconds. You could then build on that, creating a "Bleeding" unit, that in addition to creating a visual effect, also provides the "damage-over-time" unit instead of having to reimplement that behavior. This creates a nice pattern encouraging code reuse and reducing coupling. 42 | - Units can be attached to *other units*. This might sound a bit crazy at first, but this gives us a nice way to manage data *about* the unit without intermingling it with the data *of* the unit. 43 | - For example, attaching the "Replicated" unit to any other unit will tell Fabric that you want this unit to also exist on the client. 44 | - Fabric is extendible. Fabric exposes events which allow extensions to be created while keeping the core functionality focused and simple. For example, network replication and CollectionService tags are implemented as included extensions. 45 | - Planned included extensions: 46 | - Facilitate units communicating with events in a way that's statically configurable. This means that a Studio plugin would be able to be created, which for example allows you to connect a part with a Button unit to a part with a Door unit. The Button "press" event could be connected to the door's "open" method. 47 | 48 | Is this confusing? Please tell me in the [#fabric Discord channel](https://discord.gg/Heyvvnd) in my server. If you don't get it, you're probably not alone - let me know what I need to clarify! 49 | -------------------------------------------------------------------------------- /src/Batching/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | --[[ 4 | Looks for a batch key inside of components 5 | 6 | If is true or function, we maintain an array of all of the components 7 | We add a function to fabric which lets you retrieve this array 8 | If function, we call it on component registration to determine the events you want to do 9 | ]] 10 | local Promise = require(script.Parent.Parent.Promise) 11 | local SinglePromiseEvent = require(script.SinglePromiseEvent) 12 | 13 | local dt = 1 / 60 14 | 15 | RunService.Heartbeat:Connect(function(step) 16 | dt = step 17 | end) 18 | 19 | local batchConstructors = { 20 | event = function(event, callback) 21 | return { 22 | event = event, 23 | callback = callback 24 | } 25 | end, 26 | 27 | interval = function(duration, callback) 28 | local intervalEvent = SinglePromiseEvent.new(function(fire) 29 | return function(_, _, isCancelled) 30 | local loop 31 | 32 | loop = function() 33 | return Promise.try(fire):andThenCall(Promise.delay, duration):andThen(function() 34 | if not isCancelled() then 35 | return loop() 36 | end 37 | end) 38 | end 39 | 40 | loop() 41 | end 42 | end) 43 | 44 | return { 45 | event = intervalEvent, 46 | callback = callback 47 | } 48 | end, 49 | 50 | spreadInterval = function(duration, callbackCreator) 51 | local isReady = true 52 | local needsRestart = false 53 | local loop 54 | 55 | local intervalEvent = SinglePromiseEvent.new(function(fire) 56 | return function(_, _, isCancelled) 57 | loop = function() 58 | isReady = false 59 | 60 | return Promise.try(fire):andThenCall(Promise.delay, duration):andThen(function() 61 | if not isCancelled() then 62 | if isReady then 63 | return loop() 64 | else 65 | needsRestart = true 66 | end 67 | end 68 | end) 69 | end 70 | 71 | loop() 72 | end 73 | end) 74 | 75 | local function becomeReady() 76 | isReady = true 77 | 78 | if needsRestart then 79 | needsRestart = false 80 | 81 | loop() 82 | end 83 | end 84 | 85 | return { 86 | event = intervalEvent, 87 | callback = function(list) 88 | local callback = callbackCreator() 89 | 90 | local listCopy = {} 91 | for idx, unit in ipairs(list) do 92 | listCopy[idx] = unit 93 | end 94 | 95 | local copyIdx = 1 96 | local currentIdx = 1 97 | local seen = 0 98 | 99 | local function stepIdx() 100 | -- updates indices to match expeted positions 101 | if copyIdx <= #listCopy and listCopy[copyIdx] ~= list[currentIdx] then 102 | currentIdx = table.find(list, listCopy[copyIdx]) or currentIdx 103 | end 104 | 105 | local val = list[currentIdx] 106 | currentIdx = math.min(#list + 1, currentIdx + 1) 107 | copyIdx = math.min(#listCopy + 1, copyIdx + 1) 108 | if val then 109 | seen += 1 110 | end 111 | return val -- return next value to use 112 | end 113 | 114 | local currentTime = 0 115 | while currentTime <= duration do 116 | local remainingUnits = #list - seen 117 | local remainingTime = duration - currentTime 118 | local timePerUpdate = remainingTime / remainingUnits -- time we spend per future update (assuming even distribution) 119 | local updatesToDoNow = math.ceil(dt/timePerUpdate) -- if timePerUpdate < 1, we need to do more than 1 unit of work/frame - so we batch 120 | 121 | local p = Promise.delay(timePerUpdate):andThen(function(timeTaken) 122 | currentTime += timeTaken 123 | end) 124 | for _ = 1, updatesToDoNow do 125 | local nextUnit = stepIdx() 126 | if nextUnit then 127 | local ok, errorValue = coroutine.resume(coroutine.create(callback), nextUnit) 128 | 129 | if not ok then 130 | warn(errorValue) 131 | end 132 | end 133 | end 134 | p:await() 135 | end 136 | -- failsafe - if we have remaining units to update, we do them now 137 | if seen < #list then 138 | for i = seen, #list do 139 | local ok, errorValue = coroutine.resume(coroutine.create(callback), list[i]) 140 | 141 | if not ok then 142 | warn(errorValue) 143 | end 144 | end 145 | end 146 | becomeReady() 147 | end, 148 | } 149 | end 150 | } 151 | 152 | return function (fabric) 153 | local batches = {} 154 | local unitToListeners = {} 155 | 156 | local function setupBatching(staticUnit) 157 | local unitName = staticUnit.name 158 | 159 | if unitToListeners[unitName] then 160 | for _, listener in ipairs(unitToListeners[unitName]) do 161 | listener:Disconnect() 162 | end 163 | 164 | unitToListeners[unitName] = nil 165 | end 166 | 167 | if staticUnit.batch then 168 | -- add listener effect 169 | staticUnit.effects = staticUnit.effects or {} 170 | staticUnit.effects._batchListener = function(self) 171 | batches[unitName] = batches[unitName] or {} 172 | if self._batchArray == batches[unitName] then 173 | return 174 | end 175 | 176 | if unitToListeners[unitName] == nil and typeof(staticUnit.batch) == "function" then 177 | unitToListeners[unitName] = {} 178 | 179 | for _, listenerDefinition in ipairs(staticUnit.batch(batchConstructors)) do 180 | table.insert(unitToListeners[unitName], listenerDefinition.event:Connect(function() 181 | listenerDefinition.callback(batches[unitName]) 182 | end)) 183 | end 184 | end 185 | 186 | self._batchArray = batches[unitName] 187 | 188 | table.insert(self._batchArray, self) 189 | 190 | self:on("destroy", function() 191 | table.remove(self._batchArray, table.find(self._batchArray, self)) 192 | 193 | if #self._batchArray == 0 then 194 | for _, listener in ipairs(unitToListeners[unitName]) do 195 | listener:Disconnect() 196 | end 197 | 198 | batches[unitName] = nil 199 | unitToListeners[unitName] = nil 200 | end 201 | end) 202 | end 203 | 204 | function staticUnit.getAllUnits() 205 | -- return empty table to be nice to the user 206 | return batches[unitName] or {} 207 | end 208 | end 209 | end 210 | 211 | fabric:on("unitRegistered", setupBatching) 212 | fabric:on("unitHotReloaded", setupBatching) 213 | end 214 | -------------------------------------------------------------------------------- /src/Fabric/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local DEFAULT_NAMESPACE = "game" 4 | 5 | local Promise = require(script.Parent.Parent.Promise) 6 | 7 | local UnitCollection = require(script.UnitCollection) 8 | local Reactor = require(script.Reactor) 9 | local Serializer = require(script.Serializer) 10 | local HotReloader = require(script.HotReloader) 11 | local Symbol = require(script.Parent.Shared.Symbol) 12 | 13 | local Fabric = { 14 | reducers = require(script.Operators.Reducers); 15 | comparators = require(script.Operators.Comparators); 16 | t = require(script.Parent.Parent.t); 17 | DEBUG = true; 18 | Heartbeat = RunService.Heartbeat; 19 | None = Symbol.named("None"); 20 | Unit = setmetatable({}, { 21 | __index = function(_, key) 22 | error(("Unit %q is not registered!"):format(key)) 23 | end 24 | }); 25 | } 26 | Fabric.__index = Fabric 27 | 28 | function Fabric.new(namespace) 29 | local self = setmetatable({ 30 | namespace = namespace or DEFAULT_NAMESPACE; 31 | _listeners = {}; 32 | }, Fabric) 33 | 34 | self.serializer = Serializer.new(self) 35 | self._collection = UnitCollection.new(self) 36 | self._reactor = Reactor.new(self) 37 | 38 | if RunService:IsStudio() then 39 | self._hotReloader = HotReloader.new(self) 40 | end 41 | 42 | return self 43 | end 44 | 45 | --[=[ 46 | Registers a unit. This function should be called before attempting to get or create the unit. 47 | 48 | @param unitDefinition UnitDefinition -- The definition of the unit 49 | @return UnitDefinition -- The passed unit definition 50 | ]=] 51 | function Fabric:registerUnit(unitDefinition) 52 | assert(unitDefinition ~= nil, "unitDefinition is nil") 53 | self._collection:register(unitDefinition) 54 | 55 | self:fire("unitRegistered", unitDefinition) 56 | 57 | return unitDefinition 58 | end 59 | 60 | --[=[ 61 | Registers all units that are immmediate children of a container. 62 | Skips any test scripts (i.e. name of form `*.spec`) in the container. 63 | 64 | @param container Instance -- The container 65 | @return nil 66 | ]=] 67 | function Fabric:registerUnitsIn(container) 68 | for _, object in ipairs(container:GetChildren()) do 69 | if object:IsA("ModuleScript") then 70 | if not object.Name:match("%.spec$") then 71 | local unitDefinition = require(object) 72 | 73 | if unitDefinition.name == nil then 74 | unitDefinition.name = object.Name 75 | end 76 | 77 | self:registerUnit(unitDefinition) 78 | 79 | if self._hotReloader then 80 | self._hotReloader:giveModule(object, unitDefinition) 81 | end 82 | end 83 | else 84 | self:registerUnitsIn(object) 85 | end 86 | end 87 | end 88 | 89 | --[=[ 90 | Returns the unit associated with a unit resolvable that is attached to a ref, 91 | or nil if it doesn't exist. 92 | 93 | @param unitResolvable UnitResolvable -- The unit to retrieve 94 | @param ref Ref -- The ref to retrieve the unit from 95 | @return Unit? -- The attached unit 96 | ]=] 97 | function Fabric:getUnitByRef(unitResolvable, ref) 98 | return self._collection:getUnitByRef(unitResolvable, ref) 99 | end 100 | 101 | --[=[ 102 | Returns the unit associated with a unit resolvable that is attached to ref. 103 | If it does not exist, then creates and attaches the unit to ref and returns it. 104 | 105 | @param unitResolvable UnitResolvable -- The unit to retrieve 106 | @param ref Ref -- The ref to retrieve the attached unit from 107 | @return Unit -- The attached unit 108 | ]=] 109 | function Fabric:getOrCreateUnitByRef(unitResolvable, ref) 110 | return self._collection:getOrCreateUnitByRef(unitResolvable, ref) 111 | end 112 | 113 | function Fabric:getLoadedUnitByRef(unitResolvable, ref) 114 | local unit = self._collection:getUnitByRef(unitResolvable, ref) 115 | 116 | if unit == nil then 117 | error(("Attempt to get loaded unit %q on %s, but it does not exist."):format( 118 | tostring(unitResolvable), 119 | tostring(ref) 120 | )) 121 | end 122 | 123 | if not (unit._loaded or unit._loading) then 124 | error(("Attempt to call getLoadedUnitByRef on %q on %s, but it will never be loaded."):format( 125 | tostring(unitResolvable), 126 | tostring(ref) 127 | )) 128 | end 129 | 130 | return Promise.new(function(resolve, reject) 131 | if unit._loaded then 132 | return resolve(unit) 133 | else 134 | unit:on("loaded", function() 135 | resolve(unit) 136 | end) 137 | 138 | -- This must be fired by the user. It's not fired anywhere inside the Fabric library. 139 | unit:on("loadingFailed", function(...) 140 | reject(...) 141 | end) 142 | end 143 | end) 144 | end 145 | 146 | --[=[ 147 | Removes all units attached to the passed ref. 148 | 149 | @param ref Ref -- The ref to remove all units from 150 | @return nil 151 | ]=] 152 | function Fabric:removeAllUnitsWithRef(ref) 153 | self._collection:removeAllUnitsWithRef(ref) 154 | end 155 | 156 | --[=[ 157 | Fires a fabric event. 158 | 159 | @param eventName string -- The event name to fire 160 | @param ... any -- The arguments to fire the event with. 161 | @return nil 162 | ]=] 163 | function Fabric:fire(eventName, ...) 164 | if not self._listeners[eventName] then 165 | return -- Do nothing if no listeners registered 166 | end 167 | 168 | for _, callback in ipairs(self._listeners[eventName]) do 169 | local success, errorValue = coroutine.resume(coroutine.create(callback), ...) 170 | 171 | if not success then 172 | warn(("Event listener for %s encountered an error: %s"):format( 173 | tostring(eventName), 174 | tostring(errorValue) 175 | )) 176 | end 177 | end 178 | end 179 | 180 | --[=[ 181 | Listens to a fabric event. 182 | 183 | @param eventName string -- The event name to listen to 184 | @param callback function -- The callback fired 185 | @return nil 186 | ]=] 187 | function Fabric:on(eventName, callback) 188 | self._listeners[eventName] = self._listeners[eventName] or {} 189 | table.insert(self._listeners[eventName], callback) 190 | 191 | return function() 192 | for i, listCallback in ipairs(self._listeners[eventName]) do 193 | if listCallback == callback then 194 | table.remove(self._listeners[eventName], i) 195 | break 196 | end 197 | end 198 | end 199 | end 200 | 201 | --[=[ 202 | Logs a debug message. Set fabric.DEBUG = true to enable. 203 | 204 | @param ... any -- The debug information to log 205 | @return nil 206 | ]=] 207 | function Fabric:debug(...) 208 | if self.DEBUG then 209 | warn("[Fabric]", ...) 210 | end 211 | end 212 | 213 | return Fabric -------------------------------------------------------------------------------- /src/Fabric/Unit.lua: -------------------------------------------------------------------------------- 1 | local Comparators = require(script.Parent.Operators.Comparators) 2 | local Reducers = require(script.Parent.Operators.Reducers) 3 | local Symbol = require(script.Parent.Parent.Shared.Symbol) 4 | local Util = require(script.Parent.Parent.Shared.Util) 5 | 6 | local RESERVED_SCOPES = { 7 | [Symbol.named("base")] = true; 8 | [Symbol.named("remote")] = true; 9 | } 10 | 11 | local Unit = {} 12 | Unit.__index = Unit 13 | 14 | function Unit:fire(eventName, ...) 15 | if self:isDestroyed() then 16 | error(("Cannot fire event %q because this unit is destroyed."):format( 17 | tostring(eventName) 18 | )) 19 | end 20 | 21 | -- call a method of the event name if it exists 22 | local methodName = "on" .. eventName:sub(1, 1):upper() .. eventName:sub(2) 23 | if self[methodName] then 24 | debug.profilebegin(("%s: %s"):format( 25 | tostring(self), 26 | tostring(methodName) 27 | )) 28 | 29 | local thread = coroutine.create(self[methodName]) 30 | local success, errorValue = coroutine.resume(thread, self, ...) 31 | 32 | debug.profileend() 33 | 34 | if not success then 35 | warn(("%q method of %s encountered an error: %s"):format( 36 | tostring(methodName), 37 | tostring(self), 38 | tostring(errorValue) 39 | )) 40 | 41 | return 42 | end 43 | 44 | if coroutine.status(thread) ~= "dead" then 45 | warn(("Yielding in %s of %s is not allowed!"):format( 46 | tostring(methodName), 47 | tostring(self) 48 | )) 49 | end 50 | end 51 | 52 | if not self._listeners[eventName] then 53 | return -- Do nothing if no listeners registered 54 | end 55 | 56 | for i, callback in ipairs(self._listeners[eventName]) do 57 | debug.profilebegin(("%s: %s (%d)"):format( 58 | tostring(self), 59 | tostring(eventName), 60 | i 61 | )) 62 | 63 | local success, errorValue = coroutine.resume(coroutine.create(callback), ...) 64 | 65 | debug.profileend() 66 | 67 | if not success then 68 | warn(("Event listener of %s for %q encountered an error: %s"):format( 69 | tostring(self), 70 | tostring(eventName), 71 | tostring(errorValue) 72 | )) 73 | end 74 | end 75 | end 76 | 77 | function Unit:on(eventName, callback) 78 | if self:isDestroyed() then 79 | error(("Cannot attach event listener %q because this unit is destroyed."):format( 80 | tostring(eventName) 81 | )) 82 | end 83 | 84 | self._listeners[eventName] = self._listeners[eventName] or {} 85 | table.insert(self._listeners[eventName], callback) 86 | 87 | -- The disconnect callback closes on `self`, which will prevent it from being 88 | -- GC'd as long as a reference to the callback exists. We use a weak values 89 | -- container to allow the unit to be cleaned up even if a callback 90 | -- exists. 91 | local weakSelfContainer = setmetatable({ self = self }, { __mode = "v" }) 92 | 93 | return function() 94 | local weakSelf = weakSelfContainer.self 95 | 96 | if not weakSelf then 97 | return 98 | end 99 | 100 | if weakSelf._listeners == nil then 101 | -- This unit has been destroyed 102 | return 103 | end 104 | 105 | for i, listCallback in ipairs(weakSelf._listeners[eventName]) do 106 | if listCallback == callback then 107 | table.remove(weakSelf._listeners[eventName], i) 108 | break 109 | end 110 | end 111 | end 112 | end 113 | 114 | function Unit:get(key) 115 | self.fabric._reactor:react(self, key) 116 | 117 | local object = self.data 118 | 119 | if object == nil then 120 | return 121 | end 122 | 123 | if key == nil then 124 | return object 125 | end 126 | 127 | if type(key) == "table" then 128 | for _, field in ipairs(key) do 129 | object = object[field] 130 | 131 | if object == nil then 132 | return 133 | end 134 | end 135 | 136 | return object 137 | elseif type(object) == "table" then 138 | return object[key] 139 | else 140 | error("Can't call Unit:get() with a parameter when unit data isn't a table") 141 | end 142 | end 143 | 144 | function Unit:getUnit(unitResolvable) 145 | self:assertNotDestroyed() 146 | return self.fabric._collection:getUnitByRef(unitResolvable, self) 147 | end 148 | 149 | function Unit:getOrCreateUnit(unitResolvable) 150 | self:assertNotDestroyed() 151 | return self.fabric._collection:getOrCreateUnitByRef(unitResolvable, self) 152 | end 153 | 154 | function Unit:isDestroyed() 155 | return self._destroyed or false 156 | end 157 | 158 | function Unit:assertNotDestroyed() 159 | assert(self:isDestroyed() == false, "This unit is destroyed!") 160 | end 161 | 162 | function Unit:addLayer(scope, data) 163 | self:assertNotDestroyed() 164 | return self:_addLayer(scope, data) 165 | end 166 | 167 | function Unit:mergeBaseLayer(data) 168 | self:assertNotDestroyed() 169 | local existingBaseLayer = self._layers[Symbol.named("base")] or {} 170 | local newBaseLayer = {} 171 | 172 | for _, tableToMerge in ipairs({existingBaseLayer, data}) do 173 | for key, value in pairs(tableToMerge) do 174 | if value == self.fabric.None then 175 | newBaseLayer[key] = nil 176 | else 177 | newBaseLayer[key] = value 178 | end 179 | end 180 | end 181 | 182 | return self:_addLayer(Symbol.named("base"), newBaseLayer) 183 | end 184 | 185 | function Unit:removeLayer(scope) 186 | self:assertNotDestroyed() 187 | return self:_removeLayer(scope) 188 | end 189 | 190 | function Unit:_addLayer(scope, data) 191 | if data == nil then 192 | return self:_removeLayer(scope) 193 | end 194 | 195 | if self._layers[scope] == nil then 196 | table.insert(self._layerOrder, scope) 197 | end 198 | 199 | self._layers[scope] = data 200 | 201 | -- Set up automatic layer removal if scope is a unit 202 | -- This lets you use a unit as a scope, and the layer gets auto removed 203 | -- when the unit gets removed. 204 | if type(scope) == "table" and getmetatable(getmetatable(scope)) == Unit then 205 | if self._unitScopeLayers[scope] == nil then 206 | self._unitScopeLayers[scope] = scope:on("destroy", function() 207 | self:_removeLayer(scope) 208 | end) 209 | end 210 | end 211 | 212 | self:_changed() 213 | end 214 | 215 | function Unit:_removeLayer(scope) 216 | -- Disconnect listener for layer removal if the layer is removed explicitly 217 | if self._unitScopeLayers[scope] then 218 | self._unitScopeLayers[scope]() -- This is the disconnect callback 219 | self._unitScopeLayers[scope] = nil 220 | end 221 | 222 | if self._layers[scope] then 223 | table.remove(self._layerOrder, table.find(self._layerOrder, scope)) 224 | 225 | self._layers[scope] = nil 226 | self:_changed() 227 | end 228 | 229 | local shouldDestroy = next(self._layers) == nil 230 | 231 | if shouldDestroy then 232 | self:fire("destroy") 233 | end 234 | end 235 | 236 | function Unit:_runEffect(key) 237 | self:_cleanUpEffect(key) 238 | 239 | self.fabric._reactor:push(self, key) 240 | 241 | debug.profilebegin(("%s: Effect %s"):format( 242 | tostring(self), 243 | tostring(key) 244 | )) 245 | 246 | local thread = coroutine.create(self.effects[key]) 247 | local success, returnValue = coroutine.resume(thread, self) 248 | 249 | debug.profileend() 250 | 251 | if coroutine.status(thread) ~= "dead" then 252 | warn(("Effect %q of %s yielded! This is very illegal."):format( 253 | tostring(key), 254 | tostring(self) 255 | )) 256 | end 257 | 258 | self.fabric._reactor:pop() 259 | 260 | if success then 261 | if type(returnValue) == "function" then 262 | self._effectCleanUps[key] = returnValue 263 | elseif returnValue ~= nil then 264 | warn(("Effect %q of %s should only return either a function or nil."):format( 265 | tostring(key), 266 | tostring(self) 267 | )) 268 | end 269 | else 270 | warn(("Effect %q of %s encountered an error: %s"):format( 271 | tostring(key), 272 | tostring(self), 273 | tostring(returnValue) 274 | )) 275 | end 276 | end 277 | 278 | function Unit:_runEffects() 279 | if self.effects == nil then 280 | return 281 | end 282 | 283 | -- TODO: Document effects don't run in guaranteed order 284 | for key in pairs(self.effects) do 285 | self:_runEffect(key) 286 | end 287 | end 288 | 289 | function Unit:_cleanUpEffect(key) 290 | if self._effectCleanUps[key] then 291 | local thread = coroutine.create(self._effectCleanUps[key]) 292 | 293 | self._effectCleanUps[key] = nil 294 | 295 | local success, errorValue = coroutine.resume(thread) 296 | 297 | if coroutine.status(thread) ~= "dead" then 298 | warn(("Effect clean up %q of %s yielded! Please change your code to avoid doing this."):format( 299 | tostring(key), 300 | tostring(self) 301 | )) 302 | end 303 | 304 | if not success then 305 | warn(("Effect clean up %q of %s encountered an error: %s"):format( 306 | tostring(key), 307 | tostring(self), 308 | tostring(errorValue) 309 | )) 310 | end 311 | end 312 | end 313 | 314 | function Unit:_cleanUpEffects() 315 | for key in pairs(self._effectCleanUps) do 316 | self:_cleanUpEffect(key) 317 | end 318 | end 319 | 320 | function Unit:_changed() 321 | local lastData = self.data 322 | 323 | debug.profilebegin(("%s: reduce"):format( 324 | tostring(self) 325 | )) 326 | 327 | local newData = self:_reduce() 328 | 329 | debug.profileend() 330 | 331 | self.data = newData 332 | 333 | local shouldRunAllEffects = false 334 | if lastData == nil and newData ~= nil then 335 | self._loaded = true 336 | self._loading = false 337 | self:fire("loaded", newData) 338 | 339 | shouldRunAllEffects = true 340 | end 341 | 342 | if (self.shouldUpdate or Comparators.default)(newData, lastData) then 343 | self:fire("updated", newData, lastData) 344 | end 345 | 346 | if shouldRunAllEffects then 347 | self:_runEffects() 348 | end 349 | end 350 | 351 | function Unit:_reduce() 352 | if next(self._layers) == nil then 353 | return 354 | end 355 | 356 | local values = { self._layers[Symbol.named("remote")] } 357 | table.insert(values, self._layers[Symbol.named("base")]) 358 | 359 | for _, name in ipairs(self._layerOrder) do 360 | if RESERVED_SCOPES[name] == nil then 361 | table.insert(values, self._layers[name]) 362 | end 363 | end 364 | 365 | local reducedValue = (self.reducer or Reducers.default)(values) 366 | local data = reducedValue 367 | 368 | if self.defaults and type(data) == "table" then 369 | data = Util.assign({}, self.defaults, reducedValue) 370 | end 371 | 372 | if self.schema then 373 | assert(self.schema(data)) 374 | end 375 | 376 | return data 377 | end 378 | 379 | function Unit:isLoaded() 380 | self:assertNotDestroyed() 381 | return self._loaded 382 | end 383 | 384 | function Unit:setIsLoading() 385 | self:assertNotDestroyed() 386 | if self._loaded then 387 | error("Attempt to call setIsLoading when this unit is already loaded.") 388 | end 389 | 390 | self._loading = true 391 | end 392 | 393 | function Unit:__tostring() 394 | return ("Unit(%s)"):format( 395 | typeof(self.ref) == "Instance" and ("%s, %s"):format(self.name, self.ref:GetFullName()) or self.name 396 | ) 397 | end 398 | 399 | return Unit 400 | -------------------------------------------------------------------------------- /src/Fabric/Unit.spec.lua: -------------------------------------------------------------------------------- 1 | local Util = require(script.Parent.Parent.Shared.Util) 2 | local Fabric = require(script.Parent) 3 | 4 | local TEST_REF = {} 5 | local TEST_REF_2 = {} 6 | 7 | return function() 8 | local function makeTestUnitDefinition(fabric) 9 | local callCounts = Util.callCounter() 10 | 11 | local reducers = fabric.reducers 12 | 13 | return { 14 | name = "Test"; 15 | onInitialize = function(self) 16 | expect(self).to.be.ok() 17 | callCounts:call("onInitialize") 18 | end; 19 | onDestroy = function(self) 20 | expect(self).to.be.ok() 21 | callCounts:call("onDestroy") 22 | end; 23 | defaults = { 24 | testDefault = 5; 25 | }; 26 | reducer = reducers.structure({ 27 | added = reducers.add; 28 | nested = reducers.structure({ 29 | value = reducers.last; 30 | }) 31 | }); 32 | shouldUpdate = fabric.comparators.structure({ 33 | shouldUpdateTest = function() 34 | callCounts:call("shouldUpdate") 35 | return false 36 | end 37 | }); 38 | schema = function(data) 39 | expect(data).to.be.ok() 40 | expect(type(data)).to.equal("table") 41 | 42 | callCounts:call("schema") 43 | 44 | return true 45 | end; 46 | refCheck = function(ref) 47 | expect(ref).to.be.ok() 48 | expect(ref).to.equal(TEST_REF) 49 | callCounts:call("refCheck") 50 | 51 | return true 52 | end; 53 | tag = "Test"; 54 | onUpdated = function() 55 | callCounts:call("onUpdated") 56 | end; 57 | onLoaded = function() 58 | callCounts:call("onLoaded") 59 | end; 60 | }, callCounts 61 | end 62 | 63 | local fabric 64 | 65 | beforeEach(function() 66 | fabric = Fabric.new("test") 67 | end) 68 | 69 | describe("Unit", function() 70 | it("should add units", function() 71 | local testUnit, callCounts = makeTestUnitDefinition(fabric) 72 | fabric:registerUnit(testUnit) 73 | 74 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 75 | 76 | unit:addLayer("foo", { 77 | added = 1; 78 | nested = { 79 | value = "nested_value"; 80 | }; 81 | shouldUpdateTest = 1; 82 | }) 83 | 84 | expect(callCounts.refCheck).to.equal(1) 85 | expect(callCounts.onInitialize).to.equal(1) 86 | expect(unit:isLoaded()).to.equal(true) 87 | expect(callCounts.onLoaded).to.equal(1) 88 | expect(callCounts.onUpdated).to.equal(1) 89 | expect(callCounts.schema).to.equal(1) 90 | expect(callCounts.onDestroy).to.equal(0) 91 | 92 | local loadedPromise = fabric:getLoadedUnitByRef("Test", TEST_REF) 93 | expect(loadedPromise:getStatus()).to.equal("Resolved") 94 | expect(loadedPromise._values[1]).to.equal(unit) 95 | 96 | expect(unit:get("added")).to.equal(1) 97 | expect(unit:get("testDefault")).to.equal(5) 98 | expect(unit:get({"nested", "value"})).to.equal("nested_value") 99 | end) 100 | 101 | it("should have data and lastData correct in onUpdated", function() 102 | local iteration = 0 103 | local callCount = 0 104 | 105 | fabric:registerUnit({ 106 | name = "hi"; 107 | onUpdated = function(self, newData, lastData) 108 | callCount += 1 109 | 110 | if iteration == 0 then 111 | expect(newData).to.be.ok() 112 | expect(lastData).to.never.be.ok() 113 | 114 | expect(newData.foo).to.equal(1) 115 | elseif iteration == 1 then 116 | expect(newData).to.be.ok() 117 | expect(lastData).to.be.ok() 118 | 119 | expect(newData.foo).to.equal(2) 120 | expect(lastData.foo).to.equal(1) 121 | end 122 | end 123 | }) 124 | 125 | expect(callCount).to.equal(0) 126 | 127 | local unit = fabric:getOrCreateUnitByRef("hi", TEST_REF) 128 | 129 | unit:addLayer("hi", { 130 | foo = 1 131 | }) 132 | 133 | expect(callCount).to.equal(1) 134 | 135 | iteration += 1 136 | 137 | unit:addLayer("hi", { 138 | foo = 2 139 | }) 140 | 141 | expect(callCount).to.equal(2) 142 | end) 143 | 144 | it("should be safe when dealing with non-table data", function() 145 | fabric:registerUnit({ 146 | name = "foo", 147 | reducer = fabric.reducers.add, 148 | defaults = { 149 | bar = 2 150 | } 151 | }) 152 | 153 | local unit = fabric:getOrCreateUnitByRef("foo", TEST_REF) 154 | 155 | unit:addLayer("aaa", 1) 156 | 157 | expect(unit:get()).to.equal(1) 158 | end) 159 | 160 | describe("mergeBaseLayer", function() 161 | it("should allow merging into the base layer", function() 162 | local testUnit, _callCounts = makeTestUnitDefinition(fabric) 163 | fabric:registerUnit(testUnit) 164 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 165 | 166 | unit:mergeBaseLayer({ 167 | bar = 1 168 | }) 169 | 170 | unit:mergeBaseLayer({ 171 | foo = 2 172 | }) 173 | expect(unit.data.bar).to.equal(1) 174 | expect(unit.data.foo).to.equal(2) 175 | 176 | unit:mergeBaseLayer({ 177 | bar = 2 178 | }) 179 | expect(unit.data.bar).to.equal(2) 180 | expect(unit.data.foo).to.equal(2) 181 | end) 182 | 183 | it("should work when the base layer is nil", function() 184 | local testUnit, _callCounts = makeTestUnitDefinition(fabric) 185 | fabric:registerUnit(testUnit) 186 | 187 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 188 | 189 | unit:mergeBaseLayer({ 190 | baz = 4 191 | }) 192 | 193 | expect(unit.data.baz).to.equal(4) 194 | end) 195 | 196 | it("should set fabric.None values to nil", function() 197 | local testUnit, _callCounts = makeTestUnitDefinition(fabric) 198 | fabric:registerUnit(testUnit) 199 | 200 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 201 | 202 | unit:mergeBaseLayer({ 203 | bar = 1 204 | }) 205 | 206 | expect(unit.data.bar).to.equal(1) 207 | 208 | unit:mergeBaseLayer({ 209 | bar = fabric.None 210 | }) 211 | expect(unit.data.bar).to.never.be.ok() 212 | end) 213 | end) 214 | 215 | describe("get", function() 216 | it("should get values", function() 217 | fabric:registerUnit({ 218 | name = "foo" 219 | }) 220 | 221 | local unit = fabric:getOrCreateUnitByRef("foo", TEST_REF) 222 | 223 | expect(unit:get("baz")).to.equal(nil) 224 | expect(unit:get({})).to.equal(nil) 225 | 226 | unit:addLayer("bar", { 227 | baz = 1 228 | }) 229 | 230 | expect(unit:get("baz")).to.equal(1) 231 | expect(unit:get({"baz"})).to.equal(1) 232 | 233 | expect(unit:get().baz).to.equal(1) 234 | expect(unit:get({}).baz).to.equal(1) 235 | end) 236 | 237 | it("should get nested values", function() 238 | fabric:registerUnit({ 239 | name = "foo" 240 | }) 241 | 242 | local unit = fabric:getOrCreateUnitByRef("foo", TEST_REF) 243 | 244 | expect(unit:get("baz")).to.equal(nil) 245 | 246 | unit:addLayer("bar", { 247 | baz = { 248 | qux = 1 249 | } 250 | }) 251 | 252 | expect(unit:get({"baz", "qux"})).to.equal(1) 253 | 254 | expect(unit:get().baz.qux).to.equal(1) 255 | expect(unit:get({"baz"}).qux).to.equal(1) 256 | end) 257 | 258 | it("should error with non-table data", function() 259 | fabric:registerUnit({ 260 | name = "foo", 261 | reducer = fabric.reducers.add, 262 | }) 263 | 264 | local unit = fabric:getOrCreateUnitByRef("foo", TEST_REF) 265 | 266 | expect(unit:get("baz")).to.equal(nil) 267 | expect(unit:get({})).to.equal(nil) 268 | 269 | unit:addLayer("bar", 1) 270 | 271 | expect(function() 272 | unit:get("bad") 273 | end).to.throw() 274 | 275 | expect(function() 276 | unit:get({"hi"}) 277 | end).to.throw() 278 | 279 | expect(unit:get()).to.equal(1) 280 | expect(unit:get({})).to.equal(1) 281 | end) 282 | end) 283 | 284 | it("should combine layers", function() 285 | local testUnit, callCounts = makeTestUnitDefinition(fabric) 286 | fabric:registerUnit(testUnit) 287 | 288 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 289 | unit:mergeBaseLayer({ 290 | added = 1; 291 | nested = { 292 | value = "nested_value_first"; 293 | }; 294 | shouldUpdateTest = 1; 295 | }) 296 | 297 | unit:addLayer("bar", { 298 | added = 1; 299 | nested = { 300 | value = "nested_value_last"; 301 | }; 302 | shouldUpdateTest = 2; 303 | }) 304 | 305 | expect(callCounts.refCheck).to.equal(1) 306 | expect(callCounts.onInitialize).to.equal(1) 307 | expect(callCounts.onLoaded).to.equal(1) 308 | expect(callCounts.onUpdated).to.equal(2) 309 | expect(callCounts.schema).to.equal(2) 310 | expect(callCounts.onDestroy).to.equal(0) 311 | 312 | expect(unit:get("added")).to.equal(2) 313 | expect(unit:get({"nested", "value"})).to.equal("nested_value_last") 314 | end) 315 | 316 | it("should run the shouldUpdate handler", function() 317 | local testUnit, callCounts = makeTestUnitDefinition(fabric) 318 | fabric:registerUnit(testUnit) 319 | 320 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 321 | unit:addLayer("foo", { 322 | shouldUpdateTest = 1; 323 | }) 324 | 325 | unit:addLayer("bar", { 326 | shouldUpdateTest = 2; 327 | }) 328 | 329 | expect(callCounts.shouldUpdate).to.equal(3) 330 | expect(callCounts.onUpdated).to.equal(1) 331 | end) 332 | 333 | it("should remove correctly", function() 334 | local testUnit, callCounts = makeTestUnitDefinition(fabric) 335 | fabric:registerUnit(testUnit) 336 | 337 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 338 | 339 | unit:addLayer("foo", { 340 | added = 1; 341 | }) 342 | 343 | unit:addLayer("bar", { 344 | added = 2; 345 | }) 346 | 347 | expect(unit:get("added")).to.equal(3) 348 | 349 | unit:removeLayer("foo") 350 | 351 | expect(unit:get("added")).to.equal(2) 352 | 353 | expect(unit:isDestroyed()).to.equal(false) 354 | 355 | unit:removeLayer("bar") 356 | 357 | expect(unit:get("added")).to.equal(nil) 358 | expect(callCounts.onDestroy).to.equal(1) 359 | expect(unit:isDestroyed()).to.equal(true) 360 | 361 | local newUnit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 362 | newUnit:addLayer("foo", { 363 | added = 1; 364 | }) 365 | 366 | expect(newUnit).to.never.equal(unit) 367 | end) 368 | 369 | it("shouldn't remove other refs", function() 370 | local newUnit = { 371 | name = "Test2", 372 | tag = "Test2", 373 | } 374 | local newUnit2 = { 375 | name = "Test3", 376 | tag = "Test3", 377 | units = { 378 | Test2 = {} 379 | }, 380 | } 381 | fabric:registerUnit(newUnit) 382 | fabric:registerUnit(newUnit2) 383 | 384 | local unit = fabric:getOrCreateUnitByRef("Test3", TEST_REF) 385 | unit:addLayer("foo", {}) 386 | local otherUnit = fabric:getOrCreateUnitByRef("Test3", TEST_REF_2) 387 | otherUnit:addLayer("foo", {}) 388 | 389 | expect(unit:isDestroyed()).to.equal(false) 390 | expect(otherUnit:isDestroyed()).to.equal(false) 391 | 392 | unit:removeLayer("foo") 393 | 394 | expect(unit:isDestroyed()).to.equal(true) 395 | expect(otherUnit:isDestroyed()).to.equal(false) 396 | end) 397 | 398 | it("should attach nested units", function() 399 | local newUnit = { 400 | name = "Test2", 401 | tag = "Test2", 402 | } 403 | local newUnit2 = { 404 | name = "Test3", 405 | tag = "Test3", 406 | units = { 407 | Test2 = { 408 | foo = 1; 409 | } 410 | }, 411 | } 412 | fabric:registerUnit(newUnit) 413 | fabric:registerUnit(newUnit2) 414 | 415 | local unit = fabric:getOrCreateUnitByRef("Test3", TEST_REF) 416 | local nestedUnit = unit:getUnit("Test2") 417 | 418 | expect(unit).to.be.ok() 419 | expect(nestedUnit).to.be.ok() 420 | expect(nestedUnit.data.foo).to.equal(1) 421 | end) 422 | 423 | it("should remove layers whose scopes are units when the unit is destroyed", function() 424 | fabric:registerUnit({ 425 | name = "Test" 426 | }) 427 | 428 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 429 | local unitToBeRemoved = fabric:getOrCreateUnitByRef("Test", TEST_REF_2) 430 | 431 | unit:addLayer("some scope", { 432 | foo = 0 433 | }) 434 | 435 | expect(unit:get("foo")).to.equal(0) 436 | 437 | unit:addLayer(unitToBeRemoved, { 438 | foo = 1 439 | }) 440 | 441 | expect(unit:get("foo")).to.equal(1) 442 | 443 | expect(unit:isDestroyed()).to.equal(false) 444 | expect(unitToBeRemoved:isDestroyed()).to.equal(false) 445 | 446 | fabric:removeAllUnitsWithRef(TEST_REF_2) 447 | 448 | expect(unit:isDestroyed()).to.equal(false) 449 | expect(unit:get("foo")).to.equal(0) 450 | end) 451 | end) 452 | 453 | describe("effects", function() 454 | it("should run effects on creation", function() 455 | local count = 0 456 | 457 | fabric:registerUnit({ 458 | name = "Test", 459 | effects = { 460 | function() 461 | count += 1 462 | end 463 | } 464 | }) 465 | 466 | expect(count).to.equal(0) 467 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 468 | expect(count).to.equal(0) 469 | unit:mergeBaseLayer({}) 470 | expect(count).to.equal(1) 471 | unit:mergeBaseLayer({}) 472 | expect(count).to.equal(1) 473 | end) 474 | 475 | it("should only re-run effects when accessed keys change", function() 476 | local count = 0 477 | 478 | fabric:registerUnit({ 479 | name = "Test", 480 | effects = { 481 | function(self) 482 | self:get("akey") 483 | count += 1 484 | end 485 | } 486 | }) 487 | 488 | expect(count).to.equal(0) 489 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 490 | 491 | expect(count).to.equal(0) 492 | unit:mergeBaseLayer({ 493 | akey = 1, 494 | }) 495 | expect(count).to.equal(1) 496 | unit:mergeBaseLayer({ 497 | akey = 1, 498 | bkey = 2, 499 | }) 500 | expect(count).to.equal(1) 501 | unit:mergeBaseLayer({ 502 | akey = 3, 503 | }) 504 | expect(count).to.equal(2) 505 | end) 506 | 507 | it("should run effect cleanups before running effects again", function() 508 | local runCount = 0 509 | local cleanCount = 0 510 | 511 | fabric:registerUnit({ 512 | name = "Test", 513 | effects = { 514 | function(self) 515 | self:get("akey") 516 | runCount += 1 517 | 518 | return function() 519 | cleanCount += 1 520 | end 521 | end 522 | } 523 | }) 524 | 525 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 526 | 527 | expect(runCount).to.equal(0) 528 | expect(cleanCount).to.equal(0) 529 | 530 | unit:mergeBaseLayer({ 531 | akey = 1, 532 | }) 533 | 534 | expect(runCount).to.equal(1) 535 | expect(cleanCount).to.equal(0) 536 | 537 | unit:mergeBaseLayer({ 538 | akey = 1, 539 | bkey = 2, 540 | }) 541 | 542 | expect(runCount).to.equal(1) 543 | expect(cleanCount).to.equal(0) 544 | 545 | unit:mergeBaseLayer({ 546 | akey = 3, 547 | }) 548 | 549 | expect(runCount).to.equal(2) 550 | expect(cleanCount).to.equal(1) 551 | end) 552 | end) 553 | 554 | describe("Events", function() 555 | SKIP() 556 | it("shouldn't run events if connected during firing", function() 557 | fabric:registerUnit({ 558 | name = "Test", 559 | }) 560 | 561 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 562 | 563 | local count = 0 564 | unit:on("foo", function() 565 | unit:on("foo", function() 566 | count += 1 567 | end) 568 | end) 569 | 570 | unit:fire("foo") 571 | 572 | expect(count).to.equal(0) 573 | end) 574 | 575 | it("shouldn't skip events if one is disconnected during firing", function() 576 | fabric:registerUnit({ 577 | name = "Test", 578 | }) 579 | 580 | local unit = fabric:getOrCreateUnitByRef("Test", TEST_REF) 581 | 582 | local disconnect 583 | disconnect = unit:on("foo", function() 584 | disconnect() 585 | end) 586 | 587 | local count = 0 588 | unit:on("foo", function() 589 | count += 1 590 | end) 591 | 592 | expect(count).to.equal(1) 593 | end) 594 | end) 595 | end --------------------------------------------------------------------------------