diff --git a/.darklua.json b/.darklua.json new file mode 100644 index 0000000..970498b --- /dev/null +++ b/.darklua.json @@ -0,0 +1,17 @@ +{ + "process": [ + { + "rule": "convert_require", + "current": { + "name": "path", + "sources": { + "@jecs": "Packages/jecs" + } + }, + "target": { + "name": "roblox", + "rojo_sourcemap": "sourcemap.json" + } + } + ] +} diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index 4a40166..0000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Continous Integration - -on: - push: - pull_request: - branches: - - main - -jobs: - build: - name: Build - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 - steps: - - name: Checkout Project - uses: actions/checkout@v4 - - - name: Install Rokit - uses: https://github.com/CompeyDev/setup-rokit@v0.1.2 - with: - token: ${{ secrets.githubtoken }} - - - name: Build - run: | - lune run build - - - name: Upload Build Artifact - uses: https://git.devmarked.win/actions/upload-artifact@v4 - with: - name: build - path: hammer.rbxm - - lint: - name: Lint - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Install Rokit - uses: https://github.com/CompeyDev/setup-rokit@v0.1.2 - with: - token: ${{ secrets.githubtoken }} - - - name: Lint - run: | - selene lib/ - - style: - name: Styling - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Check code style - uses: https://github.com/JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.githubtoken }} - version: v2.1.0 - args: --check lib/ - - test: - name: Unit Testing - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Install Luau - uses: https://github.com/EncodedVenom/install-luau@v4 - - - name: Install Rokit - uses: https://github.com/CompeyDev/setup-rokit@v0.1.2 - with: - token: ${{ secrets.githubtoken }} - - - name: Install Packages - run: | - pesde install - - - name: Run Unit Tests - run: | - output=$(luau test/tests.luau) - echo "$output" - if [[ "$output" == *"0 fails"* ]]; then - echo "Unit Tests Passed" - else - echo "Error: One or More Unit Tests Failed." - exit 1 - fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0389aa6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +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 lib/ + + 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 lib/ + + test: + name: Unit Testing + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Luau + uses: encodedvenom/install-luau@v2.1 + + - name: Install Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download Jecs + run: | + lune run download-jecs ${{ secrets.GITHUB_TOKEN }} + + - name: Run Unit Tests + run: | + output=$(luau test/tests.luau) + echo "$output" + if [[ "$output" == *"0 fails"* ]]; then + echo "Unit Tests Passed" + else + echo "Error: One or More Unit Tests Failed." + exit 1 + fi diff --git a/.forgejo/workflows/release.yml b/.github/workflows/release.yml similarity index 51% rename from .forgejo/workflows/release.yml rename to .github/workflows/release.yml index 0b3d032..02b87f9 100644 --- a/.forgejo/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,34 +7,28 @@ on: jobs: build: name: Build - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 + runs-on: ubuntu-latest steps: - name: Checkout Project uses: actions/checkout@v4 - name: Install Rokit - uses: https://github.com/CompeyDev/setup-rokit@v0.1.2 - with: - token: ${{ secrets.githubtoken }} + uses: CompeyDev/setup-rokit@v0.1.2 - name: Build run: | lune run build - name: Upload Build Artifact - uses: https://git.devmarked.win/actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: build - path: hammer.rbxm + path: build.rbxm release: name: Release needs: [build] - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 + runs-on: ubuntu-latest permissions: contents: write steps: @@ -42,32 +36,35 @@ jobs: uses: actions/checkout@v4 - name: Download Build - uses: https://git.devmarked.win/actions/download-artifact@v4 + 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: actions/forgejo-release@v2 + uses: softprops/action-gh-release@v1 with: - direction: upload - title: Hammer ${{ github.ref_name }} - release-dir: build + name: Jecs Utils ${{ github.ref_name }} + files: | + jecs_utils.rbxm publish: name: Publish needs: [release] - runs-on: docker - container: - image: ghcr.io/catthehacker/ubuntu:act-24.04 + runs-on: ubuntu-latest steps: - name: Checkout Project uses: actions/checkout@v4 - name: Install Rokit - uses: https://github.com/CompeyDev/setup-rokit@v0.1.2 - with: - token: ${{ secrets.githubtoken }} + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Prepare for Distribution + run: | + lune run dist - name: Wally Login run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }} @@ -76,7 +73,7 @@ jobs: run: wally publish - name: Pesde Login - run: pesde auth login --token "${{ secrets.PESDE_AUTH_TOKEN }}" + run: pesde auth login --token "Bearer ${{ secrets.WALLY_AUTH_TOKEN }}" - name: Pesde Publish - run: pesde publish -y + run: pesde publish diff --git a/.gitignore b/.gitignore index b79fda6..cdd52d9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,9 +44,7 @@ dist/ # Wally files DevPackages Packages -luau_packages wally.lock -pesde.lock WallyPatches # Typescript @@ -57,6 +55,7 @@ WallyPatches roblox.toml sourcemap.json globalTypes.d.luau -jecs_src.luau +# Used for testing, unfortunately we can't just install it with wally and expect it to work in vanilla luau +jecs.luau # Stores Github PAT .env diff --git a/.luaurc b/.luaurc index b88f9c7..332de00 100644 --- a/.luaurc +++ b/.luaurc @@ -1,7 +1,8 @@ { "languageMode": "strict", "aliases": { - "pkg": "luau_packages", - "lune": "~/.lune/.typedefs/0.9.2" + "jecs_utils": "lib", + "testkit": "test/testkit", + "jecs": "jecs" } } diff --git a/.lune/.lune-defs/datetime.luau b/.lune/.lune-defs/datetime.luau new file mode 100644 index 0000000..8992dab --- /dev/null +++ b/.lune/.lune-defs/datetime.luau @@ -0,0 +1,408 @@ +--[[ + NOTE: We export a couple different DateTimeValues types below to ensure + that types are completely accurate, for method args milliseconds will + always be optional, but for return values millis are always included + + If we figure out some better strategy here where we can + export just a single type while maintaining accuracy we + can change to that in a future breaking semver release +]] + +type OptionalMillisecond = { + millisecond: number?, +} + +type Millisecond = { + millisecond: number, +} + +--[=[ + @interface Locale + @within DateTime + + Enum type representing supported DateTime locales. + + Currently supported locales are: + + - `en` - English + - `de` - German + - `es` - Spanish + - `fr` - French + - `it` - Italian + - `ja` - Japanese + - `pl` - Polish + - `pt-br` - Brazilian Portuguese + - `pt` - Portuguese + - `tr` - Turkish +]=] +export type Locale = "en" | "de" | "es" | "fr" | "it" | "ja" | "pl" | "pt-br" | "pt" | "tr" + +--[=[ + @interface DateTimeValues + @within DateTime + + Individual date & time values, representing the primitives that make up a `DateTime`. + + This is a dictionary that will contain the following values: + + - `year` - Year(s), in the range 1400 -> 9999 + - `month` - Month(s), in the range 1 -> 12 + - `day` - Day(s), in the range 1 -> 31 + - `hour` - Hour(s), in the range 0 -> 23 + - `minute` - Minute(s), in the range 0 -> 59 + - `second` - Second(s), in the range 0 -> 60, where 60 is a leap second + + An additional `millisecond` value may also be included, + and should be within the range `0 -> 999`, but is optional. + + However, any method returning this type should be guaranteed + to include milliseconds - see individual methods to verify. +]=] +export type DateTimeValues = { + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, +} + +--[=[ + @interface DateTimeValueArguments + @within DateTime + + Alias for `DateTimeValues` with an optional `millisecond` value. + + Refer to the `DateTimeValues` documentation for additional information. +]=] +export type DateTimeValueArguments = DateTimeValues & OptionalMillisecond + +--[=[ + @interface DateTimeValueReturns + @within DateTime + + Alias for `DateTimeValues` with a mandatory `millisecond` value. + + Refer to the `DateTimeValues` documentation for additional information. +]=] +export type DateTimeValueReturns = DateTimeValues & Millisecond + +local DateTime = { + --- Number of seconds passed since the UNIX epoch. + unixTimestamp = (nil :: any) :: number, + --- Number of milliseconds passed since the UNIX epoch. + unixTimestampMillis = (nil :: any) :: number, +} + +--[=[ + @within DateTime + @tag Method + + Formats this `DateTime` using the given `formatString` and `locale`, as local time. + + The given `formatString` is parsed using a `strftime`/`strptime`-inspired + date and time formatting syntax, allowing tokens such as the following: + + | Token | Example | Description | + |-------|----------|---------------| + | `%Y` | `1998` | Year number | + | `%m` | `04` | Month number | + | `%d` | `29` | Day number | + | `%A` | `Monday` | Weekday name | + | `%M` | `59` | Minute number | + | `%S` | `10` | Second number | + + For a full reference of all available tokens, see the + [chrono documentation](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). + + If not provided, `formatString` and `locale` will default + to `"%Y-%m-%d %H:%M:%S"` and `"en"` (english) respectively. + + @param formatString -- A string containing formatting tokens + @param locale -- The locale the time should be formatted in + @return string -- The formatting string +]=] +function DateTime.formatLocalTime(self: DateTime, formatString: string?, locale: Locale?): string + return nil :: any +end + +--[=[ + @within DateTime + @tag Method + + Formats this `DateTime` using the given `formatString` and `locale`, as UTC (universal) time. + + The given `formatString` is parsed using a `strftime`/`strptime`-inspired + date and time formatting syntax, allowing tokens such as the following: + + | Token | Example | Description | + |-------|----------|---------------| + | `%Y` | `1998` | Year number | + | `%m` | `04` | Month number | + | `%d` | `29` | Day number | + | `%A` | `Monday` | Weekday name | + | `%M` | `59` | Minute number | + | `%S` | `10` | Second number | + + For a full reference of all available tokens, see the + [chrono documentation](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). + + If not provided, `formatString` and `locale` will default + to `"%Y-%m-%d %H:%M:%S"` and `"en"` (english) respectively. + + @param formatString -- A string containing formatting tokens + @param locale -- The locale the time should be formatted in + @return string -- The formatting string +]=] +function DateTime.formatUniversalTime( + self: DateTime, + formatString: string?, + locale: Locale? +): string + return nil :: any +end + +--[=[ + @within DateTime + @tag Method + + Formats this `DateTime` as an ISO 8601 date-time string. + + Some examples of ISO 8601 date-time strings are: + + - `2020-02-22T18:12:08Z` + - `2000-01-31T12:34:56+05:00` + - `1970-01-01T00:00:00.055Z` + + @return string -- The ISO 8601 formatted string +]=] +function DateTime.toIsoDate(self: DateTime): string + return nil :: any +end + +--[=[ + @within DateTime + @tag Method + + Extracts separated local date & time values from this `DateTime`. + + The returned table contains the following values: + + | Key | Type | Range | + |---------------|----------|----------------| + | `year` | `number` | `1400 -> 9999` | + | `month` | `number` | `1 -> 12` | + | `day` | `number` | `1 -> 31` | + | `hour` | `number` | `0 -> 23` | + | `minute` | `number` | `0 -> 59` | + | `second` | `number` | `0 -> 60` | + | `millisecond` | `number` | `0 -> 999` | + + @return DateTimeValueReturns -- A table of DateTime values +]=] +function DateTime.toLocalTime(self: DateTime): DateTimeValueReturns + return nil :: any +end + +--[=[ + @within DateTime + @tag Method + + Extracts separated UTC (universal) date & time values from this `DateTime`. + + The returned table contains the following values: + + | Key | Type | Range | + |---------------|----------|----------------| + | `year` | `number` | `1400 -> 9999` | + | `month` | `number` | `1 -> 12` | + | `day` | `number` | `1 -> 31` | + | `hour` | `number` | `0 -> 23` | + | `minute` | `number` | `0 -> 59` | + | `second` | `number` | `0 -> 60` | + | `millisecond` | `number` | `0 -> 999` | + + @return DateTimeValueReturns -- A table of DateTime values +]=] +function DateTime.toUniversalTime(self: DateTime): DateTimeValueReturns + return nil :: any +end + +export type DateTime = typeof(DateTime) + +--[=[ + @class DateTime + + Built-in library for date & time + + ### Example usage + + ```lua + local DateTime = require("@lune/datetime") + + -- Creates a DateTime for the current exact moment in time + local now = DateTime.now() + + -- Formats the current moment in time as an ISO 8601 string + print(now:toIsoDate()) + + -- Formats the current moment in time, using the local + -- time, the French locale, and the specified time string + print(now:formatLocalTime("%A, %d %B %Y", "fr")) + + -- Returns a specific moment in time as a DateTime instance + local someDayInTheFuture = DateTime.fromLocalTime({ + year = 3033, + month = 8, + day = 26, + hour = 16, + minute = 56, + second = 28, + millisecond = 892, + }) + + -- Extracts the current local date & time as separate values (same values as above table) + print(now:toLocalTime()) + + -- Returns a DateTime instance from a given float, where the whole + -- denotes the seconds and the fraction denotes the milliseconds + -- Note that the fraction for millis here is completely optional + DateTime.fromUnixTimestamp(871978212313.321) + + -- Extracts the current universal (UTC) date & time as separate values + print(now:toUniversalTime()) + ``` +]=] +local dateTime = {} + +--[=[ + @within DateTime + @tag Constructor + + Returns a `DateTime` representing the current moment in time. + + @return DateTime -- The new DateTime object +]=] +function dateTime.now(): DateTime + return nil :: any +end + +--[=[ + @within DateTime + @tag Constructor + + Creates a new `DateTime` from the given UNIX timestamp. + + This timestamp may contain both a whole and fractional part - + where the fractional part denotes milliseconds / nanoseconds. + + Example usage of fractions: + + - `DateTime.fromUnixTimestamp(123456789.001)` - one millisecond + - `DateTime.fromUnixTimestamp(123456789.000000001)` - one nanosecond + + Note that the fractional part has limited precision down to exactly + one nanosecond, any fraction that is more precise will get truncated. + + @param unixTimestamp -- Seconds passed since the UNIX epoch + @return DateTime -- The new DateTime object +]=] +function dateTime.fromUnixTimestamp(unixTimestamp: number): DateTime + return nil :: any +end + +--[=[ + @within DateTime + @tag Constructor + + Creates a new `DateTime` from the given date & time values table, in universal (UTC) time. + + The given table must contain the following values: + + | Key | Type | Range | + |----------|----------|----------------| + | `year` | `number` | `1400 -> 9999` | + | `month` | `number` | `1 -> 12` | + | `day` | `number` | `1 -> 31` | + | `hour` | `number` | `0 -> 23` | + | `minute` | `number` | `0 -> 59` | + | `second` | `number` | `0 -> 60` | + + An additional `millisecond` value may also be included, + and should be within the range `0 -> 999`, but is optional. + + Any non-integer values in the given table will be rounded down. + + ### Errors + + This constructor is fallible and may throw an error in the following situations: + + - Date units (year, month, day) were given that produce an invalid date. For example, January 32nd or February 29th on a non-leap year. + + @param values -- Table containing date & time values + @return DateTime -- The new DateTime object +]=] +function dateTime.fromUniversalTime(values: DateTimeValueArguments): DateTime + return nil :: any +end + +--[=[ + @within DateTime + @tag Constructor + + Creates a new `DateTime` from the given date & time values table, in local time. + + The given table must contain the following values: + + | Key | Type | Range | + |----------|----------|----------------| + | `year` | `number` | `1400 -> 9999` | + | `month` | `number` | `1 -> 12` | + | `day` | `number` | `1 -> 31` | + | `hour` | `number` | `0 -> 23` | + | `minute` | `number` | `0 -> 59` | + | `second` | `number` | `0 -> 60` | + + An additional `millisecond` value may also be included, + and should be within the range `0 -> 999`, but is optional. + + Any non-integer values in the given table will be rounded down. + + ### Errors + + This constructor is fallible and may throw an error in the following situations: + + - Date units (year, month, day) were given that produce an invalid date. For example, January 32nd or February 29th on a non-leap year. + + @param values -- Table containing date & time values + @return DateTime -- The new DateTime object +]=] +function dateTime.fromLocalTime(values: DateTimeValueArguments): DateTime + return nil :: any +end + +--[=[ + @within DateTime + @tag Constructor + + Creates a new `DateTime` from an ISO 8601 date-time string. + + ### Errors + + This constructor is fallible and may throw an error if the given + string does not strictly follow the ISO 8601 date-time string format. + + Some examples of valid ISO 8601 date-time strings are: + + - `2020-02-22T18:12:08Z` + - `2000-01-31T12:34:56+05:00` + - `1970-01-01T00:00:00.055Z` + + @param isoDate -- An ISO 8601 formatted string + @return DateTime -- The new DateTime object +]=] +function dateTime.fromIsoDate(isoDate: string): DateTime + return nil :: any +end + +return dateTime diff --git a/.lune/.lune-defs/fs.luau b/.lune/.lune-defs/fs.luau new file mode 100644 index 0000000..823f6f7 --- /dev/null +++ b/.lune/.lune-defs/fs.luau @@ -0,0 +1,289 @@ +local DateTime = require("./datetime") +type DateTime = DateTime.DateTime + +export type MetadataKind = "file" | "dir" | "symlink" + +--[=[ + @interface MetadataPermissions + @within FS + + Permissions for the given file or directory. + + This is a dictionary that will contain the following values: + + * `readOnly` - If the target path is read-only or not +]=] +export type MetadataPermissions = { + readOnly: boolean, +} + +-- FIXME: We lose doc comments here below in Metadata because of the union type + +--[=[ + @interface Metadata + @within FS + + Metadata for the given file or directory. + + This is a dictionary that will contain the following values: + + * `kind` - If the target path is a `file`, `dir` or `symlink` + * `exists` - If the target path exists + * `createdAt` - The timestamp represented as a `DateTime` object at which the file or directory was created + * `modifiedAt` - The timestamp represented as a `DateTime` object at which the file or directory was last modified + * `accessedAt` - The timestamp represented as a `DateTime` object at which the file or directory was last accessed + * `permissions` - Current permissions for the file or directory + + Note that timestamps are relative to the unix epoch, and + may not be accurate if the system clock is not accurate. +]=] +export type Metadata = { + kind: MetadataKind, + exists: true, + createdAt: DateTime, + modifiedAt: DateTime, + accessedAt: DateTime, + permissions: MetadataPermissions, +} | { + kind: nil, + exists: false, + createdAt: nil, + modifiedAt: nil, + accessedAt: nil, + permissions: nil, +} + +--[=[ + @interface WriteOptions + @within FS + + Options for filesystem APIs what write to files and/or directories. + + This is a dictionary that may contain one or more of the following values: + + * `overwrite` - If the target path should be overwritten or not, in the case that it already exists +]=] +export type WriteOptions = { + overwrite: boolean?, +} + +--[=[ + @class FS + + Built-in library for filesystem access + + ### Example usage + + ```lua + local fs = require("@lune/fs") + + -- Reading a file + local myTextFile: string = fs.readFile("myFileName.txt") + + -- Reading entries (files & dirs) in a directory + for _, entryName in fs.readDir("myDirName") do + if fs.isFile("myDirName/" .. entryName) then + print("Found file " .. entryName) + elseif fs.isDir("myDirName/" .. entryName) then + print("Found subdirectory " .. entryName) + end + end + ``` +]=] +local fs = {} + +--[=[ + @within FS + @tag must_use + + Reads a file at `path`. + + An error will be thrown in the following situations: + + * `path` does not point to an existing file. + * The current process lacks permissions to read the file. + * Some other I/O error occurred. + + @param path The path to the file to read + @return The contents of the file +]=] +function fs.readFile(path: string): string + return nil :: any +end + +--[=[ + @within FS + @tag must_use + + Reads entries in a directory at `path`. + + An error will be thrown in the following situations: + + * `path` does not point to an existing directory. + * The current process lacks permissions to read the contents of the directory. + * Some other I/O error occurred. + + @param path The directory path to search in + @return A list of files & directories found +]=] +function fs.readDir(path: string): { string } + return {} +end + +--[=[ + @within FS + + Writes to a file at `path`. + + An error will be thrown in the following situations: + + * The file's parent directory does not exist. + * The current process lacks permissions to write to the file. + * Some other I/O error occurred. + + @param path The path of the file + @param contents The contents of the file +]=] +function fs.writeFile(path: string, contents: buffer | string) end + +--[=[ + @within FS + + Creates a directory and its parent directories if they are missing. + + An error will be thrown in the following situations: + + * `path` already points to an existing file or directory. + * The current process lacks permissions to create the directory or its missing parents. + * Some other I/O error occurred. + + @param path The directory to create +]=] +function fs.writeDir(path: string) end + +--[=[ + @within FS + + Removes a file. + + An error will be thrown in the following situations: + + * `path` does not point to an existing file. + * The current process lacks permissions to remove the file. + * Some other I/O error occurred. + + @param path The file to remove +]=] +function fs.removeFile(path: string) end + +--[=[ + @within FS + + Removes a directory and all of its contents. + + An error will be thrown in the following situations: + + * `path` is not an existing and empty directory. + * The current process lacks permissions to remove the directory. + * Some other I/O error occurred. + + @param path The directory to remove +]=] +function fs.removeDir(path: string) end + +--[=[ + @within FS + @tag must_use + + Gets metadata for the given path. + + An error will be thrown in the following situations: + + * The current process lacks permissions to read at `path`. + * Some other I/O error occurred. + + @param path The path to get metadata for + @return Metadata for the path +]=] +function fs.metadata(path: string): Metadata + return nil :: any +end + +--[=[ + @within FS + @tag must_use + + Checks if a given path is a file. + + An error will be thrown in the following situations: + + * The current process lacks permissions to read at `path`. + * Some other I/O error occurred. + + @param path The file path to check + @return If the path is a file or not +]=] +function fs.isFile(path: string): boolean + return nil :: any +end + +--[=[ + @within FS + @tag must_use + + Checks if a given path is a directory. + + An error will be thrown in the following situations: + + * The current process lacks permissions to read at `path`. + * Some other I/O error occurred. + + @param path The directory path to check + @return If the path is a directory or not +]=] +function fs.isDir(path: string): boolean + return nil :: any +end + +--[=[ + @within FS + + Moves a file or directory to a new path. + + Throws an error if a file or directory already exists at the target path. + This can be bypassed by passing `true` as the third argument, or a dictionary of options. + Refer to the documentation for `WriteOptions` for specific option keys and their values. + + An error will be thrown in the following situations: + + * The current process lacks permissions to read at `from` or write at `to`. + * The new path exists on a different mount point. + * Some other I/O error occurred. + + @param from The path to move from + @param to The path to move to + @param overwriteOrOptions Options for the target path, such as if should be overwritten if it already exists +]=] +function fs.move(from: string, to: string, overwriteOrOptions: (boolean | WriteOptions)?) end + +--[=[ + @within FS + + Copies a file or directory recursively to a new path. + + Throws an error if a file or directory already exists at the target path. + This can be bypassed by passing `true` as the third argument, or a dictionary of options. + Refer to the documentation for `WriteOptions` for specific option keys and their values. + + An error will be thrown in the following situations: + + * The current process lacks permissions to read at `from` or write at `to`. + * Some other I/O error occurred. + + @param from The path to copy from + @param to The path to copy to + @param overwriteOrOptions Options for the target path, such as if should be overwritten if it already exists +]=] +function fs.copy(from: string, to: string, overwriteOrOptions: (boolean | WriteOptions)?) end + +return fs diff --git a/.lune/.lune-defs/luau.luau b/.lune/.lune-defs/luau.luau new file mode 100644 index 0000000..ab40c4f --- /dev/null +++ b/.lune/.lune-defs/luau.luau @@ -0,0 +1,123 @@ +--[=[ + @interface CompileOptions + @within Luau + + The options passed to the luau compiler while compiling bytecode. + + This is a dictionary that may contain one or more of the following values: + + * `optimizationLevel` - Sets the compiler option "optimizationLevel". Defaults to `1`. + * `coverageLevel` - Sets the compiler option "coverageLevel". Defaults to `0`. + * `debugLevel` - Sets the compiler option "debugLevel". Defaults to `1`. + + Documentation regarding what these values represent can be found [here](https://github.com/Roblox/luau/blob/bd229816c0a82a8590395416c81c333087f541fd/Compiler/include/luacode.h#L13-L39). +]=] +export type CompileOptions = { + optimizationLevel: number?, + coverageLevel: number?, + debugLevel: number?, +} + +--[=[ + @interface LoadOptions + @within Luau + + The options passed while loading a luau chunk from an arbitrary string, or bytecode. + + This is a dictionary that may contain one or more of the following values: + + * `debugName` - The debug name of the closure. Defaults to `luau.load(...)`. + * `environment` - A custom environment to load the chunk in. Setting a custom environment will deoptimize the chunk and forcefully disable codegen. Defaults to the global environment. + * `injectGlobals` - Whether or not to inject globals in the custom environment. Has no effect if no custom environment is provided. Defaults to `true`. + * `codegenEnabled` - Whether or not to enable codegen. Defaults to `false`. +]=] +export type LoadOptions = { + debugName: string?, + environment: { [string]: any }?, + injectGlobals: boolean?, + codegenEnabled: boolean?, +} + +--[=[ + @class Luau + + Built-in library for generating luau bytecode & functions. + + ### Example usage + + ```lua + local luau = require("@lune/luau") + + local bytecode = luau.compile("print('Hello, World!')") + local callableFn = luau.load(bytecode) + + -- Additionally, we can skip the bytecode generation and load a callable function directly from the code itself. + -- local callableFn = luau.load("print('Hello, World!')") + + callableFn() + ``` + + Since luau bytecode is highly compressible, it may also make sense to compress it using the `serde` library + while transmitting large amounts of it. +]=] +local luau = {} + +--[=[ + @within Luau + + Compiles sourcecode into Luau bytecode + + An error will be thrown if the sourcecode given isn't valid Luau code. + + ### Example usage + + ```lua + local luau = require("@lune/luau") + + -- Compile the source to some highly optimized bytecode + local bytecode = luau.compile("print('Hello, World!')", { + optimizationLevel = 2, + coverageLevel = 0, + debugLevel = 1, + }) + ``` + + @param source The string that will be compiled into bytecode + @param compileOptions The options passed to the luau compiler that will output the bytecode + + @return luau bytecode +]=] +function luau.compile(source: string, compileOptions: CompileOptions?): string + return nil :: any +end + +--[=[ + @within Luau + + Generates a function from either bytecode or sourcecode + + An error will be thrown if the sourcecode given isn't valid luau code. + + ### Example usage + + ```lua + local luau = require("@lune/luau") + + local bytecode = luau.compile("print('Hello, World!')") + local callableFn = luau.load(bytecode, { + debugName = "'Hello, World'" + }) + + callableFn() + ``` + + @param source Either luau bytecode or string source code + @param loadOptions The options passed to luau for loading the chunk + + @return luau chunk +]=] +function luau.load(source: string, loadOptions: LoadOptions?): (...any) -> ...any + return nil :: any +end + +return luau diff --git a/.lune/.lune-defs/net.luau b/.lune/.lune-defs/net.luau new file mode 100644 index 0000000..e9b793e --- /dev/null +++ b/.lune/.lune-defs/net.luau @@ -0,0 +1,321 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH" + +type HttpQueryOrHeaderMap = { [string]: string | { string } } +export type HttpQueryMap = HttpQueryOrHeaderMap +export type HttpHeaderMap = HttpQueryOrHeaderMap + +--[=[ + @interface FetchParamsOptions + @within Net + + Extra options for `FetchParams`. + + This is a dictionary that may contain one or more of the following values: + + * `decompress` - If the request body should be automatically decompressed when possible. Defaults to `true` +]=] +export type FetchParamsOptions = { + decompress: boolean?, +} + +--[=[ + @interface FetchParams + @within Net + + Parameters for sending network requests with `net.request`. + + This is a dictionary that may contain one or more of the following values: + + * `url` - The URL to send a request to. This is always required + * `method` - The HTTP method verb, such as `"GET"`, `"POST"`, `"PATCH"`, `"PUT"`, or `"DELETE"`. Defaults to `"GET"` + * `body` - The request body + * `query` - A table of key-value pairs representing query parameters in the request path + * `headers` - A table of key-value pairs representing headers + * `options` - Extra options for things such as automatic decompression of response bodies +]=] +export type FetchParams = { + url: string, + method: HttpMethod?, + body: (string | buffer)?, + query: HttpQueryMap?, + headers: HttpHeaderMap?, + options: FetchParamsOptions?, +} + +--[=[ + @interface FetchResponse + @within Net + + Response type for sending network requests with `net.request`. + + This is a dictionary containing the following values: + + * `ok` - If the status code is a canonical success status code, meaning within the range 200 -> 299 + * `statusCode` - The status code returned for the request + * `statusMessage` - The canonical status message for the returned status code, such as `"Not Found"` for status code 404 + * `headers` - A table of key-value pairs representing headers + * `body` - The request body, or an empty string if one was not given +]=] +export type FetchResponse = { + ok: boolean, + statusCode: number, + statusMessage: string, + headers: HttpHeaderMap, + body: string, +} + +--[=[ + @interface ServeRequest + @within Net + + Data type for requests in `net.serve`. + + This is a dictionary containing the following values: + + * `path` - The path being requested, relative to the root. Will be `/` if not specified + * `query` - A table of key-value pairs representing query parameters in the request path + * `method` - The HTTP method verb, such as `"GET"`, `"POST"`, `"PATCH"`, `"PUT"`, or `"DELETE"`. Will always be uppercase + * `headers` - A table of key-value pairs representing headers + * `body` - The request body, or an empty string if one was not given +]=] +export type ServeRequest = { + path: string, + query: { [string]: string? }, + method: HttpMethod, + headers: { [string]: string }, + body: string, +} + +--[=[ + @interface ServeResponse + @within Net + + Response type for requests in `net.serve`. + + This is a dictionary that may contain one or more of the following values: + + * `status` - The status code for the request, in the range `100` -> `599` + * `headers` - A table of key-value pairs representing headers + * `body` - The response body +]=] +export type ServeResponse = { + status: number?, + headers: { [string]: string }?, + body: (string | buffer)?, +} + +type ServeHttpHandler = (request: ServeRequest) -> string | ServeResponse +type ServeWebSocketHandler = (socket: WebSocket) -> () + +--[=[ + @interface ServeConfig + @within Net + + Configuration for `net.serve`. + + This may contain one of or more of the following values: + + * `address` for setting the IP address to serve from. Defaults to the loopback interface (`http://localhost`). + * `handleRequest` for handling normal http requests, equivalent to just passing a function to `net.serve` + * `handleWebSocket` for handling web socket requests, which will receive a `WebSocket` object as its first and only parameter + + When setting `address`, the `handleRequest` callback must also be defined. + + ```lua + net.serve(8080, { + address = "http://0.0.0.0", + handleRequest = function(request) + return { + status = 200, + body = "Echo:\n" .. request.body, + } + end + }) + ``` +]=] +export type ServeConfig = { + address: string?, + handleRequest: ServeHttpHandler?, + handleWebSocket: ServeWebSocketHandler?, +} + +--[=[ + @interface ServeHandle + @within Net + + A handle to a currently running web server, containing a single `stop` function to gracefully shut down the web server. +]=] +export type ServeHandle = { + stop: () -> (), +} + +--[=[ + @interface WebSocket + @within Net + + A reference to a web socket connection. + + The web socket may be in either an "open" or a "closed" state, changing its current behavior. + + When open: + + * Any function on the socket such as `send`, `next` or `close` can be called without erroring + * `next` can be called to yield until the next message is received or the socket becomes closed + + When closed: + + * `next` will no longer return any message(s) and instead instantly return nil + * `send` will throw an error stating that the socket has been closed + + Once the websocket has been closed, `closeCode` will no longer be nil, and will be populated with a close + code according to the [WebSocket specification](https://www.iana.org/assignments/websocket/websocket.xhtml). + This will be an integer between 1000 and 4999, where 1000 is the canonical code for normal, error-free closure. +]=] +export type WebSocket = { + closeCode: number?, + close: (code: number?) -> (), + send: (message: (string | buffer)?, asBinaryMessage: boolean?) -> (), + next: () -> string?, +} + +--[=[ + @class Net + + + Built-in library for network access + + ### Example usage + + ```lua + local net = require("@lune/net") + + -- Sending a web request + local response = net.request("https://www.google.com") + print(response.ok) + print(response.statusCode, response.statusMessage) + print(response.headers) + + -- Using a JSON web API + local response = net.request({ + url = "https://dummyjson.com/products/add", + method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = net.jsonEncode({ + title = "Cool Pencil", + }) + }) + local product = net.jsonDecode(response.body) + print(product.id, "-", product.title) + + -- Starting up a webserver + net.serve(8080, function(request) + return { + status = 200, + body = "Echo:\n" .. request.body, + } + end) + ``` +]=] +local net = {} + +--[=[ + @within Net + + Sends an HTTP request using the given url and / or parameters, and returns a dictionary that describes the response received. + + Only throws an error if a miscellaneous network or I/O error occurs, never for unsuccessful status codes. + + @param config The URL or request config to use + @return A dictionary representing the response for the request +]=] +function net.request(config: string | FetchParams): FetchResponse + return nil :: any +end + +--[=[ + @within Net + @tag must_use + + Connects to a web socket at the given URL. + + Throws an error if the server at the given URL does not support + web sockets, or if a miscellaneous network or I/O error occurs. + + @param url The URL to connect to + @return A web socket handle +]=] +function net.socket(url: string): WebSocket + return nil :: any +end + +--[=[ + @within Net + + Creates an HTTP server that listens on the given `port`. + + This will ***not*** block and will keep listening for requests on the given `port` + until the `stop` function on the returned `ServeHandle` has been called. + + @param port The port to use for the server + @param handlerOrConfig The handler function or config to use for the server +]=] +function net.serve(port: number, handlerOrConfig: ServeHttpHandler | ServeConfig): ServeHandle + return nil :: any +end + +--[=[ + @within Net + @tag must_use + + Encodes the given value as JSON. + + @param value The value to encode as JSON + @param pretty If the encoded JSON string should include newlines and spaces. Defaults to false + @return The encoded JSON string +]=] +function net.jsonEncode(value: any, pretty: boolean?): string + return nil :: any +end + +--[=[ + @within Net + @tag must_use + + Decodes the given JSON string into a lua value. + + @param encoded The JSON string to decode + @return The decoded lua value +]=] +function net.jsonDecode(encoded: string): any + return nil :: any +end + +--[=[ + @within Net + @tag must_use + + Encodes the given string using URL encoding. + + @param s The string to encode + @param binary If the string should be treated as binary data and/or is not valid utf-8. Defaults to false + @return The encoded string +]=] +function net.urlEncode(s: string, binary: boolean?): string + return nil :: any +end + +--[=[ + @within Net + @tag must_use + + Decodes the given string using URL decoding. + + @param s The string to decode + @param binary If the string should be treated as binary data and/or is not valid utf-8. Defaults to false + @return The decoded string +]=] +function net.urlDecode(s: string, binary: boolean?): string + return nil :: any +end + +return net diff --git a/.lune/.lune-defs/process.luau b/.lune/.lune-defs/process.luau new file mode 100644 index 0000000..7b82052 --- /dev/null +++ b/.lune/.lune-defs/process.luau @@ -0,0 +1,182 @@ +export type OS = "linux" | "macos" | "windows" +export type Arch = "x86_64" | "aarch64" + +export type SpawnOptionsStdioKind = "default" | "inherit" | "forward" | "none" +export type SpawnOptionsStdio = { + stdout: SpawnOptionsStdioKind?, + stderr: SpawnOptionsStdioKind?, + stdin: string?, +} + +--[=[ + @interface SpawnOptions + @within Process + + A dictionary of options for `process.spawn`, with the following available values: + + * `cwd` - The current working directory for the process + * `env` - Extra environment variables to give to the process + * `shell` - Whether to run in a shell or not - set to `true` to run using the default shell, or a string to run using a specific shell + * `stdio` - How to treat output and error streams from the child process - see `SpawnOptionsStdioKind` and `SpawnOptionsStdio` for more info + * `stdin` - Optional standard input to pass to spawned child process +]=] +export type SpawnOptions = { + cwd: string?, + env: { [string]: string }?, + shell: (boolean | string)?, + stdio: (SpawnOptionsStdioKind | SpawnOptionsStdio)?, + stdin: string?, -- TODO: Remove this since it is now available in stdio above, breaking change +} + +--[=[ + @interface SpawnResult + @within Process + + Result type for child processes in `process.spawn`. + + This is a dictionary containing the following values: + + * `ok` - If the child process exited successfully or not, meaning the exit code was zero or not set + * `code` - The exit code set by the child process, or 0 if one was not set + * `stdout` - The full contents written to stdout by the child process, or an empty string if nothing was written + * `stderr` - The full contents written to stderr by the child process, or an empty string if nothing was written +]=] +export type SpawnResult = { + ok: boolean, + code: number, + stdout: string, + stderr: string, +} + +--[=[ + @class Process + + Built-in functions for the current process & child processes + + ### Example usage + + ```lua + local process = require("@lune/process") + + -- Getting the arguments passed to the Lune script + for index, arg in process.args do + print("Process argument #" .. tostring(index) .. ": " .. arg) + end + + -- Getting the currently available environment variables + local PORT: string? = process.env.PORT + local HOME: string? = process.env.HOME + for name, value in process.env do + print("Environment variable " .. name .. " is set to " .. value) + end + + -- Getting the current os and processor architecture + print("Running " .. process.os .. " on " .. process.arch .. "!") + + -- Spawning a child process + local result = process.spawn("program", { + "cli argument", + "other cli argument" + }) + if result.ok then + print(result.stdout) + else + print(result.stderr) + end + ``` +]=] +local process = {} + +--[=[ + @within Process + @prop os OS + @tag read_only + + The current operating system being used. + + Possible values: + + * `"linux"` + * `"macos"` + * `"windows"` +]=] +process.os = (nil :: any) :: OS + +--[=[ + @within Process + @prop arch Arch + @tag read_only + + The architecture of the processor currently being used. + + Possible values: + + * `"x86_64"` + * `"aarch64"` +]=] +process.arch = (nil :: any) :: Arch + +--[=[ + @within Process + @prop args { string } + @tag read_only + + The arguments given when running the Lune script. +]=] +process.args = (nil :: any) :: { string } + +--[=[ + @within Process + @prop cwd string + @tag read_only + + The current working directory in which the Lune script is running. +]=] +process.cwd = (nil :: any) :: string + +--[=[ + @within Process + @prop env { [string]: string? } + @tag read_write + + Current environment variables for this process. + + Setting a value on this table will set the corresponding environment variable. +]=] +process.env = (nil :: any) :: { [string]: string? } + +--[=[ + @within Process + + Exits the currently running script as soon as possible with the given exit code. + + Exit code 0 is treated as a successful exit, any other value is treated as an error. + + Setting the exit code using this function will override any otherwise automatic exit code. + + @param code The exit code to set +]=] +function process.exit(code: number?): never + return nil :: any +end + +--[=[ + @within Process + + Spawns a child process that will run the program `program`, and returns a dictionary that describes the final status and ouput of the child process. + + The second argument, `params`, can be passed as a list of string parameters to give to the program. + + The third argument, `options`, can be passed as a dictionary of options to give to the child process. + Refer to the documentation for `SpawnOptions` for specific option keys and their values. + + @param program The program to spawn as a child process + @param params Additional parameters to pass to the program + @param options A dictionary of options for the child process + @return A dictionary representing the result of the child process +]=] +function process.spawn(program: string, params: { string }?, options: SpawnOptions?): SpawnResult + return nil :: any +end + +return process diff --git a/.lune/.lune-defs/regex.luau b/.lune/.lune-defs/regex.luau new file mode 100644 index 0000000..59756f3 --- /dev/null +++ b/.lune/.lune-defs/regex.luau @@ -0,0 +1,218 @@ +--[=[ + @class RegexMatch + + A match from a regular expression. + + Contains the following values: + + - `start` -- The start index of the match in the original string. + - `finish` -- The end index of the match in the original string. + - `text` -- The text that was matched. + - `len` -- The length of the text that was matched. +]=] +local RegexMatch = { + start = 0, + finish = 0, + text = "", + len = 0, +} + +type RegexMatch = typeof(RegexMatch) + +--[=[ + @class RegexCaptures + + Captures from a regular expression. +]=] +local RegexCaptures = {} + +--[=[ + @within RegexCaptures + @tag Method + + Returns the match at the given index, if one exists. + + @param index -- The index of the match to get + @return RegexMatch -- The match, if one exists +]=] +function RegexCaptures.get(self: RegexCaptures, index: number): RegexMatch? + return nil :: any +end + +--[=[ + @within RegexCaptures + @tag Method + + Returns the match for the given named match group, if one exists. + + @param group -- The name of the group to get + @return RegexMatch -- The match, if one exists +]=] +function RegexCaptures.group(self: RegexCaptures, group: string): RegexMatch? + return nil :: any +end + +--[=[ + @within RegexCaptures + @tag Method + + Formats the captures using the given format string. + + ### Example usage + + ```lua + local regex = require("@lune/regex") + + local re = regex.new("(?[0-9]{2})-(?[0-9]{2})-(?[0-9]{4})") + + local caps = re:captures("On 14-03-2010, I became a Tenneessee lamb."); + assert(caps ~= nil, "Example pattern should match example text") + + local formatted = caps:format("year=$year, month=$month, day=$day") + print(formatted) -- "year=2010, month=03, day=14" + ``` + + @param format -- The format string to use + @return string -- The formatted string +]=] +function RegexCaptures.format(self: RegexCaptures, format: string): string + return nil :: any +end + +export type RegexCaptures = typeof(RegexCaptures) + +local Regex = {} + +--[=[ + @within Regex + @tag Method + + Check if the given text matches the regular expression. + + This method may be slightly more efficient than calling `find` + if you only need to know if the text matches the pattern. + + @param text -- The text to search + @return boolean -- Whether the text matches the pattern +]=] +function Regex.isMatch(self: Regex, text: string): boolean + return nil :: any +end + +--[=[ + @within Regex + @tag Method + + Finds the first match in the given text. + + Returns `nil` if no match was found. + + @param text -- The text to search + @return RegexMatch? -- The match object +]=] +function Regex.find(self: Regex, text: string): RegexMatch? + return nil :: any +end + +--[=[ + @within Regex + @tag Method + + Finds all matches in the given text as a `RegexCaptures` object. + + Returns `nil` if no matches are found. + + @param text -- The text to search + @return RegexCaptures? -- The captures object +]=] +function Regex.captures(self: Regex, text: string): RegexCaptures? + return nil :: any +end + +--[=[ + @within Regex + @tag Method + + Splits the given text using the regular expression. + + @param text -- The text to split + @return { string } -- The split text +]=] +function Regex.split(self: Regex, text: string): { string } + return nil :: any +end + +--[=[ + @within Regex + @tag Method + + Replaces the first match in the given text with the given replacer string. + + @param haystack -- The text to search + @param replacer -- The string to replace matches with + @return string -- The text with the first match replaced +]=] +function Regex.replace(self: Regex, haystack: string, replacer: string): string + return nil :: any +end + +--[=[ + @within Regex + @tag Method + + Replaces all matches in the given text with the given replacer string. + + @param haystack -- The text to search + @param replacer -- The string to replace matches with + @return string -- The text with all matches replaced +]=] +function Regex.replaceAll(self: Regex, haystack: string, replacer: string): string + return nil :: any +end + +export type Regex = typeof(Regex) + +--[=[ + @class Regex + + Built-in library for regular expressions + + ### Example usage + + ```lua + local Regex = require("@lune/regex") + + local re = Regex.new("hello") + + if re:isMatch("hello, world!") then + print("Matched!") + end + + local caps = re:captures("hello, world! hello, again!") + + print(#caps) -- 2 + print(caps:get(1)) -- "hello" + print(caps:get(2)) -- "hello" + print(caps:get(3)) -- nil + ``` +]=] +local regex = {} + +--[=[ + @within Regex + @tag Constructor + + Creates a new `Regex` from a given string pattern. + + ### Errors + + This constructor throws an error if the given pattern is invalid. + + @param pattern -- The string pattern to use + @return Regex -- The new Regex object +]=] +function regex.new(pattern: string): Regex + return nil :: any +end + +return regex diff --git a/.lune/.lune-defs/roblox.luau b/.lune/.lune-defs/roblox.luau new file mode 100644 index 0000000..b79ad60 --- /dev/null +++ b/.lune/.lune-defs/roblox.luau @@ -0,0 +1,507 @@ +export type DatabaseScriptability = "None" | "Custom" | "Read" | "ReadWrite" | "Write" + +export type DatabasePropertyTag = + "Deprecated" + | "Hidden" + | "NotBrowsable" + | "NotReplicated" + | "NotScriptable" + | "ReadOnly" + | "WriteOnly" + +export type DatabaseClassTag = + "Deprecated" + | "NotBrowsable" + | "NotCreatable" + | "NotReplicated" + | "PlayerReplicated" + | "Service" + | "Settings" + | "UserSettings" + +export type DatabaseProperty = { + --[=[ + The name of the property. + ]=] + Name: string, + --[=[ + The datatype of the property. + + For normal datatypes this will be a string such as `string`, `Color3`, ... + + For enums this will be a string formatted as `Enum.EnumName`. + ]=] + Datatype: string, + --[=[ + The scriptability of this property, meaning if it can be written / read at runtime. + + All properties are writable and readable in Lune even if scriptability is not. + ]=] + Scriptability: DatabaseScriptability, + --[=[ + Tags describing the property. + + These include information such as if the property can be replicated to players + at runtime, if the property should be hidden in Roblox Studio, and more. + ]=] + Tags: { DatabasePropertyTag }, +} + +export type DatabaseClass = { + --[=[ + The name of the class. + ]=] + Name: string, + --[=[ + The superclass (parent class) of this class. + + May be nil if no parent class exists. + ]=] + Superclass: string?, + --[=[ + Known properties for this class. + ]=] + Properties: { [string]: DatabaseProperty }, + --[=[ + Default values for properties of this class. + + Note that these default properties use Lune's built-in datatype + userdatas, and that if there is a new datatype that Lune does + not yet know about, it may be missing from this table. + ]=] + DefaultProperties: { [string]: any }, + --[=[ + Tags describing the class. + + These include information such as if the class can be replicated + to players at runtime, and top-level class categories. + ]=] + Tags: { DatabaseClassTag }, +} + +export type DatabaseEnum = { + --[=[ + The name of this enum, for example `PartType` or `UserInputState`. + ]=] + Name: string, + --[=[ + Members of this enum. + + Note that this is a direct map of name -> enum values, + and does not actually use the EnumItem datatype itself. + ]=] + Items: { [string]: number }, +} + +export type Database = { + --[=[ + The current version of the reflection database. + + This will follow the format `x.y.z.w`, which most commonly looks something like `0.567.0.123456789` + ]=] + Version: string, + --[=[ + Retrieves a list of all currently known class names. + ]=] + GetClassNames: (self: Database) -> { string }, + --[=[ + Retrieves a list of all currently known enum names. + ]=] + GetEnumNames: (self: Database) -> { string }, + --[=[ + Gets a class with the exact given name, if one exists. + ]=] + GetClass: (self: Database, name: string) -> DatabaseClass?, + --[=[ + Gets an enum with the exact given name, if one exists. + ]=] + GetEnum: (self: Database, name: string) -> DatabaseEnum?, + --[=[ + Finds a class with the given name. + + This will use case-insensitive matching and ignore leading and trailing whitespace. + ]=] + FindClass: (self: Database, name: string) -> DatabaseClass?, + --[=[ + Finds an enum with the given name. + + This will use case-insensitive matching and ignore leading and trailing whitespace. + ]=] + FindEnum: (self: Database, name: string) -> DatabaseEnum?, +} + +type InstanceProperties = { + Parent: Instance?, + ClassName: string, + Name: string, + -- FIXME: This breaks intellisense, but we need some way to access + -- instance properties without casting the entire instance to any... + -- [string]: any, +} + +type InstanceMetatable = { + Clone: (self: Instance) -> Instance, + Destroy: (self: Instance) -> (), + ClearAllChildren: (self: Instance) -> (), + + GetChildren: (self: Instance) -> { Instance }, + GetDebugId: (self: Instance) -> string, + GetDescendants: (self: Instance) -> { Instance }, + GetFullName: (self: Instance) -> string, + + FindFirstAncestor: (self: Instance, name: string) -> Instance?, + FindFirstAncestorOfClass: (self: Instance, className: string) -> Instance?, + FindFirstAncestorWhichIsA: (self: Instance, className: string) -> Instance?, + FindFirstChild: (self: Instance, name: string, recursive: boolean?) -> Instance?, + FindFirstChildOfClass: (self: Instance, className: string, recursive: boolean?) -> Instance?, + FindFirstChildWhichIsA: (self: Instance, className: string, recursive: boolean?) -> Instance?, + + IsA: (self: Instance, className: string) -> boolean, + IsAncestorOf: (self: Instance, descendant: Instance) -> boolean, + IsDescendantOf: (self: Instance, ancestor: Instance) -> boolean, + + GetAttribute: (self: Instance, name: string) -> any, + GetAttributes: (self: Instance) -> { [string]: any }, + SetAttribute: (self: Instance, name: string, value: any) -> (), + + GetTags: (self: Instance) -> { string }, + HasTag: (self: Instance, name: string) -> boolean, + AddTag: (self: Instance, name: string) -> (), + RemoveTag: (self: Instance, name: string) -> (), +} + +export type Instance = typeof(setmetatable( + (nil :: any) :: InstanceProperties, + (nil :: any) :: { __index: InstanceMetatable } +)) + +export type DataModelProperties = {} +export type DataModelMetatable = { + GetService: (self: DataModel, name: string) -> Instance, + FindService: (self: DataModel, name: string) -> Instance?, +} + +export type DataModel = + Instance + & typeof(setmetatable( + (nil :: any) :: DataModelProperties, + (nil :: any) :: { __index: DataModelMetatable } + )) + +--[=[ + @class Roblox + + Built-in library for manipulating Roblox place & model files + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local roblox = require("@lune/roblox") + + -- Reading a place file + local placeFile = fs.readFile("myPlaceFile.rbxl") + local game = roblox.deserializePlace(placeFile) + + -- Manipulating and reading instances - just like in Roblox! + local workspace = game:GetService("Workspace") + for _, child in workspace:GetChildren() do + print("Found child " .. child.Name .. " of class " .. child.ClassName) + end + + -- Writing a place file + local newPlaceFile = roblox.serializePlace(game) + fs.writeFile("myPlaceFile.rbxl", newPlaceFile) + ``` +]=] +local roblox = {} + +--[=[ + @within Roblox + @tag must_use + + Deserializes a place into a DataModel instance. + + This function accepts a string of contents, *not* a file path. + If reading a place file from a file path is desired, `fs.readFile` + can be used and the resulting string may be passed to this function. + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local roblox = require("@lune/roblox") + + local placeFile = fs.readFile("filePath.rbxl") + local game = roblox.deserializePlace(placeFile) + ``` + + @param contents The contents of the place to read +]=] +function roblox.deserializePlace(contents: string): DataModel + return nil :: any +end + +--[=[ + @within Roblox + @tag must_use + + Deserializes a model into an array of instances. + + This function accepts a string of contents, *not* a file path. + If reading a model file from a file path is desired, `fs.readFile` + can be used and the resulting string may be passed to this function. + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local roblox = require("@lune/roblox") + + local modelFile = fs.readFile("filePath.rbxm") + local instances = roblox.deserializeModel(modelFile) + ``` + + @param contents The contents of the model to read +]=] +function roblox.deserializeModel(contents: string): { Instance } + return nil :: any +end + +--[=[ + @within Roblox + @tag must_use + + Serializes a place from a DataModel instance. + + This string can then be written to a file, or sent over the network. + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local roblox = require("@lune/roblox") + + local placeFile = roblox.serializePlace(game) + fs.writeFile("filePath.rbxl", placeFile) + ``` + + @param dataModel The DataModel for the place to serialize + @param xml If the place should be serialized as xml or not. Defaults to `false`, meaning the place gets serialized using the binary format and not xml. +]=] +function roblox.serializePlace(dataModel: DataModel, xml: boolean?): string + return nil :: any +end + +--[=[ + @within Roblox + @tag must_use + + Serializes one or more instances as a model. + + This string can then be written to a file, or sent over the network. + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local roblox = require("@lune/roblox") + + local modelFile = roblox.serializeModel({ instance1, instance2, ... }) + fs.writeFile("filePath.rbxm", modelFile) + ``` + + @param instances The array of instances to serialize + @param xml If the model should be serialized as xml or not. Defaults to `false`, meaning the model gets serialized using the binary format and not xml. +]=] +function roblox.serializeModel(instances: { Instance }, xml: boolean?): string + return nil :: any +end + +--[=[ + @within Roblox + @tag must_use + + Gets the current auth cookie, for usage with Roblox web APIs. + + Note that this auth cookie is formatted for use as a "Cookie" header, + and that it contains restrictions so that it may only be used for + official Roblox endpoints. To get the raw cookie value without any + additional formatting, you can pass `true` as the first and only parameter. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + local net = require("@lune/net") + + local cookie = roblox.getAuthCookie() + assert(cookie ~= nil, "Failed to get roblox auth cookie") + + local myPrivatePlaceId = 1234567890 + + local response = net.request({ + url = "https://assetdelivery.roblox.com/v2/assetId/" .. tostring(myPrivatePlaceId), + headers = { + Cookie = cookie, + }, + }) + + local responseTable = net.jsonDecode(response.body) + local responseLocation = responseTable.locations[1].location + print("Download link to place: " .. responseLocation) + ``` + + @param raw If the cookie should be returned as a pure value or not. Defaults to false +]=] +function roblox.getAuthCookie(raw: boolean?): string? + return nil :: any +end + +--[=[ + @within Roblox + @tag must_use + + Gets the bundled reflection database. + + This database contains information about Roblox enums, classes, and their properties. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + + local db = roblox.getReflectionDatabase() + + print("There are", #db:GetClassNames(), "classes in the reflection database") + + print("All base instance properties:") + + local class = db:GetClass("Instance") + for name, prop in class.Properties do + print(string.format( + "- %s with datatype %s and default value %s", + prop.Name, + prop.Datatype, + tostring(class.DefaultProperties[prop.Name]) + )) + end + ``` +]=] +function roblox.getReflectionDatabase(): Database + return nil :: any +end + +--[=[ + @within Roblox + + Implements a property for all instances of the given `className`. + + This takes into account class hierarchies, so implementing a property + for the `BasePart` class will also implement it for `Part` and others, + unless a more specific implementation is added to the `Part` class directly. + + ### Behavior + + The given `getter` callback will be called each time the property is + indexed, with the instance as its one and only argument. The `setter` + callback, if given, will be called each time the property should be set, + with the instance as the first argument and the property value as second. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + + local part = roblox.Instance.new("Part") + + local propertyValues = {} + roblox.implementProperty( + "BasePart", + "CoolProp", + function(instance) + if propertyValues[instance] == nil then + propertyValues[instance] = 0 + end + propertyValues[instance] += 1 + return propertyValues[instance] + end, + function(instance, value) + propertyValues[instance] = value + end + ) + + print(part.CoolProp) --> 1 + print(part.CoolProp) --> 2 + print(part.CoolProp) --> 3 + + part.CoolProp = 10 + + print(part.CoolProp) --> 11 + print(part.CoolProp) --> 12 + print(part.CoolProp) --> 13 + ``` + + @param className The class to implement the property for. + @param propertyName The name of the property to implement. + @param getter The function which will be called to get the property value when indexed. + @param setter The function which will be called to set the property value when indexed. Defaults to a function that will error with a message saying the property is read-only. +]=] +function roblox.implementProperty( + className: string, + propertyName: string, + getter: (instance: Instance) -> T, + setter: ((instance: Instance, value: T) -> ())? +) + return nil :: any +end + +--[=[ + @within Roblox + + Implements a method for all instances of the given `className`. + + This takes into account class hierarchies, so implementing a method + for the `BasePart` class will also implement it for `Part` and others, + unless a more specific implementation is added to the `Part` class directly. + + ### Behavior + + The given `callback` will be called every time the method is called, + and will receive the instance it was called on as its first argument. + The remaining arguments will be what the caller passed to the method, and + all values returned from the callback will then be returned to the caller. + + ### Example usage + + ```lua + local roblox = require("@lune/roblox") + + local part = roblox.Instance.new("Part") + + roblox.implementMethod("BasePart", "TestMethod", function(instance, ...) + print("Called TestMethod on instance", instance, "with", ...) + end) + + part:TestMethod("Hello", "world!") + --> Called TestMethod on instance Part with Hello, world! + ``` + + @param className The class to implement the method for. + @param methodName The name of the method to implement. + @param callback The function which will be called when the method is called. +]=] +function roblox.implementMethod( + className: string, + methodName: string, + callback: (instance: Instance, ...any) -> ...any +) + return nil :: any +end + +-- TODO: Make typedefs for all of the datatypes as well... +roblox.Instance = (nil :: any) :: { + new: ((className: "DataModel") -> DataModel) & ((className: string) -> Instance), +} + +return roblox diff --git a/.lune/.lune-defs/serde.luau b/.lune/.lune-defs/serde.luau new file mode 100644 index 0000000..cd2658d --- /dev/null +++ b/.lune/.lune-defs/serde.luau @@ -0,0 +1,200 @@ +--[=[ + @within Serde + @interface EncodeDecodeFormat + + A serialization/deserialization format supported by the Serde library. + + Currently supported formats: + + | Name | Learn More | + |:-------|:---------------------| + | `json` | https://www.json.org | + | `yaml` | https://yaml.org | + | `toml` | https://toml.io | +]=] +export type EncodeDecodeFormat = "json" | "yaml" | "toml" + +--[=[ + @within Serde + @interface CompressDecompressFormat + + A compression/decompression format supported by the Serde library. + + Currently supported formats: + + | Name | Learn More | + |:---------|:----------------------------------| + | `brotli` | https://github.com/google/brotli | + | `gzip` | https://www.gnu.org/software/gzip | + | `lz4` | https://github.com/lz4/lz4 | + | `zlib` | https://www.zlib.net | +]=] +export type CompressDecompressFormat = "brotli" | "gzip" | "lz4" | "zlib" + +--[=[ + @within Serde + @interface HashAlgorithm + + A hash algorithm supported by the Serde library. + + Currently supported algorithms: + + | Name | Learn More | + |:-----------|:-------------------------------------| + | `md5` | https://en.wikipedia.org/wiki/MD5 | + | `sha1` | https://en.wikipedia.org/wiki/SHA-1 | + | `sha224` | https://en.wikipedia.org/wiki/SHA-2 | + | `sha256` | https://en.wikipedia.org/wiki/SHA-2 | + | `sha384` | https://en.wikipedia.org/wiki/SHA-2 | + | `sha512` | https://en.wikipedia.org/wiki/SHA-2 | + | `sha3-224` | https://en.wikipedia.org/wiki/SHA-3 | + | `sha3-256` | https://en.wikipedia.org/wiki/SHA-3 | + | `sha3-384` | https://en.wikipedia.org/wiki/SHA-3 | + | `sha3-512` | https://en.wikipedia.org/wiki/SHA-3 | + | `blake3` | https://en.wikipedia.org/wiki/BLAKE3 | +]=] +export type HashAlgorithm = + "md5" + | "sha1" + | "sha224" + | "sha256" + | "sha384" + | "sha512" + | "sha3-224" + | "sha3-256" + | "sha3-384" + | "sha3-512" + | "blake3" + +--[=[ + @class Serde + + Built-in library for: + - serialization & deserialization + - encoding & decoding + - compression + + ### Example usage + + ```lua + local fs = require("@lune/fs") + local serde = require("@lune/serde") + + -- Parse different file formats into lua tables + local someJson = serde.decode("json", fs.readFile("myFile.json")) + local someToml = serde.decode("toml", fs.readFile("myFile.toml")) + local someYaml = serde.decode("yaml", fs.readFile("myFile.yaml")) + + -- Write lua tables to files in different formats + fs.writeFile("myFile.json", serde.encode("json", someJson)) + fs.writeFile("myFile.toml", serde.encode("toml", someToml)) + fs.writeFile("myFile.yaml", serde.encode("yaml", someYaml)) + ``` +]=] +local serde = {} + +--[=[ + @within Serde + @tag must_use + + Encodes the given value using the given format. + + See [`EncodeDecodeFormat`] for a list of supported formats. + + @param format The format to use + @param value The value to encode + @param pretty If the encoded string should be human-readable, including things such as newlines and spaces. Only supported for json and toml formats, and defaults to false + @return The encoded string +]=] +function serde.encode(format: EncodeDecodeFormat, value: any, pretty: boolean?): string + return nil :: any +end + +--[=[ + @within Serde + @tag must_use + + Decodes the given string using the given format into a lua value. + + See [`EncodeDecodeFormat`] for a list of supported formats. + + @param format The format to use + @param encoded The string to decode + @return The decoded lua value +]=] +function serde.decode(format: EncodeDecodeFormat, encoded: buffer | string): any + return nil :: any +end + +--[=[ + @within Serde + @tag must_use + + Compresses the given string using the given format. + + See [`CompressDecompressFormat`] for a list of supported formats. + + @param format The format to use + @param s The string to compress + @param level The compression level to use, clamped to the format's limits. The best compression level is used by default + @return The compressed string +]=] +function serde.compress(format: CompressDecompressFormat, s: buffer | string, level: number?): string + return nil :: any +end + +--[=[ + @within Serde + @tag must_use + + Decompresses the given string using the given format. + + See [`CompressDecompressFormat`] for a list of supported formats. + + @param format The format to use + @param s The string to decompress + @return The decompressed string +]=] +function serde.decompress(format: CompressDecompressFormat, s: buffer | string): string + return nil :: any +end + +--[=[ + @within Serde + @tag must_use + + Hashes the given message using the given algorithm + and returns the hash as a hex string. + + See [`HashAlgorithm`] for a list of supported algorithms. + + @param algorithm The algorithm to use + @param message The message to hash + @return The hash as a hex string +]=] +function serde.hash(algorithm: HashAlgorithm, message: string | buffer): string + return nil :: any +end + +--[=[ + @within Serde + @tag must_use + + Hashes the given message using HMAC with the given secret + and algorithm, returning the hash as a base64 string. + + See [`HashAlgorithm`] for a list of supported algorithms. + + @param algorithm The algorithm to use + @param message The message to hash + @return The hash as a base64 string +]=] +function serde.hmac( + algorithm: HashAlgorithm, + message: string | buffer, + secret: string | buffer +): string + return nil :: any +end + +return serde diff --git a/.lune/.lune-defs/stdio.luau b/.lune/.lune-defs/stdio.luau new file mode 100644 index 0000000..e6e88a4 --- /dev/null +++ b/.lune/.lune-defs/stdio.luau @@ -0,0 +1,161 @@ +export type Color = + "reset" + | "black" + | "red" + | "green" + | "yellow" + | "blue" + | "purple" + | "cyan" + | "white" +export type Style = "reset" | "bold" | "dim" + +type PromptFn = ( + (() -> string) + & ((kind: "text", message: string?, defaultOrOptions: string?) -> string) + & ((kind: "confirm", message: string, defaultOrOptions: boolean?) -> boolean) + & ((kind: "select", message: string?, defaultOrOptions: { string }) -> number?) + & ((kind: "multiselect", message: string?, defaultOrOptions: { string }) -> { number }?) +) + +--[=[ + @within Stdio + @function prompt + @tag must_use + + Prompts for user input using the wanted kind of prompt: + + * `"text"` - Prompts for a plain text string from the user + * `"confirm"` - Prompts the user to confirm with y / n (yes / no) + * `"select"` - Prompts the user to select *one* value from a list + * `"multiselect"` - Prompts the user to select *one or more* values from a list + * `nil` - Equivalent to `"text"` with no extra arguments + + @param kind The kind of prompt to use + @param message The message to show the user + @param defaultOrOptions The default value for the prompt, or options to choose from for selection prompts +]=] +local prompt: PromptFn = function(kind: any, message: any, defaultOrOptions: any) + return nil :: any +end + +--[=[ + @class Stdio + + Built-in standard input / output & utility functions + + ### Example usage + + ```lua + local stdio = require("@lune/stdio") + + -- Prompting the user for basic input + local text: string = stdio.prompt("text", "Please write some text") + local confirmed: boolean = stdio.prompt("confirm", "Please confirm this action") + + -- Writing directly to stdout or stderr, without the auto-formatting of print/warn/error + stdio.write("Hello, ") + stdio.write("World! ") + stdio.write("All on the same line") + stdio.ewrite("\nAnd some error text, too") + + -- Reading the entire input from stdin + local input = stdio.readToEnd() + ``` +]=] +local stdio = {} + +stdio.prompt = prompt + +--[=[ + @within Stdio + @tag must_use + + Return an ANSI string that can be used to modify the persistent output color. + + Pass `"reset"` to get a string that can reset the persistent output color. + + ### Example usage + + ```lua + stdio.write(stdio.color("red")) + print("This text will be red") + stdio.write(stdio.color("reset")) + print("This text will be normal") + ``` + + @param color The color to use + @return A printable ANSI string +]=] +function stdio.color(color: Color): string + return nil :: any +end + +--[=[ + @within Stdio + @tag must_use + + Return an ANSI string that can be used to modify the persistent output style. + + Pass `"reset"` to get a string that can reset the persistent output style. + + ### Example usage + + ```lua + stdio.write(stdio.style("bold")) + print("This text will be bold") + stdio.write(stdio.style("reset")) + print("This text will be normal") + ``` + + @param style The style to use + @return A printable ANSI string +]=] +function stdio.style(style: Style): string + return nil :: any +end + +--[=[ + @within Stdio + @tag must_use + + Formats arguments into a human-readable string with syntax highlighting for tables. + + @param ... The values to format + @return The formatted string +]=] +function stdio.format(...: any): string + return nil :: any +end + +--[=[ + @within Stdio + + Writes a string directly to stdout, without any newline. + + @param s The string to write to stdout +]=] +function stdio.write(s: string) end + +--[=[ + @within Stdio + + Writes a string directly to stderr, without any newline. + + @param s The string to write to stderr +]=] +function stdio.ewrite(s: string) end + +--[=[ + @within Stdio + @tag must_use + + Reads the entire input from stdin. + + @return The input from stdin +]=] +function stdio.readToEnd(): string + return nil :: any +end + +return stdio diff --git a/.lune/.lune-defs/task.luau b/.lune/.lune-defs/task.luau new file mode 100644 index 0000000..81bdc2f --- /dev/null +++ b/.lune/.lune-defs/task.luau @@ -0,0 +1,99 @@ +--[=[ + @class Task + + Built-in task scheduler & thread spawning + + ### Example usage + + ```lua + local task = require("@lune/task") + + -- Waiting for a certain amount of time + task.wait(1) + print("Waited for one second") + + -- Running a task after a given amount of time + task.delay(2, function() + print("Ran after two seconds") + end) + + -- Spawning a new task that runs concurrently + task.spawn(function() + print("Running instantly") + task.wait(1) + print("One second passed inside the task") + end) + + print("Running after task.spawn yields") + ``` +]=] +local task = {} + +--[=[ + @within Task + + Stops a currently scheduled thread from resuming. + + @param thread The thread to cancel +]=] +function task.cancel(thread: thread) end + +--[=[ + @within Task + + Defers a thread or function to run at the end of the current task queue. + + @param functionOrThread The function or thread to defer + @return The thread that will be deferred +]=] +function task.defer(functionOrThread: thread | (T...) -> ...any, ...: T...): thread + return nil :: any +end + +--[=[ + @within Task + + Delays a thread or function to run after `duration` seconds. + + @param functionOrThread The function or thread to delay + @return The thread that will be delayed +]=] +function task.delay( + duration: number, + functionOrThread: thread | (T...) -> ...any, + ...: T... +): thread + return nil :: any +end + +--[=[ + @within Task + + Instantly runs a thread or function. + + If the spawned task yields, the thread that spawned the task + will resume, letting the spawned task run in the background. + + @param functionOrThread The function or thread to spawn + @return The thread that was spawned +]=] +function task.spawn(functionOrThread: thread | (T...) -> ...any, ...: T...): thread + return nil :: any +end + +--[=[ + @within Task + + Waits for *at least* the given amount of time. + + The minimum wait time possible when using `task.wait` is limited by the underlying OS sleep implementation. + For most systems this means `task.wait` is accurate down to about 5 milliseconds or less. + + @param duration The amount of time to wait + @return The exact amount of time waited +]=] +function task.wait(duration: number?): number + return nil :: any +end + +return task diff --git a/.lune/analyze.luau b/.lune/analyze.luau new file mode 100644 index 0000000..6f884d6 --- /dev/null +++ b/.lune/analyze.luau @@ -0,0 +1,8 @@ +--!strict +local spawn = require("util/spawn") + +spawn.start("lune run install-packages") +spawn.start("rojo sourcemap dev.project.json -o sourcemap.json") +spawn.start( + "luau-lsp analyze --base-luaurc=.luaurc --sourcemap=sourcemap.json --settings=luau_lsp_settings.json --no-strict-dm-types --ignore Packages/**/*.lua --ignore Packages/**/*.luau --ignore Packages/*.lua --ignore Packages/*.luau lib/" +) diff --git a/.lune/build.luau b/.lune/build.luau index a149ed4..8be60df 100644 --- a/.lune/build.luau +++ b/.lune/build.luau @@ -1,4 +1,7 @@ --!strict -local spawn = require("./util/spawn") +local spawn = require("util/spawn") -spawn.start("rojo build default.project.json -o hammer.rbxm") +spawn.start("lune run install-packages") +spawn.start("rojo sourcemap dev.project.json -o sourcemap.json") +spawn.start("darklua process --config .darklua.json lib/ dist/", { env = { ROBLOX_DEV = "false" } }) +spawn.start("rojo build default.project.json -o build.rbxm") diff --git a/.lune/check.luau b/.lune/check.luau index 042f118..61712a2 100644 --- a/.lune/check.luau +++ b/.lune/check.luau @@ -1,6 +1,8 @@ --!strict -local spawn = require("./util/spawn") +local spawn = require("util/spawn") +spawn.start("lune run analyze") spawn.start("stylua lib/") spawn.start("selene lib/") +spawn.start("lune run download-jecs") spawn.start("luau test/tests.luau") diff --git a/.lune/dev.luau b/.lune/dev.luau index 4b71341..188dffd 100644 --- a/.lune/dev.luau +++ b/.lune/dev.luau @@ -1,10 +1,57 @@ --!strict +local process = require("@lune/process") +local stdio = require("@lune/stdio") local task = require("@lune/task") -local spawn = require("./util/spawn") -local watch = require("./util/watch") +local spawn = require("util/spawn") +local watch = require("util/watch") -task.spawn(watch, "pesde.toml", function() - spawn.spawn("pesde install") +task.spawn(watch, "wally.toml", function() + spawn.spawn("lune run install-packages") end, false) -spawn.start("pesde install") +spawn.start("lune run install-packages") + +spawn.spawn("rojo sourcemap dev.project.json -o sourcemap.json --watch") +spawn.spawn("darklua process --config .darklua.json --watch lib/ dist/", { env = { ROBLOX_DEV = "true" } }) + +task.wait(2.5) + +while true do + local start_commit = stdio.prompt("confirm", "Start commit? -- `y` to start a commit, `n` to exit the script") + if not start_commit then + process.exit(0) + break + end + + local _, check_result = pcall(spawn.start, "lune run check") + if not check_result.ok then + warn("Check didn't go ok, aborting commit") + break + end + + local commit_title = stdio.prompt("text", "Commit title -- leave blank to stop committing") + if not commit_title or commit_title == "" then + print("Stopping commit") + continue + end + local commit_messages = { `-m`, commit_title } + + while true do + local commit_message = stdio.prompt("text", "Commit message -- added to the description, leave blank to finish") + if not commit_message or commit_message == "" then + break + end + + table.insert(commit_messages, "-m") + table.insert(commit_messages, commit_message) + end + + local confirm = stdio.prompt("confirm", "Confirm?") + if not confirm then + break + end + + spawn.start("git add .") + process.spawn("git", { "commit", unpack(commit_messages) }, { stdio = "forward" }) + spawn.start("git push") +end diff --git a/.lune/dist.luau b/.lune/dist.luau new file mode 100644 index 0000000..b6ba7f2 --- /dev/null +++ b/.lune/dist.luau @@ -0,0 +1,21 @@ +--!strict +local fs = require("@lune/fs") +local spawn = require("util/spawn") + +spawn.start("rojo sourcemap dev.project.json -o sourcemap.json") +spawn.start("lune run install-packages") +spawn.start("darklua process --config .darklua.json lib/ dist/", { env = { ROBLOX_DEV = "false" } }) + +for _, path in fs.readDir("dist") do + path = `dist/{path}` + if not fs.isFile(path) then + continue + end + + print("found working file") + + local file = fs.readFile(path) + local new_contents = + string.gsub(file, `require%("%.%./jecs"%)`, `require(script.Parent.Parent:FindFirstChild('jecs'))`) + fs.writeFile(path, new_contents) +end diff --git a/.lune/download-jecs.luau b/.lune/download-jecs.luau new file mode 100644 index 0000000..2d23e65 --- /dev/null +++ b/.lune/download-jecs.luau @@ -0,0 +1,67 @@ +--!strict +local fs = require("@lune/fs") +local net = require("@lune/net") +local process = require("@lune/process") +local serde = require("@lune/serde") +local spawn = require("util/spawn") + +type wally_manifest = { + package: { + name: string, + version: string, + registry: string, + realm: string, + license: string?, + exclude: { string }?, + include: { string }?, + }, + dependencies: { + [string]: string, + }, +} + +local github_token: string = process.args[1] + +if not github_token then + local env_exists = fs.metadata(".env").exists + if not env_exists then + error("Usage: lune run download-jecs [GITHUB_PAT]\nAlternatively, put the PAT in an .env file under GITHUB_PAT") + end + + local env = serde.decode("toml", fs.readFile(".env")) + local pat = env.GITHUB_PAT or error("Couldn't read GITHUB_PAT from .env") + github_token = pat +end + +local manifest_contents = fs.readFile("wally.toml") or error("Couldn't read manifest.") +local manifest: wally_manifest = serde.decode("toml", manifest_contents) or error("Couldn't decode manifest.") +local jecs_version = string.match(manifest.dependencies.jecs, "%d.%d.%d") or error("Couldn't find jecs version.") + +type gh_api_tag = { + ref: string, + node_id: string, + url: string, + object: { + sha: string, + type: string, + url: string, + }, +} + +local response = net.request({ + url = `https://api.github.com/repos/ukendio/jecs/git/refs/tags/v{jecs_version}`, + method = "GET", + headers = { + Accept = "application/vnd.github+json", + Authorization = `Bearer {github_token}`, + ["X-GitHub-Api-Version"] = "2022-11-28", + }, +}) + +if not response.ok then + error(`Github api response not ok:\n{response.statusCode} @ {response.statusMessage}\n{response.body}`) +end + +local gh_api_tag: gh_api_tag = serde.decode("json", response.body) + +spawn.start(`curl https://raw.githubusercontent.com/ukendio/jecs/{gh_api_tag.object.sha}/src/init.luau -o jecs.luau`) diff --git a/.lune/install-packages.luau b/.lune/install-packages.luau new file mode 100644 index 0000000..c6b363b --- /dev/null +++ b/.lune/install-packages.luau @@ -0,0 +1,6 @@ +--!strict +local spawn = require("util/spawn") + +spawn.start("wally install") +spawn.start("rojo sourcemap dev.project.json -o sourcemap.json") +spawn.start("wally-package-types --sourcemap sourcemap.json Packages/") diff --git a/.lune/util/spawn.luau b/.lune/util/spawn.luau index 0ec4e7f..6e46e5d 100644 --- a/.lune/util/spawn.luau +++ b/.lune/util/spawn.luau @@ -9,15 +9,15 @@ local task = require("@lune/task") --- @param cmd string --- @param options process.SpawnOptions? --- @return process.SpawnResult -local function start_process(cmd: string, options: process.ExecOptions?): process.ExecResult +local function start_process(cmd: string, options: process.SpawnOptions?): process.SpawnResult local arguments = string.split(cmd, " ") local command = arguments[1] table.remove(arguments, 1) - local opts: process.ExecOptions = options ~= nil and options or {} - opts.stdio = opts.stdio ~= nil and opts.stdio or "forward" :: any + local opts: process.SpawnOptions = options ~= nil and options or {} + opts.stdio = opts.stdio ~= nil and opts.stdio or "forward" - return process.exec(command, arguments, opts) + return process.spawn(command, arguments, opts) end --- `task.spawn` a process with the given command and options @@ -27,7 +27,7 @@ end --- @param cmd string --- @param options process.SpawnOptions? --- @return process.SpawnResult -local function spawn_process(cmd: string, options: process.ExecOptions?) +local function spawn_process(cmd: string, options: process.SpawnOptions?) task.spawn(start_process, cmd, options) end diff --git a/.zed/settings.json b/.zed/settings.json index 0a07aaf..6482f28 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,47 +1,52 @@ // 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#settings-files +// see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings { "lsp": { "luau-lsp": { "settings": { "luau-lsp": { - "diagnostics": { - "workspace": false - }, "completion": { "imports": { "enabled": true, "suggestServices": true, - "suggestRequires": true, - "stringRequires": { - "enabled": true - } + "suggestRequires": false } }, + "sourcemap": { + "rojoProjectFile": "dev.project.json" + }, "require": { - "mode": "relativeToFile" + "mode": "relativeToFile", + "fileAliases": { + "@jecs_utils": "lib", + "@testkit": "test/testkit", + "@jecs": "Packages/jecs" + }, + "directoryAliases": { + "@lune": ".lune/.lune-defs/" + } } }, "ext": { "roblox": { - "enabled": false, - "security_level": "roblox_script" + "enabled": false }, "fflags": { - "enable_new_solver": true, + "override": { + "LuauTinyControlFlowAnalysis": "true" + }, "sync": true, "enable_by_default": false - }, - "binary": { - "ignore_system_version": false } } } } }, - "file_types": { - "Luau": ["lua"] + "languages": { + "TypeScript": { + "tab_size": 4 + } } } diff --git a/LICENSE b/LICENSE index 1f7ab96..65ed78b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 marked +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 diff --git a/README.md b/README.md index 488164b..adb85a1 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,18 @@ -

- -

- -[![CI](https://git.devmarked.win/marked/hammer/badges/workflows/ci.yml/badge.svg?style=for-the-badge&label=CI)](https://git.devmarked.win/marked/hammer/actions?workflow=ci.yml) -[![CD](https://git.devmarked.win/marked/hammer/badges/workflows/release.yml/badge.svg?style=for-the-badge&label=RELEASE)](https://git.devmarked.win/marked/hammer/actions?workflow=release.yml) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](https://git.devmarked.win/marked/hammer/src/branch/main/LICENSE) -Wally -Pesde +# 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) A set of utilities for [Jecs](https://github.com/ukendio/jecs)
-## Installation +## Features -Hammer is available on pesde @ `marked/hammer` and Wally @ `mark-marks/hammer`. - -## Usage - -All utilities that require a Jecs world to function are exposed via a constructor pattern.\ -For instance, to build a `ref`: -```luau -local ref = hammer.ref(world) -``` -This is the easiest solution for passing a world that doesn't sacrifice readability internally and externally or bind the developer to a Jecs version that hammer is currently using. - -### collect - -A [collect](/lib/utilities/collect.luau) collects all arguments fired through the given signal, and exposes an iterator for them.\ -Its purpose is to interface with signals in ECS code, which ideally should run every frame in a loop. - -For instance, take Roblox's RemoteEvents: -```luau -local pings = hammer.collect(events.ping) -local function system() - for _, player, ping in pings do - events.ping:FireClient(player, "pong!") - end -end -``` - -### command_buffer - -A [command_buffer](/lib/utilities/command_buffer.luau) lets you buffer world commands in order to prevent iterator invalidation.\ -Iterator invalidation refers to an iterator (e.g. `world:query(Component)`) becoming unusable due to changes in the underlying data. - -To prevent this, command buffers can be used to delay world operations to the end of the current frame: -```luau -local command_buffer = hammer.command_buffer(world) - -while true do - step_systems() - command_buffer.flush() -end - --- Inside a system: -command_buffer.add(entity, component) -- This runs after all of the systems run; no data changes while things are running -``` - -### ref - -A [ref](/lib/utilities/ref.luau) allows for storing and getting entities via some form of reference.\ -This is particularly useful for situations where you reconcile entities into your world from a foreign place, e.g. from across a networking boundary. -```luau -local ref = hammer.ref(world) - -for id in net.new_entities.iter() do - local entity = ref(`foreign-{id}`) -- A new entity that can be tracked via a foreign id -end -``` - -Refs by default create a new entity if the given value doesn't reference any stored one. In case you want to see if a reference exists, you can find one: -```luau -local entity[: Entity?] = ref.find(`my-key`) -``` - -Refs can also be deleted. All functions used to a fetch a reference also return a cleanup function: -```luau -local entity, destroy_reference = ref(`my-key`) -destroy_reference() -- `entity` still persists in the world, but `my-key` doesn't refer to it anymore. -``` - -Refs are automatically cached by world. `ref(world)` will have the same underlying references as `ref(world)`.\ -In case you need an unique reference store, you can omit the cache via `ref(world, true)`. - -### tracker - -A [tracker](/lib/utilities/tracker) keeps a history of all components passed to it, and how to get to their current state in the least amount of commands.\ -They're great for replicating world state across a networking barrier, as you're able to easily get diffed snapshots and apply them. -```luau -local tracker = hammer.tracker(world, ComponentA, ComponentB) - -world:set(entity_a, ComponentA, 50) -world:add(entity_b, ComponentB) - --- Says how to give `entity_a` `ComponentA` with the value of `50` and give `entity_b` `ComponentB`. --- `state()` always tracks from when the tracker was first created. -local state = tracker.state() - --- Same as the above, but now this sets the origin for the next taken snapshot! -local snapshot = tracker.snapshot() - -world:remove(entity_b, ComponentB) - --- This now only says to remove `ComponentB` from `entity_b`. -local snapshot_b = tracker.snapshot() -``` - -Trackers simplify the state internally. Removals remove all prior commands pertaining to the entity and component pair, adds remove all prior removals, etc. - -Trackers are optimized under the hood with lookup tables for arrays, to allow for a constant time operation to check for whether it has a member or not. It can lead to worse memory usage, but makes it faster overall. +- [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 iterator invalidation +- [spawner](/lib/spawner.luau) - Spawn entities with required components diff --git a/assets/hammer-logo.png b/assets/hammer-logo.png deleted file mode 100644 index bc9f48a..0000000 Binary files a/assets/hammer-logo.png and /dev/null differ diff --git a/default.project.json b/default.project.json index 959b2a6..047a782 100644 --- a/default.project.json +++ b/default.project.json @@ -1,6 +1,6 @@ { - "name": "hammer", + "name": "jecs-utils", "tree": { - "$path": "lib" + "$path": "dist" } } diff --git a/dev.project.json b/dev.project.json new file mode 100644 index 0000000..7496e83 --- /dev/null +++ b/dev.project.json @@ -0,0 +1,16 @@ +{ + "name": "dev", + "tree": { + "$className": "DataModel", + + "ReplicatedStorage": { + "Packages": { + "$className": "Folder", + "$path": "Packages", + "jecs_utils": { + "$path": "lib" + } + } + } + } +} diff --git a/jecs.luau b/jecs.luau deleted file mode 100644 index 86e5a2d..0000000 --- a/jecs.luau +++ /dev/null @@ -1,13 +0,0 @@ ---!strict ---!native ---!optimize 2 --- pesde adds dependencies by including a folder with them in the package --- wally just adds a file for each dependency --- this is here to mitigate that -local jecs = require("./luau_packages/jecs") -export type Archetype = jecs.Archetype -export type Id = jecs.Id -export type Pair = jecs.Pair -export type Entity = jecs.Entity -export type World = jecs.World -return jecs diff --git a/lib/utilities/collect.luau b/lib/collect.luau similarity index 74% rename from lib/utilities/collect.luau rename to lib/collect.luau index 7531532..e5629ef 100644 --- a/lib/utilities/collect.luau +++ b/lib/collect.luau @@ -1,5 +1,6 @@ --!strict --!optimize 2 + --[[ original author by @memorycode @@ -27,12 +28,24 @@ SOFTWARE. --]] --- What signals passed to `collect()` should be able to be coerced into -export type SignalLike = { connect: Connector, [any]: any } | { Connect: Connector, [any]: any } -type Connector = (self: SignalLike, (T...) -> ()) -> D +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` or `connect` ***method***. -local function collect(event: SignalLike): (() -> (number, T...), D) +--- 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() @@ -40,11 +53,11 @@ local function collect(event: SignalLike): (() -> (number, T.. return function(): (number?, T...) if n <= 0 then mt.__iter = nil - return nil :: any + return nil end n -= 1 - return n + 1, unpack(table.remove(storage, 1) :: any) :: any + return n + 1, unpack(table.remove(storage, 1) :: any) end end diff --git a/lib/command_buffer.luau b/lib/command_buffer.luau new file mode 100644 index 0000000..08ccd52 --- /dev/null +++ b/lib/command_buffer.luau @@ -0,0 +1,135 @@ +--!strict +--!optimize 2 +local jecs = require("../jecs") +type entity = jecs.Entity +type id = jecs.Id + +local _world = require("./world") +local WORLD = _world.get + +-- luau-lsp literally dies if you use the actual world type +type jecs_world = any + +--- `map>` +local add_commands: { [jecs_world]: { [id]: { entity } } } = {} +--- `map>` +local set_commands: { [jecs_world]: { [id]: { [entity]: any } } } = {} +--- `map>` +local remove_commands: { [jecs_world]: { [id]: { entity } } } = {} +--- `array` +local delete_commands: { [jecs_world]: { entity } } = {} + +_world.on_set(function(world) + add_commands[world] = {} + set_commands[world] = {} + remove_commands[world] = {} + delete_commands[world] = {} +end) + +export 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() + for world, entities in delete_commands do + for _, entity in entities do + world:delete(entity) + end + end + + for world, commands in add_commands do + for component, entities in commands do + for _, entity in entities do + if delete_commands[world][entity] then + continue + end + + world:add(entity, component) + end + end + table.clear(add_commands[world]) + end + + for world, commands in set_commands do + for component, entities in commands do + for entity, value in entities do + if delete_commands[world][entity] then + continue + end + + world:set(entity, component, value) + end + end + table.clear(set_commands[world]) + end + + for world, commands in remove_commands do + for component, entities in commands do + for _, entity in entities do + if delete_commands[world][entity] then + continue + end + + world:remove(entity, component) + end + end + table.clear(remove_commands[world]) + end + + for world in delete_commands do + table.clear(delete_commands[world]) + end +end + +local function add(entity: entity, component: id) + local world = WORLD() + if not add_commands[world][component] then + add_commands[world][component] = {} + end + + table.insert(add_commands[world][component], entity) +end + +local function set(entity: entity, component: id, data: T) + local world = WORLD() + if not set_commands[world][component] then + set_commands[world][component] = {} + end + + set_commands[world][component][entity] = data +end + +local function remove(entity: entity, component: id) + local world = WORLD() + if not remove_commands[world][component] then + remove_commands[world][component] = {} + end + + table.insert(remove_commands[world][component], entity) +end + +local function delete(entity: entity) + local world = WORLD() + table.insert(delete_commands[world], 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..d4ee535 --- /dev/null +++ b/lib/handle.luau @@ -0,0 +1,78 @@ +--!strict +--!optimize 2 +local jecs = require("../jecs") +type entity = jecs.Entity +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, world: jecs.World }, {} :: interface)) + +local handle = {} :: interface +handle.__index = handle + +function handle.new(entity: entity) + local self = { + entity = entity, + world = world(), + } + + return setmetatable(self, handle) +end + +function handle:has(...: id): boolean + return self.world:has(self.entity, ...) +end + +handle.get = function(self: handle, a: id, b: id?, c: id?, d: id?) + return self.world:get(self.entity, a, b :: any, c :: any, d :: any) +end :: any + +function handle:add(id: id): handle + self.world:add(self.entity, id) + return self +end + +function handle:set(id: id, value: T): handle + self.world:set(self.entity, id, value) + return self +end + +function handle:remove(id: id): handle + self.world:remove(self.entity, id) + return self +end + +function handle:delete() + self.world:delete(self.entity) +end + +function handle:id(): entity + return self.entity +end + +return handle.new diff --git a/lib/init.luau b/lib/init.luau index 9c4f483..8bcbb66 100644 --- a/lib/init.luau +++ b/lib/init.luau @@ -1,22 +1,42 @@ --!strict --!optimize 2 -local collect = require("@self/utilities/collect") -export type SignalLike = collect.SignalLike -export type VerboseSignalLike = collect.SignalLike +local jecs = require("../jecs") -local ref = require("@self/utilities/ref") -export type Ref = ref.Identity +local WORLD = require("./world") -local tracker = require("@self/utilities/tracker") -export type Tracker = tracker.Identity -export type TrackerCommands = tracker.Commands +local collect = require("./collect") +export type collect_signal_like = collect.signal_like +export type collect_verbose_signal_like = collect.signal_like -local command_buffer = require("@self/utilities/command_buffer") -export type CommandBuffer = command_buffer.Identity +local command_buffer = require("./command_buffer") +export type command_buffer = command_buffer.command_buffer + +local handle = require("./handle") +export type handle = handle.handle + +local ref = require("./ref") + +local replicator = require("./replicator") +export type replicator = replicator.replicator +export type changes = replicator.changes + +local spawner = require("./spawner") +export type spawner = spawner.spawner + +--- 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, - tracker = tracker, command_buffer = command_buffer, + spawner = spawner, } diff --git a/lib/ref.luau b/lib/ref.luau new file mode 100644 index 0000000..7975dca --- /dev/null +++ b/lib/ref.luau @@ -0,0 +1,67 @@ +--!strict +--!optimize 2 +local handle = require("./handle") +local jecs = require("../jecs") +local WORLD = require("./world").get + +local refs: { [jecs.World]: { [any]: jecs.Entity } } = {} + +local function serve_clearer(key: any, world: jecs.World): () -> () + return function() + refs[world][key] = nil + end +end + +--- 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, () -> ()?) + local world = WORLD() + if not key then + return handle(world:entity()) + end + + if not refs[world] then + refs[world] = {} + end + + local entity = refs[world][key] + if not entity then + entity = world:entity() + refs[world][key] = entity + end + + return handle(entity), serve_clearer(key, world) +end + +-- For the `__call`` metamethod +local function __call(_, key: any): (handle.handle, () -> ()?) + return ref(key) +end + +local function search(key: any): (handle.handle?, () -> ()?) + local world = WORLD() + if not key then + return nil + end + local entity = refs[world][key] + + if not entity then + return nil + end + + return handle(entity), serve_clearer(key, world) +end + +local metatable = { + __call = __call, + __index = { + search = search, + set_ref = ref, + }, +} + +local REF = setmetatable({}, metatable) :: typeof(ref) & typeof(metatable.__index) +return REF diff --git a/lib/replicator.luau b/lib/replicator.luau new file mode 100644 index 0000000..d65e88f --- /dev/null +++ b/lib/replicator.luau @@ -0,0 +1,247 @@ +--!strict +--!optimize 2 +local jecs = require("../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 world = WORLD() + 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/spawner.luau b/lib/spawner.luau new file mode 100644 index 0000000..ccad166 --- /dev/null +++ b/lib/spawner.luau @@ -0,0 +1,49 @@ +--!strict +local spawner_type = require("./spawner_type") +local WORLD = require("./world").get +local handle = require("./handle") + +export type spawner = spawner_type.spawner + +--- Creates an entity spawner. +--- ```luau +--- local spawner = jecs_utils.spawner(components.part, components.velocity, components.position) +--- for _ = 1, 1000 do +--- spawner.spawn(part_template:Clone(), Vector3.zero, Vector3.zero) +--- end +--- ``` +--- @param ... T... -- Components to use. +--- @return spawner +local function spawner(...) + local components = { ... } + local world = WORLD() + + local function spawn(...) + local passed = { ... } + local entity = world:entity() + + for idx, component in components do + world:set(entity, component, passed[idx]) + end + + return entity + end + + local function spawn_with_handle(...) + local passed = { ... } + local entity = handle(world:entity()) + + for idx, component in components do + entity:set(component, passed[idx]) + end + + return entity + end + + return { + spawn = spawn, + spawn_with_handle = spawn_with_handle, + } +end + +return (spawner :: any) :: spawner_type.create_spawner diff --git a/lib/spawner_type.luau b/lib/spawner_type.luau new file mode 100644 index 0000000..3c9e5a3 --- /dev/null +++ b/lib/spawner_type.luau @@ -0,0 +1,391 @@ +--!strict +local jecs = require("../jecs") +type entity = jecs.Entity +type id = jecs.Id + +local handle = require("./handle") + +export type spawner = { + --- Creates an entity with the given components. + --- @param ... T... + --- @return entity + spawn: (T...) -> entity, + --- Creates an entity with the given components and returns a handle to it. + --- @param ... T... + --- @return handle + spawn_with_handle: (T...) -> handle.handle, +} + +-- Very beautiful type incoming! +-- Sadly this has to be done, components are of different types than their values (`entity` vs `T`) +export type create_spawner = + ((id) -> spawner) + & ((id, id) -> spawner) + & ((id, id, id) -> spawner) + & ((id, id, id, id) -> spawner) + & ((id, id, id, id, id) -> spawner) + & ((id, id, id, id, id, id) -> spawner) + & ((id, id, id, id, id, id, id) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id

, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id + ) -> spawner) + +return {} diff --git a/lib/utilities/command_buffer.luau b/lib/utilities/command_buffer.luau deleted file mode 100644 index a9130bb..0000000 --- a/lib/utilities/command_buffer.luau +++ /dev/null @@ -1,140 +0,0 @@ ---!strict ---!optimize 2 -local jecs = require("../../jecs") -type Entity = jecs.Entity -type Id = jecs.Id -type World = jecs.World - -export type Identity = { - --- Execute all commands and clear the buffer - flush: () -> (), - --- Peeks into the commands currently stored by the buffer - peek: () -> Commands, - - --- 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 and all it's related components and relationships - delete: (entity: Entity) -> (), -} - -export type Commands = { - add: { [Id]: { Entity } }, - set: { [Id]: { [Entity]: unknown } }, - remove: { [Id]: { Entity } }, - delete: { Entity }, - - deletion_lookup: { [Entity]: true }, -} - -local function construct(world: World): Identity - local add_commands: { [Id]: { Entity } } = {} - local set_commands: { [Id]: { [Entity]: unknown } } = {} - local remove_commands: { [Id]: { Entity } } = {} - local delete_commands: { Entity } = {} - -- Double memory usage for deletions but preserve order while keeping O(1) performance for lookups - local deletion_lookup: { [Entity]: true } = {} - - local function flush() - for _, entity in delete_commands do - world:delete(entity) - end - - for component, entities in add_commands do - for _, entity in entities do - if deletion_lookup[entity] then - continue - end - - world:add(entity, component) - end - end - table.clear(add_commands) - - for component, entities in set_commands do - for entity, value in entities do - if deletion_lookup[entity] then - continue - end - - world:set(entity, component, value) - end - end - table.clear(set_commands) - - for component, entities in remove_commands do - for _, entity in entities do - if deletion_lookup[entity] then - continue - end - - world:remove(entity, component) - end - end - table.clear(remove_commands) - - table.clear(delete_commands) - table.clear(deletion_lookup) - end - - local function peek() - return { - add = add_commands, - set = set_commands, - remove = remove_commands, - delete = delete_commands, - - deletion_lookup = deletion_lookup, - } - end - - local function add(entity: Entity, component: Id) - local cmds = add_commands[component] - if not cmds then - cmds = {} - add_commands[component] = cmds - end - - table.insert(cmds, entity) - end - - local function set(entity: Entity, component: Id, data: T) - local cmds = set_commands[component] - if not cmds then - cmds = {} - set_commands[component] = cmds - end - - cmds[entity] = data - end - - local function remove(entity: Entity, component: Id) - local cmds = remove_commands[component] - if not cmds then - cmds = {} - remove_commands[component] = cmds - end - - table.insert(cmds, entity) - end - - local function delete(entity: Entity) - table.insert(delete_commands, entity) - deletion_lookup[entity] = true - end - - return { - flush = flush, - peek = peek, - - add = add, - set = set, - remove = remove, - delete = delete, - } -end - -return construct diff --git a/lib/utilities/ref.luau b/lib/utilities/ref.luau deleted file mode 100644 index f74095a..0000000 --- a/lib/utilities/ref.luau +++ /dev/null @@ -1,85 +0,0 @@ ---!strict ---!optimize 2 -local jecs = require("../../jecs") -type Entity = jecs.Entity - -export type Identity = typeof(setmetatable( - {}, - {} :: { - __call: (any, key: unknown) -> (Entity, Cleaner), - __index: { - reference: (key: unknown) -> (Entity, Cleaner), - find: (key: unknown) -> (Entity?, Cleaner?), - }, - } -)) - -type Cleaner = () -> () - -local ref_cache: { [jecs.World]: Identity } = {} - -local function construct(world: jecs.World, skip_cache: boolean?): Identity - if not skip_cache then - local hit = ref_cache[world] - if hit then - return hit - end - end - - local lookup: { [unknown]: Entity } = {} - local cleaner_cache: { [unknown]: Cleaner } = {} - - local function serve_cleaner(key: unknown): () -> () - local hit = cleaner_cache[key] - if hit then - return hit - end - - local function cleaner() - lookup[key] = nil - cleaner_cache[key] = nil - end - cleaner_cache[key] = cleaner - - return cleaner - end - - local function ref(key: unknown): (Entity, Cleaner) - local entity = lookup[key] - if not entity then - entity = world:entity() - lookup[key] = entity - end - - return entity, serve_cleaner(key) - end - - local function find(key: unknown): (Entity?, Cleaner?) - local entity = lookup[key] - if not entity then - return nil, nil - end - - return entity, serve_cleaner(key) - end - - local function call(_, key: unknown): (Entity, Cleaner) - return ref(key) - end - - local self = setmetatable({}, { - __call = call, - __index = { - reference = ref, - find = find, - }, - }) - - if not skip_cache then - ref_cache[world] = self - end - - return self -end - -return construct diff --git a/lib/utilities/tracker.luau b/lib/utilities/tracker.luau deleted file mode 100644 index da8e4d5..0000000 --- a/lib/utilities/tracker.luau +++ /dev/null @@ -1,273 +0,0 @@ ---!strict ---!optimize 2 -local command_buffer = require("./command_buffer") -local jecs = require("../../jecs") -type Entity = jecs.Entity -type Id = jecs.Id -type i53 = number - -local OnAdd = jecs.OnAdd -local OnSet = jecs.OnSet -local OnRemove = jecs.OnRemove - -local construct_ref = require("./ref") - --- The external type differs for better DX -export type Commands = { - added: { [i53]: { i53 } }, - set: { [i53]: { [i53]: unknown } }, - removed: { [i53]: { i53 } }, -} - -type Added = { [Id]: { Entity } } -type Set = { [Id]: { [Entity]: unknown } } -type Removed = { [Id]: { Entity } } -type Lookup = { [Id]: { [Entity]: number } } -type InternalCommands = { - added: Added, - set: Set, - removed: Removed, -} - ---- Tracks operations on entities for the provided world. -export type Identity = { - --- Gets the current state. - --- A state is a representation of the minimum of commands necessary to produce the current world from a clean slate. - state: () -> Commands, - --- Gets the currently tracked snapshot. - --- A snapshot is a representation of the minimum of commands necessary to produce the current world back from when the last snapshot was taken. - snapshot: () -> Commands?, - --- Applies a set of commands to the tracked world, optionally doing it through a command buffer. - apply: (snapshot: Commands, buf: command_buffer.Identity?) -> (), -} - -local function get_non_nilable(container: {} & T, index: K): index - local data = container[index] - if not data then - data = {} - container[index] = data - end - return data -end - -local function insert_unique(container: T, value: V, lookup: { [V]: number }?) - if lookup then - if lookup[value] then - return - end - - local idx = #lookup + 1 - lookup[value] = idx; - (container :: any)[idx] = value - return - end - - if table.find(container, value) then - return - end - table.insert(container, value) -end - -local function construct(world: jecs.World, ...: Entity): Identity - local components = { ... } - local ref = construct_ref(world, true) - - local state_added: Added = {} - local state_added_lookup: Lookup = {} - local state_set: Set = {} - local state_removed: Removed = {} - local state_removed_lookup: Lookup = {} - - local snapshot_added: Added = {} - local snapshot_set: Set = {} - local snapshot_removed: Removed = {} - - for _, component in components do - world:set(component, OnAdd, function(entity: Entity) - local snapshot = get_non_nilable(snapshot_added, component) - insert_unique(snapshot, entity) - - local state = get_non_nilable(state_added, component) - local lookup = get_non_nilable(state_added_lookup, component) - insert_unique(state, entity, lookup) - - -- Clean up previous operations - local set_state = state_set[component] - if set_state and set_state[entity] then - set_state[entity] = nil - end - - local removed_lookup = state_removed_lookup[component] - if removed_lookup then - local idx = removed_lookup[entity] - if idx then - removed_lookup[entity] = nil - local removed_state = state_removed[component] - if removed_state then - -- Shifting around the array could be expensive, prefer `tbl[idx] = nil` - removed_state[idx] = nil - end - end - end - end) - - world:set(component, OnSet, function(entity, value) - local snapshot = get_non_nilable(snapshot_set, component) - snapshot[entity] = value - - local state = get_non_nilable(state_set, component) - state[entity] = value - - -- Clean up previous operations - local added_lookup = state_added_lookup[component] - if added_lookup then - local idx = added_lookup[entity] - if idx then - added_lookup[entity] = nil - local added_state = state_added[component] - if added_state then - -- Shifting around the array could get expensive, prefer `array[idx] = nil` - added_state[idx] = nil - end - end - end - - local removed_lookup = state_removed_lookup[component] - if removed_lookup then - local idx = removed_lookup[entity] - if idx then - removed_lookup[entity] = nil - local removed_state = state_removed[component] - if removed_state then - -- Shifting around the array could get expensive, prefer `array[idx] = nil` - removed_state[idx] = nil - end - end - end - end) - - world:set(component, OnRemove, function(entity: Entity) - local snapshot = get_non_nilable(snapshot_removed, component) - insert_unique(snapshot, entity) - - local state = get_non_nilable(state_removed, component) - local lookup = get_non_nilable(state_removed_lookup, component) - - -- Clean up previous operations - local added_lookup = state_added_lookup[component] - if added_lookup then - local idx = added_lookup[entity] - if idx then - added_lookup[entity] = nil - local added_state = state_added[component] - if added_state then - -- Shifting around the array could get expensive, prefer `array[idx] = nil` - added_state[idx] = nil - end - end - end - - local set_state = state_set[component] - if set_state and set_state[entity] then - set_state[entity] = nil - end - - insert_unique(state, entity, lookup) - end) - end - - -- We cast anything exposing `Commands` as `any` to improve the types for the end user - local function get_state(): InternalCommands - return { - added = state_added, - set = state_set, - removed = state_removed, - } - end - - local function get_snapshot(): InternalCommands? - local diff_added = snapshot_added - local diff_set = snapshot_set - local diff_removed = snapshot_removed - snapshot_added = {} - snapshot_set = {} - snapshot_removed = {} - - if next(diff_added) == nil and next(diff_set) == nil and next(diff_removed) == nil then - return nil - end - - return { - added = diff_added, - set = diff_set, - removed = diff_removed, - } - end - - local function apply_snapshot(snapshot: InternalCommands, buf: command_buffer.Identity?) - local add - local set - local remove - do - if buf then - add = buf.add - set = buf.set - remove = buf.remove - else - function add(entity: Entity, component: Id) - world:add(entity, component) - end - - function set(entity: Entity, component: Id, data: T) - world:set(entity, component, data) - end - - function remove(entity: Entity, component: Id) - world:remove(entity, component) - end - end - end - - for component, entities in snapshot.added do - for _, id in entities do - local entity = ref(`foreign-{id}`) - - if world:has(entity, component) then - continue - end - add(entity, component) - end - end - - for component, entities in snapshot.set do - for id, data in entities do - local entity = ref(`foreign-{id}`) - - if world:get(entity, component) == data then - continue - end - set(entity, component, data) - end - end - - for component, entities in snapshot.removed do - for _, id in entities do - local entity = ref(`foreign-{id}`) - - if world:has(entity, component) then - continue - end - remove(entity, component) - end - end - end - - -- Public types differ for better DX - return { - state = get_state, - snapshot = get_snapshot, - apply = apply_snapshot, - } :: any -end - -return construct diff --git a/lib/world.luau b/lib/world.luau new file mode 100644 index 0000000..3e7a183 --- /dev/null +++ b/lib/world.luau @@ -0,0 +1,29 @@ +--!strict +--!optimize 2 +local jecs = require("../jecs") + +local WORLD: jecs.World + +local listeners: { (jecs.World) -> () } = {} + +local function get(): jecs.World + return WORLD +end + +local function set(world: jecs.World) + WORLD = world + + for _, fn in listeners do + fn(world) + end +end + +local function on_set(fn: (jecs.World) -> ()) + table.insert(listeners, fn) +end + +return { + get = get, + set = set, + on_set = on_set, +} diff --git a/luau_lsp_settings.json b/luau_lsp_settings.json new file mode 100644 index 0000000..613b218 --- /dev/null +++ b/luau_lsp_settings.json @@ -0,0 +1,10 @@ +{ + "luau-lsp.fflags.override": { + "LuauTinyControlFlowAnalysis": "true" + }, + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.require.fileAliases": { + "@jecs": "Packages/jecs" + }, + "luau-lsp.platform.type": "roblox" +} diff --git a/pesde.toml b/pesde.toml index 285d3d2..28f60c2 100644 --- a/pesde.toml +++ b/pesde.toml @@ -1,18 +1,17 @@ -name = "marked/hammer" -version = "0.2.0" -description = "A set of utilities for Jecs" -authors = ["marked"] -repository = "https://git.devmarked.win/marked/hammer" +name = "mark_marks/jecs_utils" +version = "0.1.6" +description = "A set of utilities for jecs" +authors = ["marked/Mark-Marks"] +repository = "https://github.com/mark-marks/jecs-utils" license = "MIT" -includes = ["lib", "lib/**", "LICENSE", "pesde.toml", "README.md", "jecs.luau"] + +include = ["src", "src/**", "LICENSE", "pesde.toml", "README.md"] [target] environment = "luau" -lib = "lib/init.luau" [indices] -default = "https://github.com/pesde-pkg/index" +default = "https://github.com/daimond113/pesde-index" [dependencies] -# `marked/jecs@0.5.5` was yanked due to some issues, this is the equivalent -jecs = { name = "marked/jecs_nightly", version = "=0.5.5-nightly.20250312T202956Z" } +jecs = { repo = "https://git.devmarked.win/marked/jecs-pesde", rev = "main" } diff --git a/rokit.toml b/rokit.toml index 34f2d48..9f1de54 100644 --- a/rokit.toml +++ b/rokit.toml @@ -5,9 +5,11 @@ [tools] wally = "upliftgames/wally@0.3.2" -rojo = "rojo-rbx/rojo@7.5.1" -lune = "lune-org/lune@0.9.2" -selene = "kampfkarren/selene@0.28.0" -luau-lsp = "johnnymorganz/luau-lsp@1.45.0" -stylua = "johnnymorganz/stylua@2.1.0" -pesde = "daimond113/pesde@0.6.2+registry.0.2.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" +pesde = "daimond113/pesde@0.5.0-rc.8" diff --git a/selene.toml b/selene.toml index 9afc306..d0a4c7f 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,2 @@ std = "selene_definitions" +exclude = ["lib/jecs.luau"] diff --git a/stylua.toml b/stylua.toml index 920827f..e61530e 100644 --- a/stylua.toml +++ b/stylua.toml @@ -3,7 +3,7 @@ line_endings = "Unix" indent_type = "Spaces" indent_width = 4 quote_style = "AutoPreferDouble" -call_parentheses = "Input" +call_parentheses = "Always" collapse_simple_statement = "Never" [sort_requires] diff --git a/test/signal.luau b/test/signal.luau index e90e5a0..b508fae 100644 --- a/test/signal.luau +++ b/test/signal.luau @@ -3,19 +3,19 @@ -- https://github.com/red-blox/Util/blob/main/libs/Signal/Signal.luau -- adapted to work in pure luau -type Node = { - next: Node?, +type node = { + next: node?, callback: (T...) -> (), } -export type Signal = { - root: Node?, +export type signal = { + root: node?, - connect: (self: Signal, Callback: (T...) -> ()) -> () -> (), - wait: (self: Signal) -> T..., - once: (self: Signal, Callback: (T...) -> ()) -> () -> (), - fire: (self: Signal, T...) -> (), - disconnect_all: (self: Signal) -> (), + connect: (self: signal, Callback: (T...) -> ()) -> () -> (), + wait: (self: signal) -> T..., + once: (self: signal, Callback: (T...) -> ()) -> () -> (), + fire: (self: signal, T...) -> (), + disconnect_all: (self: signal) -> (), } local Signal = {} @@ -23,7 +23,7 @@ Signal.__index = Signal -- Extracted this function from Connect as it results in the closure -- made in Connect using less memory because this function can be static -local function disconnect(self: Signal, Node: Node) +local function disconnect(self: signal, Node: node) if self.root == Node then self.root = Node.next else @@ -40,7 +40,7 @@ local function disconnect(self: Signal, Node: Node) end end -function Signal.connect(self: Signal, Callback: (T...) -> ()): () -> () +function Signal.connect(self: signal, Callback: (T...) -> ()): () -> () local node = { next = self.root, callback = Callback, @@ -53,30 +53,30 @@ function Signal.connect(self: Signal, Callback: (T...) -> ()): () -> end end -function Signal.wait(self: Signal): T... +function Signal.wait(self: signal): T... local Thread = coroutine.running() local Disconnect Disconnect = self:connect(function(...) - (Disconnect :: any)() + Disconnect() coroutine.resume(Thread, ...) end) return coroutine.yield() end -function Signal.once(self: Signal, Callback: (T...) -> ()): () -> () +function Signal.once(self: signal, Callback: (T...) -> ()): () -> () local Disconnect Disconnect = self:connect(function(...) - (Disconnect :: any)() + Disconnect() Callback(...) end) return Disconnect end -function Signal.fire(self: Signal, ...: T...) +function Signal.fire(self: signal, ...: T...) local Current = self.root while Current do @@ -85,11 +85,11 @@ function Signal.fire(self: Signal, ...: T...) end end -function Signal.disconnect_all(self: Signal) +function Signal.disconnect_all(self: signal) self.root = nil end -return function(): Signal +return function(): signal return setmetatable({ root = nil, }, Signal) :: any diff --git a/test/tests.luau b/test/tests.luau index b29a6d8..3ea84b9 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1,15 +1,18 @@ --!strict -- stylua: ignore start -local hammer = require("../lib") -local jecs = require("@pkg/jecs") -local testkit = require("./testkit") +local jecs = require("@jecs") +local jecs_utils = require("@jecs_utils") +local testkit = require("@testkit") -type Entity = jecs.Entity +type entity = jecs.Entity -local collect = hammer.collect -local make_tracker = hammer.tracker -local make_ref = hammer.ref -local make_command_buffer = hammer.command_buffer +local collect = jecs_utils.collect +local handle = jecs_utils.handle +local replicator = jecs_utils.replicator +local ref = jecs_utils.ref +local ref_search = ref.search +local command_buffer = jecs_utils.command_buffer +local spawner = jecs_utils.spawner local signal = require("./signal") @@ -17,9 +20,9 @@ local BENCH, START = testkit.benchmark() local TEST, CASE, CHECK, FINISH, SKIP, FOCUS = testkit.test() -TEST("hammer.collect()", function() +TEST("jecs_utils.collect()", function() do CASE "collects" - local sig: signal.Signal = signal() + local sig: signal.signal = signal() local flush = collect(sig) local should = {} @@ -35,152 +38,169 @@ TEST("hammer.collect()", function() end end) -TEST("hammer.ref()", function() - do CASE "set_ref" +TEST("jecs_utils.handle()", function() + do CASE "has" local world = jecs.World.new() - local ref = make_ref(world, true) - - local a = ref(1234) - local b = ref(1234) - CHECK(a == b) - end - - do CASE "find" - local world = jecs.World.new() - local ref = make_ref(world, true) - - local a = ref(1234) - local b = ref.find(1234) - CHECK(a == b) - end - - do CASE "cleaner" - local world = jecs.World.new() - local ref = make_ref(world, true) - - local a, clean = ref(1234) - clean() - local b = ref(1234) - CHECK(b ~= a) - end - - do CASE "caching" - local world = jecs.World.new() - local ref_a = make_ref(world) - local ref_b = make_ref(world) - CHECK(ref_a == ref_b) - local ref_c = make_ref(world, true) - CHECK(ref_c ~= ref_a and ref_c ~= ref_b) - end -end) - --- TODO! write extensive tests for state operation cleaning -TEST("hammer.tracker()", function() - do CASE "snapshot" - local world = jecs.World.new() - local tag = world:entity() - local component = world:component() :: Entity - - local entity1 = world:entity() - local entity2 = world:entity() - - local tracker = make_tracker(world, component, tag) - - world:add(entity1, tag) - world:set(entity2, component, 50) - - local snapshot = tracker.snapshot() - CHECK(snapshot ~= nil) - assert(snapshot) -- Refinements - - local world2 = jecs.World.new() - local component2 = world2:component() :: Entity - local tag2 = world2:entity() - - local tracker2 = make_tracker(world2, component2, tag2) - - tracker2.apply(snapshot) - - CHECK(world:has(entity1, tag2)) - CHECK(world:get(entity2, component2) == 50) - end - - do CASE "state" - local world = jecs.World.new() - local tag = world:entity() - local component = world:component() :: Entity - - local entity1 = world:entity() - local entity2 = world:entity() - - local tracker = make_tracker(world, component, tag) - - world:add(entity1, tag) - world:set(entity2, component, 50) - - local state = tracker.state() - CHECK(state ~= nil) - - local world2 = jecs.World.new() - local component2 = world2:component() :: Entity - local tag2 = world2:entity() - - local tracker2 = make_tracker(world2, component2, tag2) - - tracker2.apply(state) - - CHECK(world:has(entity1, tag2)) - CHECK(world:get(entity2, component2) == 50) - end - - do CASE "simplifying" - local world = jecs.World.new() - local component = world:component() :: Entity + jecs_utils.initialize(world) local entity = world:entity() - local tracker = make_tracker(world, component) + local tag = world:entity() + + world:add(entity, tag) + CHECK(handle(entity):has(tag)) + end + + do CASE "get" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() - world:add(entity, component) - do - local state = tracker.state() - CHECK(table.find(state.added[component :: any], entity)) - end world:set(entity, component, 50) - do - local state = tracker.state() - CHECK(not table.find(state.added[component :: any], entity)) - CHECK(state.set[component :: any][entity :: any] == 50) - end - world:remove(entity, component) - do - local state = tracker.state() - CHECK(state.set[component :: any][entity :: any] == nil) - CHECK(table.find(state.removed[component :: any], entity)) - end - world:add(entity, component) - do - local state = tracker.state() - CHECK(not table.find(state.removed[component :: any], entity)) - end - world:remove(entity, component) - do - local state = tracker.state() - CHECK(state.set[component :: any][entity :: any] == nil) - CHECK(table.find(state.removed[component :: any], entity)) - end - world:set(entity, component, 50) - do - local state = tracker.state() - CHECK(not table.find(state.removed[component :: any], entity)) - CHECK(state.set[component :: any][entity :: any] == 50) - end + CHECK(handle(entity):get(component) == 50) + end + + do CASE "add" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local tag = world:entity() + + handle(entity):add(tag) + CHECK(world:has(entity, tag)) + end + + do CASE "set" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() + + handle(entity):set(component, 50) + CHECK(world:get(entity, component) == 50) + end + + do CASE "remove" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + local component = world:component() + + handle(entity):set(component, 50) + CHECK(world:get(entity, component) == 50) + end + + do CASE "delete" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local entity = world:entity() + handle(entity):delete() + CHECK(not world:contains(entity)) end end) -TEST("hammer.command_buffer()", function() +TEST("jecs_utils.ref()", function() + do CASE "set_ref" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local a: number = ref(1234):id() + local b: number = ref(1234):id() + CHECK(a == b) + end + + do CASE "search" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local a: number = ref(1234):id() + local b = ref_search(1234) + assert(b) -- give me the type refinements... + CHECK(a == b:id() :: number) + end + + do CASE "clearer" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local a, a_clear = ref(1234); + (a_clear :: any)() + local b = ref(1234) + CHECK(b:id() :: number ~= a:id() :: number) + end +end) + +TEST("jecs_utils.replicator()", function() + do CASE "propagates difference" + local world = jecs.World.new() + local tag = world:entity() + local component: jecs.Entity = world:component() + + local entity1 = world:entity() + local entity2 = world:entity() + + jecs_utils.initialize(world) + local rep = replicator(component, tag) + + world:add(entity1, tag) + world:set(entity2, component, 50) + + local difference: jecs_utils.changes = rep.calculate_difference() :: any + CHECK(difference ~= nil) + + local world2 = jecs.World.new() + local component2: jecs.Entity = world2:component() + local tag2 = world2:entity() + + jecs_utils.initialize(world2) + local rep2 = replicator(component2, tag2) + + rep2.apply_difference(difference) + + CHECK(ref(`replicated-{entity1}`):has(tag2)) + CHECK(ref(`replicated-{entity2}`):get(component2) == 50) + end + + do CASE "propagates full data" + local world = jecs.World.new() + local tag = world:entity() + local component: jecs.Entity = world:component() + + local entity1 = world:entity() + local entity2 = world:entity() + + jecs_utils.initialize(world) + local rep = replicator(component, tag) + + world:add(entity1, tag) + world:set(entity2, component, 50) + + local full_data = rep.get_full_data() + CHECK(full_data ~= nil) + + local world2 = jecs.World.new() + local component2: jecs.Entity = world2:component() + local tag2 = world2:entity() + + jecs_utils.initialize(world2) + local rep2 = replicator(component2, tag2) + + rep2.apply_difference(full_data) + + CHECK(ref(`replicated-{entity1}`):has(tag2)) + CHECK(ref(`replicated-{entity2}`):get(component2) == 50) + end +end) + +TEST("jecs_utils.command_buffer", function() do CASE "add" local world = jecs.World.new() - local command_buffer = make_command_buffer(world) + jecs_utils.initialize(world) local tag = world:entity() local entity = world:entity() @@ -195,7 +215,7 @@ TEST("hammer.command_buffer()", function() do CASE "set" local world = jecs.World.new() - local command_buffer = make_command_buffer(world) + jecs_utils.initialize(world) local component = world:component() local entity = world:entity() @@ -210,7 +230,7 @@ TEST("hammer.command_buffer()", function() do CASE "remove" local world = jecs.World.new() - local command_buffer = make_command_buffer(world) + jecs_utils.initialize(world) local component = world:component() local entity = world:entity() @@ -226,7 +246,7 @@ TEST("hammer.command_buffer()", function() do CASE "delete" local world = jecs.World.new() - local command_buffer = make_command_buffer(world) + jecs_utils.initialize(world) local entity = world:entity() command_buffer.delete(entity) @@ -235,32 +255,51 @@ TEST("hammer.command_buffer()", function() CHECK(not world:contains(entity)) end +end) - do CASE "peek" +TEST("jecs_utils.spawner()", function() + do CASE "spawn" local world = jecs.World.new() - local command_buffer = make_command_buffer(world) + jecs_utils.initialize(world) - local tag1 = world:entity() - local entity1 = world:entity() - command_buffer.add(entity1, tag1) + local c1: entity = world:component() + local c2: entity = world:component() + local c3: entity<{}> = world:component() - local component1 = world:component() - local entity2 = world:entity() - command_buffer.set(entity2, component1, 50) + local t1 = world:entity() - local tag2 = world:component() - local entity3 = world:entity() - command_buffer.remove(entity3, tag2) + local entity_spawner = spawner(c1, c2, c3, t1) - local entity4 = world:component() - command_buffer.delete(entity4) + local tbl = {} - local commands = command_buffer.peek() - CHECK(table.find(commands.add[tag1], entity1)) - CHECK(commands.set[component1][entity2] == 50) - CHECK(table.find(commands.remove[tag2], entity3)) - CHECK(table.find(commands.delete, entity4)) - CHECK(commands.deletion_lookup[entity4] == true) + local idx = entity_spawner.spawn(1234, "abcdef", tbl) + CHECK(world:contains(idx)) + CHECK(world:get(idx, c1) == 1234) + CHECK(world:get(idx, c2) == "abcdef") + CHECK(world:get(idx, c3) == tbl) + CHECK(world:has(idx, t1)) + end + + do CASE "spawn_with_handle" + local world = jecs.World.new() + jecs_utils.initialize(world) + + local c1: entity = world:component() + local c2: entity = world:component() + local c3: entity<{}> = world:component() + + local t1 = world:entity() + + local entity_spawner = spawner(c1, c2, c3, t1) + + local tbl = {} + + local ent = entity_spawner.spawn_with_handle(1234, "abcdef", tbl) + CHECK(world:contains(ent:id())) + CHECK(ent:get(c1) == 1234) + CHECK(ent:get(c2) == "abcdef") + CHECK(ent:get(c3) == tbl) + CHECK(ent:has(t1)) end end) diff --git a/wally.toml b/wally.toml index 16442cb..a8f4a38 100644 --- a/wally.toml +++ b/wally.toml @@ -1,18 +1,18 @@ [package] -name = "mark-marks/hammer" -version = "0.2.0" +name = "mark-marks/jecs-utils" +version = "0.1.6" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" license = "MIT" exclude = ["**"] include = [ "default.project.json", - "lib", - "lib/**", + "dist", + "dist/**", "LICENSE", "wally.toml", "README.md", ] [dependencies] -jecs = "ukendio/jecs@0.5.5" +jecs = "ukendio/jecs@0.3.2"

+ ) -> spawner) + & (( + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id, + id