Initial push

This commit is contained in:
Mark Marks 2024-09-21 19:12:15 +02:00
commit ee69c03334
31 changed files with 1578 additions and 0 deletions

17
.darklua.json Normal file
View file

@ -0,0 +1,17 @@
{
"process": [
{
"rule": "convert_require",
"current": {
"name": "path",
"sources": {
"@pkg": "Packages/"
}
},
"target": {
"name": "roblox",
"rojo_sourcemap": "sourcemap.json"
}
}
]
}

71
.github/workflows/ci.yml vendored Normal file
View file

@ -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

69
.github/workflows/release.yml vendored Normal file
View file

@ -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

57
.gitignore vendored Normal file
View file

@ -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

7
.luaurc Normal file
View file

@ -0,0 +1,7 @@
{
"languageMode": "strict",
"aliases": {
"jecs_utils": "lib",
"testkit": "test/testkit"
}
}

17
.lune/analyze.luau Normal file
View file

@ -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/"
)

14
.lune/build.luau Normal file
View file

@ -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")

14
.lune/check.luau Normal file
View file

@ -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/")

15
.lune/dev.luau Normal file
View file

@ -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")

View file

@ -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/")

51
.zed/settings.json Normal file
View file

@ -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
}
}
}

21
LICENSE Normal file
View file

@ -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.

16
README.md Normal file
View file

@ -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)
<br/>
</div>
## 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

6
build.project.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "jecs-utils",
"tree": {
"$path": "dist"
}
}

6
default.project.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "jecs-utils",
"tree": {
"$path": "lib"
}
}

15
dev.project.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "dev",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Packages": {
"$path": "Packages",
"jecs_utils": {
"$path": "lib"
}
}
}
}
}

73
lib/collect.luau Normal file
View file

@ -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<D, T...> = { connect: confn<D, T...>, [any]: any } | { Connect: confn<D, T...>, [any]: any }
type confn<D, T...> = (self: signal_like<D, T...>, (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<T...>
--- @return () -> (number, T...), D -- iterator and disconnector
local function collect<D, T...>(event: signal_like<D, T...>): (() -> (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

114
lib/command_buffer.luau Normal file
View file

@ -0,0 +1,114 @@
--!strict
--!optimize 2
local jecs = require("@pkg/jecs")
type entity<T = nil> = jecs.Entity<T>
type id<T = nil> = jecs.Id<T>
local world = require("./world").get()
--- `map<component_id, array<entity_id>>`
local add_commands: { [id]: { entity } } = {}
--- `map<component_id, array<entity_id, component_value>>`
local set_commands: { [id]: { [entity]: any } } = {}
--- `map<component_id, array<entity_id>>`
local remove_commands: { [id]: { entity } } = {}
--- `array<entity_id>`
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: <T>(entity: entity, component: id<T>, 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<T>(entity: entity, component: id<T>, 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

77
lib/handle.luau Normal file
View file

@ -0,0 +1,77 @@
--!strict
--!optimize 2
local jecs = require("@pkg/jecs")
export type entity<T = nil> = jecs.Entity<T>
export type id<T = nil> = entity<T> | 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: (<A>(self: handle, id<A>) -> A?)
& (<A, B>(self: handle, id<A>, id<B>) -> (A?, B?))
& (<A, B, C>(self: handle, id<A>, id<B>, id<C>) -> (A?, B?, C?))
& (<A, B, C, D>(self: handle, id<A>, id<B>, id<C>, id<D>) -> (A?, B?, C?, D?)),
--- Adds a component to the entity with no value
add: <T>(self: handle, id: id<T>) -> handle,
--- Assigns a value to a component on the given entity
set: <T>(self: handle, id: id<T>, 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<T>(id: id<T>): handle
world:add(self.entity, id)
return self
end
function handle:set<T>(id: id<T>, 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

26
lib/init.luau Normal file
View file

@ -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,
}

27
lib/ref.luau Normal file
View file

@ -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

246
lib/replicator.luau Normal file
View file

@ -0,0 +1,246 @@
--!strict
--!optimize 2
local jecs = require("@pkg/jecs")
type entity<T = nil> = jecs.Entity<T>
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<component_id, array<entity_id>>`
type changes_added = { [i53]: { i53 } }
--- `map<component_id, array<entity_id, component_value>>`
type changes_set = { [i53]: { [i53]: unknown } }
--- `map<component_id, array<entity_id>>`
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

18
lib/world.luau Normal file
View file

@ -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,
}

9
luau_lsp_settings.json Normal file
View file

@ -0,0 +1,9 @@
{
"luau-lsp.fflags.override": {
"LuauTinyControlFlowAnalysis": "true"
},
"luau-lsp.require.mode": "relativeToFile",
"luau-lsp.require.directoryAliases": {
"@pkg": "Packages/"
}
}

14
rokit.toml Normal file
View file

@ -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 <tool>` 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"

1
selene.toml Normal file
View file

@ -0,0 +1 @@
std = "selene_definitions"

6
selene_definitions.yaml Normal file
View file

@ -0,0 +1,6 @@
base: roblox
name: selene_definitions
globals:
require:
args:
- type: string

10
stylua.toml Normal file
View file

@ -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

0
test/runner.luau Normal file
View file

529
test/testkit.luau Normal file
View file

@ -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<T>(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,
}

18
wally.toml Normal file
View file

@ -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"