commit f52f82b867376f64a970987a8c2a221e7cbdda15 Author: Forest <77603428+ForestOfLight@users.noreply.github.com> Date: Mon Mar 10 19:24:48 2025 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..860f418 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e7eecb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ForestOfLight + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..36b2827 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +
+ + Canopy Extension Example Icon + +

Canopy Extension Example

+ +[![Codacy Quality](https://app.codacy.com/project/badge/Grade/10040a714ad84a2f912d4dae9d3f6e57)](https://app.codacy.com/gh/ForestOfLight/Canopy-Extension-Example/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Minecraft - Version](https://img.shields.io/badge/Minecraft-v1.21.60_(Bedrock)-brightgreen)](https://feedback.minecraft.net/hc/en-us/sections/360001186971-Release-Changelogs) +[![GitHub License](https://img.shields.io/github/license/forestoflight/canopy-extension-example)](LICENSE) +[![Discord](https://badgen.net/discord/members/9KGche8fxm?icon=discord&label=Discord&list=what)](https://discord.gg/9KGche8fxm) +
+ +--- + +Extensions?! In a behavior pack?! Yep! This repo contains an example pack designed to teach you how to create your own extensions for **Canopy**. All extensions must be added to the same world as **Canopy** (the dependencies in the `manifest.json` should ensure that). + +## How this example pack functions ingame + +What **Canopy** handles: + +**`./canopy [true/false]`** +**Canopy** will automatically include the names of your rules in the `./canopy` command. + +**`./help`** +**Canopy** will automatically include a Rules and a Commands help page for your extension. + +![Help Page](./example_help.png) + +What the extension handles: + +**`./example [message]`** +This command sends a message to the chat. It also has a default message if no message is provided. (Alias: `./ex`) + +**`commandExample`** +This rule will enable the `./example` command. + +**`exampleRule`** +This rule will enable a message in chat when you push a button. + +## How to create an extension + +1. Clone this repo. + ```sh + git clone https://github.com/ForestOfLight/canopy-extension-example.git + ``` +2. Remove all files that are not the `pack_icon.png`, the `manifest.json` file, or in the `scripts/` folder. +3. Rename the folder to your extension's name. + ```sh + mv canopy-extension-example your-extension-name + ``` +4. Open the folder in your favorite code editor. +5. Edit the `manifest.json` file to match your extension's information. +6. Replace the `pack_icon.png` with your extension's icon. +7. Edit the `main.js` file to add your extension's functionality. + +> [!TIP] +> Most information about how to code a **Canopy Extension** can be found in the `main.js` file. + +## How to use an extension + +1. Add the extension pack to the same world as the **Canopy** RP & BP. +2. Profit. + +**Canopy** will automatically detect the extension and load it! + +## How does this work? + +**Canopy** and its extensions all communicate using scriptevents! The vanilla `/scriptevent` command allows for sending strings to Behavior Packs and the `ScriptEventCommandMessage` event allows for recieving them. **Canopy** uses [MCBE-IPC](https://https://github.com/OmniacDev/MCBE-IPC), a library that streamlines the process of sending and recieving scriptevents. +**Canopy Extensions** use these to communicate with **Canopy** when new rules and commands are added. **Canopy** uses these as well to get rule data, set rule data, and call command callbacks from extensions. + +## Issues & Suggestions + +If you have any issues or suggestions, please open an issue on this repo. Additionally, if you're interested in contributing to the project, feel free to open a pull request! diff --git a/example_help.png b/example_help.png new file mode 100644 index 0000000..4889a69 Binary files /dev/null and b/example_help.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c4b9575 --- /dev/null +++ b/manifest.json @@ -0,0 +1,40 @@ +{ + "format_version": 2, + "header": { + "name": "Canopy Extension Example", + "description": "Example extension for §l§aCanopy§r by §aForestOfLight§r.", + "uuid": "e4e844d5-f45b-4623-82a7-534d44bb4c6f", + "min_engine_version": [1, 21, 60], + "version": [1, 0, 0] + }, + "modules": [ + { + "description": "Behavior Pack Module", + "type": "data", + "uuid": "1ce9eab8-0d4a-4e40-a899-575791a1b40d", + "version": [1, 0, 0] + }, + { + "description": "Gametest Module", + "type": "script", + "language": "javascript", + "entry": "scripts/main.js", + "uuid": "839a937e-b4f9-4484-84a4-0db984822f25", + "version": [1, 0, 0] + } + ], + "dependencies": [ + { + "module_name": "@minecraft/server", + "version": "1.18.0-beta" + }, + { + "uuid": "bcf34368-ed0c-4cf7-938e-582cccf9950d", // Canopy RP + "version": [1, 0, 2] + }, + { + "uuid": "7f6b23df-a583-476b-b0e4-87457e65f7c0", // Canopy BP + "version": [1, 3, 8] + } + ] +} diff --git a/pack_icon.png b/pack_icon.png new file mode 100644 index 0000000..dfa91e7 Binary files /dev/null and b/pack_icon.png differ diff --git a/scripts/lib/canopy/CanopyExtension.js b/scripts/lib/canopy/CanopyExtension.js new file mode 100644 index 0000000..4e39608 --- /dev/null +++ b/scripts/lib/canopy/CanopyExtension.js @@ -0,0 +1,166 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2024 ForestOfLight + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { world } from '@minecraft/server'; +import IPC from '../../lib/ipc/ipc'; +import Command from './Command'; +import Rule from './Rule'; +import { CommandCallbackRequest, CommandPrefixRequest, Ready, RegisterCommand, RegisterExtension, RegisterRule, RuleValueRequest, RuleValueSet, CommandPrefixResponse, RuleValueResponse } from './extension.ipc'; + +class CanopyExtension { + name; + version; + author; + description; + #commands = {}; + #rules = {}; + #isRegistrationReady = false; + + constructor({ name = 'Unnamed', version = '1.0.0', author = 'Unknown', description = { text: '' } }) { + this.id = this.#makeID(name); + this.name = name; + this.version = version; + this.author = author; + this.description = description; + + this.#registerExtension(); + this.#setupCommandPrefix(); + this.#handleCommandCallbacks(); + this.#handleRuleValueRequests(); + this.#handleRuleValueSetters(); + } + + addCommand(command) { + if (!(command instanceof Command)) + throw new Error('Command must be an instance of Command.'); + this.#commands[command.getName()] = command; + if (this.#isRegistrationReady) + this.#registerCommand(command); + } + + addRule(rule) { + if (!(rule instanceof Rule)) + throw new Error('Rule must be an instance of Rule.'); + this.#rules[rule.getID()] = rule; + if (this.#isRegistrationReady) + this.#registerRule(rule); + } + + getRuleValue(ruleID) { + return this.#rules[ruleID].getValue(); + } + + #makeID(name) { + if (typeof name !== 'string') + throw new Error(`[${name}] Could not register extension. Extension name must be a string.`); + const id = name.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/ /g, '_'); + if (id.length === 0) + throw new Error(`[${name}] Could not register extension. Extension name must contain at least one alphanumeric character.`); + return id; + } + + #registerExtension() { + IPC.once('canopyExtension:ready', Ready, () => { + IPC.send('canopyExtension:registerExtension', RegisterExtension, { + name: this.name, + version: this.version, + author: this.author, + description: this.description + }); + }); + IPC.once(`canopyExtension:${this.id}:ready`, Ready, () => { + this.#isRegistrationReady = true; + for (const rule of Object.values(this.#rules)) + this.#registerRule(rule); + for (const command of Object.values(this.#commands)) + this.#registerCommand(command); + }); + } + + #registerCommand(command) { + IPC.send(`canopyExtension:${this.id}:registerCommand`, RegisterCommand, { + name: command.getName(), + description: command.getDescription(), + usage: command.getUsage(), + callback: false, + args: command.getArgs(), + contingentRules: command.getContingentRules(), + adminOnly: command.isAdminOnly(), + helpEntries: command.getHelpEntries(), + helpHidden: command.isHelpHidden(), + extensionName: this.name + }); + } + + #handleCommandCallbacks() { + IPC.on(`canopyExtension:${this.id}:commandCallbackRequest`, CommandCallbackRequest, (cmdData) => { + if (cmdData.senderName === undefined) + return; + const sender = world.getPlayers({ name: cmdData.senderName })[0]; + if (!sender) + throw new Error(`Sender ${cmdData.senderName} of ${cmdData.commandName} not found.`); + const parsedArgs = JSON.parse(cmdData.args); + this.#commands[cmdData.commandName].runCallback(sender, parsedArgs); + }); + } + + #registerRule(rule) { + IPC.send(`canopyExtension:${this.id}:registerRule`, RegisterRule, { + identifier: rule.getID(), + description: rule.getDescription(), + contingentRules: rule.getContigentRules(), + independentRules: rule.getIndependentRules(), + extensionName: this.name + }); + if (rule.getValue() === true) + rule.onEnable(); + } + + #handleRuleValueRequests() { + IPC.handle(`canopyExtension:${this.id}:ruleValueRequest`, RuleValueRequest, RuleValueResponse, (data) => { + const rule = this.#rules[data.ruleID]; + if (!rule) + throw new Error(`Rule ${data.ruleID} not found.`); + const value = rule.getValue(); + return { value }; + }); + } + + #handleRuleValueSetters() { + IPC.on(`canopyExtension:${this.id}:ruleValueSet`, RuleValueSet, (data) => { + const rule = this.#rules[data.ruleID]; + if (!rule) + throw new Error(`Rule ${data.ruleID} not found.`); + rule.setValue(data.value); + }); + } + + #setupCommandPrefix() { + const prefix = IPC.invoke(`canopyExtension:commandPrefixRequest`, CommandPrefixRequest, void 0, CommandPrefixResponse).then(result => { + Command.setPrefix(result.prefix); + }); + } +} + +export { CanopyExtension, Command, Rule }; \ No newline at end of file diff --git a/scripts/lib/canopy/Command.js b/scripts/lib/canopy/Command.js new file mode 100644 index 0000000..9d0651d --- /dev/null +++ b/scripts/lib/canopy/Command.js @@ -0,0 +1,103 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2024 ForestOfLight + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +class Command { + #name; + #description; + #usage; + #callback; + #args; + #contingentRules; + #adminOnly; + #helpEntries; + #helpHidden; + static #prefix = ''; + + constructor({ name, description = '', usage, callback, args = [], contingentRules = [], adminOnly = false, helpEntries = [], helpHidden = false }) { + this.#name = name; + this.#description = description; + this.#usage = usage; + this.#callback = callback; + this.#args = args; + this.#contingentRules = contingentRules; + this.#adminOnly = adminOnly; + this.#helpEntries = helpEntries; + this.#helpHidden = helpHidden; + } + + getName() { + return this.#name; + } + + getDescription() { + return this.#description; + } + + getUsage() { + return this.#usage; + } + + getCallback() { + return this.#callback; + } + + getArgs() { + return this.#args; + } + + getContingentRules() { + return this.#contingentRules; + } + + isAdminOnly() { + return this.#adminOnly; + } + + getHelpEntries() { + return this.#helpEntries; + } + + isHelpHidden() { + return this.#helpHidden; + } + + runCallback(sender, args) { + this.#callback(sender, args); + } + + sendUsage(sender) { + sender.sendMessage(`§cUsage: ${Command.#prefix}${this.#usage}`); + } + + static setPrefix(prefix) { + Command.#prefix = prefix; + } + + static getPrefix() { + return Command.#prefix; + } +} + +export default Command; \ No newline at end of file diff --git a/scripts/lib/canopy/Rule.js b/scripts/lib/canopy/Rule.js new file mode 100644 index 0000000..cafae2f --- /dev/null +++ b/scripts/lib/canopy/Rule.js @@ -0,0 +1,76 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2024 ForestOfLight + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { world } from '@minecraft/server'; + +class Rule { + #identifier; + #description; + #contingentRules; + #independentRules; + + constructor({ identifier, description, contingentRules = [], independentRules = [], onEnableCallback = () => {}, onDisableCallback = () => {} }) { + this.#identifier = identifier; + this.#description = description; + this.#contingentRules = contingentRules; + this.#independentRules = independentRules; + this.onEnable = onEnableCallback; + this.onDisable = onDisableCallback; + } + + getID() { + return this.#identifier; + } + + getDescription() { + return this.#description; + } + + getContigentRules() { + return this.#contingentRules; + } + + getIndependentRules() { + return this.#independentRules; + } + + getValue() { + const value = world.getDynamicProperty(this.#identifier); + if (String(value) === 'true') + return true; + if (['false', 'undefined'].includes(String(value))) + return false; + throw new Error(`Rule ${this.#identifier} has an invalid value: ${value} (${typeof value})`); + } + + setValue(value) { + if (value === true) + this.onEnable(); + else + this.onDisable(); + world.setDynamicProperty(this.#identifier, value); + } +} + +export default Rule; \ No newline at end of file diff --git a/scripts/lib/canopy/extension.ipc.js b/scripts/lib/canopy/extension.ipc.js new file mode 100644 index 0000000..5e00ab3 --- /dev/null +++ b/scripts/lib/canopy/extension.ipc.js @@ -0,0 +1,70 @@ +/* eslint-disable new-cap */ +import { PROTO } from '../ipc/ipc' + +const description = PROTO.Object({ + text: PROTO.Optional(PROTO.String), + translate: PROTO.Optional(PROTO.String), + with: PROTO.Optional(PROTO.Array(PROTO.String)) +}); + +export const Ready = PROTO.Void; + +export const RegisterExtension = PROTO.Object({ + name: PROTO.String, + version: PROTO.String, + author: PROTO.String, + description: description, + isEndstone: PROTO.Boolean +}); + +export const RegisterCommand = PROTO.Object({ + name: PROTO.String, + description: description, + usage: PROTO.String, + callback: PROTO.Optional(PROTO.Undefined), + args: PROTO.Optional(PROTO.Array(PROTO.Object({ + type: PROTO.String, + name: PROTO.String + }))), + contingentRules: PROTO.Optional(PROTO.Array(PROTO.String)), + adminOnly: PROTO.Optional(PROTO.Boolean), + helpEntries: PROTO.Optional(PROTO.Array(PROTO.Object({ + usage: PROTO.String, + description: description + }))), + helpHidden: PROTO.Optional(PROTO.Boolean), + extensionName: PROTO.Optional(PROTO.String) +}); + +export const RegisterRule = PROTO.Object({ + identifier: PROTO.String, + description: description, + contingentRules: PROTO.Optional(PROTO.Array(PROTO.String)), + independentRules: PROTO.Optional(PROTO.Array(PROTO.String)), + extensionName: PROTO.Optional(PROTO.String) +}); + +export const RuleValueRequest = PROTO.Object({ + ruleID: PROTO.String +}); + +export const RuleValueResponse = PROTO.Object({ + value: PROTO.Boolean +}); + +export const RuleValueSet = PROTO.Object({ + ruleID: PROTO.String, + value: PROTO.Boolean +}); + +export const CommandCallbackRequest = PROTO.Object({ + commandName: PROTO.String, + senderName: PROTO.Optional(PROTO.String), + args: PROTO.String +}); + +export const CommandPrefixRequest = PROTO.Void; + +export const CommandPrefixResponse = PROTO.Object({ + prefix: PROTO.String +}); \ No newline at end of file diff --git a/scripts/lib/ipc/ipc.d.ts b/scripts/lib/ipc/ipc.d.ts new file mode 100644 index 0000000..dc6ef67 --- /dev/null +++ b/scripts/lib/ipc/ipc.d.ts @@ -0,0 +1,103 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2025 OmniacDev + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export declare namespace PROTO { + interface Serializable { + serialize(value: T, stream: ByteQueue): Generator; + deserialize(stream: ByteQueue): Generator; + } + class ByteQueue { + private _buffer; + private _data_view; + private _length; + private _offset; + get end(): number; + get front(): number; + get data_view(): DataView; + constructor(size?: number); + write(...values: number[]): void; + read(amount?: number): number[]; + ensure_capacity(size: number): void; + static from_uint8array(array: Uint8Array): ByteQueue; + to_uint8array(): Uint8Array; + } + namespace MIPS { + function serialize(byte_queue: PROTO.ByteQueue): Generator; + function deserialize(str: string): Generator; + } + const Void: PROTO.Serializable; + const Null: PROTO.Serializable; + const Undefined: PROTO.Serializable; + const Int8: PROTO.Serializable; + const Int16: PROTO.Serializable; + const Int32: PROTO.Serializable; + const UInt8: PROTO.Serializable; + const UInt16: PROTO.Serializable; + const UInt32: PROTO.Serializable; + const UVarInt32: PROTO.Serializable; + const Float32: PROTO.Serializable; + const Float64: PROTO.Serializable; + const String: PROTO.Serializable; + const Boolean: PROTO.Serializable; + const UInt8Array: PROTO.Serializable; + const Date: PROTO.Serializable; + function Object(obj: { + [K in keyof T]: PROTO.Serializable; + }): PROTO.Serializable; + function Array(value: PROTO.Serializable): PROTO.Serializable; + function Tuple(...values: { + [K in keyof T]: PROTO.Serializable; + }): PROTO.Serializable; + function Optional(value: PROTO.Serializable): PROTO.Serializable; + function Map(key: PROTO.Serializable, value: PROTO.Serializable): PROTO.Serializable>; + function Set(value: PROTO.Serializable): PROTO.Serializable>; + type Endpoint = string; + type Header = { + guid: string; + encoding: string; + index: number; + final: boolean; + }; + const Endpoint: PROTO.Serializable; + const Header: PROTO.Serializable
; +} +export declare namespace NET { + function serialize(byte_queue: PROTO.ByteQueue, max_size?: number): Generator; + function deserialize(strings: string[]): Generator; + function emit, T>(endpoint: string, serializer: S & PROTO.Serializable, value: T): Generator; + function listen>(endpoint: string, serializer: S & PROTO.Serializable, callback: (value: T) => Generator): () => void; +} +export declare namespace IPC { + /** Sends a message with `args` to `channel` */ + function send, T>(channel: string, serializer: S & PROTO.Serializable, value: T): void; + /** Sends an `invoke` message through IPC, and expects a result asynchronously. */ + function invoke, T, RS extends PROTO.Serializable, R>(channel: string, serializer: TS & PROTO.Serializable, value: T, deserializer: RS & PROTO.Serializable): Promise; + /** Listens to `channel`. When a new message arrives, `listener` will be called with `listener(args)`. */ + function on, T>(channel: string, deserializer: S & PROTO.Serializable, listener: (value: T) => void): () => void; + /** Listens to `channel` once. When a new message arrives, `listener` will be called with `listener(args)`, and then removed. */ + function once, T>(channel: string, deserializer: S & PROTO.Serializable, listener: (value: T) => void): () => void; + /** Adds a handler for an `invoke` IPC. This handler will be called whenever `invoke(channel, ...args)` is called */ + function handle, T, RS extends PROTO.Serializable, R>(channel: string, deserializer: TS & PROTO.Serializable, serializer: RS & PROTO.Serializable, listener: (value: T) => R): () => void; +} +export default IPC; diff --git a/scripts/lib/ipc/ipc.js b/scripts/lib/ipc/ipc.js new file mode 100644 index 0000000..8b24afe --- /dev/null +++ b/scripts/lib/ipc/ipc.js @@ -0,0 +1,593 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2025 OmniacDev + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { ScriptEventSource, system, world } from '@minecraft/server'; +export var PROTO; +(function (PROTO) { + class ByteQueue { + get end() { + return this._length + this._offset; + } + get front() { + return this._offset; + } + get data_view() { + return this._data_view; + } + constructor(size = 256) { + this._buffer = new Uint8Array(size); + this._data_view = new DataView(this._buffer.buffer); + this._length = 0; + this._offset = 0; + } + write(...values) { + this.ensure_capacity(values.length); + this._buffer.set(values, this.end); + this._length += values.length; + } + read(amount = 1) { + if (this._length > 0) { + const max_amount = amount > this._length ? this._length : amount; + const values = this._buffer.subarray(this._offset, this._offset + max_amount); + this._length -= max_amount; + this._offset += max_amount; + return globalThis.Array.from(values); + } + return []; + } + ensure_capacity(size) { + if (this.end + size > this._buffer.length) { + const larger_buffer = new Uint8Array((this.end + size) * 2); + larger_buffer.set(this._buffer.subarray(this._offset, this.end), 0); + this._buffer = larger_buffer; + this._offset = 0; + this._data_view = new DataView(this._buffer.buffer); + } + } + static from_uint8array(array) { + const byte_queue = new ByteQueue(); + byte_queue._buffer = array; + byte_queue._length = array.length; + byte_queue._offset = 0; + byte_queue._data_view = new DataView(array.buffer); + return byte_queue; + } + to_uint8array() { + return this._buffer.subarray(this._offset, this.end); + } + } + PROTO.ByteQueue = ByteQueue; + let MIPS; + (function (MIPS) { + function* serialize(byte_queue) { + const uint8array = byte_queue.to_uint8array(); + let str = '(0x'; + for (let i = 0; i < uint8array.length; i++) { + const hex = uint8array[i].toString(16).padStart(2, '0').toUpperCase(); + str += hex; + yield; + } + str += ')'; + return str; + } + MIPS.serialize = serialize; + function* deserialize(str) { + if (str.startsWith('(0x') && str.endsWith(')')) { + const result = []; + const hex_str = str.slice(3, str.length - 1); + for (let i = 0; i < hex_str.length; i++) { + const hex = hex_str[i] + hex_str[++i]; + result.push(parseInt(hex, 16)); + yield; + } + return ByteQueue.from_uint8array(new Uint8Array(result)); + } + return new ByteQueue(); + } + MIPS.deserialize = deserialize; + })(MIPS = PROTO.MIPS || (PROTO.MIPS = {})); + PROTO.Void = { + *serialize() { }, + *deserialize() { } + }; + PROTO.Null = { + *serialize() { }, + *deserialize() { + return null; + } + }; + PROTO.Undefined = { + *serialize() { }, + *deserialize() { + return undefined; + } + }; + PROTO.Int8 = { + *serialize(value, stream) { + const length = 1; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setInt8(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getInt8(stream.front); + stream.read(1); + return value; + } + }; + PROTO.Int16 = { + *serialize(value, stream) { + const length = 2; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setInt16(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getInt16(stream.front); + stream.read(2); + return value; + } + }; + PROTO.Int32 = { + *serialize(value, stream) { + const length = 4; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setInt32(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getInt32(stream.front); + stream.read(4); + return value; + } + }; + PROTO.UInt8 = { + *serialize(value, stream) { + const length = 1; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setUint8(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getUint8(stream.front); + stream.read(1); + return value; + } + }; + PROTO.UInt16 = { + *serialize(value, stream) { + const length = 2; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setUint16(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getUint16(stream.front); + stream.read(2); + return value; + } + }; + PROTO.UInt32 = { + *serialize(value, stream) { + const length = 4; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setUint32(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getUint32(stream.front); + stream.read(4); + return value; + } + }; + PROTO.UVarInt32 = { + *serialize(value, stream) { + while (value >= 0x80) { + stream.write((value & 0x7f) | 0x80); + value >>= 7; + yield; + } + stream.write(value); + }, + *deserialize(stream) { + let value = 0; + let size = 0; + let byte; + do { + byte = stream.read()[0]; + value |= (byte & 0x7f) << (size * 7); + size += 1; + yield; + } while ((byte & 0x80) !== 0 && size < 10); + return value; + } + }; + PROTO.Float32 = { + *serialize(value, stream) { + const length = 4; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setFloat32(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getFloat32(stream.front); + stream.read(4); + return value; + } + }; + PROTO.Float64 = { + *serialize(value, stream) { + const length = 8; + stream.write(...globalThis.Array(length).fill(0)); + stream.data_view.setFloat64(stream.end - length, value); + }, + *deserialize(stream) { + const value = stream.data_view.getFloat64(stream.front); + stream.read(8); + return value; + } + }; + PROTO.String = { + *serialize(value, stream) { + yield* PROTO.UVarInt32.serialize(value.length, stream); + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + yield* PROTO.UVarInt32.serialize(code, stream); + } + }, + *deserialize(stream) { + const length = yield* PROTO.UVarInt32.deserialize(stream); + let value = ''; + for (let i = 0; i < length; i++) { + const code = yield* PROTO.UVarInt32.deserialize(stream); + value += globalThis.String.fromCharCode(code); + } + return value; + } + }; + PROTO.Boolean = { + *serialize(value, stream) { + stream.write(value ? 1 : 0); + }, + *deserialize(stream) { + const value = stream.read()[0]; + return value === 1; + } + }; + PROTO.UInt8Array = { + *serialize(value, stream) { + yield* PROTO.UVarInt32.serialize(value.length, stream); + stream.write(...value); + }, + *deserialize(stream) { + const length = yield* PROTO.UVarInt32.deserialize(stream); + return new Uint8Array(stream.read(length)); + } + }; + PROTO.Date = { + *serialize(value, stream) { + yield* PROTO.Float64.serialize(value.getTime(), stream); + }, + *deserialize(stream) { + return new globalThis.Date(yield* PROTO.Float64.deserialize(stream)); + } + }; + function Object(obj) { + return { + *serialize(value, stream) { + for (const key in obj) { + yield* obj[key].serialize(value[key], stream); + } + }, + *deserialize(stream) { + const result = {}; + for (const key in obj) { + result[key] = yield* obj[key].deserialize(stream); + } + return result; + } + }; + } + PROTO.Object = Object; + function Array(value) { + return { + *serialize(array, stream) { + yield* PROTO.UVarInt32.serialize(array.length, stream); + for (const item of array) { + yield* value.serialize(item, stream); + } + }, + *deserialize(stream) { + const result = []; + const length = yield* PROTO.UVarInt32.deserialize(stream); + for (let i = 0; i < length; i++) { + result[i] = yield* value.deserialize(stream); + } + return result; + } + }; + } + PROTO.Array = Array; + function Tuple(...values) { + return { + *serialize(tuple, stream) { + for (let i = 0; i < values.length; i++) { + yield* values[i].serialize(tuple[i], stream); + } + }, + *deserialize(stream) { + const result = []; + for (let i = 0; i < values.length; i++) { + result[i] = yield* values[i].deserialize(stream); + } + return result; + } + }; + } + PROTO.Tuple = Tuple; + function Optional(value) { + return { + *serialize(optional, stream) { + yield* PROTO.Boolean.serialize(optional !== undefined, stream); + if (optional !== undefined) { + yield* value.serialize(optional, stream); + } + }, + *deserialize(stream) { + const defined = yield* PROTO.Boolean.deserialize(stream); + if (defined) { + return yield* value.deserialize(stream); + } + return undefined; + } + }; + } + PROTO.Optional = Optional; + function Map(key, value) { + return { + *serialize(map, stream) { + yield* PROTO.UVarInt32.serialize(map.size, stream); + for (const [k, v] of map.entries()) { + yield* key.serialize(k, stream); + yield* value.serialize(v, stream); + } + }, + *deserialize(stream) { + const size = yield* PROTO.UVarInt32.deserialize(stream); + const result = new globalThis.Map(); + for (let i = 0; i < size; i++) { + const k = yield* key.deserialize(stream); + const v = yield* value.deserialize(stream); + result.set(k, v); + } + return result; + } + }; + } + PROTO.Map = Map; + function Set(value) { + return { + *serialize(set, stream) { + yield* PROTO.UVarInt32.serialize(set.size, stream); + for (const [_, v] of set.entries()) { + yield* value.serialize(v, stream); + } + }, + *deserialize(stream) { + const size = yield* PROTO.UVarInt32.deserialize(stream); + const result = new globalThis.Set(); + for (let i = 0; i < size; i++) { + const v = yield* value.deserialize(stream); + result.add(v); + } + return result; + } + }; + } + PROTO.Set = Set; + PROTO.Endpoint = PROTO.String; + PROTO.Header = PROTO.Object({ + guid: PROTO.String, + encoding: PROTO.String, + index: PROTO.UVarInt32, + final: PROTO.Boolean + }); +})(PROTO || (PROTO = {})); +export var NET; +(function (NET) { + const FRAG_MAX = 2048; + const ENCODING = 'mcbe-ipc:v3'; + const ENDPOINTS = new Map(); + function* serialize(byte_queue, max_size = Infinity) { + const uint8array = byte_queue.to_uint8array(); + const result = []; + let acc_str = ''; + let acc_size = 0; + for (let i = 0; i < uint8array.length; i++) { + const char_code = uint8array[i] | (uint8array[++i] << 8); + const utf16_size = char_code <= 0x7f ? 1 : char_code <= 0x7ff ? 2 : char_code <= 0xffff ? 3 : 4; + const char_size = char_code > 0xff ? utf16_size : 2; + if (acc_size + char_size > max_size) { + result.push(acc_str); + acc_str = ''; + acc_size = 0; + } + if (char_code > 0xff) { + acc_str += String.fromCharCode(char_code); + acc_size += utf16_size; + } + else { + acc_str += char_code.toString(16).padStart(2, '0').toUpperCase(); + acc_size += 2; + } + yield; + } + result.push(acc_str); + return result; + } + NET.serialize = serialize; + function* deserialize(strings) { + const result = []; + for (let i = 0; i < strings.length; i++) { + const str = strings[i]; + for (let j = 0; j < str.length; j++) { + const char_code = str.charCodeAt(j); + if (char_code <= 0xff) { + const hex = str[j] + str[++j]; + const hex_code = parseInt(hex, 16); + result.push(hex_code & 0xff); + result.push(hex_code >> 8); + } + else { + result.push(char_code & 0xff); + result.push(char_code >> 8); + } + yield; + } + yield; + } + return PROTO.ByteQueue.from_uint8array(new Uint8Array(result)); + } + NET.deserialize = deserialize; + system.afterEvents.scriptEventReceive.subscribe(event => { + system.runJob((function* () { + const [serialized_endpoint, serialized_header] = event.id.split(':'); + const endpoint_stream = yield* PROTO.MIPS.deserialize(serialized_endpoint); + const endpoint = yield* PROTO.Endpoint.deserialize(endpoint_stream); + const listeners = ENDPOINTS.get(endpoint); + if (event.sourceType === ScriptEventSource.Server && listeners) { + const header_stream = yield* PROTO.MIPS.deserialize(serialized_header); + const header = yield* PROTO.Header.deserialize(header_stream); + for (let i = 0; i < listeners.length; i++) { + yield* listeners[i](header, event.message); + } + } + })()); + }); + function create_listener(endpoint, listener) { + let listeners = ENDPOINTS.get(endpoint); + if (!listeners) { + listeners = new Array(); + ENDPOINTS.set(endpoint, listeners); + } + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx !== -1) + listeners.splice(idx, 1); + if (listeners.length === 0) { + ENDPOINTS.delete(endpoint); + } + }; + } + function generate_id() { + const r = (Math.random() * 0x100000000) >>> 0; + return ((r & 0xff).toString(16).padStart(2, '0') + + ((r >> 8) & 0xff).toString(16).padStart(2, '0') + + ((r >> 16) & 0xff).toString(16).padStart(2, '0') + + ((r >> 24) & 0xff).toString(16).padStart(2, '0')).toUpperCase(); + } + function* emit(endpoint, serializer, value) { + const guid = generate_id(); + const endpoint_stream = new PROTO.ByteQueue(); + yield* PROTO.Endpoint.serialize(endpoint, endpoint_stream); + const serialized_endpoint = yield* PROTO.MIPS.serialize(endpoint_stream); + const RUN = function* (header, serialized_packet) { + const header_stream = new PROTO.ByteQueue(); + yield* PROTO.Header.serialize(header, header_stream); + const serialized_header = yield* PROTO.MIPS.serialize(header_stream); + world + .getDimension('overworld') + .runCommand(`scriptevent ${serialized_endpoint}:${serialized_header} ${serialized_packet}`); + }; + const packet_stream = new PROTO.ByteQueue(); + yield* serializer.serialize(value, packet_stream); + const serialized_packets = yield* serialize(packet_stream, FRAG_MAX); + for (let i = 0; i < serialized_packets.length; i++) { + const serialized_packet = serialized_packets[i]; + yield* RUN({ guid, encoding: ENCODING, index: i, final: i === serialized_packets.length - 1 }, serialized_packet); + } + } + NET.emit = emit; + function listen(endpoint, serializer, callback) { + const buffer = new Map(); + const listener = function* (payload, serialized_packet) { + let fragment = buffer.get(payload.guid); + if (!fragment) { + fragment = { size: -1, serialized_packets: [], data_size: 0 }; + buffer.set(payload.guid, fragment); + } + if (payload.final) { + fragment.size = payload.index + 1; + } + fragment.serialized_packets[payload.index] = serialized_packet; + fragment.data_size += payload.index + 1; + if (fragment.size !== -1 && fragment.data_size === (fragment.size * (fragment.size + 1)) / 2) { + const stream = yield* deserialize(fragment.serialized_packets); + const value = yield* serializer.deserialize(stream); + yield* callback(value); + buffer.delete(payload.guid); + } + }; + return create_listener(endpoint, listener); + } + NET.listen = listen; +})(NET || (NET = {})); +export var IPC; +(function (IPC) { + /** Sends a message with `args` to `channel` */ + function send(channel, serializer, value) { + system.runJob(NET.emit(`ipc:${channel}:send`, serializer, value)); + } + IPC.send = send; + /** Sends an `invoke` message through IPC, and expects a result asynchronously. */ + function invoke(channel, serializer, value, deserializer) { + system.runJob(NET.emit(`ipc:${channel}:invoke`, serializer, value)); + return new Promise(resolve => { + const terminate = NET.listen(`ipc:${channel}:handle`, deserializer, function* (value) { + resolve(value); + terminate(); + }); + }); + } + IPC.invoke = invoke; + /** Listens to `channel`. When a new message arrives, `listener` will be called with `listener(args)`. */ + function on(channel, deserializer, listener) { + return NET.listen(`ipc:${channel}:send`, deserializer, function* (value) { + listener(value); + }); + } + IPC.on = on; + /** Listens to `channel` once. When a new message arrives, `listener` will be called with `listener(args)`, and then removed. */ + function once(channel, deserializer, listener) { + const terminate = NET.listen(`ipc:${channel}:send`, deserializer, function* (value) { + listener(value); + terminate(); + }); + return terminate; + } + IPC.once = once; + /** Adds a handler for an `invoke` IPC. This handler will be called whenever `invoke(channel, ...args)` is called */ + function handle(channel, deserializer, serializer, listener) { + return NET.listen(`ipc:${channel}:invoke`, deserializer, function* (value) { + const result = listener(value); + yield* NET.emit(`ipc:${channel}:handle`, serializer, result); + }); + } + IPC.handle = handle; +})(IPC || (IPC = {})); +export default IPC; diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..9646470 --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,97 @@ +import { CanopyExtension, Command, Rule } from './lib/canopy/CanopyExtension'; +import { world } from '@minecraft/server'; + +/** + * Create a new CanopyExtension instance to define your extension. + */ +const extension = new CanopyExtension({ + author: 'YourName', + name: 'ExampleExtension', + description: 'Example extension for §l§aCanopy§r!', + version: '1.0.0' +}); + +// -------------------------------------------- + +/** + * Adding a new Rule: + */ +const exampleRule = new Rule({ + identifier: 'exampleRule', // The name of the rule + description: { text: 'An example rule that prints a message in chat when you hit a button.' }, // Shows up in the help command. Must be a RawMessage type (translatable!). + // Optional: + contingentRules: [], // Rules that will be enabled when this rule is enabled + independentRules: [], // Rules that will be disabled when this rule is enabled + onEnableCallback: () => world.afterEvents.buttonPush.subscribe(onButtonPush), // Function to run when the rule is enabled (also runs when the extension is loaded, if the rule is already enabled) + onDisableCallback: () => world.afterEvents.buttonPush.unsubscribe(onButtonPush) // Function to run when the rule is disabled +}); +extension.addRule(exampleRule); + +// use the rule to control your code flow +function onButtonPush(event) { + if (!exampleRule.getValue()) + return; + if (event.source === undefined) // Always check for undefined entities and players. Simulated players always show up as undefined in events. + return; + event.source.sendMessage('§aYou pushed a button!'); +} + +// -------------------------------------------- + +/** + * Making a second new rule which we can use to enable the example command: + * (This is not required to make a new command, but it is helpful to allow admins to disable your commands.) + */ +const commandExampleRule = new Rule({ + identifier: 'commandExample', + description: { text: 'Enables the example command.' } // Shows up in the help command. RawMessage type. +}); +extension.addRule(commandExampleRule); + +/** + * Adding a new Command: + */ +const exampleCommand = new Command({ + name: 'example', // The name of the command + description: { text: 'An example command that prints your message in chat.' }, // Shows up in the help command. RawMessage type. + usage: 'example [message]', // The usage of the command that shows up in the help command & when used incorrectly + callback: exampleCommandCallback, // The function to run when the command is executed + // Optional: + args: [ + { type: 'string|number', name: 'message' } // The arguments that the command takes. 'string|number' means it can be either a string or a number + ], + contingentRules: ['commandExample'], // Rules that must be true for the command to be enabled + adminOnly: false, // Whether the command can only be run by admins (users with the 'CanopyAdmin' tag) + helpEntries: [ // Additional help entries that show up in the help command + { usage: `example`, description: { text: `Run the example command with the default message.` } } // Description is a RawMessage type. + ], + helpHidden: false // Whether the command should be hidden from the help command. +}); +extension.addCommand(exampleCommand); + +/** + * Adding a command alias: + * This is essentially just adding a new command that runs the same function as the original command and hiding its help. + */ +const exampleCommandAlias = new Command({ + name: 'ex', + description: { text: 'An alias for the example command.' }, + usage: 'ex [message]', + callback: exampleCommandCallback, + args: [ + { type: 'string|number', name: 'message' } + ], + contingentRules: ['commandExample'], + helpHidden: true +}); +extension.addCommand(exampleCommandAlias); + +// now you can define the function that will be called when the command is executed +function exampleCommandCallback(sender, args) { + let { message } = args; + if (message === null) + message = 'Hello, world!'; + if (!isNaN(parseFloat(message)) && isFinite(message)) + message = message.toString(); + sender.sendMessage(`§aYou ran the example command with the message: §7${message}`); +}