commit ee69c0333453f2e303bca09333508c5c91a23ebe Author: Mark Marks Date: Sat Sep 21 19:12:15 2024 +0200 Initial push diff --git a/.darklua.json b/.darklua.json new file mode 100644 index 0000000..05f17e8 --- /dev/null +++ b/.darklua.json @@ -0,0 +1,17 @@ +{ + "process": [ + { + "rule": "convert_require", + "current": { + "name": "path", + "sources": { + "@pkg": "Packages/" + } + }, + "target": { + "name": "roblox", + "rojo_sourcemap": "sourcemap.json" + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..47ad325 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: Continous Integration + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Analyze + run: lune run analyze + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Lint + run: | + selene crates/ + + style: + name: Styling + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Check code style + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.20.0 + args: --check crates/ + + test: + name: Unit Testing + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run tests + run: | + lune run test/runner.luau diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aea33f4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout Project + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Build + run: | + lune run install-packages + + - name: Upload Build Artifact + uses: actions/upload-artifact@v3 + with: + name: build + path: build.rbxm + + release: + name: Release + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Project + uses: actions/checkout@v4 + + - name: Download Build + uses: actions/download-artifact@v3 + with: + name: build + path: build + + - name: Rename Build + run: mv build/build.rbxm jecs_utils.rbxm + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: Jecs Utils ${{ github.ref_name }} + files: | + jecs_utils.rbxm + + publish: + name: Publish + needs: [release] + runs-on: ubuntu-latest + steps: + - name: Checkout Project + uses: actions/checkout@v4 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Wally Login + run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }} + + - name: Publish + run: wally publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2ce70d --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz +*.rbxm +dist/ + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Wally files +DevPackages +Packages +wally.lock +WallyPatches + +# Typescript +/node_modules +/include + +# Misc +roblox.toml +sourcemap.json +globalTypes.d.luau diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..77287ab --- /dev/null +++ b/.luaurc @@ -0,0 +1,7 @@ +{ + "languageMode": "strict", + "aliases": { + "jecs_utils": "lib", + "testkit": "test/testkit" + } +} diff --git a/.lune/analyze.luau b/.lune/analyze.luau new file mode 100644 index 0000000..80baca3 --- /dev/null +++ b/.lune/analyze.luau @@ -0,0 +1,17 @@ +--!nocheck +local process = require("@lune/process") + +local function start_process(cmd: string) + local arguments = string.split(cmd, " ") + local command = arguments[1] + table.remove(arguments, 1) + + process.spawn(command, arguments, { stdio = "forward" }) +end + +start_process("lune run install-packages.luau") +--start_process("curl -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau") +start_process("rojo sourcemap dev.project.json -o sourcemap.json") +start_process( + "luau-lsp analyze --base-luaurc=.luaurc --sourcemap=sourcemap.json --settings=luau_lsp_settings.json --no-strict-dm-types --ignore Packages/**/*.lua --ignore Packages/**/*.luau lib/" +) diff --git a/.lune/build.luau b/.lune/build.luau new file mode 100644 index 0000000..dba9cc1 --- /dev/null +++ b/.lune/build.luau @@ -0,0 +1,14 @@ +--!nocheck +local process = require("@lune/process") + +local function start_process(cmd: string, env: { string }?) + local arguments = string.split(cmd, " ") + local command = arguments[1] + table.remove(arguments, 1) + + process.spawn(command, arguments, { stdio = "forward", env = env }) +end + +start_process("lune run install-packages") +start_process("darklua process --config .darklua.json lib/ dist/", { "ROBLOX_DEV=false" }) +start_process("rojo build build.project.json -o build.rbxm") diff --git a/.lune/check.luau b/.lune/check.luau new file mode 100644 index 0000000..38fe2ca --- /dev/null +++ b/.lune/check.luau @@ -0,0 +1,14 @@ +--!nocheck +local process = require("@lune/process") + +local function start_process(cmd: string) + local arguments = string.split(cmd, " ") + local command = arguments[1] + table.remove(arguments, 1) + + process.spawn(command, arguments, { stdio = "forward" }) +end + +start_process("lune run analyze") +start_process("stylua lib/") +start_process("selene lib/") diff --git a/.lune/dev.luau b/.lune/dev.luau new file mode 100644 index 0000000..7316dc0 --- /dev/null +++ b/.lune/dev.luau @@ -0,0 +1,15 @@ +--!nocheck +local process = require("@lune/process") +local task = require("@lune/task") + +local function start_process(cmd: string, env: { string }?) + local arguments = string.split(cmd, " ") + local command = arguments[1] + table.remove(arguments, 1) + + process.spawn(command, arguments, { stdio = "forward", env = env }) +end + +task.spawn(start_process, "rojo sourcemap dev.project.json -o sourcemap.json --watch") +task.spawn(start_process, "darklua process --config .darklua.json --watch lib/ dist/", { "ROBLOX_DEV=true" }) +--task.spawn(start_process, "rojo serve dev.project.json") diff --git a/.lune/install-packages.luau b/.lune/install-packages.luau new file mode 100644 index 0000000..45103fb --- /dev/null +++ b/.lune/install-packages.luau @@ -0,0 +1,14 @@ +--!nocheck +local process = require("@lune/process") + +local function start_process(cmd: string, cwd: string?) + local arguments = string.split(cmd, " ") + local command = arguments[1] + table.remove(arguments, 1) + + process.spawn(command, arguments, { stdio = "forward", cwd = cwd }) +end + +start_process("wally install") +start_process("rojo sourcemap dev.project.json -o sourcemap.json") +start_process("wally-package-types --sourcemap sourcemap.json Packages/") diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..53f2598 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,51 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings +{ + "lsp": { + "luau-lsp": { + "settings": { + "luau-lsp": { + "completion": { + "imports": { + "enabled": true, + "suggestServices": true, + "suggestRequires": false + } + }, + "sourcemap": { + "rojoProjectFile": "dev.project.json" + }, + "require": { + "mode": "relativeToFile", + "fileAliases": { + "@jecs_utils": "lib", + "@testkit": "test/testkit" + }, + "directoryAliases": { + "@pkg": "Packages/" + } + } + }, + "ext": { + "roblox": { + "enabled": false + }, + "fflags": { + "override": { + "LuauTinyControlFlowAnalysis": "true" + }, + "sync": true, + "enable_by_default": false + } + } + } + } + }, + "languages": { + "TypeScript": { + "tab_size": 4 + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65ed78b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mark-Marks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9833642 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# sapphire-utils +[![CI](https://img.shields.io/github/actions/workflow/status/mark-marks/jecs-utils/ci.yml?style=for-the-badge&label=CI)](https://github.com/mark-marks/jecs-utils/actions/workflows/ci.yml) +[![Wally](https://img.shields.io/github/v/tag/mark-marks/jecs-utils?&style=for-the-badge)](https://wally.run/package/mark-marks/jecs-utils) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](https://github.com/Mark-Marks/jecs-utils/blob/main/LICENSE) + +
+ + + +## Features + +- [collect](/lib/collect.luau) - Collects all arguments fired through the given signal, and drains the collection on iteration. +- [handle](/lib/handle.luau) - Wrap `jecs.World` functions for faster (DX wise) operating on entities +- [replicator](/lib/replicator.luau) - Keep track of all entities with the passed components and calculate differences +- [ref](/lib/ref.luau) - Reference entities by key +- [command_buffer](/lib/command_buffer.luau) - Buffer commands to prevent query invalidation diff --git a/build.project.json b/build.project.json new file mode 100644 index 0000000..047a782 --- /dev/null +++ b/build.project.json @@ -0,0 +1,6 @@ +{ + "name": "jecs-utils", + "tree": { + "$path": "dist" + } +} diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..c1b2026 --- /dev/null +++ b/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "jecs-utils", + "tree": { + "$path": "lib" + } +} diff --git a/dev.project.json b/dev.project.json new file mode 100644 index 0000000..1a17592 --- /dev/null +++ b/dev.project.json @@ -0,0 +1,15 @@ +{ + "name": "dev", + "tree": { + "$className": "DataModel", + + "ReplicatedStorage": { + "Packages": { + "$path": "Packages", + "jecs_utils": { + "$path": "lib" + } + } + } + } +} diff --git a/lib/collect.luau b/lib/collect.luau new file mode 100644 index 0000000..d1c01b1 --- /dev/null +++ b/lib/collect.luau @@ -0,0 +1,73 @@ +--!strict +--!optimize 2 + +--[[ +original author by @memorycode + +MIT License + +Copyright (c) 2024 Michael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--]] + +--- What signals passed to `collect()` should be able to be coerced into +export type signal_like = { connect: confn, [any]: any } | { Connect: confn, [any]: any } +type confn = (self: signal_like, (T...) -> ()) -> D + +--- Collects all arguments fired through the given signal, and drains the collection on iteration.\ +--- Expects signals to have a `Connect` ***method***. +--- ```luau +--- local sig = collect(some_signal) +--- +--- -- Imagine this as an ECS scheduler loop +--- while task.wait() do +--- for index, arg1 in sig do -- arg1, arg2, etc +--- print(arg1) +--- end +--- end +--- ``` +--- @param event signal +--- @return () -> (number, T...), D -- iterator and disconnector +local function collect(event: signal_like): (() -> (number, T...), D) + local storage = {} + local mt = {} + local iter = function() + local n = #storage + return function(): (number?, T...) + if n <= 0 then + mt.__iter = nil + return nil + end + + n -= 1 + return n + 1, unpack(table.remove(storage, 1) :: any) + end + end + + local disconnect = event:Connect(function(...) + table.insert(storage, { ... }) + mt.__iter = iter :: any + end) + + setmetatable(storage, mt) + return storage :: any, disconnect +end + +return collect diff --git a/lib/command_buffer.luau b/lib/command_buffer.luau new file mode 100644 index 0000000..25415a5 --- /dev/null +++ b/lib/command_buffer.luau @@ -0,0 +1,114 @@ +--!strict +--!optimize 2 +local jecs = require("@pkg/jecs") +type entity = jecs.Entity +type id = jecs.Id + +local world = require("./world").get() + +--- `map>` +local add_commands: { [id]: { entity } } = {} +--- `map>` +local set_commands: { [id]: { [entity]: any } } = {} +--- `map>` +local remove_commands: { [id]: { entity } } = {} +--- `array` +local delete_commands: { entity } = {} + +type command_buffer = { + --- Execute all buffered commands and clear the buffer + flush: () -> (), + + --- Adds a component to the entity with no value + add: (entity: entity, component: id) -> (), + --- Assigns a value to a component on the given entity + set: (entity: entity, component: id, data: T) -> (), + --- Removes a component from the given entity + remove: (entity: entity, component: id) -> (), + --- Deletes an entity from the world + delete: (entity: entity) -> (), +} + +local function flush() + local adds = add_commands + local sets = set_commands + local removes = remove_commands + local deletes = delete_commands + table.clear(add_commands) + table.clear(set_commands) + table.clear(remove_commands) + table.clear(delete_commands) + + for _, id in deletes do + world:delete(id) + end + + for component, ids in adds do + for _, id in ids do + if deletes[id] then + continue + end + + world:add(id, component) + end + end + + for component, ids in sets do + for id, value in ids do + if deletes[id] then + continue + end + + world:set(id, component, value) + end + end + + for component, ids in removes do + for _, id in ids do + if deletes[id] then + continue + end + + world:remove(id, component) + end + end +end + +local function add(entity: entity, component: id) + if not add_commands[component] then + add_commands[component] = {} + end + + table.insert(add_commands[component], entity) +end + +local function set(entity: entity, component: id, data: T) + if not set_commands[component] then + set_commands[component] = {} + end + + set_commands[component][entity] = data +end + +local function remove(entity: entity, component: id) + if not remove_commands[component] then + remove_commands[component] = {} + end + + table.insert(remove_commands[component], entity) +end + +local function delete(entity: entity) + table.insert(delete_commands, entity) +end + +local command_buffer: command_buffer = { + flush = flush, + + add = add, + set = set, + remove = remove, + delete = delete, +} + +return command_buffer diff --git a/lib/handle.luau b/lib/handle.luau new file mode 100644 index 0000000..fe36d12 --- /dev/null +++ b/lib/handle.luau @@ -0,0 +1,77 @@ +--!strict +--!optimize 2 +local jecs = require("@pkg/jecs") +export type entity = jecs.Entity +export type id = entity | jecs.Pair + +local world = require("./world").get() + +type interface = { + __index: interface, + + new: (entity: entity) -> handle, + + --- Checks if the entity has all of the given components + has: (self: handle, ...id) -> boolean, + --- Retrieves the value of up to 4 components. These values may be nil. + get: ((self: handle, id) -> A?) + & ((self: handle, id, id) -> (A?, B?)) + & ((self: handle, id, id, id) -> (A?, B?, C?)) + & ((self: handle, id, id, id, id) -> (A?, B?, C?, D?)), + --- Adds a component to the entity with no value + add: (self: handle, id: id) -> handle, + --- Assigns a value to a component on the given entity + set: (self: handle, id: id, data: T) -> handle, + --- Removes a component from the given entity + remove: (self: handle, id: id) -> handle, + --- Deletes the entity and all its related components and relationships. **Does not** refer to deleting the handle + delete: (self: handle) -> (), + --- Gets the entitys id + id: (self: handle) -> entity, +} + +export type handle = typeof(setmetatable({} :: { entity: entity }, {} :: interface)) + +local handle = {} :: interface +handle.__index = handle + +function handle.new(entity: entity) + local self = { + entity = entity, + } + + return setmetatable(self, handle) +end + +function handle:has(...: id): boolean + return world:has(self.entity, ...) +end + +handle.get = function(self: handle, a: id, b: id?, c: id?, d: id?) + return world:get(self.entity, a, b :: any, c :: any, d :: any) +end :: any + +function handle:add(id: id): handle + world:add(self.entity, id) + return self +end + +function handle:set(id: id, value: T): handle + world:set(self.entity, id, value) + return self +end + +function handle:remove(id: id): handle + world:remove(self.entity, id) + return self +end + +function handle:delete() + world:delete(self.entity) +end + +function handle:id(): entity + return self.entity +end + +return handle diff --git a/lib/init.luau b/lib/init.luau new file mode 100644 index 0000000..3374e60 --- /dev/null +++ b/lib/init.luau @@ -0,0 +1,26 @@ +--!strict +--!optimize 2 +local WORLD = require("./world") +local collect = require("./collect") +local command_buffer = require("./command_buffer") +local handle = require("./handle") +local jecs = require("@pkg/jecs") +local ref = require("./ref") +local replicator = require("./replicator") + +--- Set the world for all utilities. +--- Should be called once per context before any utility is used. +--- @param world jecs.World +local function initialize(world: jecs.World) + WORLD.set(world) +end + +return { + initialize = initialize, + + collect = collect, + handle = handle, + replicator = replicator, + ref = ref, + command_buffer = command_buffer, +} diff --git a/lib/ref.luau b/lib/ref.luau new file mode 100644 index 0000000..ae0f6ba --- /dev/null +++ b/lib/ref.luau @@ -0,0 +1,27 @@ +--!strict +--!optimize 2 +local handle = require("./handle") +local world = require("./world").get() + +local refs = {} + +--- Gets an entity the given key references to. +--- If the key is nil, an entirely new entity is created and returned. +--- If the key doesn't reference an entity, a new entity is made for it to reference and returned. +--- @param key any +--- @return handle +local function ref(key: any): handle.handle + if not key then + return handle.new(world:entity()) + end + + local entity = refs[key] + if not entity then + entity = world:entity() + refs[key] = entity + end + + return handle.new(entity) +end + +return ref diff --git a/lib/replicator.luau b/lib/replicator.luau new file mode 100644 index 0000000..dd9de5c --- /dev/null +++ b/lib/replicator.luau @@ -0,0 +1,246 @@ +--!strict +--!optimize 2 +local jecs = require("@pkg/jecs") +type entity = jecs.Entity +type i53 = number + +local ref = require("./ref") +local world = require("./world").get() + +--- A replicator keeps track of all entities with the passed components and their values - +--- whenever a component is changed (add, change, remove) and the replicator listens to it, it's also changed within the contained raw data.\ +--- The developer can then calculate the difference on the server and send it to the client every time, +--- on which the difference is then applied to the world.\ +--- Albeit it's called a replicator, it doesn't replicate the data by itself. +--- This allows the developer to use any networking libary to replicate the changes. +--- ```luau +--- -- server +--- local replicator = jecs_utils.create_replicator(component_a, component_b, ...) +--- +--- local function system() +--- local difference = replicator.calculate_difference() +--- -- There might not be any difference +--- if not difference then +--- return +--- end +--- data_replication_event.send_to_all(difference) +--- end +--- ``` +--- ```luau +--- -- client +--- local replicator = jecs_utils.replicator(component_a, component_b, ...) +--- +--- local function system() +--- for _, difference in data_replication_event.poll() do +--- replicator.apply_difference(difference) +--- end +--- end +--- ``` +export type replicator = { + --- Gets the full data representing the entire world. + --- Useful for initial replication to every player. + --- ```luau + --- local replicator = jecs_utils.replicator(component_a, component_b, ...) + --- + --- Players.PlayerAdded:Connect(function(player) + --- data_replication_event.send_to(player, replicator.get_full_data()) + --- end) + --- ``` + --- @return changes + get_full_data: () -> changes, + --- Calculates the difference between last sent data and currently stored data. + --- ```luau + --- local replicator = jecs_utils.create_replicator(component_a, component_b, ...) + --- + --- local function system() + --- local difference = replicator.calculate_difference() + --- -- There might not be any difference + --- if not difference then + --- return + --- end + --- data_replication_event.send_to_all(difference) + --- end + --- ``` + --- @return changes? -- There might not be any difference + calculate_difference: () -> changes?, + --- Applies the difference to the current data. + --- ```luau + --- local replicator = jecs_utils.replicator(component_a, component_b, ...) + --- + --- local function system() + --- for _, difference in data_replication_event.poll() do + --- replicator.apply_difference(difference) + --- end + --- end + --- ``` + --- @param difference changes + apply_difference: (difference: changes) -> (), +} + +--- `map>` +type changes_added = { [i53]: { i53 } } +--- `map>` +type changes_set = { [i53]: { [i53]: unknown } } +--- `map>` +type changes_removed = { [i53]: { i53 } } + +export type changes = { + added: changes_added, + set: changes_set, + removed: changes_removed, +} + +--- A replicator keeps track of all entities with the passed components and their values - +--- whenever a component is changed (add, change, remove) and the replicator listens to it, it's also changed within the contained raw data.\ +--- The developer can then calculate the difference on the server and send it to the client every time, +--- on which the difference is then applied to the world.\ +--- Albeit it's called a replicator, it doesn't replicate the data by itself. +--- This allows the developer to use any networking libary to replicate the changes. +--- ```luau +--- -- server +--- local replicator = jecs_utils.create_replicator(component_a, component_b, ...) +--- +--- local function system() +--- local difference = replicator.calculate_difference() +--- -- There might not be any difference +--- if not difference then +--- return +--- end +--- data_replication_event.send_to_all(difference) +--- end +--- ``` +--- ```luau +--- -- client +--- local replicator = jecs_utils.replicator(component_a, component_b, ...) +--- +--- local function system() +--- for _, difference in data_replication_event.poll() do +--- replicator.apply_difference(difference) +--- end +--- end +--- ``` +--- @param ... entity +--- @return replicator +local function replicator(...: entity): replicator + local components = { ... } + + -- don't index a changes table start + local raw_added: changes_added = {} + local raw_set: changes_set = {} + local raw_removed: changes_removed = {} + + local changes_added: changes_added = {} + local changes_set: changes_set = {} + local changes_removed: changes_removed = {} + -- don't index a changes table end + + for _, component in components do + world:set(component, jecs.OnAdd, function(entity) + if not raw_added[component] then + raw_added[component] = {} + end + if not changes_added[component] then + changes_added[component] = {} + end + table.insert(raw_added[component], entity) + table.insert(changes_added[component], entity) + end) + world:set(component, jecs.OnSet, function(entity, value) + if not raw_set[component] then + raw_set[component] = {} + end + if not changes_set[component] then + changes_set[component] = {} + end + raw_set[component][entity] = value + changes_set[component][entity] = value + end) + world:set(component, jecs.OnRemove, function(entity) + if not raw_removed[component] then + raw_removed[component] = {} + end + if not changes_removed[component] then + changes_removed[component] = {} + end + table.insert(raw_removed[component], entity) + table.insert(changes_removed[component], entity) + end) + end + + local function get_full_data(): changes + return { + added = raw_added, + set = raw_set, + removed = raw_removed, + } + end + + local function calculate_difference(): changes? + local difference_added = changes_added + local difference_set = changes_set + local difference_removed = changes_removed + changes_added = {} + changes_set = {} + changes_removed = {} + + local added_not_empty = next(difference_added) ~= nil + local set_not_empty = next(difference_set) ~= nil + local removed_not_empty = next(difference_removed) ~= nil + + if not added_not_empty and not set_not_empty and not removed_not_empty then + return nil + end + + return { + added = difference_added, + set = difference_set, + removed = difference_removed, + } + end + + local function apply_difference(difference: changes) + for component, entities in difference.added do + for _, entity_id in entities do + local entity = ref(`replicated-{entity_id}`) + + local exists = entity:has(component) + if exists then + continue + end + entity:add(component) + end + end + + for component, entities in difference.set do + for entity_id, value in entities do + local entity = ref(`replicated-{entity_id}`) + + local existing_value = entity:get(component) + if existing_value == value then + continue + end + entity:set(component, value) + end + end + + for component, entities in difference.removed do + for _, entity_id in entities do + local entity = ref(`replicated-{entity_id}`) + + local exists = entity:has(component) + if exists then + continue + end + entity:remove(component) + end + end + end + + return { + get_full_data = get_full_data, + calculate_difference = calculate_difference, + apply_difference = apply_difference, + } +end + +return replicator diff --git a/lib/world.luau b/lib/world.luau new file mode 100644 index 0000000..d431198 --- /dev/null +++ b/lib/world.luau @@ -0,0 +1,18 @@ +--!strict +--!optimize 2 +local jecs = require("@pkg/jecs") + +local WORLD: jecs.World + +local function get(): jecs.World + return WORLD +end + +local function set(world: jecs.World) + WORLD = world +end + +return { + get = get, + set = set, +} diff --git a/luau_lsp_settings.json b/luau_lsp_settings.json new file mode 100644 index 0000000..5ec3d6a --- /dev/null +++ b/luau_lsp_settings.json @@ -0,0 +1,9 @@ +{ + "luau-lsp.fflags.override": { + "LuauTinyControlFlowAnalysis": "true" + }, + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.require.directoryAliases": { + "@pkg": "Packages/" + } +} diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..a514cad --- /dev/null +++ b/rokit.toml @@ -0,0 +1,14 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +wally = "upliftgames/wally@0.3.2" +rojo = "rojo-rbx/rojo@7.4.4" +lune = "lune-org/lune@0.8.8" +selene = "kampfkarren/selene@0.27.1" +luau-lsp = "johnnymorganz/luau-lsp@1.32.4" +stylua = "johnnymorganz/stylua@0.20.0" +wally-package-types = "johnnymorganz/wally-package-types@1.3.2" +darklua = "seaofvoices/darklua@0.13.1" diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..9afc306 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "selene_definitions" diff --git a/selene_definitions.yaml b/selene_definitions.yaml new file mode 100644 index 0000000..72faae9 --- /dev/null +++ b/selene_definitions.yaml @@ -0,0 +1,6 @@ +base: roblox +name: selene_definitions +globals: + require: + args: + - type: string diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..e61530e --- /dev/null +++ b/stylua.toml @@ -0,0 +1,10 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" + +[sort_requires] +enabled = true diff --git a/test/runner.luau b/test/runner.luau new file mode 100644 index 0000000..e69de29 diff --git a/test/testkit.luau b/test/testkit.luau new file mode 100644 index 0000000..ce2583f --- /dev/null +++ b/test/testkit.luau @@ -0,0 +1,529 @@ +-------------------------------------------------------------------------------- +-- testkit.luau +-- v0.7.3 +-- MIT License +-- Copyright (c) 2022 centau +-------------------------------------------------------------------------------- + +local disable_ansi = false + +local color = { + white_underline = function(s: string): string + return if disable_ansi then s else `\27[1;4m{s}\27[0m` + end, + + white = function(s: string): string + return if disable_ansi then s else `\27[37;1m{s}\27[0m` + end, + + green = function(s: string): string + return if disable_ansi then s else `\27[32;1m{s}\27[0m` + end, + + red = function(s: string): string + return if disable_ansi then s else `\27[31;1m{s}\27[0m` + end, + + yellow = function(s: string): string + return if disable_ansi then s else `\27[33;1m{s}\27[0m` + end, + + red_highlight = function(s: string): string + return if disable_ansi then s else `\27[41;1;30m{s}\27[0m` + end, + + green_highlight = function(s: string): string + return if disable_ansi then s else `\27[42;1;30m{s}\27[0m` + end, + + gray = function(s: string): string + return if disable_ansi then s else `\27[38;1m{s}\27[0m` + end, + + orange = function(s: string): string + return if disable_ansi then s else `\27[38;5;208m{s}\27[0m` + end, +} + +local function convert_units(unit: string, value: number): (number, string) + local sign = math.sign(value) + value = math.abs(value) + + local prefix_colors = { + [4] = color.red, + [3] = color.red, + [2] = color.yellow, + [1] = color.yellow, + [0] = color.green, + [-1] = color.red, + [-2] = color.yellow, + [-3] = color.green, + [-4] = color.red, + } + + local prefixes = { + [4] = "T", + [3] = "G", + [2] = "M", + [1] = "k", + [0] = " ", + [-1] = "m", + [-2] = "u", + [-3] = "n", + [-4] = "p", + } + + local order = 0 + + while value >= 1000 do + order += 1 + value /= 1000 + end + + while value ~= 0 and value < 1 do + order -= 1 + value *= 1000 + end + + if value >= 100 then + value = math.floor(value) + elseif value >= 10 then + value = math.floor(value * 1e1) / 1e1 + elseif value >= 1 then + value = math.floor(value * 1e2) / 1e2 + end + + return value * sign, prefix_colors[order](prefixes[order] .. unit) +end + +local WALL = color.gray("│") + +-------------------------------------------------------------------------------- +-- Testing +-------------------------------------------------------------------------------- + +type Test = { + name: string, + case: Case?, + cases: { Case }, + duration: number, + error: { + message: string, + trace: string, + }?, + focus: boolean, +} + +type Case = { + name: string, + result: number, + line: number?, + focus: boolean, +} + +local PASS, FAIL, NONE, ERROR, SKIPPED = 1, 2, 3, 4, 5 + +local check_for_focused = false +local skip = false +local test: Test? +local tests: { Test } = {} + +local function output_test_result(test: Test) + if check_for_focused then + local any_focused = test.focus + for _, case in test.cases do + any_focused = any_focused or case.focus + end + + if not any_focused then + return + end + end + + print(color.white(test.name)) + + for _, case in test.cases do + local status = ({ + [PASS] = color.green("PASS"), + [FAIL] = color.red("FAIL"), + [NONE] = color.orange("NONE"), + [ERROR] = color.red("FAIL"), + [SKIPPED] = color.yellow("SKIP"), + })[case.result] + + local line = case.result == FAIL and color.red(`{case.line}:`) or "" + if check_for_focused and case.focus == false and test.focus == false then + continue + end + print(`{status}{WALL} {line}{color.gray(case.name)}`) + end + + if test.error then + print(color.gray("error: ") .. color.red(test.error.message)) + print(color.gray("trace: ") .. color.red(test.error.trace)) + else + print() + end +end + +local function CASE(name: string) + skip = false + assert(test, "no active test") + + local case = { + name = name, + result = NONE, + focus = false, + } + + test.case = case + table.insert(test.cases, case) +end + +local function CHECK(value: T, stack: number?): T? + assert(test, "no active test") + + local case = test.case + + if not case then + CASE("") + case = test.case + end + + assert(case, "no active case") + + if case.result ~= FAIL then + case.result = value and PASS or FAIL + if skip then + case.result = SKIPPED + end + case.line = debug.info(stack and stack + 1 or 2, "l") + end + + return value +end + +local function TEST(name: string, fn: () -> ()) + local active = test + assert(not active, "cannot start test while another test is in progress") + + test = { + name = name, + cases = {}, + duration = 0, + focus = false, + } + assert(test) + + table.insert(tests, test) + + local start = os.clock() + local err + local success = xpcall(fn, function(m: string) + err = { message = m, trace = debug.traceback(nil, 2) } + end) + test.duration = os.clock() - start + + if not test.case then + CASE("") + end + assert(test.case, "no active case") + + if not success then + test.case.result = ERROR + test.error = err + end + + test = nil +end + +local function FOCUS() + assert(test, "no active test") + + check_for_focused = true + if test.case then + test.case.focus = true + else + test.focus = true + end +end + +local function FINISH(): boolean + local success = true + local total_cases = 0 + local passed_cases = 0 + local passed_focus_cases = 0 + local total_focus_cases = 0 + local duration = 0 + + for _, test in tests do + duration += test.duration + for _, case in test.cases do + total_cases += 1 + if case.focus or test.focus then + total_focus_cases += 1 + end + if case.result == PASS or case.result == NONE or case.result == SKIPPED then + if case.focus or test.focus then + passed_focus_cases += 1 + end + passed_cases += 1 + else + success = false + end + end + + output_test_result(test) + end + + print(color.gray(string.format(`{passed_cases}/{total_cases} test cases passed in %.3f ms.`, duration * 1e3))) + if check_for_focused then + print(color.gray(`{passed_focus_cases}/{total_focus_cases} focused test cases passed`)) + end + + local fails = total_cases - passed_cases + + print((fails > 0 and color.red or color.green)(`{fails} {fails == 1 and "fail" or "fails"}`)) + + check_for_focused = false + return success, table.clear(tests) +end + +local function SKIP(name: string) + skip = true +end + +-------------------------------------------------------------------------------- +-- Benchmarking +-------------------------------------------------------------------------------- + +type Bench = { + time_start: number?, + memory_start: number?, + iterations: number?, +} + +local bench: Bench? + +function START(iter: number?): number + local n = iter or 1 + assert(n > 0, "iterations must be greater than 0") + assert(bench, "no active benchmark") + assert(not bench.time_start, "clock was already started") + + bench.iterations = n + bench.memory_start = gcinfo() + bench.time_start = os.clock() + return n +end + +local function BENCH(name: string, fn: () -> ()) + local active = bench + assert(not active, "a benchmark is already in progress") + + bench = {} + assert(bench); + (collectgarbage :: any)("collect") + + local mem_start = gcinfo() + local time_start = os.clock() + local err_msg: string? + + local success = xpcall(fn, function(m: string) + err_msg = m .. debug.traceback(nil, 2) + end) + + local time_stop = os.clock() + local mem_stop = gcinfo() + + if not success then + print(`{WALL}{color.red("ERROR")}{WALL} {name}`) + print(color.gray(err_msg :: string)) + else + time_start = bench.time_start or time_start + mem_start = bench.memory_start or mem_start + + local n = bench.iterations or 1 + local d, d_unit = convert_units("s", (time_stop - time_start) / n) + local a, a_unit = convert_units("B", math.round((mem_stop - mem_start) / n * 1e3)) + + local function round(x: number): string + return x > 0 and x < 10 and (x - math.floor(x)) > 0 and string.format("%2.1f", x) + or string.format("%3.f", x) + end + + print( + string.format( + `%s %s %s %s{WALL} %s`, + color.gray(round(d)), + d_unit, + color.gray(round(a)), + a_unit, + color.gray(name) + ) + ) + end + + bench = nil +end + +-------------------------------------------------------------------------------- +-- Printing +-------------------------------------------------------------------------------- + +local function print2(v: unknown) + type Buffer = { n: number, [number]: string } + type Cyclic = { n: number, [{}]: number } + + -- overkill concatenationless string buffer + local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic) + local TAB = " " + local indent = table.concat(table.create(stack, TAB)) + + if type(value) == "string" then + local n = str.n + str[n + 1] = '"' + str[n + 2] = value + str[n + 3] = '"' + str.n = n + 3 + elseif type(value) ~= "table" then + local n = str.n + str[n + 1] = value == nil and "nil" or tostring(value) + str.n = n + 1 + elseif next(value) == nil then + local n = str.n + str[n + 1] = "{}" + str.n = n + 1 + else -- is table + local tabbed_indent = indent .. TAB + + if cyclic[value] then + str.n += 1 + str[str.n] = color.gray(`CYCLIC REF {cyclic[value]}`) + return + else + cyclic.n += 1 + cyclic[value] = cyclic.n + end + + str.n += 3 + str[str.n - 2] = "{ " + str[str.n - 1] = color.gray(tostring(cyclic[value])) + str[str.n - 0] = "\n" + + local i, v = next(value, nil) + while v ~= nil do + local n = str.n + str[n + 1] = tabbed_indent + + if type(i) ~= "string" then + str[n + 2] = "[" + str[n + 3] = tostring(i) + str[n + 4] = "]" + n += 4 + else + str[n + 2] = tostring(i) + n += 2 + end + + str[n + 1] = " = " + str.n = n + 1 + + tos(v, stack + 1, str, cyclic) + + i, v = next(value, i) + + n = str.n + str[n + 1] = v ~= nil and ",\n" or "\n" + str.n = n + 1 + end + + local n = str.n + str[n + 1] = indent + str[n + 2] = "}" + str.n = n + 2 + end + end + + local str = { n = 0 } + local cyclic = { n = 0 } + tos(v, 0, str, cyclic) + print(table.concat(str)) +end + +-------------------------------------------------------------------------------- +-- Equality +-------------------------------------------------------------------------------- + +local function shallow_eq(a: {}, b: {}): boolean + if #a ~= #b then + return false + end + + for i, v in next, a do + if b[i] ~= v then + return false + end + end + + for i, v in next, b do + if a[i] ~= v then + return false + end + end + + return true +end + +local function deep_eq(a: {}, b: {}): boolean + if #a ~= #b then + return false + end + + for i, v in next, a do + if type(b[i]) == "table" and type(v) == "table" then + if deep_eq(b[i], v) == false then + return false + end + elseif b[i] ~= v then + return false + end + end + + for i, v in next, b do + if type(a[i]) == "table" and type(v) == "table" then + if deep_eq(a[i], v) == false then + return false + end + elseif a[i] ~= v then + return false + end + end + + return true +end + +-------------------------------------------------------------------------------- +-- Return +-------------------------------------------------------------------------------- + +return { + test = function() + return TEST, CASE, CHECK, FINISH, SKIP, FOCUS + end, + + benchmark = function() + return BENCH, START + end, + + disable_formatting = function() + disable_ansi = true + end, + + print = print2, + + seq = shallow_eq, + deq = deep_eq, + + color = color, +} diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..d319ce3 --- /dev/null +++ b/wally.toml @@ -0,0 +1,18 @@ +[package] +name = "mark-marks/jecs-utils" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +license = "MIT" +exclude = ["**"] +include = [ + "default.project.json", + "lib", + "lib/**", + "LICENSE", + "wally.toml", + "README.md", +] + +[dependencies] +jecs = "ukendio/jecs@0.2.10"