diff --git a/Canopy[BP]/manifest.json b/Canopy[BP]/manifest.json index b3b9052c..5e212a9e 100644 --- a/Canopy[BP]/manifest.json +++ b/Canopy[BP]/manifest.json @@ -1,18 +1,18 @@ { "format_version": 2, "header": { - "name": "Canopy [BP] v1.5.7", + "name": "Canopy [BP] v1.6.0", "description": "Technical informatics & features addon by §aForestOfLight§r.", "uuid": "7f6b23df-a583-476b-b0e4-87457e65f7c0", "version": [ 1, - 5, - 7 + 6, + 0 ], "min_engine_version": [ 1, 26, - 30 + 40 ] }, "modules": [ @@ -42,7 +42,7 @@ "dependencies": [ { "module_name": "@minecraft/server", - "version": "2.9.0-beta" + "version": "2.10.0-beta" }, { "module_name": "@minecraft/server-ui", @@ -52,12 +52,16 @@ "module_name": "@minecraft/debug-utilities", "version": "1.0.0-beta" }, + { + "module_name": "@minecraft/server-gametest", + "version": "1.0.0-beta" + }, { "uuid": "bcf34368-ed0c-4cf7-938e-582cccf9950d", "version": [ 1, - 5, - 7 + 6, + 0 ] } ], diff --git a/Canopy[BP]/scripts/constants.js b/Canopy[BP]/scripts/constants.js index 2847abab..30aef539 100644 --- a/Canopy[BP]/scripts/constants.js +++ b/Canopy[BP]/scripts/constants.js @@ -1,4 +1,4 @@ -const PACK_VERSION = '1.5.7'; -const MC_VERSION = '1.26.30.5'; +const PACK_VERSION = '1.6.0'; +const MC_VERSION = '1.26.40.5'; export { PACK_VERSION, MC_VERSION }; \ No newline at end of file diff --git a/Canopy[BP]/scripts/include/utils.js b/Canopy[BP]/scripts/include/utils.js index 0f685adf..514f70e8 100644 --- a/Canopy[BP]/scripts/include/utils.js +++ b/Canopy[BP]/scripts/include/utils.js @@ -281,7 +281,7 @@ export function getNameFromEntityId(id) { let entityName = id; try { const entity = world.getEntity(id); - entityName = entity?.name || entity?.nameTag || entityName; + entityName = entity?.name || entity?.nameTag || id; } catch (error) { if (!error.message.includes("is invalid") && error.name !== 'InvalidArgumentError') throw error; diff --git a/Canopy[BP]/scripts/lib/SRCItemDatabase/ItemDatabase.js b/Canopy[BP]/scripts/lib/SRCItemDatabase/ItemDatabase.js index 358b46cf..15659639 100644 --- a/Canopy[BP]/scripts/lib/SRCItemDatabase/ItemDatabase.js +++ b/Canopy[BP]/scripts/lib/SRCItemDatabase/ItemDatabase.js @@ -13,10 +13,21 @@ class AsyncQueue { this.processing = false; } enqueue(callback) { - this.queue.push(callback); - if (!this.processing) { - this.dequeue(); - } + return new Promise((resolve, reject) => { + this.queue.push(async () => { + try { + const result = await callback(); + resolve(result); + return result; + } catch (error) { + reject(error); + throw error; + } + }); + if (!this.processing) { + this.dequeue(); + } + }); } async dequeue() { if (this.processing || this.queue.length === 0) return; @@ -78,8 +89,7 @@ class SRCItemDatabase { async set(key, itemStack) { if (key.length > 12) throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`); - let success = false; - this.asyncQueue.enqueue(() => { + return this.asyncQueue.enqueue(() => { const newId = this.table + key, existingStructure = world.structureManager.get(newId), location = SRCItemDatabase.location; if (existingStructure) { world.structureManager.delete(newId); @@ -98,9 +108,8 @@ class SRCItemDatabase { const structureIds = Array.from(Databases.structureIds.get(this.table) ?? []); structureIds.push(newId); Databases.structureIds.set(this.table, structureIds); - success = true; + return true; }); - return success; }; setMany(items) { return items.map(item => this.set(item.key, item.item)) }; getAsync(key) { @@ -139,7 +148,6 @@ class SRCItemDatabase { setItems(key, items) { if (key.length > 12) throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`); - let success = false; return this.asyncQueue.enqueue(() => { const newId = this.table + key, existingStructure = world.structureManager.get(newId); if (existingStructure) { @@ -157,21 +165,21 @@ class SRCItemDatabase { saveMode: this.saveMode }); itemMemory.set(newId, items); - Databases.structureIds.set(this.table, Array.from(Databases.structureIds.get(this.table) ?? []).push(newId)); - success = true; - return success; + const structureIds = Array.from(Databases.structureIds.get(this.table) ?? []).filter(id => id !== newId); + structureIds.push(newId); + Databases.structureIds.set(this.table, structureIds); + return true; }); } getItems(key) { if (key.length > 12) throw new Error(`The provided key "${key}" exceeds the maximum allowed length of 12 characters (actual length: ${key.length}).`); const newId = this.table + key, location = SRCItemDatabase.location; - if (!itemMemory.get(newId)) return []; if (!world.structureManager.get(newId)) return []; SRCItemDatabase.dimension.getEntities({ type: 'minecraft:item', location, maxDistance: 3 }).forEach(item => item.remove()) world.structureManager.place(newId, SRCItemDatabase.dimension, location, { includeBlocks: false, includeEntities: true }); const items = SRCItemDatabase.dimension.getEntities({ type: 'minecraft:item', location: location, maxDistance: 3 }); - if (items.length === 0) return undefined; + if (items.length === 0) return []; const itemStacksArray = []; for (const item of items) { itemStacksArray.push(item.getComponent(EntityItemComponent.componentId).itemStack); diff --git a/Canopy[BP]/scripts/lib/canopy/commands/BlockCommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/BlockCommandOrigin.js index 33814bd2..230f91a0 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/BlockCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/BlockCommandOrigin.js @@ -2,6 +2,10 @@ import { CommandOrigin } from "./CommandOrigin"; import { FeedbackMessageType } from "./FeedbackMessageType"; export class BlockCommandOrigin extends CommandOrigin { + getName() { + return 'Command Block'; + } + getSource() { return this.source.sourceBlock; } diff --git a/Canopy[BP]/scripts/lib/canopy/commands/CommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/CommandOrigin.js index 140bffcc..78159205 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/CommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/CommandOrigin.js @@ -9,6 +9,10 @@ export class CommandOrigin { return this.source.sourceType; } + getName() { + throw new Error("getName() not implemented"); + } + getSource() { throw new Error("getSource() not implemented"); } diff --git a/Canopy[BP]/scripts/lib/canopy/commands/EntityCommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/EntityCommandOrigin.js index 44f03b7f..ce89997f 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/EntityCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/EntityCommandOrigin.js @@ -2,6 +2,11 @@ import { CommandOrigin } from "./CommandOrigin"; import { FeedbackMessageType } from "./FeedbackMessageType"; export class EntityCommandOrigin extends CommandOrigin { + getName() { + const source = this.getSource(); + return source.nameTag || source.typeId; + } + getSource() { return this.source.sourceEntity; } diff --git a/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js index f533ba56..29854e72 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/PlayerCommandOrigin.js @@ -6,6 +6,10 @@ export class PlayerCommandOrigin extends CommandOrigin { return "Player"; } + getName() { + return this.getSource().name; + } + getSource() { return this.source.sourceEntity; } diff --git a/Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin.js b/Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin.js index b6f322a5..4bf4a461 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin.js @@ -2,6 +2,10 @@ import { CommandOrigin } from "./CommandOrigin"; import { FeedbackMessageType } from "./FeedbackMessageType"; export class ServerCommandOrigin extends CommandOrigin { + getName() { + return "Server"; + } + getSource() { return this.source.sourceType; } diff --git a/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js b/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js index e6c73354..af1341ce 100644 --- a/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js +++ b/Canopy[BP]/scripts/lib/canopy/commands/VanillaCommand.js @@ -1,4 +1,4 @@ -import { CustomCommandSource, CustomCommandStatus, Player, system } from "@minecraft/server"; +import { CustomCommandError, CustomCommandErrorReason, CustomCommandSource, CustomCommandStatus, Player, system } from "@minecraft/server"; import { Rules } from "../rules/Rules"; import { VanillaCommands } from "./VanillaCommands"; import { BlockCommandOrigin } from "./BlockCommandOrigin"; @@ -48,8 +48,18 @@ export class VanillaCommand { registerEnums(customCommandRegistry) { if (this.customCommand.enums) { - for (const customEnum of this.customCommand.enums) - customCommandRegistry.registerEnum(customEnum.name, customEnum.values); + for (const customEnum of this.customCommand.enums) { + const values = typeof customEnum.values === 'function' + ? customEnum.values() + : customEnum.values; + try { + customCommandRegistry.registerEnum(customEnum.name, values); + } catch (error) { + if (error instanceof CustomCommandError && error.reason === CustomCommandErrorReason.AlreadyRegistered) + continue; + throw error; + } + } } } diff --git a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js index a6ba089c..bf276f76 100644 --- a/Canopy[BP]/scripts/lib/canopy/rules/Rules.js +++ b/Canopy[BP]/scripts/lib/canopy/rules/Rules.js @@ -6,10 +6,11 @@ class Rules { static worldLoaded = false; static async register(rule) { + const ruleID = rule.getID(); if (this.worldLoaded) { - if (this.exists(rule.getID())) - throw new Error(`[Canopy] Rule with identifier '${rule.getID()}' already exists.`); - this.#rules[rule.getID()] = rule; + if (this.exists(ruleID)) + throw new Error(`[Canopy] Rule with identifier '${ruleID}' already exists.`); + this.#rules[ruleID] = rule; if (rule.getCategory() === "Rules") { await Promise.resolve(); const value = await rule.getValue(); @@ -19,6 +20,9 @@ class Rules { rule.onModify(value); } } else { + const alreadyQueued = this.rulesToRegister.some(queuedRule => queuedRule.getID() === ruleID); + if (alreadyQueued || this.exists(ruleID)) + return; this.rulesToRegister.push(rule); } } @@ -79,6 +83,17 @@ class Rules { return this.getAll().filter(rule => rule.getCategory() === category); } + static getRuleIDsByCategory(category) { + const registered = this.getByCategory(category); + const queued = this.rulesToRegister.filter(rule => rule.getCategory() === category); + const ids = new Set([...registered, ...queued].map(rule => rule.getID())); + return [...ids]; + } + + static getSettableRuleIDs() { + return this.getRuleIDsByCategory("Rules"); + } + static registerQueuedRules() { for (const rule of this.rulesToRegister) this.register(rule); diff --git a/Canopy[BP]/scripts/lib/jsep/README.md b/Canopy[BP]/scripts/lib/jsep/README.md new file mode 100644 index 00000000..8864283e --- /dev/null +++ b/Canopy[BP]/scripts/lib/jsep/README.md @@ -0,0 +1,7 @@ +# jsep (vendored) + +Vendored ESM build of [jsep](https://github.com/EricSmekens/jsep) v1.4.0, copied from +`node_modules/jsep/dist/jsep.js`. Do not edit. Used by +`src/classes/analyzearea/ExpressionEvaluator.js` to parse block-analysis expressions. + +To update: `npm install jsep@` then copy `dist/jsep.js` here. diff --git a/Canopy[BP]/scripts/lib/jsep/jsep.js b/Canopy[BP]/scripts/lib/jsep/jsep.js new file mode 100644 index 00000000..b28ee42d --- /dev/null +++ b/Canopy[BP]/scripts/lib/jsep/jsep.js @@ -0,0 +1,1126 @@ +/** + * @implements {IHooks} + */ +class Hooks { + /** + * @callback HookCallback + * @this {*|Jsep} this + * @param {Jsep} env + * @returns: void + */ + /** + * Adds the given callback to the list of callbacks for the given hook. + * + * The callback will be invoked when the hook it is registered for is run. + * + * One callback function can be registered to multiple hooks and the same hook multiple times. + * + * @param {string|object} name The name of the hook, or an object of callbacks keyed by name + * @param {HookCallback|boolean} callback The callback function which is given environment variables. + * @param {?boolean} [first=false] Will add the hook to the top of the list (defaults to the bottom) + * @public + */ + add(name, callback, first) { + if (typeof arguments[0] != 'string') { + // Multiple hook callbacks, keyed by name + for (const name in arguments[0]) + this.add(name, arguments[0][name], arguments[1]); + + } + else { + (Array.isArray(name) ? name : [name]).forEach(function (name) { + this[name] = this[name] || []; + + if (callback) + this[name][first ? 'unshift' : 'push'](callback); + + }, this); + } + } + + /** + * Runs a hook invoking all registered callbacks with the given environment variables. + * + * Callbacks will be invoked synchronously and in the order in which they were registered. + * + * @param {string} name The name of the hook. + * @param {Object} env The environment variables of the hook passed to all callbacks registered. + * @public + */ + run(name, env) { + this[name] = this[name] || []; + this[name].forEach(function (callback) { + callback.call(env && env.context ? env.context : env, env); + }); + } +} + +/** + * @implements {IPlugins} + */ +class Plugins { + constructor(jsep) { + this.jsep = jsep; + this.registered = {}; + } + + /** + * @callback PluginSetup + * @this {Jsep} jsep + * @returns: void + */ + /** + * Adds the given plugin(s) to the registry + * + * @param {object} plugins + * @param {string} plugins.name The name of the plugin + * @param {PluginSetup} plugins.init The init function + * @public + */ + register(...plugins) { + plugins.forEach((plugin) => { + if (typeof plugin !== 'object' || !plugin.name || !plugin.init) + throw new Error('Invalid JSEP plugin format'); + + if (this.registered[plugin.name]) { + // already registered. Ignore. + return; + } + plugin.init(this.jsep); + this.registered[plugin.name] = plugin; + }); + } +} + +// JavaScript Expression Parser (JSEP) 1.4.0 + +class Jsep { + /** + * @returns {string} + */ + static get version() { + // To be filled in by the template + return '1.4.0'; + } + + /** + * @returns {string} + */ + static toString() { + return 'JavaScript Expression Parser (JSEP) v' + Jsep.version; + }; + + // ==================== CONFIG ================================ + /** + * @method addUnaryOp + * @param {string} op_name The name of the unary op to add + * @returns {Jsep} + */ + static addUnaryOp(op_name) { + Jsep.max_unop_len = Math.max(op_name.length, Jsep.max_unop_len); + Jsep.unary_ops[op_name] = 1; + return Jsep; + } + + /** + * @method jsep.addBinaryOp + * @param {string} op_name The name of the binary op to add + * @param {number} precedence The precedence of the binary op (can be a float). Higher number = higher precedence + * @param {boolean} [isRightAssociative=false] whether operator is right-associative + * @returns {Jsep} + */ + static addBinaryOp(op_name, precedence, isRightAssociative) { + Jsep.max_binop_len = Math.max(op_name.length, Jsep.max_binop_len); + Jsep.binary_ops[op_name] = precedence; + if (isRightAssociative) + Jsep.right_associative.add(op_name); + + else + Jsep.right_associative.delete(op_name); + + return Jsep; + } + + /** + * @method addIdentifierChar + * @param {string} char The additional character to treat as a valid part of an identifier + * @returns {Jsep} + */ + static addIdentifierChar(char) { + Jsep.additional_identifier_chars.add(char); + return Jsep; + } + + /** + * @method addLiteral + * @param {string} literal_name The name of the literal to add + * @param {*} literal_value The value of the literal + * @returns {Jsep} + */ + static addLiteral(literal_name, literal_value) { + Jsep.literals[literal_name] = literal_value; + return Jsep; + } + + /** + * @method removeUnaryOp + * @param {string} op_name The name of the unary op to remove + * @returns {Jsep} + */ + static removeUnaryOp(op_name) { + delete Jsep.unary_ops[op_name]; + if (op_name.length === Jsep.max_unop_len) + Jsep.max_unop_len = Jsep.getMaxKeyLen(Jsep.unary_ops); + + return Jsep; + } + + /** + * @method removeAllUnaryOps + * @returns {Jsep} + */ + static removeAllUnaryOps() { + Jsep.unary_ops = {}; + Jsep.max_unop_len = 0; + + return Jsep; + } + + /** + * @method removeIdentifierChar + * @param {string} char The additional character to stop treating as a valid part of an identifier + * @returns {Jsep} + */ + static removeIdentifierChar(char) { + Jsep.additional_identifier_chars.delete(char); + return Jsep; + } + + /** + * @method removeBinaryOp + * @param {string} op_name The name of the binary op to remove + * @returns {Jsep} + */ + static removeBinaryOp(op_name) { + delete Jsep.binary_ops[op_name]; + + if (op_name.length === Jsep.max_binop_len) + Jsep.max_binop_len = Jsep.getMaxKeyLen(Jsep.binary_ops); + + Jsep.right_associative.delete(op_name); + + return Jsep; + } + + /** + * @method removeAllBinaryOps + * @returns {Jsep} + */ + static removeAllBinaryOps() { + Jsep.binary_ops = {}; + Jsep.max_binop_len = 0; + + return Jsep; + } + + /** + * @method removeLiteral + * @param {string} literal_name The name of the literal to remove + * @returns {Jsep} + */ + static removeLiteral(literal_name) { + delete Jsep.literals[literal_name]; + return Jsep; + } + + /** + * @method removeAllLiterals + * @returns {Jsep} + */ + static removeAllLiterals() { + Jsep.literals = {}; + + return Jsep; + } + // ==================== END CONFIG ============================ + + + /** + * @returns {string} + */ + get char() { + return this.expr.charAt(this.index); + } + + /** + * @returns {number} + */ + get code() { + return this.expr.charCodeAt(this.index); + }; + + + /** + * @param {string} expr a string with the passed in express + * @returns Jsep + */ + constructor(expr) { + // `index` stores the character number we are currently at + // All of the gobbles below will modify `index` as we move along + this.expr = expr; + this.index = 0; + } + + /** + * static top-level parser + * @returns {jsep.Expression} + */ + static parse(expr) { + return (new Jsep(expr)).parse(); + } + + /** + * Get the longest key length of any object + * @param {object} obj + * @returns {number} + */ + static getMaxKeyLen(obj) { + return Math.max(0, ...Object.keys(obj).map(k => k.length)); + } + + /** + * `ch` is a character code in the next three functions + * @param {number} ch + * @returns {boolean} + */ + static isDecimalDigit(ch) { + return (ch >= 48 && ch <= 57); // 0...9 + } + + /** + * Returns the precedence of a binary operator or `0` if it isn't a binary operator. Can be float. + * @param {string} op_val + * @returns {number} + */ + static binaryPrecedence(op_val) { + return Jsep.binary_ops[op_val] || 0; + } + + /** + * Looks for start of identifier + * @param {number} ch + * @returns {boolean} + */ + static isIdentifierStart(ch) { + return (ch >= 65 && ch <= 90) || // A...Z + (ch >= 97 && ch <= 122) || // a...z + (ch >= 128 && !Jsep.binary_ops[String.fromCharCode(ch)]) || // any non-ASCII that is not an operator + (Jsep.additional_identifier_chars.has(String.fromCharCode(ch))); // additional characters + } + + /** + * @param {number} ch + * @returns {boolean} + */ + static isIdentifierPart(ch) { + return Jsep.isIdentifierStart(ch) || Jsep.isDecimalDigit(ch); + } + + /** + * throw error at index of the expression + * @param {string} message + * @throws + */ + throwError(message) { + const error = new Error(message + ' at character ' + this.index); + error.index = this.index; + error.description = message; + throw error; + } + + /** + * Run a given hook + * @param {string} name + * @param {jsep.Expression|false} [node] + * @returns {?jsep.Expression} + */ + runHook(name, node) { + if (Jsep.hooks[name]) { + const env = { context: this, node }; + Jsep.hooks.run(name, env); + return env.node; + } + return node; + } + + /** + * Runs a given hook until one returns a node + * @param {string} name + * @returns {?jsep.Expression} + */ + searchHook(name) { + if (Jsep.hooks[name]) { + const env = { context: this }; + Jsep.hooks[name].find(function (callback) { + callback.call(env.context, env); + return env.node; + }); + return env.node; + } + } + + /** + * Push `index` up to the next non-space character + */ + gobbleSpaces() { + let ch = this.code; + // Whitespace + while (ch === Jsep.SPACE_CODE + || ch === Jsep.TAB_CODE + || ch === Jsep.LF_CODE + || ch === Jsep.CR_CODE) + ch = this.expr.charCodeAt(++this.index); + + this.runHook('gobble-spaces'); + } + + /** + * Top-level method to parse all expressions and returns compound or single node + * @returns {jsep.Expression} + */ + parse() { + this.runHook('before-all'); + const nodes = this.gobbleExpressions(); + + // If there's only one expression just try returning the expression + const node = nodes.length === 1 + ? nodes[0] + : { + type: Jsep.COMPOUND, + body: nodes + }; + return this.runHook('after-all', node); + } + + /** + * top-level parser (but can be reused within as well) + * @param {number} [untilICode] + * @returns {jsep.Expression[]} + */ + gobbleExpressions(untilICode) { + const nodes = []; let ch_i; let node; + + while (this.index < this.expr.length) { + ch_i = this.code; + + // Expressions can be separated by semicolons, commas, or just inferred without any + // separators + if (ch_i === Jsep.SEMCOL_CODE || ch_i === Jsep.COMMA_CODE) { + this.index++; // ignore separators + } + else { + // Try to gobble each expression individually + if (node = this.gobbleExpression()) { + nodes.push(node); + // If we weren't able to find a binary expression and are out of room, then + // the expression passed in probably has too much + } + else if (this.index < this.expr.length) { + if (ch_i === untilICode) + break; + + this.throwError('Unexpected "' + this.char + '"'); + } + } + } + + return nodes; + } + + /** + * The main parsing function. + * @returns {?jsep.Expression} + */ + gobbleExpression() { + const node = this.searchHook('gobble-expression') || this.gobbleBinaryExpression(); + this.gobbleSpaces(); + + return this.runHook('after-expression', node); + } + + /** + * Search for the operation portion of the string (e.g. `+`, `===`) + * Start by taking the longest possible binary operations (3 characters: `===`, `!==`, `>>>`) + * and move down from 3 to 2 to 1 character until a matching binary operation is found + * then, return that binary operation + * @returns {string|boolean} + */ + gobbleBinaryOp() { + this.gobbleSpaces(); + let to_check = this.expr.substr(this.index, Jsep.max_binop_len); + let tc_len = to_check.length; + + while (tc_len > 0) { + // Don't accept a binary op when it is an identifier. + // Binary ops that start with a identifier-valid character must be followed + // by a non identifier-part valid character + if (Jsep.binary_ops.hasOwnProperty(to_check) && ( + !Jsep.isIdentifierStart(this.code) || + (this.index + to_check.length < this.expr.length && !Jsep.isIdentifierPart(this.expr.charCodeAt(this.index + to_check.length))) + )) { + this.index += tc_len; + return to_check; + } + to_check = to_check.substr(0, --tc_len); + } + return false; + } + + /** + * This function is responsible for gobbling an individual expression, + * e.g. `1`, `1+2`, `a+(b*2)-Math.sqrt(2)` + * @returns {?jsep.BinaryExpression} + */ + gobbleBinaryExpression() { + let node; let biop; let prec; let stack; let biop_info; let left; let right; let i; let cur_biop; + + // First, try to get the leftmost thing + // Then, check to see if there's a binary operator operating on that leftmost thing + // Don't gobbleBinaryOp without a left-hand-side + left = this.gobbleToken(); + if (!left) + return left; + + biop = this.gobbleBinaryOp(); + + // If there wasn't a binary operator, just return the leftmost node + if (!biop) + return left; + + + // Otherwise, we need to start a stack to properly place the binary operations in their + // precedence structure + biop_info = { value: biop, prec: Jsep.binaryPrecedence(biop), right_a: Jsep.right_associative.has(biop) }; + + right = this.gobbleToken(); + + if (!right) + this.throwError("Expected expression after " + biop); + + + stack = [left, biop_info, right]; + + // Properly deal with precedence using [recursive descent](http://www.engr.mun.ca/~theo/Misc/exp_parsing.htm) + while ((biop = this.gobbleBinaryOp())) { + prec = Jsep.binaryPrecedence(biop); + + if (prec === 0) { + this.index -= biop.length; + break; + } + + biop_info = { value: biop, prec, right_a: Jsep.right_associative.has(biop) }; + + cur_biop = biop; + + // Reduce: make a binary expression from the three topmost entries. + const comparePrev = prev => biop_info.right_a && prev.right_a + ? prec > prev.prec + : prec <= prev.prec; + while ((stack.length > 2) && comparePrev(stack[stack.length - 2])) { + right = stack.pop(); + biop = stack.pop().value; + left = stack.pop(); + node = { + type: Jsep.BINARY_EXP, + operator: biop, + left, + right + }; + stack.push(node); + } + + node = this.gobbleToken(); + + if (!node) + this.throwError("Expected expression after " + cur_biop); + + + stack.push(biop_info, node); + } + + i = stack.length - 1; + node = stack[i]; + + while (i > 1) { + node = { + type: Jsep.BINARY_EXP, + operator: stack[i - 1].value, + left: stack[i - 2], + right: node + }; + i -= 2; + } + + return node; + } + + /** + * An individual part of a binary expression: + * e.g. `foo.bar(baz)`, `1`, `"abc"`, `(a % 2)` (because it's in parenthesis) + * @returns {boolean|jsep.Expression} + */ + gobbleToken() { + let ch; let to_check; let tc_len; let node; + + this.gobbleSpaces(); + node = this.searchHook('gobble-token'); + if (node) + return this.runHook('after-token', node); + + + ch = this.code; + + if (Jsep.isDecimalDigit(ch) || ch === Jsep.PERIOD_CODE) { + // Char code 46 is a dot `.` which can start off a numeric literal + return this.gobbleNumericLiteral(); + } + + if (ch === Jsep.SQUOTE_CODE || ch === Jsep.DQUOTE_CODE) { + // Single or double quotes + node = this.gobbleStringLiteral(); + } + else if (ch === Jsep.OBRACK_CODE) { + node = this.gobbleArray(); + } + else { + to_check = this.expr.substr(this.index, Jsep.max_unop_len); + tc_len = to_check.length; + + while (tc_len > 0) { + // Don't accept an unary op when it is an identifier. + // Unary ops that start with a identifier-valid character must be followed + // by a non identifier-part valid character + if (Jsep.unary_ops.hasOwnProperty(to_check) && ( + !Jsep.isIdentifierStart(this.code) || + (this.index + to_check.length < this.expr.length && !Jsep.isIdentifierPart(this.expr.charCodeAt(this.index + to_check.length))) + )) { + this.index += tc_len; + const argument = this.gobbleToken(); + if (!argument) + this.throwError('missing unaryOp argument'); + + return this.runHook('after-token', { + type: Jsep.UNARY_EXP, + operator: to_check, + argument, + prefix: true + }); + } + + to_check = to_check.substr(0, --tc_len); + } + + if (Jsep.isIdentifierStart(ch)) { + node = this.gobbleIdentifier(); + if (Jsep.literals.hasOwnProperty(node.name)) { + node = { + type: Jsep.LITERAL, + value: Jsep.literals[node.name], + raw: node.name, + }; + } + else if (node.name === Jsep.this_str) { + node = { type: Jsep.THIS_EXP }; + } + } + else if (ch === Jsep.OPAREN_CODE) { // open parenthesis + node = this.gobbleGroup(); + } + } + + if (!node) + return this.runHook('after-token', false); + + + node = this.gobbleTokenProperty(node); + return this.runHook('after-token', node); + } + + /** + * Gobble properties of of identifiers/strings/arrays/groups. + * e.g. `foo`, `bar.baz`, `foo['bar'].baz` + * It also gobbles function calls: + * e.g. `Math.acos(obj.angle)` + * @param {jsep.Expression} node + * @returns {jsep.Expression} + */ + gobbleTokenProperty(node) { + this.gobbleSpaces(); + + let ch = this.code; + while (ch === Jsep.PERIOD_CODE || ch === Jsep.OBRACK_CODE || ch === Jsep.OPAREN_CODE || ch === Jsep.QUMARK_CODE) { + let optional; + if (ch === Jsep.QUMARK_CODE) { + if (this.expr.charCodeAt(this.index + 1) !== Jsep.PERIOD_CODE) + break; + + optional = true; + this.index += 2; + this.gobbleSpaces(); + ch = this.code; + } + this.index++; + + if (ch === Jsep.OBRACK_CODE) { + node = { + type: Jsep.MEMBER_EXP, + computed: true, + object: node, + property: this.gobbleExpression() + }; + if (!node.property) + this.throwError('Unexpected "' + this.char + '"'); + + this.gobbleSpaces(); + ch = this.code; + if (ch !== Jsep.CBRACK_CODE) + this.throwError('Unclosed ['); + + this.index++; + } + else if (ch === Jsep.OPAREN_CODE) { + // A function call is being made; gobble all the arguments + node = { + type: Jsep.CALL_EXP, + 'arguments': this.gobbleArguments(Jsep.CPAREN_CODE), + callee: node + }; + } + else if (ch === Jsep.PERIOD_CODE || optional) { + if (optional) + this.index--; + + this.gobbleSpaces(); + node = { + type: Jsep.MEMBER_EXP, + computed: false, + object: node, + property: this.gobbleIdentifier(), + }; + } + + if (optional) + node.optional = true; + // else leave undefined for compatibility with esprima + + this.gobbleSpaces(); + ch = this.code; + } + + return node; + } + + /** + * Parse simple numeric literals: `12`, `3.4`, `.5`. Do this by using a string to + * keep track of everything in the numeric literal and then calling `parseFloat` on that string + * @returns {jsep.Literal} + */ + gobbleNumericLiteral() { + let number = ''; let ch; let chCode; + + while (Jsep.isDecimalDigit(this.code)) + number += this.expr.charAt(this.index++); + + + if (this.code === Jsep.PERIOD_CODE) { // can start with a decimal marker + number += this.expr.charAt(this.index++); + + while (Jsep.isDecimalDigit(this.code)) + number += this.expr.charAt(this.index++); + + } + + ch = this.char; + + if (ch === 'e' || ch === 'E') { // exponent marker + number += this.expr.charAt(this.index++); + ch = this.char; + + if (ch === '+' || ch === '-') { // exponent sign + number += this.expr.charAt(this.index++); + } + + while (Jsep.isDecimalDigit(this.code)) { // exponent itself + number += this.expr.charAt(this.index++); + } + + if (!Jsep.isDecimalDigit(this.expr.charCodeAt(this.index - 1)) ) + this.throwError('Expected exponent (' + number + this.char + ')'); + + } + + chCode = this.code; + + // Check to make sure this isn't a variable name that start with a number (123abc) + if (Jsep.isIdentifierStart(chCode)) { + this.throwError('Variable names cannot start with a number (' + + number + this.char + ')'); + } + else if (chCode === Jsep.PERIOD_CODE || (number.length === 1 && number.charCodeAt(0) === Jsep.PERIOD_CODE)) { + this.throwError('Unexpected period'); + } + + return { + type: Jsep.LITERAL, + value: parseFloat(number), + raw: number + }; + } + + /** + * Parses a string literal, staring with single or double quotes with basic support for escape codes + * e.g. `"hello world"`, `'this is\nJSEP'` + * @returns {jsep.Literal} + */ + gobbleStringLiteral() { + let str = ''; + const startIndex = this.index; + const quote = this.expr.charAt(this.index++); + let closed = false; + + while (this.index < this.expr.length) { + let ch = this.expr.charAt(this.index++); + + if (ch === quote) { + closed = true; + break; + } + else if (ch === '\\') { + // Check for all of the common escape codes + ch = this.expr.charAt(this.index++); + + switch (ch) { + case 'n': str += '\n'; break; + case 'r': str += '\r'; break; + case 't': str += '\t'; break; + case 'b': str += '\b'; break; + case 'f': str += '\f'; break; + case 'v': str += '\x0B'; break; + default : str += ch; + } + } + else { + str += ch; + } + } + + if (!closed) + this.throwError('Unclosed quote after "' + str + '"'); + + + return { + type: Jsep.LITERAL, + value: str, + raw: this.expr.substring(startIndex, this.index), + }; + } + + /** + * Gobbles only identifiers + * e.g.: `foo`, `_value`, `$x1` + * Also, this function checks if that identifier is a literal: + * (e.g. `true`, `false`, `null`) or `this` + * @returns {jsep.Identifier} + */ + gobbleIdentifier() { + let ch = this.code; const start = this.index; + + if (Jsep.isIdentifierStart(ch)) + this.index++; + + else + this.throwError('Unexpected ' + this.char); + + + while (this.index < this.expr.length) { + ch = this.code; + + if (Jsep.isIdentifierPart(ch)) + this.index++; + + else + break; + + } + return { + type: Jsep.IDENTIFIER, + name: this.expr.slice(start, this.index), + }; + } + + /** + * Gobbles a list of arguments within the context of a function call + * or array literal. This function also assumes that the opening character + * `(` or `[` has already been gobbled, and gobbles expressions and commas + * until the terminator character `)` or `]` is encountered. + * e.g. `foo(bar, baz)`, `my_func()`, or `[bar, baz]` + * @param {number} termination + * @returns {jsep.Expression[]} + */ + gobbleArguments(termination) { + const args = []; + let closed = false; + let separator_count = 0; + + while (this.index < this.expr.length) { + this.gobbleSpaces(); + const ch_i = this.code; + + if (ch_i === termination) { // done parsing + closed = true; + this.index++; + + if (termination === Jsep.CPAREN_CODE && separator_count && separator_count >= args.length) + this.throwError('Unexpected token ' + String.fromCharCode(termination)); + + + break; + } + else if (ch_i === Jsep.COMMA_CODE) { // between expressions + this.index++; + separator_count++; + + if (separator_count !== args.length) { // missing argument + if (termination === Jsep.CPAREN_CODE) { + this.throwError('Unexpected token ,'); + } + else if (termination === Jsep.CBRACK_CODE) { + for (let arg = args.length; arg < separator_count; arg++) + args.push(null); + + } + } + } + else if (args.length !== separator_count && separator_count !== 0) { + // NOTE: `&& separator_count !== 0` allows for either all commas, or all spaces as arguments + this.throwError('Expected comma'); + } + else { + const node = this.gobbleExpression(); + + if (!node || node.type === Jsep.COMPOUND) + this.throwError('Expected comma'); + + + args.push(node); + } + } + + if (!closed) + this.throwError('Expected ' + String.fromCharCode(termination)); + + + return args; + } + + /** + * Responsible for parsing a group of things within parentheses `()` + * that have no identifier in front (so not a function call) + * This function assumes that it needs to gobble the opening parenthesis + * and then tries to gobble everything within that parenthesis, assuming + * that the next thing it should see is the close parenthesis. If not, + * then the expression probably doesn't have a `)` + * @returns {boolean|jsep.Expression} + */ + gobbleGroup() { + this.index++; + const nodes = this.gobbleExpressions(Jsep.CPAREN_CODE); + if (this.code === Jsep.CPAREN_CODE) { + this.index++; + if (nodes.length === 1) + return nodes[0]; + + else if (!nodes.length) + return false; + + + return { + type: Jsep.SEQUENCE_EXP, + expressions: nodes, + }; + + } + + this.throwError('Unclosed ('); + + } + + /** + * Responsible for parsing Array literals `[1, 2, 3]` + * This function assumes that it needs to gobble the opening bracket + * and then tries to gobble the expressions as arguments. + * @returns {jsep.ArrayExpression} + */ + gobbleArray() { + this.index++; + + return { + type: Jsep.ARRAY_EXP, + elements: this.gobbleArguments(Jsep.CBRACK_CODE) + }; + } +} + +// Static fields: +const hooks = new Hooks(); +Object.assign(Jsep, { + hooks, + plugins: new Plugins(Jsep), + + // Node Types + // ---------- + // This is the full set of types that any JSEP node can be. + // Store them here to save space when minified + COMPOUND: 'Compound', + SEQUENCE_EXP: 'SequenceExpression', + IDENTIFIER: 'Identifier', + MEMBER_EXP: 'MemberExpression', + LITERAL: 'Literal', + THIS_EXP: 'ThisExpression', + CALL_EXP: 'CallExpression', + UNARY_EXP: 'UnaryExpression', + BINARY_EXP: 'BinaryExpression', + ARRAY_EXP: 'ArrayExpression', + + TAB_CODE: 9, + LF_CODE: 10, + CR_CODE: 13, + SPACE_CODE: 32, + PERIOD_CODE: 46, // '.' + COMMA_CODE: 44, // ',' + SQUOTE_CODE: 39, // single quote + DQUOTE_CODE: 34, // double quotes + OPAREN_CODE: 40, // ( + CPAREN_CODE: 41, // ) + OBRACK_CODE: 91, // [ + CBRACK_CODE: 93, // ] + QUMARK_CODE: 63, // ? + SEMCOL_CODE: 59, // ; + COLON_CODE: 58, // : + + + // Operations + // ---------- + // Use a quickly-accessible map to store all of the unary operators + // Values are set to `1` (it really doesn't matter) + unary_ops: { + '-': 1, + '!': 1, + '~': 1, + '+': 1 + }, + + // Also use a map for the binary operations but set their values to their + // binary precedence for quick reference (higher number = higher precedence) + // see [Order of operations](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence) + binary_ops: { + '||': 1, '??': 1, + '&&': 2, '|': 3, '^': 4, '&': 5, + '==': 6, '!=': 6, '===': 6, '!==': 6, + '<': 7, '>': 7, '<=': 7, '>=': 7, + '<<': 8, '>>': 8, '>>>': 8, + '+': 9, '-': 9, + '*': 10, '/': 10, '%': 10, + '**': 11, + }, + + // sets specific binary_ops as right-associative + right_associative: new Set(['**']), + + // Additional valid identifier chars, apart from a-z, A-Z and 0-9 (except on the starting char) + additional_identifier_chars: new Set(['$', '_']), + + // Literals + // ---------- + // Store the values to return for the various literals we may encounter + literals: { + 'true': true, + 'false': false, + 'null': null + }, + + // Except for `this`, which is special. This could be changed to something like `'self'` as well + this_str: 'this', +}); +Jsep.max_unop_len = Jsep.getMaxKeyLen(Jsep.unary_ops); +Jsep.max_binop_len = Jsep.getMaxKeyLen(Jsep.binary_ops); + +// Backward Compatibility: +const jsep = expr => (new Jsep(expr)).parse(); +const stdClassProps = Object.getOwnPropertyNames(class Test{}); +Object.getOwnPropertyNames(Jsep) + .filter(prop => !stdClassProps.includes(prop) && jsep[prop] === undefined) + .forEach((m) => { + jsep[m] = Jsep[m]; + }); +jsep.Jsep = Jsep; // allows for const { Jsep } = require('jsep'); + +const CONDITIONAL_EXP = 'ConditionalExpression'; + +const ternary = { + name: 'ternary', + + init(jsep) { + // Ternary expression: test ? consequent : alternate + jsep.hooks.add('after-expression', function gobbleTernary(env) { + if (env.node && this.code === jsep.QUMARK_CODE) { + this.index++; + const test = env.node; + const consequent = this.gobbleExpression(); + + if (!consequent) + this.throwError('Expected expression'); + + + this.gobbleSpaces(); + + if (this.code === jsep.COLON_CODE) { + this.index++; + const alternate = this.gobbleExpression(); + + if (!alternate) + this.throwError('Expected expression'); + + env.node = { + type: CONDITIONAL_EXP, + test, + consequent, + alternate, + }; + + // check for operators of higher priority than ternary (i.e. assignment) + // jsep sets || at 1, and assignment at 0.9, and conditional should be between them + if (test.operator && jsep.binary_ops[test.operator] <= 0.9) { + let newTest = test; + while (newTest.right.operator && jsep.binary_ops[newTest.right.operator] <= 0.9) + newTest = newTest.right; + + env.node.test = newTest.right; + newTest.right = env.node; + env.node = test; + } + } + else { + this.throwError('Expected :'); + } + } + }); + }, +}; + +// Add default plugins: + +jsep.plugins.register(ternary); + +export { Jsep, jsep as default }; diff --git a/Canopy[BP]/scripts/main.js b/Canopy[BP]/scripts/main.js index 909284c4..2f844b1d 100644 --- a/Canopy[BP]/scripts/main.js +++ b/Canopy[BP]/scripts/main.js @@ -34,6 +34,24 @@ import './src/commands/lifetimetracking' import './src/commands/lifetimequery' import './src/commands/lifetimequeryitem' import './src/commands/velocity' +import './src/commands/analyzearea' + +// Simulated Player Commands +import './src/commands/simplayer/playerjoin' +import './src/commands/simplayer/playerleave' +import './src/commands/simplayer/playerrejoin' +import './src/commands/simplayer/playertp' +import './src/commands/simplayer/playerlook' +import './src/commands/simplayer/playermove' +import './src/commands/simplayer/playerselect' +import './src/commands/simplayer/playersprint' +import './src/commands/simplayer/playersneak' +import './src/commands/simplayer/playerstop' +import './src/commands/simplayer/playerswapheld' +import './src/commands/simplayer/playerinventory' +import './src/commands/simplayer/playerprefix' +import './src/commands/simplayer/playeraction' +import './src/commands/simplayer/playerglide' // Script Events import './src/commands/scriptevents/counter' @@ -81,6 +99,10 @@ import './src/rules/entitySeparation' import './src/rules/enderPearlChunkLoading' import './src/rules/renderEndGatewayExits' +// Simulated Player Rules +import './src/rules/simplayer/simplayerSaving' +import './src/rules/simplayer/simplayerRejoining' + // Load Time Processes import './src/onStart' import './src/onReload' diff --git a/Canopy[BP]/scripts/src/classes/EntityLifetimeRecord.js b/Canopy[BP]/scripts/src/classes/EntityLifetimeRecord.js index 9e47e573..ae3445b9 100644 --- a/Canopy[BP]/scripts/src/classes/EntityLifetimeRecord.js +++ b/Canopy[BP]/scripts/src/classes/EntityLifetimeRecord.js @@ -5,25 +5,29 @@ export class EntityLifetimeRecord { entityType; localizationKey; spawnReason; + spawnPriority; spawnTick; spawnDate; removalReason; + removalPriority; removalTick; removalDate; - constructor(entity, spawnReason) { + constructor(entity, spawnReason, spawnPriority = 0) { this.entityId = entity.id; this.entityType = entity.typeId; this.localizationKey = { translate: entity.localizationKey }; this.spawnReason = spawnReason; + this.spawnPriority = spawnPriority; this.spawnTick = system.currentTick; this.spawnDate = Date.now(); } - collectRemoval(removalReason) { + collectRemoval(removalReason, removalPriority = 0) { if (this.hasBeenRemoved()) return; this.removalReason = removalReason; + this.removalPriority = removalPriority; this.removalTick = system.currentTick; this.removalDate = Date.now(); } diff --git a/Canopy[BP]/scripts/src/classes/EntityLifetimeRecords.js b/Canopy[BP]/scripts/src/classes/EntityLifetimeRecords.js index 0e21b67f..5d89eb08 100644 --- a/Canopy[BP]/scripts/src/classes/EntityLifetimeRecords.js +++ b/Canopy[BP]/scripts/src/classes/EntityLifetimeRecords.js @@ -6,6 +6,7 @@ export class EntityLifetimeRecords { worldLifetimeTracker; dimensionId; entityLifetimeRecords = []; + activeRecordsByEntityId = new Map(); constructor(worldLifetimeTracker, dimensionId) { this.worldLifetimeTracker = worldLifetimeTracker; @@ -14,22 +15,53 @@ export class EntityLifetimeRecords { destroy() { this.entityLifetimeRecords.length = 0; + this.activeRecordsByEntityId.clear(); this.worldLifetimeTracker = void 0; } - collectSpawn(entity, spawnReason) { + collectSpawn(entity, spawnReason, spawnPriority = 0) { + const existingRecord = this.activeRecordsByEntityId.get(entity.id); + if (existingRecord) { + if (this.shouldPreferPriority(existingRecord.spawnPriority, spawnPriority)) { + existingRecord.spawnReason = spawnReason; + existingRecord.spawnPriority = spawnPriority; + } + return; + } let record; if (entity.typeId === "minecraft:item") - record = new ItemLifetimeRecord(entity, spawnReason); + record = new ItemLifetimeRecord(entity, spawnReason, spawnPriority); else - record = new EntityLifetimeRecord(entity, spawnReason); + record = new EntityLifetimeRecord(entity, spawnReason, spawnPriority); this.worldLifetimeTracker.setLocalizationKey(record.entityType, record.localizationKey); this.entityLifetimeRecords.push(record); + this.activeRecordsByEntityId.set(record.entityId, record); } - collectRemoval(entity, removalReason) { - const record = this.entityLifetimeRecords.find(lifetimeRecord => lifetimeRecord.entityId === entity.id); - record?.collectRemoval(removalReason); + collectRemoval(entity, removalReason, removalPriority = 0) { + const record = this.activeRecordsByEntityId.get(entity.id); + if (!record) { + const existingRecord = this.getLatestRecordByEntityId(entity.id); + if (existingRecord?.hasBeenRemoved() && this.shouldPreferPriority(existingRecord.removalPriority, removalPriority)) { + existingRecord.removalReason = removalReason; + existingRecord.removalPriority = removalPriority; + } + return; + } + record.collectRemoval(removalReason, removalPriority); + this.activeRecordsByEntityId.delete(entity.id); + } + + shouldPreferPriority(currentPriority, incomingPriority) { + return incomingPriority < (currentPriority ?? 0); + } + + getLatestRecordByEntityId(entityId) { + for (let i = this.entityLifetimeRecords.length - 1; i >= 0; i--) { + if (this.entityLifetimeRecords[i].entityId === entityId) + return this.entityLifetimeRecords[i]; + } + return void 0; } hasRecords() { diff --git a/Canopy[BP]/scripts/src/classes/HSSFinder.js b/Canopy[BP]/scripts/src/classes/HSSFinder.js index 4ea168cd..383061cc 100644 --- a/Canopy[BP]/scripts/src/classes/HSSFinder.js +++ b/Canopy[BP]/scripts/src/classes/HSSFinder.js @@ -54,12 +54,12 @@ export class HSSFinder { for (let chunkZ = chunkOverlay.min.z; chunkZ < chunkOverlay.max.z; chunkZ += CHUNK_SIZE) { const baseX = Math.max(structureBounds.min.x, chunkX); const baseZ = Math.max(structureBounds.min.z, chunkZ); - const remainingX = Math.min(structureBounds.max.x - baseX, chunkX + CHUNK_SIZE - baseX); - const remainingZ = Math.min(structureBounds.max.z - baseZ, chunkZ + CHUNK_SIZE - baseZ); + const remainingX = Math.min(structureBounds.max.x - baseX + 1, chunkX + CHUNK_SIZE - baseX); + const remainingZ = Math.min(structureBounds.max.z - baseZ + 1, chunkZ + CHUNK_SIZE - baseZ); const location = new Vector( - baseX + Math.floor((remainingX) * 0.5), + baseX + Math.floor(remainingX * 0.5), 86, - baseZ + Math.floor((remainingZ) * 0.5) + baseZ + Math.floor(remainingZ * 0.5) ); hssLocations.push(location); } diff --git a/Canopy[BP]/scripts/src/classes/ItemLifetimeRecord.js b/Canopy[BP]/scripts/src/classes/ItemLifetimeRecord.js index 7523570f..0a229437 100644 --- a/Canopy[BP]/scripts/src/classes/ItemLifetimeRecord.js +++ b/Canopy[BP]/scripts/src/classes/ItemLifetimeRecord.js @@ -5,8 +5,8 @@ export class ItemLifetimeRecord extends EntityLifetimeRecord { entityType; localizationKey; - constructor(itemEntity, spawnReason) { - super(itemEntity, spawnReason); + constructor(itemEntity, spawnReason, spawnPriority = 0) { + super(itemEntity, spawnReason, spawnPriority); const itemStack = itemEntity.getComponent(EntityComponentTypes.Item).itemStack; this.entityType = itemEntity.typeId + '-' + itemStack.typeId; this.localizationKey = { rawtext: [{ translate: itemEntity.localizationKey }, { text: ' §7(§f' }, { translate: itemStack.localizationKey }, { text: '§7)§f' }, ] }; diff --git a/Canopy[BP]/scripts/src/classes/LightLevelRenderer.js b/Canopy[BP]/scripts/src/classes/LightLevelRenderer.js new file mode 100644 index 00000000..e4eaa307 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/LightLevelRenderer.js @@ -0,0 +1,78 @@ +import { system, TextPrimitive, world } from "@minecraft/server"; +import { Vector } from "../../lib/Vector"; + +export class LightLevelRenderer { + block; + dimension; + visibleToPlayer; + textShape; + runner = void 0; + + constructor(block, dimension, visibleToPlayer) { + this.block = block; + this.dimension = dimension; + this.visibleToPlayer = visibleToPlayer; + this.startRender(); + } + + destroy() { + this.stopRender(); + this.block = void 0; + this.visibleToPlayer = void 0; + } + + startRender() { + this.createTextShape(); + this.runner = system.runInterval(this.onTick.bind(this)); + } + + stopRender() { + if (this.runner !== void 0) { + system.clearRun(this.runner); + this.runner = void 0; + } + this.textShape?.remove(); + this.textShape = void 0; + } + + onTick() { + if (!this.block?.isValid) { + this.stopRender(); + return; + } + this.updateLightLevel(); + } + + updateLightLevel() { + const lightLevel = this.block.getLightLevel(); + if (this.textShape.text !== this.colorLightLevel(lightLevel)) + this.textShape.setText(this.colorLightLevel(lightLevel)); + } + + createTextShape() { + const dimensionlocation = Vector.from(this.block.center()).add(new Vector(-0.0125, -0.499, 0.0925)); + dimensionlocation.dimension = this.dimension; + const lightLevel = this.block.getLightLevel(); + this.textShape = new TextPrimitive(dimensionlocation, this.colorLightLevel(lightLevel)); + this.textShape.backgroundColorOverride = { red: 0, green: 0, blue: 0, alpha: 0 }; + this.textShape.rotation = { x: 90, y: 0, z: 0 }; + this.textShape.useRotation = true; + this.textShape.depthTest = true; + this.textShape.backfaceVisible = false; + this.drawShape(); + } + + colorLightLevel(lightLevel) { + if (lightLevel < 1) + return `§c${lightLevel}`; + if (lightLevel < 5) + return `§6${lightLevel}`; + return `§e${lightLevel}`; + } + + drawShape() { + if (this.visibleToPlayer) + this.textShape.visibleTo = [this.visibleToPlayer]; + world.primitiveShapesManager.addText(this.textShape); + } +} \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/RegionLoader.js b/Canopy[BP]/scripts/src/classes/RegionLoader.js new file mode 100644 index 00000000..9ec87b00 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/RegionLoader.js @@ -0,0 +1,30 @@ +import { world } from '@minecraft/server'; + +export class RegionLoader { + constructor(dimension, min, max, id) { + this.dimension = dimension; + this.min = min; + this.max = max; + this.id = id; + } + + get #options() { + return { dimension: this.dimension, from: this.min, to: this.max }; + } + + hasCapacity() { + return world.tickingAreaManager.hasCapacity(this.#options); + } + + load() { + return world.tickingAreaManager.createTickingArea(this.id, this.#options); + } + + unload() { + try { + world.tickingAreaManager.removeTickingArea(this.id); + } catch { + /* pass */ + } + } +} diff --git a/Canopy[BP]/scripts/src/classes/SignalStrengthRenderer.js b/Canopy[BP]/scripts/src/classes/SignalStrengthRenderer.js index 04458998..b1d79a1c 100644 --- a/Canopy[BP]/scripts/src/classes/SignalStrengthRenderer.js +++ b/Canopy[BP]/scripts/src/classes/SignalStrengthRenderer.js @@ -44,7 +44,7 @@ export class SignalStrengthRenderer { } updateRedstonePower() { - const power = this.block.getRedstonePower(); + const power = this.#getRedstonePower(this.block); if (this.textShape.text !== String(power)) this.textShape.setText(String(power)); } @@ -52,7 +52,7 @@ export class SignalStrengthRenderer { createTextShape() { const dimensionlocation = Vector.from(this.block.center()).add(new Vector(-0.0125, -7.7/16, 0.0925)); dimensionlocation.dimension = this.dimension; - this.textShape = new TextPrimitive(dimensionlocation, String(this.block.getRedstonePower())); + this.textShape = new TextPrimitive(dimensionlocation, String(this.#getRedstonePower(this.block))); this.textShape.backgroundColorOverride = { red: 0, green: 0, blue: 0, alpha: 0 }; this.textShape.rotation = { x: 90, y: 0, z: 0 }; this.textShape.useRotation = true; @@ -66,4 +66,8 @@ export class SignalStrengthRenderer { this.textShape.visibleTo = [this.visibleToPlayer]; world.primitiveShapesManager.addText(this.textShape); } + + #getRedstonePower(block) { + return block.getRedstonePower() || '?'; + } } \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js index 29371a42..a14568e4 100644 --- a/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js +++ b/Canopy[BP]/scripts/src/classes/WorldLifetimeTracker.js @@ -11,6 +11,13 @@ export class WorldLifetimeTracker { dimensionToEntityLifetimeRecordMap = {}; localizationKeys = {}; + onEntitySpawnBound = this.onEntitySpawn.bind(this); + onEntityLoadBound = this.onEntityLoad.bind(this); + onEntityItemDropBound = this.onEntityItemDrop.bind(this); + onEntityDieBound = this.onEntityDie.bind(this); + onEntityRemoveBound = this.onEntityRemove.bind(this); + onEntityItemPickupBound = this.onEntityItemPickup.bind(this); + constructor() { this.createDimensionRecords(); this.startCollecting(); @@ -104,7 +111,7 @@ export class WorldLifetimeTracker { setLocalizationKey(entityType, localizationKey) { this.localizationKeys[entityType] = localizationKey; } - + createDimensionRecords() { this.dimensionToEntityLifetimeRecordMap["minecraft:overworld"] = new EntityLifetimeRecords(this, "minecraft:overworld"); this.dimensionToEntityLifetimeRecordMap["minecraft:nether"] = new EntityLifetimeRecords(this, "minecraft:nether"); @@ -118,53 +125,81 @@ export class WorldLifetimeTracker { } subscribeToEvents() { - world.afterEvents.entitySpawn.subscribe(this.onEntitySpawn.bind(this)); - world.afterEvents.entityLoad.subscribe(this.onEntityLoad.bind(this)); - world.afterEvents.entityDie.subscribe(this.onEntityDie.bind(this)); - world.beforeEvents.entityRemove.subscribe(this.onEntityRemove.bind(this)); + world.afterEvents.entitySpawn.subscribe(this.onEntitySpawnBound); + world.afterEvents.entityLoad.subscribe(this.onEntityLoadBound); + world.afterEvents.entityItemDrop.subscribe(this.onEntityItemDropBound); + world.afterEvents.entityDie.subscribe(this.onEntityDieBound); + world.beforeEvents.entityRemove.subscribe(this.onEntityRemoveBound); + world.beforeEvents.entityItemPickup.subscribe(this.onEntityItemPickupBound) } unsubscribeFromEvents() { - world.afterEvents.entitySpawn.unsubscribe(this.onEntitySpawn.bind(this)); - world.afterEvents.entityLoad.unsubscribe(this.onEntityLoad.bind(this)); - world.afterEvents.entityDie.unsubscribe(this.onEntityDie.bind(this)); - world.beforeEvents.entityRemove.unsubscribe(this.onEntityRemove.bind(this)); + world.afterEvents.entitySpawn.unsubscribe(this.onEntitySpawnBound); + world.afterEvents.entityLoad.unsubscribe(this.onEntityLoadBound); + world.afterEvents.entityItemDrop.unsubscribe(this.onEntityItemDropBound); + world.afterEvents.entityDie.unsubscribe(this.onEntityDieBound); + world.beforeEvents.entityRemove.unsubscribe(this.onEntityRemoveBound); + world.beforeEvents.entityItemPickup.unsubscribe(this.onEntityItemPickupBound); } onEntitySpawn(event) { + event.priority = 2; this.collectSpawn(event); } onEntityLoad(event) { this.localizationKeys[event.entity.typeId] = event.entity.localizationKey; event.cause = EntityInitializationCause.Loaded; + event.priority = 3; this.collectSpawn(event); } + onEntityItemDrop(event) { + for (let i = 0; i < event.items.length; i++) { + const spawnEvent = { + entity: event.items[i], + cause: `Dropped by ${event.entity?.typeId || "unknown"}`, + priority: 0 + }; + this.collectSpawn(spawnEvent); + } + } + onEntityDie(event) { event.entity = event.deadEntity; event.cause = `Death §7(§f${event.damageSource.cause}§7)`; + event.priority = 1; this.collectRemoval(event); } onEntityRemove(event) { event.entity = event.removedEntity; event.cause = "Despawn"; + event.priority = 2; this.collectRemoval(event); } + onEntityItemPickup(event) { + const removalEvent = { + entity: event.item, + cause: `Picked up by ${event.entity?.typeId || "unknown"}`, + priority: 0 + }; + this.collectRemoval(removalEvent); + } + collectSpawn(event) { try { - this.dimensionToEntityLifetimeRecordMap[event.entity.dimension.id].collectSpawn(event.entity, event.cause); - } catch(error) { + this.dimensionToEntityLifetimeRecordMap[event.entity.dimension.id].collectSpawn(event.entity, event.cause, event.priority ?? WorldLifetimeTracker.SPAWN_PRIORITY_GENERIC); + } catch (error) { if (error.name === "InvalidActorError") - console.warn('[Canopy] Entity was skipped because it was removed before its spawn data could not be collected.'); + console.warn('[Canopy] Entity was skipped because it was removed before its spawn data could be collected.'); else throw error; } } collectRemoval(event) { - this.dimensionToEntityLifetimeRecordMap[event.entity.dimension.id].collectRemoval(event.entity, event.cause); + this.dimensionToEntityLifetimeRecordMap[event.entity.dimension.id].collectRemoval(event.entity, event.cause, event.priority ?? WorldLifetimeTracker.REMOVAL_PRIORITY_GENERIC); } } \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js new file mode 100644 index 00000000..342d4904 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js @@ -0,0 +1,274 @@ +import { system, world } from '@minecraft/server'; +import { ExpressionEvaluator } from './ExpressionEvaluator.js'; +import { ExpressionForbiddenError } from './ExpressionForbiddenError.js'; +import { LoadCapacityError } from './LoadCapacityError.js'; +import { AreaAnalyzer, SCAN_CAP } from './AreaAnalyzer.js'; +import { RegionLoader } from '../RegionLoader.js'; +import { AnalyzeAreaRenderer } from './AnalyzeAreaRenderer.js'; +import { stringifyLocation } from '../../../include/utils'; + +export class Analysis { + constructor({ id, from, to, dimensionId, expression, createdAt }) { + const { min, max } = Analysis.#normalizeCorners(from, to); + this.id = id; + this.min = min; + this.max = max; + this.dimensionId = dimensionId; + this.expression = expression; + this.createdAt = createdAt; + + this.matches = []; + this.renderer = void 0; + this.boxesVisible = true; + this.hasRun = false; + this.capped = false; + this.jobId = void 0; + this.loader = void 0; + this.running = false; + this.progress = 0; + this.subscribers = new Set(); + } + + subscribe(handlers) { + this.subscribers.add(handlers); + return () => this.subscribers.delete(handlers); + } + + #emit(event, arg) { + for (const handlers of [...this.subscribers]) { + try { + handlers[event]?.(arg); + } catch { + this.subscribers.delete(handlers); + } + } + } + + static create(from, to, dimensionId, expression) { + const id = `${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + return new Analysis({ id, from, to, dimensionId, expression, createdAt: Date.now() }); + } + + static tryCreate(from, to, dimensionId, expression) { + const { min, max } = Analysis.#normalizeCorners(from, to); + if (Analysis.#regionCapacity(min, max) > SCAN_CAP) + return { ok: false, reason: 'overcapacity' }; + try { + void new ExpressionEvaluator(expression); + } catch (error) { + const reason = error instanceof ExpressionForbiddenError ? 'forbidden' : 'syntaxerror'; + return { ok: false, reason }; + } + return { ok: true, analysis: Analysis.create(from, to, dimensionId, expression) }; + } + + static errorMessage(error) { + if (error instanceof LoadCapacityError) + return { translate: 'commands.analyzearea.loadcapacity' }; + if (error instanceof ExpressionForbiddenError) + return { translate: 'commands.analyzearea.forbidden' }; + console.warn('[Canopy] AnalyzeArea error:', error, error?.stack); + return { translate: 'commands.analyzearea.unknownerror' }; + } + + serialize() { + return { + id: this.id, + from: this.min, + to: this.max, + dimensionId: this.dimensionId, + expression: this.expression, + createdAt: this.createdAt + }; + } + + static deserialize(obj) { + return new Analysis(obj); + } + + matchesCoords(from, to, dimensionId) { + if (dimensionId !== this.dimensionId) + return false; + const { min, max } = Analysis.#normalizeCorners(from, to); + return Analysis.#sameCorner(min, this.min) && Analysis.#sameCorner(max, this.max); + } + + capacity() { + return Analysis.#regionCapacity(this.min, this.max); + } + + tickingId() { + return `canopy_analyzearea_${this.id}`; + } + + #cancelJob() { + if (this.jobId !== void 0) { + system.clearJob(this.jobId); + this.jobId = void 0; + } + } + + #initializeLoader(dimension) { + if (this.loader) { + this.loader.unload(); + this.loader = void 0; + } + const loader = new RegionLoader(dimension, this.min, this.max, this.tickingId()); + if (!loader.hasCapacity()) + return void 0; + this.loader = loader; + return loader; + } + + *#runScan(analyzer, loader, total, progress, done, fail) { + let error = void 0; + try { + for (const scan = analyzer.scan(); !scan.next().done;) { + progress(Math.min(analyzer.scanned / total, 1)); + yield; + } + this.matches = analyzer.matches; + this.capped = analyzer.capped; + this.hasRun = true; + progress(1); + this.running = false; + this.#finishRender(); + } catch (thrown) { + error = thrown; + } finally { + this.jobId = void 0; + loader.unload(); + if (this.loader === loader) + this.loader = void 0; + } + if (error) + fail(error); + else + done(); + } + + run(onProgress) { + const dimension = world.getDimension(this.dimensionId); + this.dimension = dimension; + this.#cancelJob(); + const loader = this.#initializeLoader(dimension); + if (!loader) { + const error = new LoadCapacityError(); + this.#fail(error); + return Promise.reject(error); + } + this.running = true; + this.progress = 0; + this.#beginRender(); + const progress = (fraction) => { + this.progress = fraction; + this.#syncText(); + if (onProgress) + onProgress(fraction); + this.#emit('onProgress', fraction); + }; + return loader.load().then(() => new Promise((resolve, reject) => { + let evaluator; + try { + evaluator = new ExpressionEvaluator(this.expression); + } catch (error) { + loader.unload(); + if (this.loader === loader) + this.loader = void 0; + this.#fail(error); + reject(error); + return; + } + const analyzer = new AreaAnalyzer(dimension, this.min, this.max, evaluator); + const total = Analysis.#regionCapacity(this.min, this.max); + const done = () => { this.#emit('onDone'); resolve(); }; + const fail = (error) => { this.#fail(error); reject(error); }; + this.jobId = system.runJob(this.#runScan(analyzer, loader, total, progress, done, fail)); + })); + } + + #beginRender() { + if (this.renderer) + this.renderer.destroy(); + this.renderer = new AnalyzeAreaRenderer(this.dimension, this.min, this.max, [], this.statusMessage()); + this.renderer.showOutline(); + } + + #syncText() { + if (this.renderer) + this.renderer.setText(this.statusMessage()); + } + + #finishRender() { + if (!this.renderer) + return; + this.renderer.locations = this.matches; + this.#syncText(); + if (this.boxesVisible) + this.renderer.showMatches(); + } + + #fail(error) { + this.running = false; + if (this.renderer) { + this.renderer.destroy(); + this.renderer = void 0; + } + this.#emit('onError', error); + } + + statusMessage() { + const from = stringifyLocation(this.min, 0); + const to = stringifyLocation(this.max, 0); + if (this.running) { + const pct = `${Math.floor(this.progress * 100)}%`; + return { translate: 'commands.analyzearea.stats.analyzing', with: [from, to, this.expression, pct] }; + } + if (!this.hasRun) + return { translate: 'commands.analyzearea.ui.page.notrun' }; + const size = `${this.max.x - this.min.x + 1}x${this.max.y - this.min.y + 1}x${this.max.z - this.min.z + 1}`; + const key = this.capped ? 'commands.analyzearea.stats.capped' : 'commands.analyzearea.stats'; + return { translate: key, with: [from, to, this.expression, String(this.matches.length), size] }; + } + + toggleBoxes() { + if (!this.renderer) + return; + if (this.boxesVisible) + this.renderer.hideMatches(); + else + this.renderer.showMatches(); + this.boxesVisible = !this.boxesVisible; + } + + destroy() { + this.#cancelJob(); + if (this.renderer) + this.renderer.destroy(); + if (this.loader) + this.loader.unload(); + } + + static #normalizeCorners(a, b) { + return { + min: { + x: Math.floor(Math.min(a.x, b.x)), + y: Math.floor(Math.min(a.y, b.y)), + z: Math.floor(Math.min(a.z, b.z)) + }, + max: { + x: Math.floor(Math.max(a.x, b.x)), + y: Math.floor(Math.max(a.y, b.y)), + z: Math.floor(Math.max(a.z, b.z)) + } + }; + } + + static #regionCapacity(min, max) { + return (max.x - min.x + 1) * (max.y - min.y + 1) * (max.z - min.z + 1); + } + + static #sameCorner(a, b) { + return a.x === b.x && a.y === b.y && a.z === b.z; + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js new file mode 100644 index 00000000..745d671f --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js @@ -0,0 +1,91 @@ +import { debugDrawer, DebugBox, DebugText } from '@minecraft/debug-utilities'; + +export class AnalyzeAreaRenderer { + dimension; + min; + max; + locations; + statsText; + outlineShape; + textShape; + matchShapes; + matchesVisible; + + constructor(dimension, min, max, locations, statsText) { + this.dimension = dimension; + this.min = min; + this.max = max; + this.locations = locations; + this.statsText = statsText; + this.outlineShape = void 0; + this.textShape = void 0; + this.matchShapes = []; + this.matchesVisible = false; + } + + showOutline() { + if (this.outlineShape) + return; + const center = { + x: (this.min.x + this.max.x + 1) / 2, + y: (this.min.y + this.max.y + 1) / 2, + z: (this.min.z + this.max.z + 1) / 2, + dimension: this.dimension + }; + const box = new DebugBox(center); + box.bound = { + x: this.max.x - this.min.x + 1, + y: this.max.y - this.min.y + 1, + z: this.max.z - this.min.z + 1 + }; + box.color = { red: 1, green: 1, blue: 1, alpha: 1 }; + this.outlineShape = box; + debugDrawer.addShape(box); + + const text = new DebugText(center, this.statsText); + this.textShape = text; + debugDrawer.addShape(text); + } + + setText(statsText) { + this.statsText = { rawtext: [ { translate: "commands.analyzearea.stats.header" }, { text: '\n' }, statsText] }; + if (this.textShape) + this.textShape.setText(this.statsText); + } + + hideOutline() { + if (this.outlineShape) { + this.outlineShape.remove(); + this.outlineShape = void 0; + } + if (this.textShape) { + this.textShape.remove(); + this.textShape = void 0; + } + } + + showMatches() { + if (this.matchesVisible) + return; + for (const loc of this.locations) { + const center = { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5, dimension: this.dimension }; + const box = new DebugBox(center); + box.bound = { x: 1, y: 1, z: 1 }; + box.color = { red: 0, green: 1, blue: 0, alpha: 1 }; + this.matchShapes.push(box); + debugDrawer.addShape(box); + } + this.matchesVisible = true; + } + + hideMatches() { + for (const shape of this.matchShapes) shape.remove(); + this.matchShapes = []; + this.matchesVisible = false; + } + + destroy() { + this.hideOutline(); + this.hideMatches(); + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js new file mode 100644 index 00000000..c0f6b748 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js @@ -0,0 +1,229 @@ +import { CustomForm, ObservableString, ObservableNumber, ObservableBoolean, ObservableUIRawMessage } from '@minecraft/server-ui'; +import { DimensionTypes, GameMode, system, world } from '@minecraft/server'; +import { Analysis } from './Analysis.js'; +import { stringifyLocation } from '../../../include/utils'; + +export const LIST_PAGE_SIZE = 50; + +export class AnalyzeAreaUI { + constructor(player, manager) { + this.player = player; + this.manager = manager; + } + + showSelector() { + const form = new CustomForm(this.player, { translate: 'commands.analyzearea.ui.selector.title' }); + const analyses = this.manager.list(); + if (analyses.length === 0) { + form.label({ translate: 'commands.analyzearea.ui.selector.empty' }); + form.divider(); + } + form.button({ translate: 'commands.analyzearea.ui.selector.new' }, () => { + form.close(); + system.run(() => this.showCreateForm(null)); + }); + for (const analysis of analyses) { + const label = `${stringifyLocation(analysis.min, 0)} -> ${stringifyLocation(analysis.max, 0)} (${analysis.dimensionId})`; + form.button(label, () => { + form.close(); + system.run(() => this.showAnalysisPage(analysis)); + }); + } + form.show(); + } + + showCreateForm(prefill, initialError) { + const from = prefill?.from ?? this.player.location; + const to = prefill?.to ?? this.player.location; + const form = new CustomForm(this.player, { translate: 'commands.analyzearea.ui.create.title' }); + const inputs = this.#buildCreateInputs(form, from, to); + const showError = this.#addErrorLabel(form); + if (initialError) + showError(initialError); + form.button({ translate: 'commands.analyzearea.ui.create.submit' }, () => this.#submitCreate(form, inputs, showError)); + form.closeButton(); + form.show(); + } + + #buildCreateInputs(form, from, to) { + const fields = { + fromX: new ObservableString(String(Math.floor(from.x)), { clientWritable: true }), + fromY: new ObservableString(String(Math.floor(from.y)), { clientWritable: true }), + fromZ: new ObservableString(String(Math.floor(from.z)), { clientWritable: true }), + toX: new ObservableString(String(Math.floor(to.x)), { clientWritable: true }), + toY: new ObservableString(String(Math.floor(to.y)), { clientWritable: true }), + toZ: new ObservableString(String(Math.floor(to.z)), { clientWritable: true }) + }; + const dimensions = DimensionTypes.getAll().map((dimensionType) => dimensionType.typeId); + const dimObservable = new ObservableNumber(Math.max(0, dimensions.indexOf(this.player.dimension.id)), { clientWritable: true }); + const expression = new ObservableString('', { clientWritable: true }); + const dimensionLabels = dimensions.map((id, index) => ({ label: id.replace('minecraft:', ''), value: index })); + + form.textField({ translate: 'commands.analyzearea.ui.create.fromX' }, fields.fromX); + form.textField({ translate: 'commands.analyzearea.ui.create.fromY' }, fields.fromY); + form.textField({ translate: 'commands.analyzearea.ui.create.fromZ' }, fields.fromZ); + form.textField({ translate: 'commands.analyzearea.ui.create.toX' }, fields.toX); + form.textField({ translate: 'commands.analyzearea.ui.create.toY' }, fields.toY); + form.textField({ translate: 'commands.analyzearea.ui.create.toZ' }, fields.toZ); + form.dropdown({ translate: 'commands.analyzearea.ui.create.dimension' }, dimObservable, dimensionLabels); + form.textField({ translate: 'commands.analyzearea.ui.create.expression' }, expression); + form.spacer(); + return { fields, dimensions, dimObservable, expression }; + } + + #submitCreate(form, inputs, showError) { + const parsedFrom = this.#parseCorner(inputs.fields.fromX, inputs.fields.fromY, inputs.fields.fromZ); + const parsedTo = this.#parseCorner(inputs.fields.toX, inputs.fields.toY, inputs.fields.toZ); + const expr = inputs.expression.getData().trim(); + if (!parsedFrom || !parsedTo || expr.length === 0) { + showError({ translate: 'commands.analyzearea.create.invalid' }); + return; + } + const result = Analysis.tryCreate(parsedFrom, parsedTo, inputs.dimensions[inputs.dimObservable.getData()], expr); + if (!result.ok) { + showError({ translate: `commands.analyzearea.${result.reason}` }); + return; + } + this.manager.add(result.analysis); + form.close(); + system.run(() => this.showAnalysisPage(result.analysis, true)); + } + + showAnalysisPage(analysis, autoRun) { + const form = new CustomForm(this.player, { translate: 'commands.analyzearea.ui.page.title' }); + const status = new ObservableUIRawMessage(analysis.statusMessage()); + form.label(status); + form.spacer(); + const showError = this.#addErrorLabel(form); + const list = { refresh: () => {} }; + const { runAnalysis, unsubscribe } = this.#wirePageProgress(analysis, status, showError, list); + this.#addPageButtons(form, analysis, runAnalysis); + form.divider(); + + list.refresh = this.#buildLocationList(form, analysis, showError, status); + form.closeButton(); + + list.refresh(); + if (autoRun && !analysis.running) + runAnalysis(); + form.show().then(unsubscribe, unsubscribe); + } + + #wirePageProgress(analysis, status, showError, list) { + const syncStatus = () => status.setData(analysis.statusMessage()); + const unsubscribe = analysis.subscribe({ + onProgress: syncStatus, + onDone: () => list.refresh(), + onError: (error) => { + syncStatus(); + showError(Analysis.errorMessage(error)); + } + }); + const runAnalysis = () => { + analysis.run().catch(() => {}); + syncStatus(); + }; + return { runAnalysis, unsubscribe }; + } + + #addPageButtons(form, analysis, runAnalysis) { + form.button({ translate: 'commands.analyzearea.ui.page.reanalyze' }, runAnalysis); + const toggleLabel = new ObservableUIRawMessage(this.#toggleBoxesMessage(analysis)); + form.button(toggleLabel, () => { + analysis.toggleBoxes(); + toggleLabel.setData(this.#toggleBoxesMessage(analysis)); + }); + form.button({ translate: 'commands.analyzearea.ui.page.remove' }, () => { + this.manager.remove(analysis); + form.close(); + system.run(() => this.showSelector()); + }); + form.button({ translate: 'commands.analyzearea.ui.page.back' }, () => { + form.close(); + system.run(() => this.showSelector()); + }); + } + + #buildSlots(form, analysis, showError) { + const slots = []; + for (let i = 0; i < LIST_PAGE_SIZE; i++) { + const label = new ObservableString(''); + const visible = new ObservableBoolean(false); + slots.push({ label, visible, location: void 0 }); + form.button(label, () => { + if (!this.#teleportTo(slots[i].location, analysis.dimensionId)) + showError({ translate: 'commands.analyzearea.teleport.gamemode' }); + }, { visible }); + } + return slots; + } + + #buildLocationList(form, analysis, showError, status) { + const slots = this.#buildSlots(form, analysis, showError); + const pageIndicator = new ObservableString(''); + const pagingVisible = new ObservableBoolean(false); + let page = 0; + const totalPages = () => Math.max(1, Math.ceil(analysis.matches.length / LIST_PAGE_SIZE)); + const renderPage = () => { + const start = page * LIST_PAGE_SIZE; + for (let i = 0; i < LIST_PAGE_SIZE; i++) { + const match = analysis.matches[start + i]; + slots[i].location = match ?? void 0; + slots[i].label.setData(match ? stringifyLocation(match, 0) : ''); + slots[i].visible.setData(Boolean(match)); + } + pageIndicator.setData(`${page + 1} / ${totalPages()}`); + pagingVisible.setData(totalPages() > 1); + status.setData(analysis.statusMessage()); + }; + + form.divider({ visible: pagingVisible }); + form.label(pageIndicator, { visible: pagingVisible }); + form.spacer({ visible: pagingVisible }); + form.button({ translate: 'commands.analyzearea.ui.page.next' }, () => { + if (page < totalPages() - 1) + page++; + renderPage(); + }, { visible: pagingVisible }); + form.button({ translate: 'commands.analyzearea.ui.page.prev' }, () => { + if (page > 0) + page--; + renderPage(); + }, { visible: pagingVisible }); + return () => { page = 0; renderPage(); }; + } + + #teleportTo(location, dimensionId) { + if (!location) + return true; + const mode = this.player.getGameMode(); + if (mode !== GameMode.Creative && mode !== GameMode.Spectator) + return false; + this.player.teleport({ x: location.x + 0.5, y: location.y, z: location.z + 0.5 }, { dimension: world.getDimension(dimensionId) }); + return true; + } + + #addErrorLabel(form) { + const text = new ObservableUIRawMessage({ text: '' }); + const visible = new ObservableBoolean(false); + form.label(text, { visible }); + form.spacer({ visible }); + return (message) => { + text.setData(message); + visible.setData(true); + }; + } + + #toggleBoxesMessage(analysis) { + return { translate: analysis.boxesVisible ? 'commands.analyzearea.ui.page.disableboxes' : 'commands.analyzearea.ui.page.enableboxes' }; + } + + #parseCorner(xObs, yObs, zObs) { + const x = Number(xObs.getData()); + const y = Number(yObs.getData()); + const z = Number(zObs.getData()); + if ([x, y, z].some((n) => !Number.isFinite(n))) + return void 0; + return { x, y, z }; + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js new file mode 100644 index 00000000..586fb081 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js @@ -0,0 +1,53 @@ +import { world } from '@minecraft/server'; +import { Analysis } from './Analysis.js'; + +export class AreaAnalysisManager { + static #DP_KEY = 'areaanalyses'; + static #instance; + + constructor() { + this.analyses = this.#load(); + } + + static getInstance() { + if (!AreaAnalysisManager.#instance) + AreaAnalysisManager.#instance = new AreaAnalysisManager(); + return AreaAnalysisManager.#instance; + } + + #load() { + const raw = world.getDynamicProperty(AreaAnalysisManager.#DP_KEY); + if (typeof raw !== 'string' || raw.length === 0) + return []; + try { + return JSON.parse(raw).map((obj) => Analysis.deserialize(obj)); + } catch { + return []; + } + } + + #save() { + world.setDynamicProperty(AreaAnalysisManager.#DP_KEY, JSON.stringify(this.analyses.map((a) => a.serialize()))); + } + + list() { + return this.analyses; + } + + add(analysis) { + this.analyses.push(analysis); + this.#save(); + } + + remove(analysis) { + const index = this.analyses.indexOf(analysis); + if (index === -1) return; + this.analyses.splice(index, 1); + analysis.destroy(); + this.#save(); + } + + findByCoords(from, to, dimensionId) { + return this.analyses.find((a) => a.matchesCoords(from, to, dimensionId)); + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js new file mode 100644 index 00000000..8178ad91 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js @@ -0,0 +1,58 @@ +export const MATCH_CAP = 10000; +export const SCAN_CAP = 2 ** 32; +const YIELD_EVERY = 32; + +export class AreaAnalyzer { + constructor(dimension, min, max, evaluator, { matchCap = MATCH_CAP } = {}) { + this.dimension = dimension; + this.min = min; + this.max = max; + this.evaluator = evaluator; + this.matchCap = matchCap; + this.matches = []; + this.scanned = 0; + this.errorCount = 0; + this.capped = false; + } + + *scan() { + let sinceYield = 0; + for (let y = this.min.y; y <= this.max.y; y++) { + for (let x = this.min.x; x <= this.max.x; x++) { + for (let z = this.min.z; z <= this.max.z; z++) { + this.scanned++; + const loc = { x, y, z }; + if (this.#evaluateLocation(loc)) + return; + if (++sinceYield >= YIELD_EVERY) { + sinceYield = 0; + yield; + } + } + } + } + } + + #evaluateLocation(loc) { + try { + const block = this.dimension.getBlock(loc); + if (block === void 0) { + this.errorCount++; + } else if (this.evaluator.evaluate(block)) { + this.matches.push(loc); + if (this.matches.length >= this.matchCap) { + this.capped = true; + return true; + } + } + } catch { + this.errorCount++; + } + return false; + } + + runToCompletion() { + const scan = this.scan(); + while (!scan.next().done); + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js new file mode 100644 index 00000000..6fa4d34d --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js @@ -0,0 +1,173 @@ +import jsep from '../../../lib/jsep/jsep.js'; +import { ItemStack } from '@minecraft/server'; +import { readOnlyMethods } from './readOnlyMethods.js'; +import { SAFE_JS_METHODS } from './safeJsMethods.js'; +import { ExpressionForbiddenError } from './ExpressionForbiddenError.js'; + +const FORBIDDEN_KEYS = new Set(['constructor', '__proto__', 'prototype']); +const ALLOWED_METHODS = new Set([...readOnlyMethods, ...SAFE_JS_METHODS]); + +jsep.plugins.register({ + name: 'analyzearea-new', + init() { + jsep.hooks.add('gobble-token', function gobbleNew(env) { + if (this.expr.substr(this.index, 3) !== 'new' || jsep.isIdentifierPart(this.expr.charCodeAt(this.index + 3))) + return; + this.index += 3; + this.gobbleSpaces(); + const callee = this.gobbleIdentifier(); + this.gobbleSpaces(); + if (this.code !== jsep.OPAREN_CODE) + this.throwError(`Expected ( after new ${callee.name}`); + this.index++; + const args = this.gobbleArguments(jsep.CPAREN_CODE); + env.node = this.gobbleTokenProperty({ type: 'NewExpression', callee, arguments: args }); + }); + } +}); + +export class ExpressionEvaluator { + constructor(expression) { + this.expression = expression; + this.ast = jsep(expression); + this.#assertSafe(this.ast); + } + + #assertSafe(node) { + switch (node.type) { + case 'Literal': + case 'Identifier': + return; + case 'MemberExpression': + if (!node.computed && FORBIDDEN_KEYS.has(node.property.name)) { + console.warn(`[Canopy] Forbidden property access in expression: ${this.expression}, property: ${node.property.name}`); + throw new ExpressionForbiddenError(`Forbidden property access: ${node.property.name}`); + } + this.#assertSafe(node.object); + this.#assertSafe(node.property); + return; + case 'CallExpression': { + this.#assertSafe(node.callee); + const name = this.#staticCalleeName(node.callee); + if (name !== void 0 && !ALLOWED_METHODS.has(name)) + throw new ExpressionForbiddenError(`Forbidden method call: ${name}`); + node.arguments.forEach((arg) => this.#assertSafe(arg)); + return; + } + case 'NewExpression': + if (node.callee.type !== 'Identifier' || node.callee.name !== 'ItemStack') + throw new ExpressionForbiddenError("Only 'new ItemStack(...)' may be constructed"); + node.arguments.forEach((arg) => this.#assertSafe(arg)); + return; + case 'UnaryExpression': + this.#assertSafe(node.argument); + return; + case 'BinaryExpression': + case 'LogicalExpression': + this.#assertSafe(node.left); + this.#assertSafe(node.right); + return; + default: + throw new Error(`Unsupported expression node: ${node.type}`); + } + } + + #staticCalleeName(callee) { + if (callee.type === 'Identifier') + return callee.name; + if (callee.type === 'MemberExpression' && !callee.computed) + return callee.property.name; + return void 0; + } + + evaluate(block) { + return this.#evalNode(this.ast, block); + } + + #evalNode(node, block) { + switch (node.type) { + case 'Literal': + return node.value; + case 'Identifier': + return node.name === 'block' ? block : block[node.name]; + case 'MemberExpression': + return this.#evalMember(node, block).value; + case 'CallExpression': + return this.#evalCall(node, block); + case 'NewExpression': + return this.#evalNew(node, block); + case 'UnaryExpression': + return this.#evalUnary(node, block); + case 'BinaryExpression': + case 'LogicalExpression': + return this.#evalBinary(node, block); + default: + throw new Error(`Unsupported expression node: ${node.type}`); + } + } + + #evalMember(node, block) { + const object = this.#evalNode(node.object, block); + const key = node.computed ? this.#evalNode(node.property, block) : node.property.name; + if (FORBIDDEN_KEYS.has(key)) + throw new ExpressionForbiddenError(`Forbidden property access: ${key}`); + return { object, key, value: object?.[key] }; + } + + #evalCall(node, block) { + if (node.callee.type === 'MemberExpression') { + const { object, key, value: fn } = this.#evalMember(node.callee, block); + if (!ALLOWED_METHODS.has(key)) + throw new ExpressionForbiddenError(`Forbidden method call: ${key}`); + const args = node.arguments.map((arg) => this.#evalNode(arg, block)); + return fn.apply(object, args); + } + const name = node.callee.name; + if (!ALLOWED_METHODS.has(name)) + throw new ExpressionForbiddenError(`Forbidden method call: ${name}`); + const fn = this.#evalNode(node.callee, block); + const args = node.arguments.map((arg) => this.#evalNode(arg, block)); + return fn.apply(block, args); + } + + #evalNew(node, block) { + if (node.callee.type !== 'Identifier' || node.callee.name !== 'ItemStack') + throw new ExpressionForbiddenError("Only 'new ItemStack(...)' may be constructed"); + const args = node.arguments.map((arg) => this.#evalNode(arg, block)); + return new ItemStack(...args); + } + + #evalUnary(node, block) { + const arg = this.#evalNode(node.argument, block); + switch (node.operator) { + case '!': return !arg; + case '-': return -arg; + case '+': return +arg; + default: throw new Error(`Unsupported unary operator: ${node.operator}`); + } + } + + #evalBinary(node, block) { + const op = node.operator; + if (op === '&&') return this.#evalNode(node.left, block) && this.#evalNode(node.right, block); + if (op === '||') return this.#evalNode(node.left, block) || this.#evalNode(node.right, block); + const left = this.#evalNode(node.left, block); + const right = this.#evalNode(node.right, block); + switch (op) { + case '===': return left === right; + case '!==': return left !== right; + case '==': return left === right; + case '!=': return left !== right; + case '<': return left < right; + case '>': return left > right; + case '<=': return left <= right; + case '>=': return left >= right; + case '+': return left + right; + case '-': return left - right; + case '*': return left * right; + case '/': return left / right; + case '%': return left % right; + default: throw new Error(`Unsupported binary operator: ${op}`); + } + } +} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionForbiddenError.js b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionForbiddenError.js new file mode 100644 index 00000000..9733e6dd --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/ExpressionForbiddenError.js @@ -0,0 +1 @@ +export class ExpressionForbiddenError extends Error {} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/LoadCapacityError.js b/Canopy[BP]/scripts/src/classes/analyzearea/LoadCapacityError.js new file mode 100644 index 00000000..676b1e6c --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/LoadCapacityError.js @@ -0,0 +1 @@ +export class LoadCapacityError extends Error {} diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/readOnlyMethods.js b/Canopy[BP]/scripts/src/classes/analyzearea/readOnlyMethods.js new file mode 100644 index 00000000..7db27fcf --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/readOnlyMethods.js @@ -0,0 +1,189 @@ +// AUTO-GENERATED by filters/generate_readonly_methods. Do not edit by hand. +// Source: @minecraft/server@2.9.0-beta.1.26.30-stable +export const readOnlyMethods = new Set([ + 'above', + 'accumulatesSnow', + 'add', + 'addText', + 'below', + 'bottomCenter', + 'canAddEnchantment', + 'canBeDestroyedByLiquidSpread', + 'canContainLiquid', + 'canPlace', + 'center', + 'clearDynamicProperties', + 'clearJob', + 'clearRun', + 'clone', + 'contains', + 'containsBiomes', + 'containsBlock', + 'doesLocationTouchFaces', + 'doesVolumeTouchFaces', + 'east', + 'find', + 'findClosestBiome', + 'findLast', + 'firstEmptySlot', + 'firstItem', + 'generateLootFromBlock', + 'generateLootFromBlockPermutation', + 'generateLootFromBlockType', + 'generateLootFromEntity', + 'generateLootFromEntityType', + 'generateLootFromTable', + 'getAABB', + 'getAbsoluteTime', + 'getAimAssist', + 'getAllBlocksStandingOn', + 'getAllDeliveryTypes', + 'getAllEffectTypes', + 'getAllPlayers', + 'getAllStates', + 'getAttachedBlocks', + 'getAttachedBlocksLocations', + 'getBiome', + 'getBlock', + 'getBlockAbove', + 'getBlockBelow', + 'getBlockFromRay', + 'getBlockFromViewDirection', + 'getBlockPermutation', + 'getBlockPriorities', + 'getBlockStandingOn', + 'getBlockTagPriorities', + 'getBlocks', + 'getBreatheBlocks', + 'getButtonState', + 'getCapacity', + 'getCategories', + 'getComponent', + 'getComponents', + 'getConnectedFaces', + 'getControlScheme', + 'getDay', + 'getDefaultSpawnLocation', + 'getDeliveryType', + 'getDifficulty', + 'getDimension', + 'getDropItems', + 'getDynamicProperty', + 'getDynamicPropertyIds', + 'getDynamicPropertyTotalByteCount', + 'getEffect', + 'getEffectType', + 'getEffects', + 'getEnchantment', + 'getEnchantments', + 'getEntities', + 'getEntitiesAtBlockLocation', + 'getEntitiesFromRay', + 'getEntitiesFromViewDirection', + 'getEntity', + 'getEntityPriorities', + 'getEntityTypeFamilyPriorities', + 'getEquipment', + 'getEquipmentSlot', + 'getExcludedBlockTagTargets', + 'getExcludedBlockTargets', + 'getExcludedEntityTargets', + 'getExcludedEntityTypeFamilyTargets', + 'getFamilyTypes', + 'getFeedItems', + 'getGameMode', + 'getGeneratedStructures', + 'getHeadLocation', + 'getImpactedBlocks', + 'getIsWaterlogged', + 'getItem', + 'getItemCooldown', + 'getItemSettings', + 'getItemStack', + 'getLiquidTargetingItems', + 'getLootTable', + 'getLootTableManager', + 'getLore', + 'getMapColor', + 'getModifiers', + 'getMoonPhase', + 'getMovementVector', + 'getName', + 'getNonBreatheBlocks', + 'getObjective', + 'getObjectiveAtDisplaySlot', + 'getObjectives', + 'getPackSettings', + 'getPageContent', + 'getParticipants', + 'getParts', + 'getPlayers', + 'getPresets', + 'getProperty', + 'getRawLore', + 'getRawPageContent', + 'getRawText', + 'getRecord', + 'getRedstonePower', + 'getRiders', + 'getRotation', + 'getScore', + 'getScores', + 'getSeats', + 'getSlot', + 'getSpawnPoint', + 'getState', + 'getStronglyPoweredFace', + 'getText', + 'getTextDyeColor', + 'getTimeOfDay', + 'getTopmostBlock', + 'getTotalXp', + 'getTypeFamilies', + 'getVelocity', + 'getViewDirection', + 'getWeather', + 'hasComponent', + 'hasEnchantment', + 'hasItem', + 'hasParticipant', + 'hasTag', + 'hasTags', + 'hasTypeFamily', + 'isChunkLoaded', + 'isLiquidBlocking', + 'isPlaying', + 'isSnowLoggable', + 'isStackableWith', + 'liquidCanFlowFromDirection', + 'liquidSpreadCausesSpawn', + 'matches', + 'north', + 'obstructsRain', + 'offset', + 'registerCustomComponent', + 'registerCustomDimension', + 'removeAll', + 'removeText', + 'resolve', + 'run', + 'runInterval', + 'runJob', + 'runTimeout', + 'sendMessage', + 'sendScriptEvent', + 'setColorRGB', + 'setColorRGBA', + 'setDynamicProperties', + 'setDynamicProperty', + 'setFloat', + 'setImpactedBlocks', + 'setLocation', + 'setSpeedAndDirection', + 'setVector3', + 'south', + 'totalByteCount', + 'waitTicks', + 'west', + 'withState' +]); diff --git a/Canopy[BP]/scripts/src/classes/analyzearea/safeJsMethods.js b/Canopy[BP]/scripts/src/classes/analyzearea/safeJsMethods.js new file mode 100644 index 00000000..2838e114 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/analyzearea/safeJsMethods.js @@ -0,0 +1,47 @@ +export const SAFE_JS_METHODS = new Set([ + 'at', + 'charAt', + 'charCodeAt', + 'codePointAt', + 'concat', + 'endsWith', + 'entries', + 'flat', + 'hasOwnProperty', + 'includes', + 'indexOf', + 'isPrototypeOf', + 'join', + 'keys', + 'lastIndexOf', + 'localeCompare', + 'match', + 'matchAll', + 'normalize', + 'propertyIsEnumerable', + 'replace', + 'replaceAll', + 'search', + 'slice', + 'split', + 'startsWith', + 'substr', + 'substring', + 'toExponential', + 'toFixed', + 'toLocaleLowerCase', + 'toLocaleString', + 'toLocaleUpperCase', + 'toLowerCase', + 'toPrecision', + 'toReversed', + 'toSorted', + 'toSpliced', + 'toString', + 'toUpperCase', + 'trim', + 'trimEnd', + 'trimStart', + 'valueOf', + 'values' +]); diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplay.js b/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplay.js index 41c65734..21be41f8 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplay.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplay.js @@ -135,8 +135,7 @@ export class DebugDisplay { this.textDrawer.destroy(); this.textDrawer = void 0; this.enabledElements.forEach(element => { - if (element instanceof DebugDisplayShapeElement) - element.destroy(); + element.destroy(); }); delete entityToDebugDisplayMap[this.entity.id]; } diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplayElement.js b/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplayElement.js index 5974e775..43087601 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplayElement.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/DebugDisplayElement.js @@ -5,4 +5,8 @@ export class DebugDisplayElement { this.entity = entity; this.type = this.constructor.name; } + + destroy() { + // Overload if needed + } } \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/GrowUp.js b/Canopy[BP]/scripts/src/classes/debugdisplay/GrowUp.js index f2540700..52b051a5 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/GrowUp.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/GrowUp.js @@ -6,9 +6,14 @@ export class GrowUp extends ComponentDebugDisplayElement { constructor(entity) { super(entity, EntityComponentTypes.Ageable); + this.onPlayerInteractWithEntityBound = this.onPlayerInteractWithEntity.bind(this); this.subscribeToEvents(); } + destroy() { + this.unsubscribeFromEvents(); + } + getFormattedData() { if (!this.component?.isValid) { this.component = this.entity.getComponent(this.componentType); @@ -23,14 +28,20 @@ export class GrowUp extends ComponentDebugDisplayElement { subscribeToEvents() { system.run(() => { - world.beforeEvents.playerInteractWithEntity.subscribe(this.onPlayerInteractWithEntity.bind(this)); + world.beforeEvents.playerInteractWithEntity.subscribe(this.onPlayerInteractWithEntityBound); + }); + } + + unsubscribeFromEvents() { + system.run(() => { + world.beforeEvents.playerInteractWithEntity.unsubscribe(this.onPlayerInteractWithEntityBound); }); } onPlayerInteractWithEntity(event) { const entity = event.target; const itemStack = event.itemStack; - if (entity?.id !== this.entity.id || !this.component.isValid) + if (entity?.id !== this.entity.id || !this.component?.isValid) return; const growth = this.findGrowthScalar(this.component.getFeedItems(), itemStack); this.applyGrowth(growth); diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js b/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js index 27475ec0..cb2cc8d0 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/Horse.js @@ -1,10 +1,10 @@ import { DebugDisplayTextElement } from './DebugDisplayTextElement.js'; import { EntityComponentTypes } from '@minecraft/server'; -const UNITS_TO_MPS = 44.05289; +export const MOVEMENT_UNITS_TO_MPS = 44.05289; export class Horse extends DebugDisplayTextElement { - speedCalcTypes = ['horse', 'donkey', 'mule']; + speedCalcTypes = ['horse', 'donkey', 'mule', 'skeleton_horse', 'zombie_horse']; constructor(entity) { super(entity); @@ -15,6 +15,6 @@ export class Horse extends DebugDisplayTextElement { getFormattedData() { if (!this.speedCalcTypes.includes(this.entity.typeId.replace("minecraft:", ""))) return 'n/a'; - return `§7Speed: §a${this.movementComponent.currentValue * UNITS_TO_MPS} m/s§7, Health: §c${this.healthComponent.effectiveMax}`; + return `§7Speed: §a${this.movementComponent.currentValue * MOVEMENT_UNITS_TO_MPS} m/s§7, Max Health: §c${this.healthComponent.effectiveMax}`; } } \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/Item.js b/Canopy[BP]/scripts/src/classes/debugdisplay/Item.js index 5667a1d7..cc488f75 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/Item.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/Item.js @@ -9,7 +9,7 @@ export class Item extends ComponentDebugDisplayElement { getFormattedData() { if (!this.component?.isValid) { - this.component = this.entity.getComponent(this.componentType); + this.component = this.entity.getComponent(this.componentType) || system.currentTick; return; } const itemStack = this.component.itemStack; diff --git a/Canopy[BP]/scripts/src/classes/debugdisplay/Tame.js b/Canopy[BP]/scripts/src/classes/debugdisplay/Tame.js index 7fc6a24d..c3c4f451 100644 --- a/Canopy[BP]/scripts/src/classes/debugdisplay/Tame.js +++ b/Canopy[BP]/scripts/src/classes/debugdisplay/Tame.js @@ -1,6 +1,5 @@ import { DebugDisplayTextElement } from './DebugDisplayTextElement.js'; -import { playerTameEntityEvent } from '../../events/PlayerTameEntityEvent.js'; -import { EntityComponentTypes } from '@minecraft/server'; +import { EntityComponentTypes, world } from '@minecraft/server'; import { getNameFromEntityId } from '../../../include/utils.js'; export class Tame extends DebugDisplayTextElement { @@ -9,9 +8,12 @@ export class Tame extends DebugDisplayTextElement { tameItems; tamedToPlayerIdCache; + static DP_ID = 'tamedToEntityId'; + constructor(entity) { super(entity); - this.tamedToPlayerIdCache = this.entity.getDynamicProperty('tamedToPlayerId'); + this.tryPortDPToNewUpdate(); + this.tamedToPlayerIdCache = this.entity.getDynamicProperty(Tame.DP_ID); } getFormattedData() { @@ -52,7 +54,7 @@ export class Tame extends DebugDisplayTextElement { } updateTamedToPlayerIdCache() { - const playerId = this.tamedToPlayerIdCache || this.entity.getDynamicProperty('tamedToPlayerId'); + const playerId = this.tamedToPlayerIdCache || this.entity.getDynamicProperty(Tame.DP_ID); this.tamedToPlayerIdCache = playerId; } @@ -68,11 +70,19 @@ export class Tame extends DebugDisplayTextElement { return this.tameable && this.tameable.isValid; } - static onPlayerTameEntity(event) { - if (!event.player || !event.entity) + tryPortDPToNewUpdate() { + const tamedToPlayerId = this.entity.getDynamicProperty('tamedToPlayerId'); + if (tamedToPlayerId) { + this.entity.setDynamicProperty(Tame.DP_ID, tamedToPlayerId); + this.entity.setDynamicProperty('tamedToPlayerId', void 0); + } + } + + static onEntityTamed(event) { + if (!event.tamingEntity || !event.entity) return; - event.entity.setDynamicProperty('tamedToPlayerId', event.player.id); + event.entity.setDynamicProperty(Tame.DP_ID, event.tamingEntity.id); } } -playerTameEntityEvent.subscribe(Tame.onPlayerTameEntity); \ No newline at end of file +world.afterEvents.entityTamed.subscribe(Tame.onEntityTamed); \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js new file mode 100644 index 00000000..1d8d245f --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudyConnectedError.js @@ -0,0 +1,6 @@ +export class UnderstudyConnectedError extends Error { + constructor(name) { + super(`[Canopy] Simulated player '${name}' is already connected.`); + this.name = 'UnderstudyConnectedError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js new file mode 100644 index 00000000..2303858a --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError.js @@ -0,0 +1,6 @@ +export class UnderstudyNotConnectedError extends Error { + constructor(name) { + super(`[Canopy] Simulated player '${name}' is not connected.`); + this.name = 'UnderstudyNotConnectedError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js b/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js new file mode 100644 index 00000000..e3b47536 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError.js @@ -0,0 +1,6 @@ +export class UnderstudySaveInfoError extends Error { + constructor(message) { + super(message); + this.name = 'UnderstudySaveInfoError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js b/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js new file mode 100644 index 00000000..612a4791 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError.js @@ -0,0 +1,6 @@ +export class UnknownRepeatingActionError extends Error { + constructor(name, type) { + super(`[Canopy] Unknown repeating action '${type}' for simulated player '${name}'.`); + this.name = 'UnknownRepeatingActionError'; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Actions.js b/Canopy[BP]/scripts/src/classes/simplayer/Actions.js new file mode 100644 index 00000000..eced3559 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Actions.js @@ -0,0 +1,55 @@ +import { system } from "@minecraft/server"; +import { RepeatableAction } from "./RepeatableAction"; + +export class Actions { + #singleActions = []; + #repeatingActions = []; + + constructor(understudy) { + this.understudy = understudy; + } + + onTick() { + for (const singleAction of this.#singleActions) + singleAction.perform(); + this.#singleActions.length = 0; + for (const repeatingAction of this.#repeatingActions) + repeatingAction.onTick(); + } + + once(type, afterTicks = void 0) { + const repeatableAction = new RepeatableAction(this.understudy, type); + if (afterTicks === void 0) + this.#singleActions.push(repeatableAction); + else + system.runTimeout(() => this.#singleActions.push(repeatableAction), afterTicks); + } + + repeat(type, intervalTicks = 0) { + if (this.has(type)) + this.remove(type); + const repeatingAction = new RepeatableAction(this.understudy, type, intervalTicks); + this.#repeatingActions.push(repeatingAction); + } + + get(type) { + return this.#repeatingActions.find(action => action.type === type); + } + + has(type) { + return this.#repeatingActions.some(action => action.type === type); + } + + isEmpty() { + return this.#singleActions.length === 0 && this.#repeatingActions.length === 0; + } + + remove(type) { + this.#repeatingActions = this.#repeatingActions.filter(action => action.type !== type); + } + + clear() { + this.#repeatingActions.length = 0; + this.#singleActions.length = 0; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js new file mode 100644 index 00000000..c7f28929 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver.js @@ -0,0 +1,94 @@ +import { world, system, DimensionTypes, TicksPerSecond, EntityComponentTypes } from "@minecraft/server"; +import { UnderstudyInventorySaver } from "./UnderstudyInventorySaver"; +import { simplayerSaving } from "../../rules/simplayer/simplayerSaving"; +import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError"; +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; + +export class PlayerInfoSaver { + saveInterval = 600; + #understudy; + #inventory; + + constructor(understudy) { + this.#understudy = understudy; + this.#inventory = new UnderstudyInventorySaver(understudy); + } + + onConnectedTick() { + this.#saveOnInterval(); + } + + #saveOnInterval() { + if (!simplayerSaving.getNativeValue()) + return; + if ((system.currentTick - this.#understudy.createdTick) % this.saveInterval === 0) { + this.save(); + return; + } + if (!this.#understudy.actions.isEmpty()) { + if ((system.currentTick - this.#understudy.createdTick) % (TicksPerSecond * 5) === 0) + this.save(); + else + this.#inventory.saveWithoutNBT(); + } + } + + get() { + if (!simplayerSaving.getNativeValue()) + throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has no player info saved due to '${simplayerSaving.getID()}' rule being disabled.`); + let playerInfo; + try { + playerInfo = JSON.parse(world.getDynamicProperty(`${this.#understudy.name}:playerinfo`)); + } catch (error) { + if (error.name === 'SyntaxError') + throw new UnderstudySaveInfoError(`Player ${this.#understudy.name} has corrupted player info saved, unable to parse player info.`); + throw error; + } + return playerInfo; + } + + save() { + if (!simplayerSaving.getNativeValue()) + return; + if (!this.#understudy.isConnected()) + throw new UnderstudyNotConnectedError(); + const simulatedPlayer = this.#understudy.simulatedPlayer; + const playerInfo = { + location: simulatedPlayer.location, + rotation: this.#understudy.headRotation, + dimensionId: simulatedPlayer.dimension.id, + gameMode: simulatedPlayer.getGameMode(), + projectileIds: this.#findOwnedProjectileIds() + }; + world.setDynamicProperty(`${this.#understudy.name}:playerinfo`, JSON.stringify(playerInfo)); + this.#inventory.save(); + } + + #findOwnedProjectileIds() { + let projectileIds = []; + for (const dimensionType of DimensionTypes.getAll()) { + const dimension = world.getDimension(dimensionType.typeId); + const projectiles = dimension.getEntities().filter(entity => { + const projectileComponent = entity.getComponent(EntityComponentTypes.Projectile); + return projectileComponent?.owner === this.#understudy.simulatedPlayer; + }); + projectileIds = projectileIds.concat(projectiles.map(projectile => projectile.id)); + } + return projectileIds; + } + + loadInventoryAndProjectileOwnership() { + const playerInfo = this.get(); + this.#claimProjectileIds(playerInfo.projectileIds); + this.#inventory.load(); + } + + #claimProjectileIds(projectileIds) { + projectileIds?.forEach(projectileId => { + const projectile = world.getEntity(projectileId); + const projectileComponent = projectile?.getComponent(EntityComponentTypes.Projectile); + if (projectileComponent) + projectileComponent.owner = this.#understudy.simulatedPlayer; + }); + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js new file mode 100644 index 00000000..51a9d2b1 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction.js @@ -0,0 +1,127 @@ +import { system } from "@minecraft/server"; +import { UnknownRepeatingActionError } from "../errors/UnknownRepeatingActionError"; +import { swapSlots } from "./utils"; + +export const REPEATABLE_ACTIONS = Object.freeze({ + ATTACK: 'attack', + INTERACT: 'interact', + USE: 'use', + BUILD: 'build', + BREAK: 'break', + DROP: 'drop', + DROP_STACK: 'dropstack', + DROP_ALL: 'dropall', + JUMP: 'jump' +}); + +export class RepeatableAction { + understudy; + type; + intervalTicks = 0; + startTick; + + constructor(understudy, type, intervalTicks = 0) { + this.understudy = understudy; + this.type = type; + this.intervalTicks = intervalTicks; + this.startTick = system.currentTick; + } + + onTick() { + if (this.isActionTick()) + this.perform(); + } + + setInterval(newIntervalTicks) { + this.intervalTicks = newIntervalTicks; + } + + isActionTick() { + return (system.currentTick - this.startTick) % this.intervalTicks === 0 || this.intervalTicks === 0; + } + + perform() { + const simulatedPlayer = this.understudy.simulatedPlayer; + switch (this.type) { + case REPEATABLE_ACTIONS.ATTACK: + simulatedPlayer.attack(); + break; + case REPEATABLE_ACTIONS.INTERACT: + simulatedPlayer.interact(); + break; + case REPEATABLE_ACTIONS.USE: + simulatedPlayer.useItemInSlot(simulatedPlayer.selectedSlotIndex); + break; + case REPEATABLE_ACTIONS.BUILD: + this.#build(); + break; + case REPEATABLE_ACTIONS.BREAK: + this.#break(); + break; + case REPEATABLE_ACTIONS.DROP: + this.#drop(); + break; + case REPEATABLE_ACTIONS.DROP_STACK: + simulatedPlayer.dropSelectedItem(); + break; + case REPEATABLE_ACTIONS.DROP_ALL: + this.#dropAll(); + break; + case REPEATABLE_ACTIONS.JUMP: + simulatedPlayer.jump(); + break; + default: + throw new UnknownRepeatingActionError(this.understudy.name, this.type); + } + } + + #build() { + const simulatedPlayer = this.understudy.simulatedPlayer; + const invContainer = this.understudy.getInventory(); + const selectedSlot = simulatedPlayer.selectedSlotIndex; + swapSlots(invContainer, 0, selectedSlot); + simulatedPlayer.startBuild(); + simulatedPlayer.stopBuild(); + swapSlots(invContainer, 0, selectedSlot); + simulatedPlayer.selectedSlotIndex = selectedSlot; + } + + #break() { + const simulatedPlayer = this.understudy.simulatedPlayer; + const lookingAtLocation = simulatedPlayer.getBlockFromViewDirection({ maxDistance: 6 })?.block?.location; + if (lookingAtLocation === void 0) + return; + simulatedPlayer.breakBlock(lookingAtLocation); + } + + #drop() { + const invContainer = this.understudy.getInventory(); + const simulatedPlayer = this.understudy.simulatedPlayer; + const itemStack = invContainer.getItem(simulatedPlayer.selectedSlotIndex); + if (itemStack === void 0) + return; + const savedAmount = itemStack.amount; + if (savedAmount > 1) { + itemStack.amount = 1; + invContainer.setItem(simulatedPlayer.selectedSlotIndex, itemStack); + simulatedPlayer.dropSelectedItem(); + itemStack.amount = savedAmount - 1; + invContainer.setItem(simulatedPlayer.selectedSlotIndex, itemStack); + } else { + simulatedPlayer.dropSelectedItem(); + } + } + + #dropAll() { + const invContainer = this.understudy.getInventory(); + const simulatedPlayer = this.understudy.simulatedPlayer; + const selectedSlot = simulatedPlayer.selectedSlotIndex; + simulatedPlayer.selectedSlotIndex = 0; + simulatedPlayer.dropSelectedItem(); + for (let i = 0; i < invContainer.size; i++) { + invContainer.moveItem(i, simulatedPlayer.selectedSlotIndex, invContainer); + simulatedPlayer.dropSelectedItem(); + } + simulatedPlayer.selectedSlotIndex = selectedSlot; + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js new file mode 100644 index 00000000..3e00b214 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudies.js @@ -0,0 +1,128 @@ +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; +import Understudy from "./Understudy"; +import { system, world } from "@minecraft/server"; + +class Understudies { + static understudies = []; + static #runner = null; + static #entityDieHandle = null; + static #playerGameModeChangeHandle = null; + + static onConnect() { + if (Understudies.#runner === null) + Understudies.#startProcessing(); + } + + static #startProcessing() { + Understudies.#runner = system.runInterval(() => { + for (const understudy of Understudies.understudies) { + if (understudy.isConnected()) + understudy.onConnectedTick(); + } + }); + Understudies.#entityDieHandle = Understudies.onEntityDie.bind(Understudies); + world.afterEvents.entityDie.subscribe(Understudies.#entityDieHandle); + Understudies.#playerGameModeChangeHandle = Understudies.onPlayerGameModeChange.bind(Understudies); + world.afterEvents.playerGameModeChange.subscribe(Understudies.#playerGameModeChangeHandle); + } + + static #stopProcessing() { + system.clearRun(Understudies.#runner); + Understudies.#runner = null; + world.afterEvents.entityDie.unsubscribe(Understudies.#entityDieHandle); + world.afterEvents.playerGameModeChange.unsubscribe(Understudies.#playerGameModeChangeHandle); + Understudies.#entityDieHandle = null; + Understudies.#playerGameModeChangeHandle = null; + } + + static onEntityDie(event) { + if (event.deadEntity.typeId !== 'minecraft:player') + return; + const understudy = Understudies.get(event.deadEntity?.name); + if (understudy !== void 0) { + understudy.leave(); + Understudies.remove(understudy); + } + } + + static onPlayerGameModeChange(event) { + const understudy = Understudies.get(event.player?.name); + if (understudy !== void 0) + understudy.savePlayerInfo(); + } + + static create(name) { + if (Understudies.isOnline(name)) + throw new Error(`[Canopy] Simulated player with name ${name} already exists.`); + const understudy = new Understudy(name); + Understudies.understudies.push(understudy); + return understudy; + } + + static addNametagPrefix(understudy) { + const prefix = world.getDynamicProperty('nametagPrefix'); + if (prefix) + understudy.simulatedPlayer.nameTag = Understudies.#formatNametagWithPrefix(understudy.name, prefix); + } + + static get(name) { + return Understudies.understudies.find(p => p.name === name); + } + + static remove(understudy) { + try { + understudy.leave(); + } catch (error) { + if (!(error instanceof UnderstudyNotConnectedError)) + throw error; + } + const runner = system.runInterval(() => { + if (!understudy.isConnected()) { + system.clearRun(runner); + const index = Understudies.understudies.indexOf(understudy); + Understudies.understudies.splice(index, 1); + if (Understudies.understudies.length === 0) + Understudies.#stopProcessing(); + } + }); + } + + static removeAll() { + for (const understudy of [...Understudies.understudies]) + Understudies.remove(understudy); + } + + static length() { + return Understudies.understudies.length; + } + + static setNametagPrefix(prefix) { + world.setDynamicProperty('nametagPrefix', prefix); + for (const understudy of Understudies.understudies) + understudy.simulatedPlayer.nameTag = Understudies.#formatNametagWithPrefix(understudy.name, prefix); + } + + static #formatNametagWithPrefix(name, prefix) { + if (prefix === '') + return name; + return `§r[${prefix}§r] ${name}`; + } + + static isOnline(name) { + return Understudies.get(name) !== void 0; + } + + static isUnderstudy(player) { + return Understudies.understudies.some(u => u.isConnected() && u.name === player?.name); + } + + static getNotOnlineMessage(name) { + return { translate: 'simplayer.notonline', with: [name] }; + } + + static getAlreadyOnlineMessage(name) { + return { translate: 'simplayer.alreadyonline', with: [name] }; + } +} + +export default Understudies; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js new file mode 100644 index 00000000..4a3c3e14 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/Understudy.js @@ -0,0 +1,299 @@ +import { Block, Entity, Player, world, system, GameMode, EntityComponentTypes } from "@minecraft/server"; +import { spawnSimulatedPlayer } from "@minecraft/server-gametest"; +import { getLookAtLocation, getLookAtRotation, portOldGameModeToNewUpdate } from "./utils"; +import { Vector } from "../../../lib/Vector"; +import { PlayerInfoSaver } from "./PlayerInfoSaver"; +import { Actions } from "./Actions"; +import { UnderstudyNotConnectedError } from "../errors/UnderstudyNotConnectedError"; +import { UnderstudyConnectedError } from "../errors/UnderstudyConnectedError"; +import { UnderstudySaveInfoError } from "../errors/UnderstudySaveInfoError"; +import Understudies from "./Understudies"; + +class Understudy { + name; + #simulatedPlayer = null; + #createdTick; + #isConnected = false; + #lookTarget; + #actions; + #playerInfoSaver; + + constructor(name) { + this.name = name; + this.#createdTick = system.currentTick; + this.#playerInfoSaver = new PlayerInfoSaver(this); + this.#actions = new Actions(this); + } + + isConnected() { + return this.#isConnected; + } + + onConnectedTick() { + this.#playerInfoSaver.onConnectedTick(); + if (!this.#lookTarget?.isValid) + this.clearLookTarget(); + if (this.#simulatedPlayer !== null) + this.refreshHeldItem(); + this.#actions.onTick(); + } + + get createdTick() { + return this.#createdTick; + } + + get simulatedPlayer() { + this.#assertConnected(); + return this.#simulatedPlayer; + } + + get actions() { + this.#assertConnected(); + return this.#actions; + } + + get lookTarget() { + this.#assertConnected(); + return this.#lookTarget; + } + + clearLookTarget() { + this.#assertConnected(); + this.#lookTarget = void 0; + } + + get headRotation() { + this.#assertConnected(); + if (!this.#lookTarget?.isValid) + this.clearLookTarget(); + if (this.#lookTarget === void 0) + return this.#simulatedPlayer.headRotation; + let targetLocation; + if (this.#lookTarget instanceof Entity) { + try { + targetLocation = this.#lookTarget.getHeadLocation(); + } catch { + return this.#simulatedPlayer.headRotation; + } + } else { + targetLocation = this.#lookTarget.location; + } + return getLookAtRotation(this.#simulatedPlayer.location, targetLocation); + } + + savePlayerInfo() { + this.#assertConnected(); + this.#playerInfoSaver.save(); + } + + join({ location, dimension, rotation = { x: 0, y: 0 }, gameMode = GameMode.Survival }) { + this.#assertNotConnected(); + Understudies.onConnect(); + const updatedGameMode = portOldGameModeToNewUpdate(gameMode); + this.#simulatedPlayer = spawnSimulatedPlayer({ ...location, dimension }, this.name, updatedGameMode); + this.#isConnected = true; + const teleportOptions = { + dimension, + facingLocation: getLookAtLocation(location, rotation), + rotation + }; + this.#simulatedPlayer.teleport(location, teleportOptions); + try { + this.#playerInfoSaver.loadInventoryAndProjectileOwnership(); + } catch (error) { + if (error instanceof UnderstudySaveInfoError) + console.warn(`[Canopy] Failed to load player info for ${this.name}:`, error); + else + throw error; + } + } + + leave() { + this.#assertConnected(); + this.savePlayerInfo(); + this.#simulatedPlayer.remove(); + this.#simulatedPlayer = void 0; + this.clearLookTarget(); + this.#isConnected = false; + world.sendMessage({ translate: 'simplayer.leave.broadcast', with: [this.name] }); + } + + rejoin() { + this.#assertNotConnected(); + const playerInfo = this.#playerInfoSaver.get(); + this.join({ + location: playerInfo.location, + rotation: playerInfo.rotation, + dimension: world.getDimension(playerInfo.dimensionId), + gameMode: playerInfo.gameMode + }); + } + + teleport({ location, dimension, rotation = { x: 0, y: 0 } }) { + const teleportOptions = { + dimension, + facingLocation: getLookAtLocation(location, rotation), + rotation + }; + this.simulatedPlayer.teleport(location, teleportOptions); + this.savePlayerInfo(); + } + + look(target) { + if (target instanceof Block) { + this.simulatedPlayer.lookAt(target); + this.#lookTarget = target; + } else if (target instanceof Entity) { + this.simulatedPlayer.lookAtEntity(target); + this.#lookTarget = target; + } else if (target instanceof Vector) { + this.simulatedPlayer.lookAtLocation(target); + } else { + const rotation = target; + this.simulatedPlayer.lookAtLocation(getLookAtLocation(this.simulatedPlayer.location, rotation)); + this.simulatedPlayer.setRotation(rotation); + } + } + + stopLooking() { + const target = this.lookTarget; + if (target === void 0) + return; + this.clearLookTarget(); + if (target instanceof Player) + this.look(Vector.from(target.getHeadLocation())); + else if (target instanceof Block) + this.look(Vector.from(target.location)); + else + this.look(Vector.from(target)); + } + + moveLocation(target) { + if (target instanceof Block) + this.simulatedPlayer.navigateToBlock(target); + else if (target instanceof Entity) + this.simulatedPlayer.navigateToEntity(target); + else + this.simulatedPlayer.navigateToLocation(target); + } + + moveRelative(direction) { + const relativeDirectionMap = { + forward: [0, 1], + backward: [0, -1], + left: [1, 0], + right: [-1, 0] + }; + const relativeDirection = relativeDirectionMap[direction]; + if (!relativeDirection) + throw new Error(`[Canopy] Invalid relative movement direction: ${direction}`); + this.simulatedPlayer.moveRelative(...relativeDirection); + } + + stopMoving() { + this.simulatedPlayer.stopMoving(); + } + + selectSlot(slotNumber) { + this.simulatedPlayer.selectedSlotIndex = slotNumber; + this.savePlayerInfo(); + } + + sprint(shouldSprint) { + this.simulatedPlayer.isSprinting = shouldSprint; + } + + sneak(shouldSneak) { + this.simulatedPlayer.isSneaking = shouldSneak; + } + + glide(shouldGlide) { + if (shouldGlide) + this.simulatedPlayer.glide(); + else + this.simulatedPlayer.stopGliding(); + } + + claimProjectiles(radius) { + const simulatedPlayer = this.simulatedPlayer; + const projectileComponents = this.#getProjectileComponentsInRange(simulatedPlayer, radius); + const numChanged = this.#changeProjectileOwner(projectileComponents, simulatedPlayer); + if (numChanged === 0) + return world.sendMessage({ translate: 'simplayer.claimprojectiles.none', with: [simulatedPlayer.name, String(radius)] }); + world.sendMessage({ translate: 'simplayer.claimprojectiles.success', with: [simulatedPlayer.name, String(numChanged)] }); + this.savePlayerInfo(); + } + + #getProjectileComponentsInRange(player, radius) { + const projectileComponents = []; + const radiusEntities = player.dimension.getEntities({ location: player.location, maxDistance: radius }); + for (const entity of radiusEntities) { + const projectileComponent = entity?.getComponent(EntityComponentTypes.Projectile); + if (projectileComponent) + projectileComponents.push(projectileComponent); + } + return projectileComponents; + } + + #changeProjectileOwner(projectileComponents, newOwner) { + const successfullyChanged = []; + for (const projectileComponent of projectileComponents) { + if (!projectileComponent?.isValid) + continue; + projectileComponent.owner = newOwner; + successfullyChanged.push(projectileComponent); + } + return successfullyChanged.length; + } + + stopAll() { + this.actions.clear(); + this.stopMoving(); + this.#simulatedPlayer.stopBuild(); + this.#simulatedPlayer.stopInteracting(); + this.#simulatedPlayer.stopBreakingBlock(); + this.#simulatedPlayer.stopUsingItem(); + this.#simulatedPlayer.stopSwimming(); + this.#simulatedPlayer.stopGliding(); + this.#simulatedPlayer.stopUsingItem(); + this.sprint(false); + this.sneak(false); + this.clearLookTarget(); + this.savePlayerInfo(); + } + + getInventory() { + const simulatedPlayer = this.simulatedPlayer; + const inventoryComponent = simulatedPlayer.getComponent(EntityComponentTypes.Inventory); + return inventoryComponent?.container; + } + + swapHeldItemWithPlayer(targetPlayer) { + const playerInvContainer = this.getInventory(); + const targetInvContainer = targetPlayer.getComponent(EntityComponentTypes.Inventory)?.container; + try { + playerInvContainer.swapItems(this.#simulatedPlayer.selectedSlotIndex, targetPlayer.selectedSlotIndex, targetInvContainer); + } catch (error) { + targetPlayer.sendMessage({ translate: 'simplayer.swapheld.error', with: [error.name] }); + console.warn(error); + } + this.refreshHeldItem(); + this.savePlayerInfo(); + } + + refreshHeldItem() { + this.#simulatedPlayer.selectedSlotIndex = this.simulatedPlayer.selectedSlotIndex; + } + + #assertConnected() { + if (!this.isConnected()) + throw new UnderstudyNotConnectedError(this.name); + } + + #assertNotConnected() { + if (this.isConnected()) + throw new UnderstudyConnectedError(this.name); + } +} + +export default Understudy; diff --git a/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js new file mode 100644 index 00000000..ea1e5916 --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver.js @@ -0,0 +1,118 @@ +import { EntityComponentTypes, EquipmentSlot, world } from "@minecraft/server"; +import SRCItemDatabase from "../../../lib/SRCItemDatabase/ItemDatabase.js"; + +export class UnderstudyInventorySaver { + constructor(understudy) { + this.understudy = understudy; + const tableName = `bot_${understudy.name.substr(0, 8)}`; + this.itemDatabase = new SRCItemDatabase(tableName); + this.inventoryDP = `${tableName}_inventory`; + this.equippableDP = `${tableName}_equippable`; + this.inventoryDBKey = 'inv'; + this.equippableDBKey = 'equ'; + } + + save() { + this.#saveInventoryItems({ saveNBT: true }); + this.#saveEquippableItems({ saveNBT: true }); + } + + saveWithoutNBT() { + this.#saveInventoryItems({ saveNBT: false }); + this.#saveEquippableItems({ saveNBT: false }); + } + + load() { + this.#loadInventoryItems(); + this.#loadEquippableItems(); + } + + #saveInventoryItems({ saveNBT = true } = {}) { + const inventoryItems = {}; + const inventoryContainer = this.understudy.getInventory(); + if (inventoryContainer !== void 0) { + for (let i = 0; i < inventoryContainer.size; i++) { + const itemStack = inventoryContainer.getItem(i); + inventoryItems[i] = itemStack ?? void 0; + } + this.#saveItemsWithoutNBT(this.inventoryDP, inventoryItems); + if (saveNBT) + this.#saveItemsWithNBT(this.inventoryDBKey, inventoryItems); + } + } + + #saveEquippableItems({ saveNBT = true } = {}) { + const equippableItems = {}; + const equippable = this.understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + if (equippable !== void 0) { + for (const equipmentSlot in EquipmentSlot) { + const itemStack = equippable.getEquipment(equipmentSlot); + if (itemStack !== void 0) + equippableItems[equipmentSlot] = itemStack; + } + this.#saveItemsWithoutNBT(this.equippableDP, equippableItems); + if (saveNBT) + this.#saveItemsWithNBT(this.equippableDBKey, equippableItems); + } + } + + #saveItemsWithoutNBT(dynamicProperty, itemStacks) { + const items = {}; + for (const [key, itemStack] of Object.entries(itemStacks)) { + if (itemStack) + items[key] = { typeId: itemStack.typeId, amount: itemStack.amount }; + } + world.setDynamicProperty(dynamicProperty, JSON.stringify(items)); + } + + #saveItemsWithNBT(DBKey, itemStacks) { + const itemsWithNBT = Object.values(itemStacks).filter(item => item !== void 0); + this.itemDatabase.setItems(DBKey, itemsWithNBT); + } + + #loadInventoryItems() { + const inventoryContainer = this.understudy.getInventory(); + if (inventoryContainer === void 0) + return; + const itemsWithoutNBTStr = world.getDynamicProperty(this.inventoryDP); + if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) + return; + const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); + const itemsWithNBT = this.itemDatabase.getItems(this.inventoryDBKey) ?? []; + for (let i = 0; i < inventoryContainer.size; i++) { + const itemWithoutNBT = itemsWithoutNBT[i]; + let itemStack = void 0; + if (itemWithoutNBT !== void 0) { + const foundIndex = itemsWithNBT.findIndex(item => item?.typeId === itemWithoutNBT?.typeId && item?.amount === itemWithoutNBT?.amount); + if (foundIndex >= 0) { + itemStack = itemsWithNBT[foundIndex]; + itemsWithNBT.splice(foundIndex, 1); + } else if (itemWithoutNBT && typeof itemWithoutNBT.typeId === 'string' && Number.isInteger(itemWithoutNBT.amount) && itemWithoutNBT.amount > 0) { + itemStack = { typeId: itemWithoutNBT.typeId, amount: itemWithoutNBT.amount }; + } + } + inventoryContainer.setItem(i, itemStack); + } + } + + #loadEquippableItems() { + const equippable = this.understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + if (equippable === void 0) + return; + const itemsWithoutNBTStr = world.getDynamicProperty(this.equippableDP); + if (itemsWithoutNBTStr === '{}' || itemsWithoutNBTStr === void 0) + return; + const itemsWithoutNBT = JSON.parse(itemsWithoutNBTStr); + const itemsWithNBT = this.itemDatabase.getItems(this.equippableDBKey) ?? []; + for (const equipmentSlot in EquipmentSlot) { + const itemWithoutNBT = itemsWithoutNBT[equipmentSlot]; + let itemStack = void 0; + if (itemWithoutNBT !== void 0) { + itemStack = itemsWithNBT.find(item => item?.typeId === itemWithoutNBT?.typeId && item?.amount === itemWithoutNBT?.amount); + if (itemStack === void 0 && itemWithoutNBT && typeof itemWithoutNBT.typeId === 'string' && Number.isInteger(itemWithoutNBT.amount) && itemWithoutNBT.amount > 0) + itemStack = { typeId: itemWithoutNBT.typeId, amount: itemWithoutNBT.amount }; + } + equippable.setEquipment(equipmentSlot, itemStack); + } + } +} diff --git a/Canopy[BP]/scripts/src/classes/simplayer/utils.js b/Canopy[BP]/scripts/src/classes/simplayer/utils.js new file mode 100644 index 00000000..8a72c61d --- /dev/null +++ b/Canopy[BP]/scripts/src/classes/simplayer/utils.js @@ -0,0 +1,68 @@ +import { Block, Direction, Entity, GameMode, Player } from "@minecraft/server"; +import { Vector } from "../../../lib/Vector"; + +const PLAYER_EYE_HEIGHT = 1.62001002; + +export function getLookAtLocation(baseLocation, targetRotation) { + const extraDistance = 1000; + const pitch = targetRotation.x; + const yaw = targetRotation.y + 90; + const xz = Math.cos(pitch * Math.PI / 180); + const x = xz * Math.cos(yaw * Math.PI / 180) * extraDistance; + const y = Math.sin(-pitch * Math.PI / 180) * extraDistance; + const z = xz * Math.sin(yaw * Math.PI / 180) * extraDistance; + return { x: baseLocation.x + x, y: baseLocation.y + y + PLAYER_EYE_HEIGHT, z: baseLocation.z + z }; +} + +export function getLookAtRotation(baseLocation, targetLocation) { + const x = targetLocation.x - baseLocation.x; + const y = targetLocation.y - baseLocation.y - PLAYER_EYE_HEIGHT; + const z = targetLocation.z - baseLocation.z; + const yaw = Math.atan2(z, x) * 180 / Math.PI - 90; + const xz = Math.sqrt(x * x + z * z); + const pitch = -Math.atan2(y, xz) * 180 / Math.PI; + return { x: pitch, y: yaw }; +} + +export function swapSlots(invContainer, slotNumber1, slotNumber2) { + if (!invContainer) + throw new Error('[Canopy] Inventory container is not available.'); + const slot1 = invContainer.getItem(slotNumber1); + const slot2 = invContainer.getItem(slotNumber2); + invContainer.setItem(slotNumber1, slot2); + invContainer.setItem(slotNumber2, slot1); +} + +export function portOldGameModeToNewUpdate(gameMode) { + if (typeof gameMode === 'string') { + switch (gameMode.toLowerCase()) { + case 'survival': return GameMode.Survival; + case 'creative': return GameMode.Creative; + case 'adventure': return GameMode.Adventure; + case 'spectator': return GameMode.Spectator; + default: throw new Error(`[Canopy] Unknown game mode: ${gameMode}`); + } + } + throw new Error(`[Canopy] Game mode must be a string, received: ${typeof gameMode}`); +} + +export function getLocationInfoFromSource(source) { + if (source instanceof Block) + return { location: { x: source.x + .5, y: source.y + 1, z: source.z + .5 }, dimension: source.dimension }; + else if (source instanceof Player) + return { location: source.location, dimension: source.dimension, rotation: source.getRotation(), gameMode: source.getGameMode() }; + else if (source instanceof Entity) + return { location: source.location, dimension: source.dimension, rotation: source.getRotation() }; + throw new Error(`[Canopy] Invalid source`); +} + +export function getBlockFaceLocationFromRaycastHit(raycastHit) { + const location = Vector.from(raycastHit.block.location).add(raycastHit.faceLocation); + if (raycastHit.face === Direction.Up) + location.y += 1; + else if (raycastHit.face === Direction.East) + location.x += 1; + else if (raycastHit.face === Direction.South) + location.z += 1; + return location; +} diff --git a/Canopy[BP]/scripts/src/commands/analyzearea.js b/Canopy[BP]/scripts/src/commands/analyzearea.js new file mode 100644 index 00000000..0ff7ea28 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/analyzearea.js @@ -0,0 +1,108 @@ +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin } from "../../lib/canopy/Canopy"; +import { CommandPermissionLevel, CustomCommandParamType, CustomCommandStatus, system } from "@minecraft/server"; +import { AreaAnalysisManager } from "../classes/analyzearea/AreaAnalysisManager"; +import { Analysis } from "../classes/analyzearea/Analysis"; +import { AnalyzeAreaUI } from "../classes/analyzearea/AnalyzeAreaUI"; + +const REMOVE_TOKEN = 'remove'; + +export class AnalyzeAreaCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:analyzearea', + description: 'commands.analyzearea', + optionalParameters: [ + { name: 'from', type: CustomCommandParamType.Location }, + { name: 'to', type: CustomCommandParamType.Location }, + { name: 'expression', type: CustomCommandParamType.String } + ], + permissionLevel: CommandPermissionLevel.GameDirectors, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.analyzeAreaCommand(origin, ...args), + wikiDescription: 'Analyze a region of blocks with a JavaScript expression (parsed by jsep). ' + + 'The expression is evaluated for each block in the region, and if it returns a truthy value, the block is considered a match. ' + + 'The expression has access all properties and methods that can be accessed in restricted-execution mode from a ' + + '[block](https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/minecraft/server/block?view=minecraft-bedrock-experimental) object. ' + + 'The `block` source keyword can be included or not.\n\n' + + 'Example expressions:\n' + + "- Stone block: `typeId == 'minecraft:stone'`\n" + + "- Immovable block: `getComponent('minecraft:movable').movementType == 'Immovable'`\n" + + "- Liquid source block: `permutation.getState('liquid_depth') == 0`", + subCommandWikiDescription: { + '': { + description: 'Open the area analyses menu.' + }, + ' ': { + description: 'Open the saved analysis for those coordinates, or a prefilled create form.' + }, + ' remove': { + description: 'Remove the saved analysis for those coordinates.' + }, + ' ': { + description: 'Create and run an analysis.' + } + } + }); + } + + analyzeAreaCommand(origin, from, to, expression) { + const manager = AreaAnalysisManager.getInstance(); + + if (from && to && expression !== void 0) { + if (expression === REMOVE_TOKEN) + return this.#removeAnalysis(origin, manager, from, to); + return this.#createAndRun(origin, manager, from, to, expression); + } + + if (!(origin instanceof PlayerCommandOrigin)) + return { status: CustomCommandStatus.Failure, message: 'commands.generic.invalidsource' }; + const player = origin.getSource(); + const ui = new AnalyzeAreaUI(player, manager); + + if (from && to) { + const existing = manager.findByCoords(from, to, player.dimension.id); + system.run(() => { + if (existing) + ui.showAnalysisPage(existing); + else + ui.showCreateForm({ from, to }); + }); + return { status: CustomCommandStatus.Success }; + } + + system.run(() => ui.showSelector()); + return { status: CustomCommandStatus.Success }; + } + + #removeAnalysis(origin, manager, from, to) { + const dimensionId = origin.getSource().dimension.id; + const existing = manager.findByCoords(from, to, dimensionId); + if (!existing) + return { status: CustomCommandStatus.Failure, message: 'commands.analyzearea.removenotfound' }; + system.run(() => manager.remove(existing)); + return { status: CustomCommandStatus.Success, message: 'commands.analyzearea.removed' }; + } + + #createAndRun(origin, manager, from, to, expression) { + const source = origin.getSource(); + const result = Analysis.tryCreate(from, to, source.dimension.id, expression); + if (!result.ok) + return { status: CustomCommandStatus.Failure, message: `commands.analyzearea.${result.reason}` }; + + const analysis = result.analysis; + const isPlayer = origin instanceof PlayerCommandOrigin; + system.run(() => { + manager.add(analysis); + if (isPlayer) { + new AnalyzeAreaUI(source, manager).showAnalysisPage(analysis, true); + return; + } + analysis.run() + .then(() => origin.sendMessage({ translate: 'commands.analyzearea.completed', with: [String(analysis.matches.length)] })) + .catch((error) => origin.sendMessage(Analysis.errorMessage(error))); + }); + return { status: CustomCommandStatus.Success }; + } +} + +export const analyzeAreaCommand = new AnalyzeAreaCommand(); diff --git a/Canopy[BP]/scripts/src/commands/camera.js b/Canopy[BP]/scripts/src/commands/camera.js index 893adf05..ef5544ad 100644 --- a/Canopy[BP]/scripts/src/commands/camera.js +++ b/Canopy[BP]/scripts/src/commands/camera.js @@ -57,11 +57,12 @@ new VanillaCommand({ name: 'canopy:cs', description: 'commands.camera.spectate', usage: 'canopy:cs', + optionalParameters: [{ name: 'player', type: CustomCommandParamType.PlayerSelector }], permissionLevel: CommandPermissionLevel.Any, contingentRules: ['commandCamera'], allowedSources: [PlayerCommandOrigin], - callback: (origin) => cameraCommand(origin, CAM_ACTIONS.Spectate), - wikiDescription: 'Alias for `/cam spectate`.' + callback: (origin, targets) => spectateAction(origin.getSource(), targets?.[0]), + wikiDescription: 'Alias for `/cam spectate`. Use the player argument to spectate another player (requires OP).', }); class BeforeSpectatorPlayer { @@ -167,13 +168,13 @@ function endCameraView(player) { player.onScreenDisplay.setActionBar({ translate: 'commands.camera.view.ended' }); } -function spectateAction(player) { +function spectateAction(player, target = void 0) { system.run(() => { if (player.getDynamicProperty('isSpectating')) { endSpectate(player); } else { try { - startSpectate(player); + startSpectate(player, target); } catch (error) { player.sendMessage({ translate: error.message }); player.setDynamicProperty('isSpectating', false); @@ -182,13 +183,15 @@ function spectateAction(player) { }); } -function startSpectate(player) { +function startSpectate(player, target = void 0) { if (world.isHardcore) throw new Error('commands.camera.spectate.hardcore'); if (player.getDynamicProperty('isViewingCamera')) throw new Error('commands.camera.spectate.viewing'); if (!player.isOnGround && player.getGameMode() !== GameMode.Creative) throw new Error('commands.camera.spectate.flying'); + if (target && player.commandPermissionLevel === CommandPermissionLevel.Admin) + target = void 0; cameraFadeOut(player); player.setDynamicProperty('isSpectating', true); const savedPlayer = new BeforeSpectatorPlayer(player); @@ -205,6 +208,8 @@ function startSpectate(player) { } player.addEffect('night_vision', MAX_EFFECT_DURATION, { amplifier: 0, showParticles: false }); player.addEffect('conduit_power', MAX_EFFECT_DURATION, { amplifier: 0, showParticles: false }); + if (target?.isValid) + player.teleport(target.location, { dimension: target.dimension, rotation: target.getRotation() }); player.onScreenDisplay.setActionBar({ translate: 'commands.camera.spectate.started' }); }, TICKS_TO_COMPLETE_FADE); } diff --git a/Canopy[BP]/scripts/src/commands/canopy.js b/Canopy[BP]/scripts/src/commands/canopy.js index 99866aa1..a76a927c 100644 --- a/Canopy[BP]/scripts/src/commands/canopy.js +++ b/Canopy[BP]/scripts/src/commands/canopy.js @@ -1,179 +1,224 @@ -import { Command, InfoDisplayRule, Extensions, Rules, Commands } from "../../lib/canopy/Canopy"; -import { PACK_VERSION } from "../../constants"; -import { ModalFormData } from "@minecraft/server-ui"; -import { forceShow } from "../../include/utils"; - -const cmd = new Command({ - name: 'canopy', - description: { translate: 'commands.canopy' }, - usage: 'canopy [true/false/integer/float]', - args: [ - { type: 'string|array', name: 'ruleIDs' }, - { type: 'boolean|float|integer', name: 'newValue' } - ], - callback: canopyCommand, - helpEntries: [ - { usage: 'canopy menu', description: { translate: 'commands.canopy.menu' }, wikiDescription: 'Displays a menu with toggles for every rule. Flip the switches for the ones you want a hit the submit button at the bottom to save your changes.' }, - { usage: 'canopy [true/false/integer/float]', description: { translate: 'commands.canopy.single' } }, - { usage: 'canopy <[rule1,rule2,...]> [true/false/integer/float]', description: { translate: 'commands.canopy.multiple' } }, - { usage: 'canopy version', description: { translate: 'commands.canopy.version' } } - ], - opOnly: true -}); - -async function canopyCommand(sender, args) { - const { ruleIDs, newValue } = args; - if (ruleIDs === null && newValue === null) { - cmd.sendUsage(sender); - return; - } - if (typeof ruleIDs === 'string' && ruleIDs === 'menu') { - openMenu(sender); - return; - } - if (typeof ruleIDs === 'string' && ruleIDs === 'version') { - sender.sendMessage(getVersionMessage()); - return; - } - if (typeof ruleIDs === 'string') { - handleRuleChange(sender, ruleIDs, newValue); - return; - } - for (const ruleID of ruleIDs) - await handleRuleChange(sender, ruleID, newValue); -} - -function getVersionMessage() { - const message = { rawtext: [ - { translate: 'commands.canopy.version.message' }, - { text: ` §av${PACK_VERSION}§r§7.\n` } - ]}; - const extensionNames = Extensions.getVersionedNames(); - if (extensionNames.length === 0) return message; - message.rawtext.push({ translate: 'commands.canopy.version.extensions' }); - for (let i = 0; i < extensionNames.length; i++) { - const extensionName = extensionNames[i]; - if (i > 0) - message.rawtext.push({ text: '§r§7,' }); - message.rawtext.push({ text: ` §2§o${extensionName.name} v${extensionName.version}` }); - } - return message; -} - -async function handleRuleChange(sender, ruleID, newValue) { - if (!Rules.exists(ruleID)) - return sender.sendMessage({ translate: 'rules.generic.unknown', with: [ruleID, Commands.getPrefix()] }); - const rule = Rules.get(ruleID); - if (rule instanceof InfoDisplayRule) - return sender.sendMessage({ translate: 'commands.canopy.infodisplayRule', with: [ruleID, Commands.getPrefix()] }); - const ruleValue = await rule.getValue(); - if (newValue === null) - return sender.sendMessage({ rawtext: [{ translate: 'rules.generic.status', with: [rule.getID()] }, getValueRawText(ruleValue, rule.getType()), { text: '§r§7.' }] }); - if (ruleValue === newValue) - return sender.sendMessage({ rawtext: [{ translate: 'rules.generic.nochange', with: [rule.getID()] }, getValueRawText(newValue, rule.getType()), { text: '§r§7.' }] }); - - if (newValue) - await updateRules(sender, rule.getContingentRuleIDs(), newValue); - else - await updateRules(sender, rule.getDependentRuleIDs(), newValue); - await updateRules(sender, rule.getIndependentRuleIDs(), false); - - await updateRule(sender, ruleID, newValue); -} - -async function updateRules(sender, ruleIDs, newValue) { - for (const ruleID of ruleIDs) { - await updateRule(sender, ruleID, newValue).catch(error => { - console.warn(`[Canopy] Error updating rule ${ruleID}: ${error.message}`); - }); - } -} - -async function updateRule(sender, ruleID, newValue) { - const ruleValue = await Rules.getValue(ruleID); - if (ruleValue === newValue) - return; - try { - Rules.get(ruleID).setValue(newValue); - sendUpdatedMessage(sender, ruleID, newValue); - } catch(error) { - if (error.message.includes('Incorrect value type')) - return sendIncorrectValueTypeMessage(sender, ruleID); - if (error.message.includes('Value out of range')) - return sendValueOutOfRangeMessage(sender, ruleID); - throw error; - } -} - -function sendIncorrectValueTypeMessage(sender, ruleID) { - sender.sendMessage({ translate: 'rules.generic.invalidtype', with: [ruleID, Rules.get(ruleID).getType()] }); -} - -function sendValueOutOfRangeMessage(sender, ruleID) { - const valueRange = Rules.get(ruleID).getAllowedValues(); - const message = { rawtext: [{ translate: 'rules.generic.outofrange', with: [ruleID, String(valueRange.range.min), String(valueRange.range.max)] }] }; - if (valueRange.other?.length > 0) - message.rawtext.push({ translate: 'rules.generic.outofrange.withother', with: [valueRange.other.join(', ')] }); - sender.sendMessage(message); -} - -function sendUpdatedMessage(sender, ruleID, newValue) { - const valueRawText = getValueRawText(newValue, Rules.get(ruleID).getType()); - sender.sendMessage({ rawtext: [{ translate: 'rules.generic.updated', with: [ruleID] }, valueRawText, { text: '§r§7.' }] }); -} - -async function openMenu(sender) { - const form = new ModalFormData().title("§l§2Canopy§r §2Rules"); - const rules = getRulesInAlphabeticalOrder(); - for (const rule of rules) { - try { - const ruleValue = await rule.getValue(); - if (rule.getType() === 'boolean') - form.toggle(rule.getID(), { defaultValue: ruleValue, tooltip: rule.getDescription() }); - else - form.textField(rule.getID(), rule.getType(), { defaultValue: String(ruleValue), tooltip: rule.getDescription() }); - } catch (error) { - sender.sendMessage(`§cError: ${error.message} for rule ${rule.getID()}`); - } - } - form.submitButton({ translate: 'commands.canopy.menu.submit' }); - - forceShow(sender, form, 1000).then(response => { - if (response.canceled) - sender.sendMessage({ translate: 'commands.canopy.menu.canceled' }); - else - updateChangedValues(sender, response.formValues); - }).catch(error => { - sender.sendMessage(`§cError: ${error.message}`); - }); -} - -async function updateChangedValues(sender, formValues) { - const rules = getRulesInAlphabeticalOrder(); - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - const interpretedValue = ['integer', 'float'].includes(rule.getType()) ? Number(formValues[i]) : formValues[i]; - if (await rule.getValue() !== interpretedValue) { - await handleRuleChange(sender, rule.getID(), interpretedValue).catch(error => { - console.warn(`Error updating rule ${rule.getID()}: ${error.message}`); - }); - } - } -} - -function getRulesInAlphabeticalOrder() { - return Rules.getByCategory("Rules").sort((a, b) => a.getID().localeCompare(b.getID())); -} - -function getValueRawText(newValue, type) { - switch(type) { - case ('boolean'): - return newValue ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; - case('integer'): - return { text: '§u' + newValue }; - case('float'): - return { text: '§d' + newValue }; - default: - return { text: newValue }; - } -} \ No newline at end of file +import { VanillaCommand, PlayerCommandOrigin, EntityCommandOrigin, BlockCommandOrigin, ServerCommandOrigin, InfoDisplayRule, Extensions, Rules, Commands } from "../../lib/canopy/Canopy"; +import { CommandPermissionLevel, CustomCommandParamType, CustomCommandStatus, system } from "@minecraft/server"; +import { PACK_VERSION } from "../../constants"; +import { ModalFormData } from "@minecraft/server-ui"; +import { forceShow } from "../../include/utils"; + +export class CanopyCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:canopy', + description: 'commands.canopy', + enums: [{ name: 'canopy:rule', values: () => CanopyCommand.getRuleEnumValues() }], + mandatoryParameters: [{ name: 'canopy:rule', type: CustomCommandParamType.Enum }], + optionalParameters: [{ name: 'value', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.GameDirectors, + allowedSources: [PlayerCommandOrigin, EntityCommandOrigin, BlockCommandOrigin, ServerCommandOrigin], + cheatsRequired: true, + callback: (origin, ...args) => this.canopyCommand(origin, ...args), + wikiDescription: 'Enable, disable, or set the value of a rule, open the rules menu, or display the Canopy version.', + subCommandWikiDescription: { + '': { + description: "Enable, disable, or set a rule's value. Omit the value to query the current setting. Numeric values must be wrapped in quotes (e.g. `\"16\"`), as the vanilla command parser will not accept an unquoted number for this argument.", + params: ['value'] + }, + menu: { + description: 'Opens a form with a toggle or field for every rule.', + params: [] + }, + version: { + description: 'Displays the Canopy version and all loaded extensions.', + params: [] + } + } + }); + } + + static getRuleEnumValues() { + return [...Rules.getSettableRuleIDs(), 'menu', 'version']; + } + + static parseValue(rawValue, type) { + if (rawValue === null || rawValue === void 0) + return null; + switch (type) { + case 'boolean': + if (rawValue === 'true') return true; + if (rawValue === 'false') return false; + return NaN; + case 'integer': { + const parsed = parseInt(rawValue, 10); + return Number.isNaN(parsed) ? NaN : parsed; + } + case 'float': { + const parsed = parseFloat(rawValue); + return Number.isNaN(parsed) ? NaN : parsed; + } + default: + return rawValue; + } + } + + canopyCommand(origin, rule, value) { + if (rule === 'menu') { + if (!(origin instanceof PlayerCommandOrigin)) + return { status: CustomCommandStatus.Failure, message: 'commands.generic.invalidsource' }; + const player = origin.getSource(); + system.run(() => this.openMenu(player)); + return { status: CustomCommandStatus.Success }; + } + if (rule === 'version') { + origin.sendMessage(this.getVersionMessage()); + return { status: CustomCommandStatus.Success }; + } + system.run(() => this.handleRuleChangeFromCommand(origin, rule, value ?? null)); + return { status: CustomCommandStatus.Success }; + } + + getVersionMessage() { + const message = { rawtext: [ + { translate: 'commands.canopy.version.message' }, + { text: ` §av${PACK_VERSION}§r§7.\n` } + ]}; + const extensionNames = Extensions.getVersionedNames(); + if (extensionNames.length === 0) return message; + message.rawtext.push({ translate: 'commands.canopy.version.extensions' }); + for (let i = 0; i < extensionNames.length; i++) { + const extensionName = extensionNames[i]; + if (i > 0) + message.rawtext.push({ text: '§r§7,' }); + message.rawtext.push({ text: ` §2§o${extensionName.name} v${extensionName.version}` }); + } + return message; + } + + handleRuleChangeFromCommand(origin, ruleID, rawValue) { + const rule = Rules.get(ruleID); + if (!rule) + return this.handleRuleChange(origin, ruleID, rawValue); + const newValue = CanopyCommand.parseValue(rawValue, rule.getType()); + if (typeof newValue === 'number' && Number.isNaN(newValue)) + return origin.sendMessage({ translate: 'rules.generic.invalidtype', with: [ruleID, rule.getType()] }); + return this.handleRuleChange(origin, ruleID, newValue); + } + + async handleRuleChange(origin, ruleID, newValue) { + if (!Rules.exists(ruleID)) + return origin.sendMessage({ translate: 'rules.generic.unknown', with: [ruleID, Commands.getPrefix()] }); + const rule = Rules.get(ruleID); + if (rule instanceof InfoDisplayRule) + return origin.sendMessage({ translate: 'commands.canopy.infodisplayRule', with: [ruleID, Commands.getPrefix()] }); + const ruleValue = await rule.getValue(); + if (newValue === null) + return origin.sendMessage({ rawtext: [{ translate: 'rules.generic.status', with: [rule.getID()] }, this.getValueRawText(ruleValue, rule.getType()), { text: '§r§7.' }] }); + if (ruleValue === newValue) + return origin.sendMessage({ rawtext: [{ translate: 'rules.generic.nochange', with: [rule.getID()] }, this.getValueRawText(newValue, rule.getType()), { text: '§r§7.' }] }); + + if (newValue) + await this.updateRules(origin, rule.getContingentRuleIDs(), newValue); + else + await this.updateRules(origin, rule.getDependentRuleIDs(), newValue); + await this.updateRules(origin, rule.getIndependentRuleIDs(), false); + + await this.updateRule(origin, ruleID, newValue); + } + + async updateRules(origin, ruleIDs, newValue) { + for (const ruleID of ruleIDs) { + await this.updateRule(origin, ruleID, newValue).catch(error => { + console.warn(`[Canopy] Error updating rule ${ruleID}: ${error.message}`); + }); + } + } + + async updateRule(origin, ruleID, newValue) { + const ruleValue = await Rules.getValue(ruleID); + if (ruleValue === newValue) + return; + try { + Rules.get(ruleID).setValue(newValue); + this.sendUpdatedMessage(origin, ruleID, newValue); + } catch (error) { + if (error.message.includes('Incorrect value type')) + return this.sendIncorrectValueTypeMessage(origin, ruleID); + if (error.message.includes('Value out of range')) + return this.sendValueOutOfRangeMessage(origin, ruleID); + throw error; + } + } + + sendIncorrectValueTypeMessage(origin, ruleID) { + origin.sendMessage({ translate: 'rules.generic.invalidtype', with: [ruleID, Rules.get(ruleID).getType()] }); + } + + sendValueOutOfRangeMessage(origin, ruleID) { + const valueRange = Rules.get(ruleID).getAllowedValues(); + const message = { rawtext: [{ translate: 'rules.generic.outofrange', with: [ruleID, String(valueRange.range.min), String(valueRange.range.max)] }] }; + if (valueRange.other?.length > 0) + message.rawtext.push({ translate: 'rules.generic.outofrange.withother', with: [valueRange.other.join(', ')] }); + origin.sendMessage(message); + } + + sendUpdatedMessage(origin, ruleID, newValue) { + const valueRawText = this.getValueRawText(newValue, Rules.get(ruleID).getType()); + origin.sendMessage({ rawtext: [{ translate: 'rules.generic.updated', with: [ruleID] }, valueRawText, { text: '§r§7.' }] }); + } + + async openMenu(player) { + const form = new ModalFormData().title("§l§2Canopy§r §2Rules"); + const rules = this.getRulesInAlphabeticalOrder(); + for (const rule of rules) { + try { + const ruleValue = await rule.getValue(); + if (rule.getType() === 'boolean') + form.toggle(rule.getID(), { defaultValue: ruleValue, tooltip: rule.getDescription() }); + else + form.textField(rule.getID(), rule.getType(), { defaultValue: String(ruleValue), tooltip: rule.getDescription() }); + } catch (error) { + player.sendMessage(`§cError: ${error.message} for rule ${rule.getID()}`); + } + } + form.submitButton({ translate: 'commands.canopy.menu.submit' }); + + forceShow(player, form, { timeout: 1000 }).then(response => { + if (response.canceled) + player.sendMessage({ translate: 'commands.canopy.menu.canceled' }); + else + this.updateChangedValues(player, response.formValues); + }).catch(error => { + player.sendMessage(`§cError: ${error.message}`); + }); + } + + async updateChangedValues(player, formValues) { + const rules = this.getRulesInAlphabeticalOrder(); + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + const interpretedValue = ['integer', 'float'].includes(rule.getType()) ? Number(formValues[i]) : formValues[i]; + if (await rule.getValue() !== interpretedValue) { + await this.handleRuleChange(player, rule.getID(), interpretedValue).catch(error => { + console.warn(`Error updating rule ${rule.getID()}: ${error.message}`); + }); + } + } + } + + getRulesInAlphabeticalOrder() { + return Rules.getByCategory("Rules").sort((a, b) => a.getID().localeCompare(b.getID())); + } + + getValueRawText(newValue, type) { + switch (type) { + case ('boolean'): + return newValue ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; + case ('integer'): + return { text: '§u' + newValue }; + case ('float'): + return { text: '§d' + newValue }; + default: + return { text: newValue }; + } + } +} + +export const canopyCommand = new CanopyCommand(); diff --git a/Canopy[BP]/scripts/src/commands/changedimension.js b/Canopy[BP]/scripts/src/commands/changedimension.js index a1f5fd31..8040549f 100644 --- a/Canopy[BP]/scripts/src/commands/changedimension.js +++ b/Canopy[BP]/scripts/src/commands/changedimension.js @@ -16,6 +16,7 @@ const validDimensions = { new VanillaCommand({ name: 'canopy:dtp', description: 'commands.changedimension', + enums: [{ name: 'canopy:dimension', values: Object.keys(validDimensions) }], mandatoryParameters: [{ name: 'canopy:dimension', type: CustomCommandParamType.Enum }], optionalParameters: [ { name: 'destination', type: CustomCommandParamType.Location }, diff --git a/Canopy[BP]/scripts/src/commands/data.js b/Canopy[BP]/scripts/src/commands/data.js index 4b5f9eb2..0a464352 100644 --- a/Canopy[BP]/scripts/src/commands/data.js +++ b/Canopy[BP]/scripts/src/commands/data.js @@ -75,6 +75,7 @@ function formatEntityOutput(entity) { const rotation = entity.getRotation(); const velocityStr = entity.getVelocity() ? stringifyLocation(entity.getVelocity(), 3) : 'none'; const viewDirectionStr = entity.getViewDirection() ? stringifyLocation(entity.getViewDirection(), 2) : 'none'; + const AABBStr = entity.getAABB() ? formatObject(entity, entity.getAABB(), true) : 'none'; const message = { rawtext: [ @@ -85,7 +86,7 @@ function formatEntityOutput(entity) { { translate: 'commands.data.dynamicProperties', with: [dynamicProperties, entity.getDynamicPropertyTotalByteCount().toString()] }, { text: '\n' }, { translate: 'commands.data.effects', with: [effects] }, { text: '\n' }, { translate: 'commands.data.tags', with: [tags] }, { text: '\n' }, - { translate: 'commands.data.other', with: [headLocationStr, rotation.x.toFixed(2), rotation.y.toFixed(2), velocityStr, viewDirectionStr] }, { text: '\n' } + { translate: 'commands.data.other', with: [headLocationStr, rotation.x.toFixed(2), rotation.y.toFixed(2), velocityStr, viewDirectionStr, AABBStr] }, { text: '\n' } ]} ] } @@ -157,22 +158,28 @@ function formatComponent(target, component) { return `\n §7>§f ${component.typeId}§7 - {${output}}`; } -function formatObject(target, object) { +function formatObject(target, object, shouldColorTopLevel = false) { let output = ''; for (const key in object) { try { - if (typeof object[key] === 'function') continue; + if (typeof object[key] === 'function') + continue; let value = object[key]; if (target === value) value = 'this'; else if (typeof value === 'object') - formatObject(target, value); + value = formatObject(target, value, false); - output += `${key}=${JSON.stringify(value)}, `; + if (shouldColorTopLevel) + output += `§7${key}=§b${JSON.stringify(value)}§7, `; + else + output += `${key}=${JSON.stringify(value)}, `; } catch(error) { console.warn(error); } } output = output.slice(0, -2); + if (shouldColorTopLevel) + return `§7{${output}}§r`; return `{${output}}`; } diff --git a/Canopy[BP]/scripts/src/commands/debugentity.js b/Canopy[BP]/scripts/src/commands/debugentity.js index 4cc12889..aa8c846b 100644 --- a/Canopy[BP]/scripts/src/commands/debugentity.js +++ b/Canopy[BP]/scripts/src/commands/debugentity.js @@ -16,13 +16,13 @@ new VanillaCommand({ {name: 'canopy:debugableProperty', values: DebugDisplay.getDebugableProperties()} ], mandatoryParameters: [ - {name: 'entity', type: CustomCommandParamType.EntitySelector}, + {name: 'entities', type: CustomCommandParamType.EntitySelector}, {name: 'canopy:debugAction', type: CustomCommandParamType.Enum}, {name: 'canopy:debugableProperty', type: CustomCommandParamType.Enum} ], permissionLevel: CommandPermissionLevel.GameDirectors, callback: debugEntityCommand, - wikiDescription: "Overlays debug information on selected entities. Not available in realms version." + wikiDescription: "Overlays debug information on selected entities." }); function debugEntityCommand(origin, entities, addOrRemove, property) { diff --git a/Canopy[BP]/scripts/src/commands/entitydensity.js b/Canopy[BP]/scripts/src/commands/entitydensity.js index 96e47cb9..d7d54e1e 100644 --- a/Canopy[BP]/scripts/src/commands/entitydensity.js +++ b/Canopy[BP]/scripts/src/commands/entitydensity.js @@ -17,12 +17,14 @@ const validDimensions = { new VanillaCommand({ name: 'canopy:entitydensity', description: 'commands.entitydensity', + enums: [{name: 'canopy:dimension', values: Object.keys(validDimensions)}], mandatoryParameters: [{name: 'gridSize', type: CustomCommandParamType.Integer}], optionalParameters: [{name: 'canopy:dimension', type: CustomCommandParamType.Enum}], permissionLevel: CommandPermissionLevel.Any, allowedSources: [PlayerCommandOrigin], callback: entityDensityCommand, - wikiDescription: 'Displays the entity count for each dimension and identifies dense areas of entities in the specified dimension. The dimension argument can be omitted to use your current dimension. Valid dimension names include `overworld`, `nether`, `end`, `the_end`, `o`, `n`, and, `e`. Recommended grid sizes: 100-512 or more.' + wikiDescription: 'Displays the entity count for each dimension and identifies dense areas of entities in the specified dimension. ' + + 'The dimension argument can be omitted to use your current dimension. Recommended grid sizes: 100-512 or more.' }); function entityDensityCommand(origin, gridSize, dimension) { diff --git a/Canopy[BP]/scripts/src/commands/help.js b/Canopy[BP]/scripts/src/commands/help.js index e3f885fb..b31c766b 100644 --- a/Canopy[BP]/scripts/src/commands/help.js +++ b/Canopy[BP]/scripts/src/commands/help.js @@ -45,13 +45,13 @@ function populateNativeCommandPages(helpBook) { } function populateNativeRulePages(helpBook, player) { - const infoDisplayPage = new InfoDisplayRuleHelpPage({ title: 'InfoDisplay', description: { translate: 'commands.help.infodisplay' }, usage: Commands.getPrefix() + 'info ' }); + const infoDisplayPage = new InfoDisplayRuleHelpPage({ title: 'InfoDisplay', description: { translate: 'commands.help.infodisplay' }, usage: '/info ' }); const infoDisplayRules = InfoDisplayRule.getAll(); helpBook.newPage(infoDisplayPage); for (const infoDisplayRule of infoDisplayRules) helpBook.addEntry(infoDisplayRule.getCategory(), infoDisplayRule, player); - const rulesPage = new RuleHelpPage({ title: 'Rules', description: { translate: 'commands.help.rules' }, usage: Commands.getPrefix() + 'canopy ' }); + const rulesPage = new RuleHelpPage({ title: 'Rules', description: { translate: 'commands.help.rules' }, usage: '/canopy ' }); const globalRules = Rules.getByCategory('Rules').sort((a, b) => a.getID().localeCompare(b.getID())).filter(rule => !rule.getExtension()); helpBook.newPage(rulesPage); for (const rule of globalRules) @@ -68,7 +68,7 @@ function populateExtensionRulePages(helpBook) { for (const extension of extensions) { const rules = extension.getRules(); if (rules.length > 0) { - const rulePage = new RuleHelpPage({ title: `Rules`, description: { translate: 'commands.help.extension.rules', with: [extension.getName()] }, usage: Commands.getPrefix() + `canopy ` }, extension.getName()); + const rulePage = new RuleHelpPage({ title: `Rules`, description: { translate: 'commands.help.extension.rules', with: [extension.getName()] }, usage: '/canopy ' }, extension.getName()); helpBook.newPage(rulePage); for (const rule of rules) helpBook.addEntry(rulePage.title, rule); diff --git a/Canopy[BP]/scripts/src/commands/info.js b/Canopy[BP]/scripts/src/commands/info.js index d35f622d..b2721ab4 100644 --- a/Canopy[BP]/scripts/src/commands/info.js +++ b/Canopy[BP]/scripts/src/commands/info.js @@ -1,154 +1,140 @@ -import { Command, InfoDisplayRule, Commands, Rules } from "../../lib/canopy/Canopy"; -import { ModalFormData } from "@minecraft/server-ui"; -import { forceShow } from "../../include/utils"; - -const cmd = new Command({ - name: 'info', - description: { translate: 'commands.info' }, - usage: 'info [true/false]', - args: [ - { type: 'string|array', name: 'ruleIDs' }, - { type: 'boolean', name: 'enable' } - ], - callback: infoCommand, - helpEntries: [ - { usage: 'info menu', description: { translate: 'commands.info.menu' }, wikiDescription: 'Displays a menu with toggles for every InfoDisplay rule. Flip the switches for the ones you want a hit the submit button at the bottom to save your changes.' }, - { usage: 'info [true/false]', description: { translate: 'commands.info.single' } }, - { usage: 'info <[rule1,rule2,...]> [true/false]', description: { translate: 'commands.info.multiple' } }, - { usage: 'info all [true/false]', description: { translate: 'commands.info.all' } } - ] -}); - -new Command({ - name: 'i', - description: { translate: 'commands.info' }, - usage: 'i', - args: [ - { type: 'string|array', name: 'ruleIDs' }, - { type: 'boolean', name: 'enable' } - ], - callback: infoCommand, - helpHidden: true -}); - -function infoCommand(sender, args) { - const { ruleIDs, enable } = args; - if (ruleIDs === null && enable === null) { - cmd.sendUsage(sender); - return; - } - if (ruleIDs === 'menu') { - openMenu(sender); - return; - } - if (ruleIDs === 'all') { - changeAll(sender, enable); - return; - } - if (typeof ruleIDs === 'string') { - handleRuleChange(sender, ruleIDs, enable); - return; - } - for (const ruleID of ruleIDs) - handleRuleChange(sender, ruleID, enable); -} - -async function handleRuleChange(sender, ruleID, enable) { - if (!InfoDisplayRule.exists(ruleID)) - return sender.sendMessage({ rawtext: [ { translate: 'rules.generic.unknown', with: [ruleID, Commands.getPrefix()] } ] }); - if (!(InfoDisplayRule.get(ruleID) instanceof InfoDisplayRule)) - return sender.sendMessage({ translate: 'commands.info.canopyRule', with: [ruleID, Command.getPrefix()] }); - const ruleValue = InfoDisplayRule.getValue(sender, ruleID); - if (enable === null) { - const enabledRawText = ruleValue ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; - return sender.sendMessage({ rawtext: [ { translate: 'rules.generic.status', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); - } - if (enable === ruleValue) { - const enabledRawText = enable ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; - return sender.sendMessage({ rawtext: [ { translate: 'rules.generic.nochange', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); - } - - const rule = InfoDisplayRule.get(ruleID); - const blockingGlobalContingents = await getBlockingGlobalContingents(rule); - if (enable && blockingGlobalContingents.length > 0) { - for (const blockingRuleID of blockingGlobalContingents) - sender.sendMessage({ translate: 'rules.generic.blocked', with: [blockingRuleID] }); - return; - } - if (enable) - updateRules(sender, rule.getContingentRuleIDs(), enable); - else - updateRules(sender, rule.getDependentRuleIDs(), enable); - updateRules(sender, rule.getIndependentRuleIDs(), !enable); - - updateRule(sender, ruleID, enable); -} - -function changeAll(sender, enable) { - for (const entry of InfoDisplayRule.getAll()) - entry.setValue(sender, enable); - if (!enable) clearInfoDisplay(sender); - const enabledRawText = enable ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; - sender.sendMessage({ rawtext: [ { translate: 'commands.info.allupdated' }, enabledRawText, { text: '§r§7.' } ] }); -} - -function clearInfoDisplay(sender) { - sender.onScreenDisplay.setTitle(''); -} - -function updateRule(sender, ruleID, enable) { - const ruleValue = InfoDisplayRule.getValue(sender, ruleID); - if (ruleValue === enable) return; - InfoDisplayRule.setValue(sender, ruleID, enable); - const enabledRawText = enable ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; - sender.sendMessage({ rawtext: [ { translate: 'rules.generic.updated', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); -} - -function updateRules(sender, ruleIDs, enable) { - for (const ruleID of ruleIDs) - updateRule(sender, ruleID, enable); -} - -async function getBlockingGlobalContingents(rule) { - const blockingGlobalContingents = []; - const globalContingentRules = rule.getGlobalContingentRuleIDs(); - for (const contingentRuleID of globalContingentRules) { - const contingentRule = Rules.get(contingentRuleID); - if (!(await contingentRule.getValue())) - blockingGlobalContingents.push(contingentRuleID); - } - return blockingGlobalContingents; -} - -function openMenu(sender) { - const form = new ModalFormData().title("§2InfoDisplay Rules"); - const rules = Rules.getByCategory("InfoDisplay").sort((a, b) => a.getID().localeCompare(b.getID())); - for (const rule of rules) { - try { - const ruleValue = rule.getValue(sender); - form.toggle(rule.getID(), { defaultValue: ruleValue, tooltip: rule.getDescription() }); - } catch (error) { - sender.sendMessage(`§cError: ${error.message} for rule ${rule.getID()}`); - } - } - form.submitButton({ translate: 'commands.canopy.menu.submit' }); - forceShow(sender, form, 1000) - .then(response => { - if (response.canceled) - sender.sendMessage({ translate: 'commands.canopy.menu.canceled' }); - else - updateChangedValues(sender, response.formValues); - }) - .catch(error => { - sender.sendMessage(`§cError: ${error.message}`); - }); -} - -function updateChangedValues(sender, formValues) { - const rules = Rules.getByCategory("InfoDisplay").sort((a, b) => a.getID().localeCompare(b.getID())); - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - if (rule.getValue(sender) !== formValues[i]) - handleRuleChange(sender, rule.getID(), formValues[i]); - } -} \ No newline at end of file +import { VanillaCommand, PlayerCommandOrigin, InfoDisplayRule, Commands, Rules } from "../../lib/canopy/Canopy"; +import { CommandPermissionLevel, CustomCommandParamType, CustomCommandStatus, system } from "@minecraft/server"; +import { ModalFormData } from "@minecraft/server-ui"; +import { forceShow } from "../../include/utils"; +import { InfoDisplay } from "../rules/infodisplay/InfoDisplay"; + +export class InfoDisplayCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:info', + description: 'commands.info', + enums: [{ name: 'canopy:infoRule', values: () => InfoDisplayCommand.getRuleEnumValues() }], + mandatoryParameters: [{ name: 'canopy:infoRule', type: CustomCommandParamType.Enum }], + optionalParameters: [{ name: 'value', type: CustomCommandParamType.Boolean }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin], + callback: (origin, ...args) => this.infoCommand(origin, ...args), + wikiDescription: 'Toggle InfoDisplay rules for yourself, or open the InfoDisplay menu.', + subCommandWikiDescription: { + '': { + description: 'Enable or disable an InfoDisplay rule for yourself. Omit the value to query the current setting.', + params: ['value'] + }, + menu: { + description: 'Opens a form with a toggle for every InfoDisplay rule.', + params: [] + } + } + }); + } + + static getRuleEnumValues() { + return [...InfoDisplay.getRuleIdentifiers(), 'menu']; + } + + infoCommand(origin, rule, value) { + const player = origin.getSource(); + if (rule === 'menu') { + system.run(() => this.openMenu(player)); + return { status: CustomCommandStatus.Success }; + } + system.run(() => this.handleRuleChange(player, rule, value ?? null)); + return { status: CustomCommandStatus.Success }; + } + + async handleRuleChange(player, ruleID, enable) { + if (!InfoDisplayRule.exists(ruleID)) { + if (Rules.exists(ruleID)) + return player.sendMessage({ translate: 'commands.info.canopyRule', with: [ruleID, Commands.getPrefix()] }); + return player.sendMessage({ rawtext: [ { translate: 'rules.generic.unknown', with: [ruleID, Commands.getPrefix()] } ] }); + } + const ruleValue = InfoDisplayRule.getValue(player, ruleID); + if (enable === null) { + const enabledRawText = ruleValue ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; + return player.sendMessage({ rawtext: [ { translate: 'rules.generic.status', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); + } + if (enable === ruleValue) { + const enabledRawText = enable ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; + return player.sendMessage({ rawtext: [ { translate: 'rules.generic.nochange', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); + } + + const rule = InfoDisplayRule.get(ruleID); + const blockingGlobalContingents = await this.getBlockingGlobalContingents(rule); + if (enable && blockingGlobalContingents.length > 0) { + for (const blockingRuleID of blockingGlobalContingents) + player.sendMessage({ translate: 'rules.generic.blocked', with: [blockingRuleID] }); + return; + } + if (enable) + this.updateRules(player, rule.getContingentRuleIDs(), enable); + else + this.updateRules(player, rule.getDependentRuleIDs(), enable); + this.updateRules(player, rule.getIndependentRuleIDs(), !enable); + + this.updateRule(player, ruleID, enable); + } + + updateRule(player, ruleID, enable) { + const ruleValue = InfoDisplayRule.getValue(player, ruleID); + if (ruleValue === enable) return; + InfoDisplayRule.setValue(player, ruleID, enable); + const enabledRawText = enable ? { translate: 'rules.generic.enabled' } : { translate: 'rules.generic.disabled' }; + player.sendMessage({ rawtext: [ { translate: 'rules.generic.updated', with: [ruleID] }, enabledRawText, { text: '§r§7.' } ] }); + } + + updateRules(player, ruleIDs, enable) { + for (const ruleID of ruleIDs) + this.updateRule(player, ruleID, enable); + } + + async getBlockingGlobalContingents(rule) { + const blockingGlobalContingents = []; + const globalContingentRules = rule.getGlobalContingentRuleIDs(); + for (const contingentRuleID of globalContingentRules) { + const contingentRule = Rules.get(contingentRuleID); + if (!(await contingentRule.getValue())) + blockingGlobalContingents.push(contingentRuleID); + } + return blockingGlobalContingents; + } + + openMenu(player) { + const form = new ModalFormData().title("§2InfoDisplay Rules"); + const rules = this.getRulesInAlphabeticalOrder(); + for (const rule of rules) { + try { + const ruleValue = rule.getValue(player); + form.toggle(rule.getID(), { defaultValue: ruleValue, tooltip: rule.getDescription() }); + } catch (error) { + player.sendMessage(`§cError: ${error.message} for rule ${rule.getID()}`); + } + } + form.submitButton({ translate: 'commands.canopy.menu.submit' }); + forceShow(player, form, { timeout: 1000 }) + .then(response => { + if (response.canceled) + player.sendMessage({ translate: 'commands.canopy.menu.canceled' }); + else + this.updateChangedValues(player, response.formValues); + }) + .catch(error => { + player.sendMessage(`§cError: ${error.message}`); + }); + } + + updateChangedValues(player, formValues) { + const rules = this.getRulesInAlphabeticalOrder(); + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (rule.getValue(player) !== formValues[i]) + this.handleRuleChange(player, rule.getID(), formValues[i]); + } + } + + getRulesInAlphabeticalOrder() { + return Rules.getByCategory("InfoDisplay").sort((a, b) => a.getID().localeCompare(b.getID())); + } +} + +export const infoCommand = new InfoDisplayCommand(); diff --git a/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js b/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js index 520c554e..fdb1b549 100644 --- a/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js +++ b/Canopy[BP]/scripts/src/commands/lifetimequeryitem.js @@ -1,6 +1,6 @@ import { CommandPermissionLevel, CustomCommandParamType } from "@minecraft/server"; import { VanillaCommand, PlayerCommandOrigin, ServerCommandOrigin } from "../../lib/canopy/Canopy"; -import { lifetimeQueryCommand } from "./lifetimequery"; +import { LIFETIME_QUERY_ACTIONS, lifetimeQueryCommand } from "./lifetimequery"; export class LifetimeQueryItem extends VanillaCommand { worldLifetimeTracker = void 0; @@ -9,8 +9,11 @@ export class LifetimeQueryItem extends VanillaCommand { super({ name: 'canopy:lifetimequeryitem', description: 'commands.lifetime.query.item', - optionalParameters: [ + enums: [{ name: 'canopy:lifetimeQueryActions', values: Object.values(LIFETIME_QUERY_ACTIONS) }], + mandatoryParameters: [ { name: 'itemType', type: CustomCommandParamType.ItemType }, + ], + optionalParameters: [ { name: 'canopy:lifetimeQueryActions', type: CustomCommandParamType.Enum }, { name: 'useRealTime', type: CustomCommandParamType.Boolean } ], diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js new file mode 100644 index 00000000..23cfc01b --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playeraction.js @@ -0,0 +1,104 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { REPEATABLE_ACTIONS } from "../../classes/simplayer/RepeatableAction"; + +export const TIMING_OPTIONS = Object.freeze({ + ONCE: 'once', + CONTINUOUS: 'continuous', + INTERVAL: 'interval', + AFTER: 'after', + STOP: 'stop' +}); + +export class PlayerActionCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playeraction', + description: 'commands.playeraction', + enums: [ + { name: 'canopy:simplayerAction', values: [ ...Object.values(REPEATABLE_ACTIONS), "stop" ] }, + { name: 'canopy:simplayerTimingOption', values: Object.values(TIMING_OPTIONS) } + ], + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'canopy:simplayerAction', type: CustomCommandParamType.Enum } + ], + optionalParameters: [ + { name: 'canopy:simplayerTimingOption', type: CustomCommandParamType.Enum }, + { name: 'ticks', type: CustomCommandParamType.Integer } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playeractionCommand(origin, ...args), + wikiDescription: "Make the simulated player with the given name perform the specified action.\n\n" + + "Actions: \n" + + "- `attack` will make the simulated player attack the block or entity they are looking at.\n" + + "- `interact` will make the simulated player interact with the block or entity they are looking at.\n" + + "- `use` will make the simulated player use the item they are holding.\n" + + "- `build` will make the simulated player place a block from their inventory at the location they are looking at.\n" + + "- `break` will make the simulated player break the block they are looking at.\n" + + "- `drop` will make the simulated player drop one item from their hand.\n" + + "- `dropstack` will make the simulated player drop the entire stack of items from their hand.\n" + + "- `dropall` will make the simulated player drop their entire inventory.\n" + + "- `jump` will make the simulated player jump.\n\n" + + "Timing Options: \n" + + "If no timing option is specified, the simulated player will perform the action once.\n" + + "- `once` will make the simulated player perform the action once.\n" + + "- `after` will make the simulated player perform the action after a delay specified by the last argument.\n" + + "- `continuous` will make the simulated player perform the action continuously.\n" + + "- `interval` will make the simulated player perform the action at regular intervals specified by the last argument.\n" + + "- `stop` will make the simulated player stop performing the action." + }); + } + + playeractionCommand(origin, playername, action, timingOption = TIMING_OPTIONS.ONCE, ticks) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + if (!Object.values(REPEATABLE_ACTIONS).includes(action)) + return { status: CustomCommandStatus.Failure, message: `commands.generic.invalidaction` }; + const actions = understudy.actions; + switch (timingOption) { + case TIMING_OPTIONS.ONCE: + actions.once(action); + break; + case TIMING_OPTIONS.AFTER: + return this.#singleAfterAction(origin, actions, action, timingOption, ticks); + case TIMING_OPTIONS.CONTINUOUS: + actions.repeat(action); + break; + case TIMING_OPTIONS.INTERVAL: + return this.#intervalAction(origin, actions, action, timingOption, ticks); + case TIMING_OPTIONS.STOP: + actions.remove(action); + break; + default: + origin.sendMessage({ translate: 'commands.playeraction.invalidtiming', with: [action, timingOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #singleAfterAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return; + } + actions.once(action, ticks); + return { status: CustomCommandStatus.Success }; + } + + #intervalAction(origin, actions, action, timingOption, ticks) { + if (ticks === void 0) { + origin.sendMessage({ translate: 'commands.playeraction.invalidticks', with: [timingOption, String(ticks)] }); + return; + } + actions.repeat(action, ticks); + return { status: CustomCommandStatus.Success }; + } +} + +export const playeractionCommand = new PlayerActionCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerglide.js b/Canopy[BP]/scripts/src/commands/simplayer/playerglide.js new file mode 100644 index 00000000..f0e5d23a --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerglide.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerGlideCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerglide', + description: 'commands.playerglide', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'shouldGlide', type: CustomCommandParamType.Boolean } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerglideCommand(origin, ...args) + }); + } + + playerglideCommand(origin, playername, shouldGlide) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.glide(shouldGlide)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerglideCommand = new PlayerGlideCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js new file mode 100644 index 00000000..a762fa6c --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerinventory.js @@ -0,0 +1,50 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerInventoryCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerinventory', + description: 'commands.playerinventory', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerinventoryCommand(origin, ...args) + }); + } + + playerinventoryCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + const playerInventory = understudy.getInventory(); + if (!playerInventory) + return { status: CustomCommandStatus.Success, message: 'commands.playerinventory.noinventory' }; + origin.sendMessage(this.#getInventoryMessage(understudy, playerInventory)); + return { status: CustomCommandStatus.Success }; + } + + #getInventoryMessage(understudy, playerInventory) { + if (playerInventory.size === playerInventory.emptySlotsCount) + return { translate: 'commands.playerinventory.empty', with: [understudy.name] }; + return this.#getFormattedInventoryMessage(understudy, playerInventory); + } + + #getFormattedInventoryMessage(understudy, playerInventory) { + const rawtext = [{ translate: 'commands.playerinventory.header', with: [understudy.name] }]; + for (let i = 0; i < playerInventory.size; i++) { + const itemStack = playerInventory.getItem(i); + if (itemStack !== void 0) { + const colorCode = i < 10 ? '§a' : ''; + rawtext.push({ text: '\n' }); + rawtext.push({ translate: 'commands.playerinventory.item', with: [colorCode, String(i), itemStack.typeId, String(itemStack.amount)] }); + } + } + return { rawtext }; + } +} + +export const playerinventoryCommand = new PlayerInventoryCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js new file mode 100644 index 00000000..10a5e5fc --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerjoin.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerJoinCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerjoin', + description: 'commands.playerjoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playerjoinCommand(origin, ...args) + }); + } + + playerjoinCommand(origin, playername) { + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return; + } + system.run(() => { + const understudy = Understudies.create(playername); + understudy.join(getLocationInfoFromSource(origin.getSource())); + Understudies.addNametagPrefix(understudy); + }); + } +} + +export const playerjoinCommand = new PlayerJoinCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js new file mode 100644 index 00000000..4ca3949b --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerleave.js @@ -0,0 +1,30 @@ +import { CustomCommandParamType, CommandPermissionLevel, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerLeaveCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerleave', + description: 'commands.playerleave', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerleaveCommand(origin, ...args) + }); + } + + playerleaveCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => { + understudy.leave(); + Understudies.remove(understudy); + }); + } +} + +export const playerleaveCommand = new PlayerLeaveCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js new file mode 100644 index 00000000..6c06951c --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerlook.js @@ -0,0 +1,130 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, Entity, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { Vector } from "../../../lib/Vector"; +import { getBlockFaceLocationFromRaycastHit } from "../../classes/simplayer/utils"; + +export const LOOK_OPTIONS = Object.freeze({ + UP: 'up', DOWN: 'down', NORTH: 'north', SOUTH: 'south', + EAST: 'east', WEST: 'west', BLOCK: 'block', ENTITY: 'entity', + ME: 'me', AT: 'at', ROTATION: 'rotation', STOP: 'stop' +}); + +export const CARDINAL_ROTATIONS = { + up: { x: -90, y: 0 }, down: { x: 90, y: 0 }, north: { x: 0, y: 180 }, + south: { x: 0, y: 0 }, east: { x: 0, y: -90 }, west: { x: 0, y: 90 } +}; + +export class PlayerLookCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerlook', + description: 'commands.playerlook', + enums: [{ name: 'canopy:simplayerLookOption', values: Object.values(LOOK_OPTIONS) }], + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [ + { name: 'canopy:simplayerLookOption', type: CustomCommandParamType.Enum }, + { name: 'x', type: CustomCommandParamType.Float }, + { name: 'y', type: CustomCommandParamType.Float }, + { name: 'z', type: CustomCommandParamType.Float } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerlookCommand(origin, ...args), + wikiDescription: "Make the simulated player with the given name look at the specified target.\n\n" + + "Look Options: \n" + + "- `up`, `down`, `north`, `south`, `east`, `west` will make the simulated player look in different cardinal directions.\n" + + "- `block` and `entity` will make the simulated player look at the block or entity you are looking at.\n" + + "- `me` will make the simulated player look at you.\n" + + "- `at ` will make the simulated player look at the specified coordinates. This does not support relative coordinates.\n" + + "- `rotation ` will make the simulated player look in the specified rotation.\n" + + "- `stop` will make the simulated player stop looking at anything." + }); + } + + playerlookCommand(origin, playername, lookOption, x, y, z) { + const location = { x, y, z }; + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + switch (lookOption) { + case LOOK_OPTIONS.UP: case LOOK_OPTIONS.DOWN: case LOOK_OPTIONS.NORTH: + case LOOK_OPTIONS.SOUTH: case LOOK_OPTIONS.EAST: case LOOK_OPTIONS.WEST: + this.#lookAtCardinal(understudy, lookOption); + break; + case LOOK_OPTIONS.BLOCK: + return this.#lookAtBlock(origin, understudy); + case LOOK_OPTIONS.ENTITY: + return this.#lookAtEntity(origin, understudy); + case LOOK_OPTIONS.ME: + return this.#lookAtMe(origin, understudy); + case LOOK_OPTIONS.AT: + if (x === void 0 || y === void 0 || z === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.at.missing' }; + this.#lookAtLocation(understudy, location); + break; + case LOOK_OPTIONS.ROTATION: + if (x === void 0 || y === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.rotation.missing' }; + this.#lookRotation(understudy, { x: location.x, y: location.y }); + break; + case LOOK_OPTIONS.STOP: + this.#stopLooking(understudy); + break; + default: + origin.sendMessage({ translate: 'commands.playerlook.invalidoption', with: [lookOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #lookAtCardinal(understudy, direction) { + system.run(() => understudy.look(CARDINAL_ROTATIONS[direction])); + } + + #lookAtBlock(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.entityonly' }; + const raycastHit = source.getBlockFromViewDirection({ maxDistance: 16*64 }); + const block = raycastHit?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.block.noblock' }; + system.run(() => understudy.look(getBlockFaceLocationFromRaycastHit(raycastHit))); + return { status: CustomCommandStatus.Success }; + } + + #lookAtEntity(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.entity.entityonly' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.entity.noentity' }; + system.run(() => understudy.look(entity)); + return { status: CustomCommandStatus.Success }; + } + + #lookAtMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: 'commands.playerlook.me.noserver' }; + system.run(() => understudy.look(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } + + #lookAtLocation(understudy, location) { + system.run(() => understudy.look(Vector.from(location))); + } + + #lookRotation(understudy, rotation) { + system.run(() => understudy.look(rotation)); + } + + #stopLooking(understudy) { + system.run(() => understudy.stopLooking()); + } +} + +export const playerlookCommand = new PlayerLookCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playermove.js b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js new file mode 100644 index 00000000..22552aaf --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playermove.js @@ -0,0 +1,107 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, Entity, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { Vector } from "../../../lib/Vector"; + +export const MOVE_OPTIONS = Object.freeze({ + FORWARD: 'forward', BACKWARD: 'backward', LEFT: 'left', RIGHT: 'right', + BLOCK: 'block', ENTITY: 'entity', ME: 'me', TO: 'to', STOP: 'stop' +}); + +export class PlayerMoveCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playermove', + description: 'commands.playermove', + enums: [{ name: 'canopy:simplayerMoveOption', values: Object.values(MOVE_OPTIONS) }], + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + optionalParameters: [ + { name: 'canopy:simplayerMoveOption', type: CustomCommandParamType.Enum }, + { name: 'location', type: CustomCommandParamType.Location } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playermoveCommand(origin, ...args), + wikiDescription: "Make the simulated player with the given name move in the specified direction or (navigate) to the specified location. This command uses Minecraft's normal pathfinding system, so the simulated player won't be able to navigate very far very far at once.\n\n" + + "Move Options: \n" + + "- `forward`, `backward`, `left`, `right` will make the simulated player move continuously relative to the direction they are facing.\n" + + "- `block` and `entity` will make the simulated player move towards the block or entity you are looking at.\n" + + "- `me` will make the simulated player move towards you.\n" + + "- `to ` will make the simulated player move towards the specified coordinates.\n" + + "- `stop` will make the simulated player stop moving." + }); + } + + playermoveCommand(origin, playername, moveOption, location) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + switch (moveOption) { + case MOVE_OPTIONS.FORWARD: case MOVE_OPTIONS.BACKWARD: + case MOVE_OPTIONS.LEFT: case MOVE_OPTIONS.RIGHT: + this.#moveRelatively(understudy, moveOption); + break; + case MOVE_OPTIONS.BLOCK: + return this.#moveToBlock(origin, understudy); + case MOVE_OPTIONS.ENTITY: + return this.#moveToEntity(origin, understudy); + case MOVE_OPTIONS.ME: + return this.#moveToMe(origin, understudy); + case MOVE_OPTIONS.TO: + this.#moveToLocation(understudy, location); + break; + case MOVE_OPTIONS.STOP: + this.#stopMoving(understudy); + break; + default: + origin.sendMessage({ translate: 'commands.playermove.invalidoption', with: [moveOption] }); + return; + } + return { status: CustomCommandStatus.Success }; + } + + #moveRelatively(understudy, moveOption) { + system.run(() => understudy.moveRelative(moveOption)); + } + + #moveToBlock(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.block.entityonly' }; + const block = source.getBlockFromViewDirection({ maxDistance: 16*64 })?.block; + if (block === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.block.noblock' }; + system.run(() => understudy.moveLocation(block)); + return { status: CustomCommandStatus.Success }; + } + + #moveToEntity(origin, understudy) { + const source = origin.getSource(); + if (source instanceof Entity === false) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.entity.entityonly' }; + const entity = source.getEntitiesFromViewDirection({ maxDistance: 16*64 })[0]?.entity; + if (entity === void 0) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.entity.noentity' }; + system.run(() => understudy.moveLocation(entity)); + return { status: CustomCommandStatus.Success }; + } + + #moveToMe(origin, understudy) { + if (origin instanceof ServerCommandOrigin) + return { status: CustomCommandStatus.Failure, message: 'commands.playermove.me.noserver' }; + system.run(() => understudy.moveLocation(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } + + #moveToLocation(understudy, location) { + system.run(() => understudy.moveLocation(Vector.from(location))); + } + + #stopMoving(understudy) { + system.run(() => understudy.stopMoving()); + } +} + +export const playermoveCommand = new PlayerMoveCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js new file mode 100644 index 00000000..9469f24c --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerprefix.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerPrefixCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerprefix', + description: 'commands.playerprefix', + mandatoryParameters: [{ name: 'prefix', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerprefixCommand(origin, ...args) + }); + } + + playerprefixCommand(origin, prefix) { + if (prefix === '-none') { + system.run(() => Understudies.setNametagPrefix('')); + return { status: CustomCommandStatus.Success, message: 'commands.playerprefix.removed' }; + } + system.run(() => Understudies.setNametagPrefix(prefix)); + origin.sendMessage({ translate: 'commands.playerprefix.set', with: [prefix] }); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerprefixCommand = new PlayerPrefixCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js new file mode 100644 index 00000000..6b01f817 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerrejoin.js @@ -0,0 +1,39 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerRejoinCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerrejoin', + description: 'commands.playerrejoin', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerrejoinCommand(origin, ...args) + }); + } + + playerrejoinCommand(origin, playername) { + if (Understudies.isOnline(playername)) { + origin.sendMessage(Understudies.getAlreadyOnlineMessage(playername)); + return; + } + system.run(() => this.#tryRejoin(origin, playername)); + return { status: CustomCommandStatus.Success }; + } + + #tryRejoin(origin, playername) { + const understudy = Understudies.create(playername); + try { + understudy.rejoin(); + } catch (error) { + console.warn(`[Canopy] Error while rejoining. Joining normally instead. Error: ${String(error)}`); + understudy.join(getLocationInfoFromSource(origin.getSource())); + } + Understudies.addNametagPrefix(understudy); + } +} + +export const playerrejoinCommand = new PlayerRejoinCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js new file mode 100644 index 00000000..63ff1d56 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerselect.js @@ -0,0 +1,35 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSelectCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerselect', + description: 'commands.playerselect', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'slotNumber', type: CustomCommandParamType.Integer } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerselectCommand(origin, ...args) + }); + } + + playerselectCommand(origin, playername, slotNumber) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + if (slotNumber < 0 || slotNumber > 8) { + origin.sendMessage({ translate: 'commands.playerselect.invalidslot', with: [String(slotNumber)] }); + return; + } + system.run(() => understudy.selectSlot(slotNumber)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerselectCommand = new PlayerSelectCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js new file mode 100644 index 00000000..c0f432b5 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersneak.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSneakCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playersneak', + description: 'commands.playersneak', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'shouldSneak', type: CustomCommandParamType.Boolean } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playersneakCommand(origin, ...args) + }); + } + + playersneakCommand(origin, playername, shouldSneak) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.sneak(shouldSneak)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playersneakCommand = new PlayerSneakCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js new file mode 100644 index 00000000..1eb78a0e --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playersprint.js @@ -0,0 +1,31 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSprintCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playersprint', + description: 'commands.playersprint', + mandatoryParameters: [ + { name: 'playername', type: CustomCommandParamType.String }, + { name: 'shouldSprint', type: CustomCommandParamType.Boolean } + ], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playersprintCommand(origin, ...args) + }); + } + + playersprintCommand(origin, playername, shouldSprint) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.sprint(shouldSprint)); + return { status: CustomCommandStatus.Success }; + } +} + +export const playersprintCommand = new PlayerSprintCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js new file mode 100644 index 00000000..a32862a5 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerstop.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerStopCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerstop', + description: 'commands.playerstop', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin, ServerCommandOrigin], + callback: (origin, ...args) => this.playerstopCommand(origin, ...args) + }); + } + + playerstopCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.stopAll()); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerstopCommand = new PlayerStopCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js new file mode 100644 index 00000000..7ea6d671 --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playerswapheld.js @@ -0,0 +1,28 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; + +export class PlayerSwapHeldCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playerswapheld', + description: 'commands.playerswapheld', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playerswapheldCommand(origin, ...args) + }); + } + + playerswapheldCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.swapHeldItemWithPlayer(origin.getSource())); + return { status: CustomCommandStatus.Success }; + } +} + +export const playerswapheldCommand = new PlayerSwapHeldCommand(); diff --git a/Canopy[BP]/scripts/src/commands/simplayer/playertp.js b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js new file mode 100644 index 00000000..3257046f --- /dev/null +++ b/Canopy[BP]/scripts/src/commands/simplayer/playertp.js @@ -0,0 +1,29 @@ +import { CustomCommandParamType, CommandPermissionLevel, CustomCommandStatus, system } from "@minecraft/server"; +import { VanillaCommand, PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin } from "../../../lib/canopy/Canopy"; +import Understudies from "../../classes/simplayer/Understudies"; +import { getLocationInfoFromSource } from "../../classes/simplayer/utils"; + +export class PlayerTpCommand extends VanillaCommand { + constructor() { + super({ + name: 'canopy:playertp', + description: 'commands.playertp', + mandatoryParameters: [{ name: 'playername', type: CustomCommandParamType.String }], + permissionLevel: CommandPermissionLevel.Any, + allowedSources: [PlayerCommandOrigin, BlockCommandOrigin, EntityCommandOrigin], + callback: (origin, ...args) => this.playertpCommand(origin, ...args) + }); + } + + playertpCommand(origin, playername) { + const understudy = Understudies.get(playername); + if (!understudy) { + origin.sendMessage(Understudies.getNotOnlineMessage(playername)); + return; + } + system.run(() => understudy.teleport(getLocationInfoFromSource(origin.getSource()))); + return { status: CustomCommandStatus.Success }; + } +} + +export const playertpCommand = new PlayerTpCommand(); diff --git a/Canopy[BP]/scripts/src/commands/tick.js b/Canopy[BP]/scripts/src/commands/tick.js index 3dfac88d..6cd66f99 100644 --- a/Canopy[BP]/scripts/src/commands/tick.js +++ b/Canopy[BP]/scripts/src/commands/tick.js @@ -70,14 +70,14 @@ export class TickCommand extends VanillaCommand { if (mspt < VANILLA_MSPT) return { status: CustomCommandStatus.Failure, message: 'commands.tick.mspt.fail' }; this.targetMSPT = mspt; - world.sendMessage({ translate: 'commands.tick.mspt.success', with: [origin.getSource().name, String(mspt)] }); + world.sendMessage({ translate: 'commands.tick.mspt.success', with: [origin.getName(), String(mspt)] }); this.tickSpeed(mspt); return { status: CustomCommandStatus.Success }; } tickReset(origin) { this.targetMSPT = VANILLA_MSPT; - world.sendMessage({ translate: 'commands.tick.reset.success', with: [origin.getSource().name] }); + world.sendMessage({ translate: 'commands.tick.reset.success', with: [origin.getName()] }); return { status: CustomCommandStatus.Success }; } @@ -90,14 +90,14 @@ export class TickCommand extends VanillaCommand { this.shouldStep = 1; else this.shouldStep = steps; - world.sendMessage({ translate: 'commands.tick.step.start', with: [origin.getSource().name, String(this.shouldStep)] }); + world.sendMessage({ translate: 'commands.tick.step.start', with: [origin.getName(), String(this.shouldStep)] }); return { status: CustomCommandStatus.Success }; } tickSleep(origin, milliseconds) { if (!milliseconds || milliseconds < 1) return { status: CustomCommandStatus.Success, message: 'commands.tick.sleep.fail' }; - world.sendMessage({ translate: 'commands.tick.sleep.success', with: [origin.getSource().name, String(milliseconds)] }); + world.sendMessage({ translate: 'commands.tick.sleep.success', with: [origin.getName(), String(milliseconds)] }); const startTime = Date.now(); let waitTime = 0; while (waitTime < milliseconds) diff --git a/Canopy[BP]/scripts/src/events/PlayerTameEntityEvent.js b/Canopy[BP]/scripts/src/events/PlayerTameEntityEvent.js deleted file mode 100644 index fd3bf7cb..00000000 --- a/Canopy[BP]/scripts/src/events/PlayerTameEntityEvent.js +++ /dev/null @@ -1,99 +0,0 @@ -import { EntityComponentTypes, system, world } from "@minecraft/server"; -import { Event } from './Event'; - -class PlayerTameEntityEvent extends Event { - successfulTameAttempts = []; - untamedMountsLastTick = []; - untamedMountsThisTick = []; - - constructor() { - super(); - this.successfulTameAttempts = []; - } - - startTrackingEvent() { - super.startTrackingEvent(); - world.beforeEvents.playerInteractWithEntity.subscribe(this.onPlayerInteractWithEntity.bind(this)); - } - - provideEvents() { - this.updateMountLists(); - const events = this.successfulTameAttempts.map(tameAttempt => ({ - player: tameAttempt.player, - itemStack: tameAttempt.itemStack, - entity: tameAttempt.entity - })); - this.successfulTameAttempts = []; - return events; - } - - updateMountLists() { - this.untamedMountsLastTick = [...this.untamedMountsThisTick]; - this.untamedMountsThisTick = []; - world.getAllPlayers().forEach(player => { - if (!player) - return; - const mountEntity = player.getComponent(EntityComponentTypes.Riding)?.entityRidingOn; - this.tryAddMount(player, mountEntity); - if (this.wasUntamedMountLastTick(player, mountEntity) && this.isTamed(mountEntity)) - this.successfulTameAttempts.push({ player, entity: mountEntity }); - }); - } - - tryAddMount(player, mountEntity) { - if (!player || !mountEntity?.hasComponent(EntityComponentTypes.TameMount) || !this.isPlayerInFirstSeat(mountEntity, player)) - return; - this.untamedMountsThisTick.push({ player, entity: mountEntity }); - } - - isPlayerInFirstSeat(mountEntity, player) { - return mountEntity.getComponent(EntityComponentTypes.Rideable).getRiders()[0]?.id === player.id; - } - - wasUntamedMountLastTick(player, mountEntity) { - return this.untamedMountsLastTick.some(mount => mount.player.id === player.id && mount.entity.id === mountEntity?.id); - } - - onPlayerInteractWithEntity(event) { - if (!event.player || !event.target) - return; - let tameAttempt; - if (event.target?.hasComponent(EntityComponentTypes.Tameable)) - tameAttempt = this.getTameAttemptForTameable(event); - if (event.target?.hasComponent(EntityComponentTypes.TameMount)) - tameAttempt = this.getTameAttemptForTameMount(event); - if (!tameAttempt) - return; - system.runTimeout(() => { - if (this.isTamed(tameAttempt.entity)) - this.successfulTameAttempts.push(tameAttempt); - }, 2); - } - - getTameAttemptForTameable(event) { - if (!this.isValidTameItem(event.target, event.itemStack.typeId)) - return; - return { player: event.player, itemStack: event.itemStack.typeId, entity: event.target }; - } - - getTameAttemptForTameMount(event) { - return { player: event.player, entity: event.target }; - } - - isValidTameItem(entity, itemType) { - return entity.getComponent(EntityComponentTypes.Tameable)?.getTameItems.some(item => item.typeId === itemType); - } - - isTamed(entity) { - return entity?.hasComponent(EntityComponentTypes.IsTamed); - } - - stopTrackingEvent() { - super.stopTrackingEvent(); - world.beforeEvents.playerInteractWithEntity.unsubscribe(this.onPlayerInteractWithEntity.bind(this)); - } -} - -const playerTameEntityEvent = new PlayerTameEntityEvent(); - -export { PlayerTameEntityEvent, playerTameEntityEvent }; \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/onReload.js b/Canopy[BP]/scripts/src/onReload.js index 94592138..82f433b5 100644 --- a/Canopy[BP]/scripts/src/onReload.js +++ b/Canopy[BP]/scripts/src/onReload.js @@ -1,8 +1,11 @@ import { world } from '@minecraft/server'; import { broadcastActionBar } from "../include/utils"; +import { simplayerRejoining } from "./rules/simplayer/simplayerRejoining"; world.afterEvents.worldLoad.subscribe(() => { -const players = world.getAllPlayers(); - if (players[0]?.isValid) + const players = world.getAllPlayers(); + if (players[0]?.isValid) { broadcastActionBar('§aBehavior packs have been reloaded.'); + simplayerRejoining.onStartup(); + } }); \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/onStart.js b/Canopy[BP]/scripts/src/onStart.js index 98c5906c..0ad2e69a 100644 --- a/Canopy[BP]/scripts/src/onStart.js +++ b/Canopy[BP]/scripts/src/onStart.js @@ -1,5 +1,6 @@ import { world, system } from "@minecraft/server"; import { displayWelcome } from "./rules/noWelcomeMessage"; +import { simplayerRejoining } from "./rules/simplayer/simplayerRejoining"; let worldIsValid = false; @@ -24,5 +25,5 @@ function onValidPlayer(player) { } function onValidWorld() { - + simplayerRejoining.onStartup(); } diff --git a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js index 1d93dfeb..ab29956e 100644 --- a/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js +++ b/Canopy[BP]/scripts/src/rules/creativeNoTileDrops.js @@ -1,43 +1,68 @@ -import { BooleanRule, Rules } from "../../lib/canopy/Canopy"; -import { system, world, GameMode } from "@minecraft/server"; +import { BooleanRule, GlobalRule } from "../../lib/canopy/Canopy"; +import { system, world, GameMode, EntityInitializationCause } from "@minecraft/server"; import { calcDistance } from "../../include/utils"; -const REMOVAL_DISTANCE = 2.5; - -new BooleanRule({ - category: 'Rules', - identifier: 'creativeNoTileDrops', - description: { translate: 'rules.creativeNoTileDrops' }, - wikiDescription: 'Enables/disables items dropping from blocks when breaking them in creative mode. Unlike the vanilla gamerule, this also suppresses drops from containers and only applies when you break them — not when they break in the world.' -}); - -let brokenBlockEventsThisTick = []; -let brokenBlockEventsLastTick = []; - -system.runInterval(() => { - brokenBlockEventsLastTick = brokenBlockEventsThisTick; +export class CreativeNoTileDrops extends BooleanRule { + REMOVAL_DISTANCE = 2.5; + brokenBlockEventsThisTick = []; -}); + brokenBlockEventsLastTick = []; + #runner = void 0; + #onPlayerBreakBlockBound; + #onEntitySpawnBound; -world.afterEvents.playerBreakBlock.subscribe((blockEvent) => { - if (blockEvent.player?.getGameMode() !== GameMode.Creative - || !Rules.getNativeValue('creativeNoTileDrops')) - return; - brokenBlockEventsThisTick.push(blockEvent); -}); + constructor() { + super(GlobalRule.morphOptions({ + identifier: 'creativeNoTileDrops', + wikiDescription: 'Removes items dropping from blocks and entities when removing them in creative mode. Unlike the vanilla gamerule, this also suppresses drops from containers and only applies when *you* break them - not when they break in the world.', + onEnableCallback: () => this.subscribeToEvents(), + onDisableCallback: () => this.unsubscribeFromEvents() + })); + this.#onPlayerBreakBlockBound = this.onPlayerBreakBlock.bind(this); + this.#onEntitySpawnBound = this.onEntitySpawn.bind(this); + } -world.afterEvents.entitySpawn.subscribe((entityEvent) => { - if (entityEvent.cause !== 'Spawned' || entityEvent.entity.typeId !== 'minecraft:item') return; - if (!Rules.getNativeValue('creativeNoTileDrops')) return; + subscribeToEvents() { + this.#runner = system.runInterval(this.onTick.bind(this)); + world.afterEvents.playerBreakBlock.subscribe(this.#onPlayerBreakBlockBound); + world.afterEvents.entitySpawn.subscribe(this.#onEntitySpawnBound); + } - const item = entityEvent.entity; - const brokenBlockEvents = brokenBlockEventsThisTick.concat(brokenBlockEventsLastTick); - const brokenBlockEvent = brokenBlockEvents.find(blockEvent => isItemWithinRemovalDistance(blockEvent.block.location, item)); - if (!brokenBlockEvent) return; + unsubscribeFromEvents() { + if (this.#runner !== void 0) { + system.clearRun(this.#runner); + this.#runner = void 0; + } + world.afterEvents.playerBreakBlock.unsubscribe(this.#onPlayerBreakBlockBound); + world.afterEvents.entitySpawn.unsubscribe(this.#onEntitySpawnBound); + } + + onTick() { + this.brokenBlockEventsLastTick = this.brokenBlockEventsThisTick; + this.brokenBlockEventsThisTick = []; + } + + onPlayerBreakBlock(blockEvent) { + if (blockEvent.player?.getGameMode() !== GameMode.Creative) + return; + this.brokenBlockEventsThisTick.push(blockEvent); + } + + onEntitySpawn(entityEvent) { + if (entityEvent.entity.typeId !== 'minecraft:item', entityEvent.cause !== EntityInitializationCause.Spawned) + return; + const item = entityEvent.entity; + const brokenBlockEvents = this.brokenBlockEventsThisTick.concat(this.brokenBlockEventsLastTick); + const brokenBlockEvent = brokenBlockEvents.find(blockEvent => this.isItemWithinRemovalDistance(blockEvent.block.location, item)); + if (!brokenBlockEvent) + return; - item.remove(); -}); + item.remove(); + } + + isItemWithinRemovalDistance(location, item) { + return calcDistance(location, item.location) < this.REMOVAL_DISTANCE; + } +} -function isItemWithinRemovalDistance(location, item) { - return calcDistance(location, item.location) < REMOVAL_DISTANCE; -} \ No newline at end of file +export const creativeNoTileDrops = new CreativeNoTileDrops(); \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js b/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js index a9e81227..dc85b8fc 100644 --- a/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js +++ b/Canopy[BP]/scripts/src/rules/creativeOneHitKill.js @@ -1,5 +1,5 @@ import { BooleanRule, Rules } from "../../lib/canopy/Canopy"; -import { world, InputButton, ButtonState, GameMode } from "@minecraft/server"; +import { world, InputButton, ButtonState, GameMode, EntityComponentTypes } from "@minecraft/server"; new BooleanRule({ category: 'Rules', @@ -9,18 +9,30 @@ new BooleanRule({ }); world.afterEvents.entityHitEntity.subscribe((event) => { - if (!Rules.getNativeValue('creativeOneHitKill') || event.damagingEntity?.typeId !== 'minecraft:player') return; - if (!event.hitEntity) return; + if (!Rules.getNativeValue('creativeOneHitKill') || event.damagingEntity?.typeId !== 'minecraft:player') + return; + if (!event.hitEntity) + return; + if (isSulfurCubeWithBlockInside(event.hitEntity)) + return; const player = event.damagingEntity; if (player.getGameMode() === GameMode.Creative) { if (player.inputInfo.getButtonState(InputButton.Sneak) === ButtonState.Pressed) { player.dimension.getEntities({ location: event.hitEntity.location, maxDistance: 3 }).forEach(entity => { - if (['item', 'player', 'experience_orb'].includes(entity.typeId.replace('minecraft:', ''))) return; + if (['item', 'player', 'experience_orb'].includes(entity.typeId.replace('minecraft:', ''))) + return; entity?.kill(); }); } else { - if (event.hitEntity?.typeId === 'minecraft:player') return; + if (event.hitEntity?.typeId === 'minecraft:player') + return; event.hitEntity?.kill(); } } -}); \ No newline at end of file +}); + +function isSulfurCubeWithBlockInside(entity) { + const frictionComponent = entity.getComponent(EntityComponentTypes.FrictionModifier); + const ageableComponent = entity.getComponent(EntityComponentTypes.Ageable); + return entity.typeId === 'minecraft:sulfur_cube' && (frictionComponent?.value !== 1 && !ageableComponent); +} \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/rules/flippinArrows.js b/Canopy[BP]/scripts/src/rules/flippinArrows.js index 8a77acb5..25a1e8b6 100644 --- a/Canopy[BP]/scripts/src/rules/flippinArrows.js +++ b/Canopy[BP]/scripts/src/rules/flippinArrows.js @@ -18,7 +18,7 @@ const flipOnPlaceIds = ['piston', 'sticky_piston', 'dropper', 'dispenser', 'obse const flipIds = ['piston', 'sticky_piston', 'observer', 'end_rod', 'lightning_rod']; const flipWhenVerticalIds = ['dropper', 'dispenser', 'barrel', 'command_block', 'chain_command_block', 'repeating_command_block']; const openIds = ['iron_trapdoor', 'iron_door']; -const noInteractBlockIds = ['piston_arm_collision', 'sticky_piston_arm_collision', 'bed', 'frame']; +const noInteractBlockIds = ['piston_arm_collision', 'sticky_piston_arm_collision', 'bed', 'frame', 'decorated_pot']; system.runInterval(() => { previousBlocks.shift(); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js index 0b07f8eb..6a465599 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Biome.js @@ -1,11 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Biome extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'biome'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'biome', description: { translate: 'rules.infoDisplay.biome' }, wikiDescription: 'Shows the biome at your current location.' }; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/BlockBreakSpeed.js b/Canopy[BP]/scripts/src/rules/infodisplay/BlockBreakSpeed.js new file mode 100644 index 00000000..90ab2dfe --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/infodisplay/BlockBreakSpeed.js @@ -0,0 +1,71 @@ +import { system, world, TicksPerSecond } from '@minecraft/server'; +import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; + +export class BlockBreakSpeed extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'blockBreakSpeed'; + } + + player; + #trackedTickCount; + #blockBrokenHistory = []; + #runner = void 0; + + constructor(player, displayLine) { + const trackedTickCount = 100; + const ruleData = { + description: { translate: 'rules.infoDisplay.blockBreakSpeed', with: [String(trackedTickCount), String((trackedTickCount / 20).toFixed(0))] }, + wikiDescription: `Shows your average blocks broken per second over the last ${trackedTickCount} ticks (${(trackedTickCount / 20).toFixed(0)} seconds).`, + onEnableCallback: () => this.#subscribeToEvents(), + onDisableCallback: () => this.#unsubscribeFromEvents() + }; + super(ruleData, displayLine); + this.player = player; + this.#trackedTickCount = trackedTickCount; + this.onPlayerBreakBlockBound = this.#onPlayerBreakBlock.bind(this); + } + + getFormattedDataOwnLine() { + return { translate: `rules.infoDisplay.blockBreakSpeed.display`, with: [String(this.#getAverageBlocksBrokenPerSecond().toFixed(1))] }; + } + + getFormattedDataSharedLine() { + return { text: `§cBlockBreakSpeed should always be on its own InfoDisplay line.§r` }; + } + + #subscribeToEvents() { + world.afterEvents.playerBreakBlock.subscribe(this.onPlayerBreakBlockBound); + this.#runner = system.runInterval(this.#onTick.bind(this)); + } + + #unsubscribeFromEvents() { + world.afterEvents.playerBreakBlock.unsubscribe(this.onPlayerBreakBlockBound); + if (this.#runner) { + system.clearRun(this.#runner); + this.#runner = void 0; + } + } + + #onTick() { + if (!this.#blockWasBrokenThisTick()) + this.#blockBrokenHistory.push(false); + if (this.#blockBrokenHistory.length > this.#trackedTickCount) + this.#blockBrokenHistory.shift(); + } + + #onPlayerBreakBlock(event) { + if (event.player?.id !== this.player.id) + return; + this.#blockBrokenHistory.push(system.currentTick); + } + + #blockWasBrokenThisTick() { + return this.#blockBrokenHistory[this.#blockBrokenHistory.length - 1] === system.currentTick; + } + + #getAverageBlocksBrokenPerSecond() { + const blocksBrokenInTrackedTicks = this.#blockBrokenHistory.filter(tick => tick !== false).length; + const secondsElapsed = this.#blockBrokenHistory.length / TicksPerSecond; + return blocksBrokenInTrackedTicks / secondsElapsed; + } +} diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js index 8b290946..1a4ed60d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/BlockStates.js @@ -1,13 +1,16 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getRaycastResults } from '../../../include/utils.js'; -import { LiquidType } from '@minecraft/server'; +import { LiquidType, LocationInUnloadedChunkError } from '@minecraft/server'; export class BlockStates extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'blockStates'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'blockStates', description: { translate: 'rules.infoDisplay.blockStates' }, wikiDescription: 'Shows the block states of the block you are targeting. Especially useful with [Construct](https://github.com/ForestOfLight/Construct), which shows desired block states as you build. Includes waterlogged status.' } @@ -24,11 +27,17 @@ export class BlockStates extends InfoDisplayTextElement { } tryFormatBlockStates() { - const { blockRayResult, entityRayResult } = getRaycastResults(this.player, 7); - const entity = entityRayResult[0]?.entity; - if (entity || blockRayResult?.block.isLiquid) - return ''; - return this.formatBlockStates(blockRayResult); + try { + const { blockRayResult, entityRayResult } = getRaycastResults(this.player, 7); + const entity = entityRayResult[0]?.entity; + if (entity || blockRayResult?.block.isLiquid) + return ''; + return this.formatBlockStates(blockRayResult); + } catch (error) { + if (error instanceof LocationInUnloadedChunkError) + return ''; + throw error; + } } formatBlockStates(lookingAtBlock) { diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js index 441578aa..cef0dacf 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/CardinalFacing.js @@ -1,10 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class CardinalFacing extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'cardinalFacing'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'cardinalFacing', description: { translate: 'rules.infoDisplay.cardinalFacing' }, wikiDescription: 'Shows which direction you are facing using cardinal directions (N, S, E, W) and the corresponding coordinate axis (e.g., N (-z)).' }; + const ruleData = { description: { translate: 'rules.infoDisplay.cardinalFacing' }, wikiDescription: 'Shows which direction you are facing using cardinal directions (N, S, E, W) and the corresponding coordinate axis (e.g., N (-z)).' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js b/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js index 2c90567a..eb28d1d0 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/ChunkCoords.js @@ -1,8 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class ChunkCoords extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'chunkCoords'; + } + constructor(player, displayLine) { - const ruleData = { identifier: 'chunkCoords', description: { translate: 'rules.infoDisplay.chunkCoords' }, wikiDescription: 'Shows the coordinates of the chunk you are in and your relative position within that chunk.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.chunkCoords' }, wikiDescription: 'Shows the coordinates of the chunk you are in and your relative position within that chunk.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js b/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js index 60f13447..bb39f90a 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Coords.js @@ -1,10 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Coords extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'coords'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'coords', description: { translate: 'rules.infoDisplay.coords' }, wikiDescription: 'Shows your coordinates truncated at 2 decimal places.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.coords' }, wikiDescription: 'Shows your coordinates truncated at 2 decimal places.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js b/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js index 8cd6b0e8..f6953a7f 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Dimension.js @@ -2,8 +2,12 @@ import { getColorByDimension } from '../../../include/utils.js'; import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Dimension extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'dimension'; + } + constructor(player, displayLine) { - const ruleData = { identifier: 'dimension', description: { translate: 'rules.infoDisplay.dimension' }, wikiDescription: 'Shows your current dimension\'s identifier.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.dimension' }, wikiDescription: 'Shows your current dimension\'s identifier.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js b/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js index 585ef9f3..6e5b8b0d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Entities.js @@ -2,10 +2,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement.js"; import { Vector } from "../../../lib/Vector.js"; class Entities extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'entities'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'entities', description: { translate: 'rules.infoDisplay.entities' }, wikiDescription: 'Shows the number of entities in front of your player. If there are many entities in the world, having this enabled may cause lag.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.entities' }, wikiDescription: 'Shows the number of entities in front of your player. If there are many entities in the world, having this enabled may cause lag.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js b/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js index 28d4d8db..5e11a809 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/EventTrackers.js @@ -1,9 +1,13 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -import { getAllTrackerInfoString } from 'src/commands/trackevent'; +import { getAllTrackerInfoString } from '../../commands/trackevent'; class EventTrackers extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'eventTrackers'; + } + constructor(displayLine) { - const ruleData = { identifier: 'eventTrackers', description: { translate: 'rules.infoDisplay.eventTrackers' }, wikiDescription: 'Shows the counts of currently tracked events. Tracking is controlled with `/trackevent`.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.eventTrackers' }, wikiDescription: 'Shows the counts of currently tracked events. Tracking is controlled with `/trackevent`.' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js b/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js index c5eb8a09..de347de4 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Facing.js @@ -1,10 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Facing extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'facing'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'facing', description: { translate: 'rules.infoDisplay.facing' }, wikiDescription: 'Shows your exact facing direction using yaw and pitch values.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.facing' }, wikiDescription: 'Shows your exact facing direction using yaw and pitch values.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js b/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js index a7bbcf94..f09dc893 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HeldItemDurability.js @@ -2,11 +2,14 @@ import { EntityComponentTypes, EquipmentSlot, ItemComponentTypes } from '@minecr import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; export class HeldItemDurability extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'heldItemDurability'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'heldItemDurability', description: { translate: 'rules.infoDisplay.heldItemDurability' } }; super(ruleData, displayLine); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js b/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js index 7063091e..16372453 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/HopperCounterCounts.js @@ -3,8 +3,12 @@ import { counterChannels } from "../../classes/CounterChannels"; import { getColorCode } from "../../../include/utils"; class HopperCounterCounts extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'hopperCounterCounts'; + } + constructor(displayLine) { - const ruleData = { identifier: 'hopperCounterCounts', description: { translate: 'rules.infoDisplay.hopperCounterCounts' }, wikiDescription: 'Shows all active hopper counter channels in real-time, displayed in their respective wool colors. Channel display mode (count, hr, min, sec) is controlled with `./counter `.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.hopperCounterCounts' }, wikiDescription: 'Shows all active hopper counter channels in real-time, displayed in their respective wool colors. Channel display mode (count, hr, min, sec) is controlled with `./counter `.' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Horse.js b/Canopy[BP]/scripts/src/rules/infodisplay/Horse.js new file mode 100644 index 00000000..c9588a28 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Horse.js @@ -0,0 +1,40 @@ +import { EntityComponentTypes } from '@minecraft/server'; +import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; +import { MOVEMENT_UNITS_TO_MPS } from '../../classes/debugdisplay/Horse.js'; + +export class Horse extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'horse'; + } + + player; + #horseTypes = ['horse', 'donkey', 'mule', 'skeleton_horse', 'zombie_horse']; + + constructor(player, displayLine) { + const ruleData = { description: { translate: 'rules.infoDisplay.horse' }, wikiDescription: "Shows the speed and max health of the horse you are riding." }; + super(ruleData, displayLine); + this.player = player; + } + + getFormattedDataOwnLine() { + const horseStats = this.getHorseStats(); + if (!horseStats) + return { text: '' }; + return { translate: 'rules.infoDisplay.horse.display', with: [String(horseStats.speed.toFixed(3)), String(horseStats.maxHealth)] }; + } + + getFormattedDataSharedLine() { + return this.getFormattedDataOwnLine(); + } + + getHorseStats() { + const ridingComponent = this.player.getComponent(EntityComponentTypes.Riding); + if (ridingComponent && this.#horseTypes.includes(ridingComponent.entityRidingOn.typeId.replace("minecraft:", ""))) { + const horse = ridingComponent.entityRidingOn; + this.movementComponent = horse.getComponent(EntityComponentTypes.Movement); + this.healthComponent = horse.getComponent(EntityComponentTypes.Health); + return { speed: this.movementComponent.currentValue * MOVEMENT_UNITS_TO_MPS, maxHealth: this.healthComponent.effectiveMax }; + } + return void 0; + } +} diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js index ccfadf05..1caf5a77 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay.js @@ -30,10 +30,13 @@ import { Weather } from './Weather'; import { LiquidTarget } from './LiquidTarget'; import { LiquidStates } from './LiquidStates'; import { HeldItemDurability } from './HeldItemDurability'; +import { BlockBreakSpeed } from './BlockBreakSpeed'; +import { Horse } from './Horse'; import { RenderSignalStrength } from './RenderSignalStrength'; import { NoFog } from './NoFog'; import { Ping } from './Ping'; +import { RenderLightLevel } from './RenderLightLevel'; class InfoDisplay { player; @@ -43,42 +46,51 @@ class InfoDisplay { static playerToInfoDisplayMap = {}; static currentTickWorldwideElementData = {}; + static elementSpecs = [ + [TPS, () => [1]], + [Ping, (player) => [player, 2]], + [Dimension, (player) => [player, 3]], + [Coords, (player) => [player, 4]], + [CardinalFacing, (player) => [player, 4]], + [ChunkCoords, (player) => [player, 5]], + [SlimeChunk, (player) => [player, 6]], + [Light, (player) => [player, 7]], + [Biome, (player) => [player, 8]], + [Structures, (player) => [player, 9]], + [Velocity, (player) => [player, 10]], + [Speed, (player) => [player, 11]], + [Facing, (player) => [player, 12]], + [Entities, (player) => [player, 13]], + [MoonPhase, () => [14]], + [Weather, (player) => [player, 15]], + [WorldDay, () => [16]], + [TimeOfDay, () => [17]], + [SessionTime, (player) => [player, 17]], + [EventTrackers, () => [18]], + [HopperCounterCounts, () => [19]], + [SimulationMap, (player) => [player, 20]], + [Horse, (player) => [player, 21]], + [HeldItemDurability, (player) => [player, 22]], + [BlockBreakSpeed, (player) => [player, 23]], + [Target, (player) => [player, 24]], + [SignalStrength, (player) => [player, 25]], + [BlockStates, (player) => [player, 26]], + [PeekInventory, (player) => [player, 27]], + [LiquidTarget, (player) => [player, 28]], + [LiquidStates, (player) => [player, 29]], + + [RenderSignalStrength, (player) => [player]], + [RenderLightLevel, (player) => [player]], + [NoFog, (player) => [player]] + ]; + + static getRuleIdentifiers() { + return InfoDisplay.elementSpecs.map(([ElementClass]) => ElementClass.getRuleIdentifier()); + } + constructor(player) { this.player = player; - this.elements = [ - new TPS(1), - new Ping(player, 2), - new Dimension(player, 3), - new Coords(player, 4), - new CardinalFacing(player, 4), - new ChunkCoords(player, 5), - new SlimeChunk(player, 6), - new Light(player, 7), - new Biome(player, 8), - new Structures(player, 9), - new Velocity(player, 10), - new Speed(player, 11), - new Facing(player, 12), - new Entities(player, 13), - new MoonPhase(14), - new Weather(player, 15), - new WorldDay(16), - new TimeOfDay(17), - new SessionTime(player, 17), - new EventTrackers(18), - new HopperCounterCounts(19), - new SimulationMap(player, 20), - new HeldItemDurability(player, 21), - new Target(player, 22), - new SignalStrength(player, 22), - new BlockStates(player, 23), - new PeekInventory(player, 24), - new LiquidTarget(player, 25), - new LiquidStates(player, 26), - - new RenderSignalStrength(player), - new NoFog(player) - ]; + this.elements = InfoDisplay.elementSpecs.map(([ElementClass, makeArgs]) => new ElementClass(...makeArgs(player))); InfoDisplay.playerToInfoDisplayMap[player.id] = this; this.enableEnabledRules(); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js index e08aa8de..75813df7 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement.js @@ -1,17 +1,21 @@ -import { InfoDisplayRule, Rules } from '../../../lib/canopy/Canopy'; +import { InfoDisplayRule } from '../../../lib/canopy/Canopy'; class InfoDisplayElement { identifier; rule; isWorldwide; + static getRuleIdentifier() { + throw new Error(`${this.name} must implement a static getRuleIdentifier() method.`); + } + constructor(ruleData, isWorldwide = false) { - if (this.constructor === InfoDisplayElement) + if (this.constructor === InfoDisplayElement) throw new TypeError("Abstract class 'InfoDisplayElement' cannot be instantiated directly."); - if (!ruleData.identifier || !ruleData.description) - throw new Error("ruleData must have 'identifier' and 'description' properties."); - this.identifier = ruleData.identifier; - this.rule = Rules.get(this.identifier) || new InfoDisplayRule({ identifier: this.identifier, ...ruleData }); + if (!ruleData.description) + throw new Error("ruleData must have a 'description' property."); + this.identifier = this.constructor.getRuleIdentifier(); + this.rule = InfoDisplayRule.get(this.identifier) || new InfoDisplayRule({ identifier: this.identifier, ...ruleData }); this.isWorldwide = isWorldwide; } } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Light.js b/Canopy[BP]/scripts/src/rules/infodisplay/Light.js index ab4d2ac2..665b1525 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Light.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Light.js @@ -1,11 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Light extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'light'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'light', description: { translate: 'rules.infoDisplay.light' }, wikiDescription: 'Shows the light level at your feet, including the sky light contribution.' }; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js index 1438737d..b4c78468 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidStates.js @@ -1,12 +1,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; -import { LiquidType } from '@minecraft/server'; +import { LiquidType, LocationInUnloadedChunkError } from '@minecraft/server'; export class LiquidStates extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'liquidStates'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'liquidStates', description: { translate: 'rules.infoDisplay.liquidStates' }, wikiDescription: 'Shows the states of the liquid you are targeting.' } @@ -23,10 +26,16 @@ export class LiquidStates extends InfoDisplayTextElement { } tryFormatBlockStates() { - const blockRayResult = this.player.getBlockFromViewDirection({ includeLiquidBlocks: true, includePassableBlocks: true, maxDistance: 7 }) - if (blockRayResult?.block.isLiquid) - return this.formatBlockStates(blockRayResult); - return ''; + try { + const blockRayResult = this.player.getBlockFromViewDirection({ includeLiquidBlocks: true, includePassableBlocks: true, maxDistance: 7 }); + if (blockRayResult?.block.isLiquid) + return this.formatBlockStates(blockRayResult); + return ''; + } catch (error) { + if (error instanceof LocationInUnloadedChunkError) + return ''; + throw error; + } } formatBlockStates(lookingAtBlock) { diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js index 49cf841f..66d9ea53 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/LiquidTarget.js @@ -1,9 +1,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { parseName, stringifyLocation } from "../../../include/utils"; +import { LocationInUnloadedChunkError } from "@minecraft/server"; export class LiquidTarget extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'liquidTarget'; + } + constructor(player, displayLine) { - const ruleData = { identifier: 'liquidTarget', description: { translate: 'rules.infoDisplay.liquidTarget' }, wikiDescription: 'Shows the identifier of the liquid you are targeting.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.liquidTarget' }, wikiDescription: 'Shows the identifier of the liquid you are targeting.' }; super(ruleData, displayLine); this.player = player; } @@ -17,8 +22,14 @@ export class LiquidTarget extends InfoDisplayTextElement { } getLookingAtName() { - const blockRayResult = this.player.getBlockFromViewDirection({ includeLiquidBlocks: true, includePassableBlocks: true, maxDistance: 7 }); - return this.#parseLookingAtLiquid(blockRayResult).LookingAtName; + try { + const blockRayResult = this.player.getBlockFromViewDirection({ includeLiquidBlocks: true, includePassableBlocks: true, maxDistance: 7 }); + return this.#parseLookingAtLiquid(blockRayResult).LookingAtName; + } catch (error) { + if (error instanceof LocationInUnloadedChunkError) + return ''; + throw error; + } } #parseLookingAtLiquid(lookingAtBlock) { @@ -30,7 +41,7 @@ export class LiquidTarget extends InfoDisplayTextElement { try { blockName = `§2${parseName(block)}`; } catch (error) { - if (error.message.includes('loaded')) + if (error instanceof LocationInUnloadedChunkError) blockName = `§c${stringifyLocation(block.location, 0)} Unloaded`; else if (error.message.includes('undefined')) blockName = '§7Undefined'; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js index fea59f97..ba378145 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/MoonPhase.js @@ -2,8 +2,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; class MoonPhase extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'moonPhase'; + } + constructor(displayLine) { - const ruleData = { identifier: 'moonPhase', description: { translate: 'rules.infoDisplay.moonPhase' }, wikiDescription: 'Shows the current phase of the moon.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.moonPhase' }, wikiDescription: 'Shows the current phase of the moon.' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js index 5fc27370..3402e114 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/NoFog.js @@ -1,7 +1,11 @@ -import { EntityComponentTypes, world } from "@minecraft/server"; +import { world } from "@minecraft/server"; import { InfoDisplayShapeElement } from "./InfoDisplayShapeElement"; export class NoFog extends InfoDisplayShapeElement { + static getRuleIdentifier() { + return 'noFog'; + } + static FOG_REMOVAL_IDS = { "minecraft:overworld": "canopy:overworld_no_fog", "minecraft:nether": "canopy:nether_no_fog", @@ -9,11 +13,10 @@ export class NoFog extends InfoDisplayShapeElement { }; static FOG_TAG = "canopy_no_fog"; - playerFogComponent; + playerFogSettings; constructor(player) { const ruleData = { - identifier: 'noFog', description: { translate: 'rules.infoDisplay.noFog' }, wikiDescription: `Disables the fog effect for the player. Water and lava are unaffected.`, onEnableCallback: () => this.removeFog(), @@ -21,22 +24,23 @@ export class NoFog extends InfoDisplayShapeElement { }; super(ruleData, 0); this.player = player; - this.playerFogComponent = player.getComponent(EntityComponentTypes.Fog); + this.playerFogSettings = player.fogSettings; this.onDimensionChangeBound = this.onDimensionChange.bind(this); } removeFog() { - this.playerFogComponent.push(this.getCurrentFogId(), NoFog.FOG_TAG); + this.clearFogSettings(); + this.playerFogSettings.push(this.getCurrentFogId(), NoFog.FOG_TAG); world.afterEvents.playerDimensionChange.subscribe(this.onDimensionChangeBound); } resetFog() { world.afterEvents.playerDimensionChange.unsubscribe(this.onDimensionChangeBound); - this.clearFog(); + this.clearFogSettings(); } - clearFog() { - this.playerFogComponent.remove(NoFog.FOG_TAG); + clearFogSettings() { + this.playerFogSettings?.remove(NoFog.FOG_TAG); } getCurrentFogId() { @@ -49,8 +53,8 @@ export class NoFog extends InfoDisplayShapeElement { } onDimensionChange() { - this.clearFog(); + this.clearFogSettings(); const fogRemovalId = this.getCurrentFogId(); - this.playerFogComponent.push(fogRemovalId, NoFog.FOG_TAG); + this.playerFogSettings.push(fogRemovalId, NoFog.FOG_TAG); } } \ No newline at end of file diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js index bf9c8b8e..91d3e02d 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/PeekInventory.js @@ -4,11 +4,14 @@ import { currentQuery } from "../../commands/peek"; import { ItemStack } from "@minecraft/server"; class PeekInventory extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'peekInventory'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'peekInventory', - description: { translate: 'rules.infoDisplay.peekInventory' }, + const ruleData = { description: { translate: 'rules.infoDisplay.peekInventory' }, contingentRules: ['target'], globalContingentRules: ['allowPeekInventory'], wikiDescription: 'Shows the inventory of the block or entity you are targeting in your InfoDisplay. Requires the `allowPeekInventory` global rule to be enabled.' diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js index d3c4932d..920c7fe5 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Ping.js @@ -1,17 +1,22 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; export class Ping extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'ping'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'ping', description: { translate: 'rules.infoDisplay.ping' }, wikiDescription: 'Shows your current network latency to the server.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.ping' }, wikiDescription: 'Shows your current network latency to the server.' }; super(ruleData, displayLine); this.player = player; player.setDynamicProperty('joinDate', Date.now()); } getFormattedDataOwnLine() { - return { translate: 'rules.infoDisplay.ping.display', with: ['§a' + this.getPing()] }; + const ping = this.getPing(); + return { translate: 'rules.infoDisplay.ping.display', with: [this.getPingColor(ping) + ping] }; } getFormattedDataSharedLine() { @@ -21,4 +26,11 @@ export class Ping extends InfoDisplayTextElement { getPing() { return this.player.getPing(); } + + getPingColor(ping) { + if (ping < 100) return '§a'; + else if (ping < 300) return '§e'; + else if (ping < 1000) return '§c'; + return '§5'; + } } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js new file mode 100644 index 00000000..510bc984 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderLightLevel.js @@ -0,0 +1,85 @@ +import { InfoDisplayShapeElement } from './InfoDisplayShapeElement'; +import { BlockVolume, LiquidType } from '@minecraft/server'; +import { LightLevelRenderer } from '../../classes/LightLevelRenderer'; +import { Vector } from '../../../lib/Vector'; + +class RenderLightLevel extends InfoDisplayShapeElement { + static getRuleIdentifier() { + return 'renderLightLevel'; + } + + player; + playerId; + static RENDER_DISTANCE = 4; + signalStrengthRenderers = {}; + + constructor(player) { + const ruleData = { + description: { translate: 'rules.infoDisplay.renderLightLevel' }, + wikiDescription: `Renders the light level of nearby blocks in the world. Only renders for blocks within ${RenderLightLevel.RENDER_DISTANCE} blocks from the player to avoid excessive rendering. Warning: This rule can be very laggy.`, + onEnableCallback: () => this.start(), + onDisableCallback: () => this.stop() + }; + super(ruleData, 0); + this.player = player; + this.playerId = player.id; + } + + start() { + this.lightLevelRenderers = {}; + } + + stop() { + for (const [key, renderer] of Object.entries(this.lightLevelRenderers)) { + renderer.destroy(); + delete this.lightLevelRenderers[key]; + } + this.lightLevelRenderers = {}; + } + + onTick() { + this.renderForNearbyBlocks(); + } + + renderForNearbyBlocks() { + const dimension = this.player.dimension; + const lightLevelRendererKeys = Object.keys(this.lightLevelRenderers); + const blockKeys = []; + const blockLocationIterator = this.getNearbyBlockLocationIterator(dimension, this.player.location); + let locationResult = blockLocationIterator.next(); + while (!locationResult.done) { + const block = dimension.getBlock(locationResult.value); + const blockAbove = block.above(); + if ((blockAbove.isSolid || blockAbove.isLiquidBlocking(LiquidType.Water)) || !block.isLiquidBlocking(LiquidType.Water)) { + locationResult = blockLocationIterator.next(); + continue; + } + const key = this.getKey(block); + blockKeys.push(key); + if (!lightLevelRendererKeys.includes(key)) + this.lightLevelRenderers[key] = new LightLevelRenderer(blockAbove, dimension, this.player); + locationResult = blockLocationIterator.next(); + } + for (const [key, renderer] of Object.entries(this.lightLevelRenderers)) { + if (!blockKeys.includes(key)) { + renderer.destroy(); + delete this.lightLevelRenderers[key]; + } + } + } + + getNearbyBlockLocationIterator(dimension, location) { + const locationVector = Vector.from(location); + const min = locationVector.subtract(new Vector(RenderLightLevel.RENDER_DISTANCE, RenderLightLevel.RENDER_DISTANCE, RenderLightLevel.RENDER_DISTANCE)); + const max = locationVector.add(new Vector(RenderLightLevel.RENDER_DISTANCE, 1, RenderLightLevel.RENDER_DISTANCE)); + const volume = new BlockVolume(min, max); + const blockVolume = dimension.getBlocks(volume, { excludeTypes: ["minecraft:air", "minecraft:water", "minecraft:lava"] }, true); + return blockVolume.getBlockLocationIterator(); + } + + getKey(block) { + return `<${block.x}, ${block.y}, ${block.z}>`; + } +} + +export { RenderLightLevel }; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js index 877cd20e..aeb13679 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/RenderSignalStrength.js @@ -4,6 +4,10 @@ import { SignalStrengthRenderer } from '../../classes/SignalStrengthRenderer'; import { Vector } from '../../../lib/Vector'; class RenderSignalStrength extends InfoDisplayShapeElement { + static getRuleIdentifier() { + return 'renderSignalStrength'; + } + player; playerId; static RENDER_DISTANCE = 10; @@ -11,7 +15,6 @@ class RenderSignalStrength extends InfoDisplayShapeElement { constructor(player) { const ruleData = { - identifier: 'renderSignalStrength', description: { translate: 'rules.infoDisplay.renderSignalStrength' }, wikiDescription: `Renders the signal strength of nearby redstone dust in the world. Only renders for redstone dust within ${RenderSignalStrength.RENDER_DISTANCE} blocks from the player to avoid excessive rendering.`, onEnableCallback: () => this.start(), diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js b/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js index e37e7bbb..bbc6825c 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SessionTime.js @@ -1,10 +1,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class SessionTime extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'sessionTime'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'sessionTime', description: { translate: 'rules.infoDisplay.sessionTime' }, wikiDescription: 'Shows the elapsed time since you joined the world in this session.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.sessionTime' }, wikiDescription: 'Shows the elapsed time since you joined the world in this session.' }; super(ruleData, displayLine); this.player = player; player.setDynamicProperty('joinDate', Date.now()); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js b/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js index 951936ee..ef5ce944 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SignalStrength.js @@ -2,10 +2,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults } from "../../../include/utils"; class SignalStrength extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'signalStrength'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'signalStrength', description: { translate: 'rules.infoDisplay.signalStrength' }, contingentRules: ['target'], wikiDescription: 'Shows the redstone signal strength of the block you are targeting.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.signalStrength' }, contingentRules: ['target'], wikiDescription: 'Shows the redstone signal strength of the block you are targeting.' }; super(ruleData, displayLine, false); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js b/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js index ab44a76a..7290db3b 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SimulationMap.js @@ -3,10 +3,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { getConfig, getLoadedChunksMessage } from '../../commands/simmap.js'; class SimulationMap extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'simulationMap'; + } + player; constructor(player, displayLine) { - const ruleData = { identifier: 'simulationMap', description: { translate: 'rules.infoDisplay.simulationMap' }, wikiDescription: 'Shows a map of loaded chunks around you or a configured location. Ticking chunks are green; non-ticking chunks are red. Configure with the `./simmap` command. **Warning:** This rule is performance-intensive.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.simulationMap' }, wikiDescription: 'Shows a map of loaded chunks around you or a configured location. Ticking chunks are green; non-ticking chunks are red. Configure with the `./simmap` command. **Warning:** This rule is performance-intensive.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js b/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js index 6e58340a..45927089 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/SlimeChunk.js @@ -2,11 +2,15 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js' import { playerChangeSubChunkEvent } from '../../events/PlayerChangeSubChunkEvent.js' export class SlimeChunk extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'slimeChunk'; + } + player; infoMessage = { text: '' }; constructor(player, displayLine) { - const ruleData = { identifier: 'slimeChunk', description: { translate: 'rules.infoDisplay.slimeChunk' }, wikiDescription: 'Shows whether the chunk you are currently standing in is a slime chunk.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.slimeChunk' }, wikiDescription: 'Shows whether the chunk you are currently standing in is a slime chunk.' }; super(ruleData, displayLine); this.player = player; playerChangeSubChunkEvent.subscribe(this.onPlayerChangeSubChunk.bind(this)); diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js b/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js index bfc784af..57eb5f0a 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Speed.js @@ -3,11 +3,14 @@ import { Vector } from '../../../lib/Vector.js'; import { TicksPerSecond } from '@minecraft/server'; export class Speed extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'speed'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'speed', description: { translate: 'rules.infoDisplay.speed' }, wikiDescription: 'Shows your current movement speed in meters per second.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js index c43899a1..e9819089 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Structures.js @@ -1,11 +1,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; export class Structures extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'structures'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'structures', description: { translate: 'rules.infoDisplay.structures' }, wikiDescription: 'Shows naturally generated structures present at your current location.' } @@ -16,8 +19,8 @@ export class Structures extends InfoDisplayTextElement { getFormattedDataOwnLine() { const structures = this.getFormattedStructures(); if (structures === '') - return { rawtext: [{ translate: 'rules.infodisplay.structures.display' }, { translate: 'rules.infodisplay.structures.display.none' }] }; - return { rawtext: [{ translate: 'rules.infodisplay.structures.display' }, { text: '§d' + structures }] }; + return { rawtext: [{ translate: 'rules.infoDisplay.structures.display' }, { translate: 'rules.infoDisplay.structures.display.none' }] }; + return { rawtext: [{ translate: 'rules.infoDisplay.structures.display' }, { text: '§d' + structures }] }; } getFormattedDataSharedLine() { diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js index 0e43ce29..4ac39f6e 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TPS.js @@ -3,8 +3,12 @@ import { Profiler } from '../../classes/Profiler.js'; import { TicksPerSecond } from '@minecraft/server'; class TPS extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'tps'; + } + constructor(displayLine) { - const ruleData = { identifier: 'tps', description: { translate: 'rules.infoDisplay.tps' }, wikiDescription: 'Shows the server\'s current ticks per second (TPS).' }; + const ruleData = { description: { translate: 'rules.infoDisplay.tps' }, wikiDescription: 'Shows the server\'s current ticks per second (TPS).' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js index bb58e398..adb070b2 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Target.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Target.js @@ -1,9 +1,14 @@ import { InfoDisplayTextElement } from "./InfoDisplayTextElement"; import { getRaycastResults, parseName, stringifyLocation } from "../../../include/utils"; +import { LocationInUnloadedChunkError } from "@minecraft/server"; export class Target extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'target'; + } + constructor(player, displayLine) { - const ruleData = { identifier: 'target', description: { translate: 'rules.infoDisplay.target' }, wikiDescription: 'Shows the identifier of the block or entity you are targeting.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.target' }, wikiDescription: 'Shows the identifier of the block or entity you are targeting.' }; super(ruleData, displayLine); this.player = player; } @@ -18,7 +23,13 @@ export class Target extends InfoDisplayTextElement { getLookingAtName() { const { blockRayResult, entityRayResult } = getRaycastResults(this.player, 7); - return this.#parseLookingAtEntity(entityRayResult).LookingAtName || this.#parseLookingAtBlock(blockRayResult).LookingAtName; + try { + return this.#parseLookingAtEntity(entityRayResult).LookingAtName || this.#parseLookingAtBlock(blockRayResult).LookingAtName; + } catch (error) { + if (error instanceof LocationInUnloadedChunkError) + return ''; + throw error; + } } #parseLookingAtBlock(lookingAtBlock) { @@ -30,7 +41,7 @@ export class Target extends InfoDisplayTextElement { try { blockName = `§a${parseName(block)}`; } catch (error) { - if (error.message.includes('loaded')) + if (error instanceof LocationInUnloadedChunkError) blockName = `§c${stringifyLocation(block.location, 0)} Unloaded`; else if (error.message.includes('undefined')) blockName = '§7Undefined'; diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js index db266019..4c9d1921 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/TimeOfDay.js @@ -2,8 +2,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; class TimeOfDay extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'timeOfDay'; + } + constructor(displayLine) { - const ruleData = { identifier: 'timeOfDay', description: { translate: 'rules.infoDisplay.timeOfDay' }, wikiDescription: 'Shows the Minecraft day-cycle time displayed as a 12-hour digital clock.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.timeOfDay' }, wikiDescription: 'Shows the Minecraft day-cycle time displayed as a 12-hour digital clock.' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js b/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js index 19e3a3e8..902d93dc 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Velocity.js @@ -2,11 +2,14 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { Vector } from '../../../lib/Vector.js'; export class Velocity extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'velocity'; + } + player; constructor(player, displayLine) { const ruleData = { - identifier: 'velocity', description: { translate: 'rules.infoDisplay.velocity' }, wikiDescription: 'Shows your current x, y, and z velocity values in meters per tick.' } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js b/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js index bf5d3997..c4d781ce 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/Weather.js @@ -1,8 +1,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; class Weather extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'weather'; + } + constructor(player, displayLine) { - const ruleData = { identifier: 'weather', description: { translate: 'rules.infoDisplay.weather' }, wikiDescription: 'Shows the current weather in your dimension.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.weather' }, wikiDescription: 'Shows the current weather in your dimension.' }; super(ruleData, displayLine); this.player = player; } diff --git a/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js b/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js index f637f72f..bdb20880 100644 --- a/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js +++ b/Canopy[BP]/scripts/src/rules/infodisplay/WorldDay.js @@ -2,8 +2,12 @@ import { InfoDisplayTextElement } from './InfoDisplayTextElement.js'; import { world } from '@minecraft/server'; class WorldDay extends InfoDisplayTextElement { + static getRuleIdentifier() { + return 'worldDay'; + } + constructor(displayLine) { - const ruleData = { identifier: 'worldDay', description: { translate: 'rules.infoDisplay.worldDay' }, wikiDescription: 'Shows the count of Minecraft days elapsed since the world was created.' }; + const ruleData = { description: { translate: 'rules.infoDisplay.worldDay' }, wikiDescription: 'Shows the count of Minecraft days elapsed since the world was created.' }; super(ruleData, displayLine, true); } diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js new file mode 100644 index 00000000..89756646 --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining.js @@ -0,0 +1,61 @@ +import { BooleanRule, GlobalRule } from "../../../lib/canopy/Canopy"; +import { system, world } from "@minecraft/server"; +import Understudies from "../../classes/simplayer/Understudies"; + +class SimplayerRejoining extends BooleanRule { + simplayersToRejoinDP = 'simplayersToRejoin'; + + constructor() { + super(GlobalRule.morphOptions({ + identifier: 'simplayerRejoining', + defaultValue: false, + onEnableCallback: () => this.subscribeToEvent(), + onDisableCallback: () => this.unsubscribeFromEvent() + })); + this.onShutdownBound = this.onShutdown.bind(this); + } + + subscribeToEvent() { + system.beforeEvents.shutdown.subscribe(this.onShutdownBound); + } + + unsubscribeFromEvent() { + system.beforeEvents.shutdown.unsubscribe(this.onShutdownBound); + } + + onStartup() { + if (!this.getNativeValue()) + return; + const simplayersToRejoinStr = world.getDynamicProperty(this.simplayersToRejoinDP); + let playersToRejoin; + try { + const parsedPlayers = JSON.parse(simplayersToRejoinStr); + if (Array.isArray(parsedPlayers)) + playersToRejoin = parsedPlayers; + } catch (error) { + console.error(`[Canopy] Error parsing ${this.simplayersToRejoinDP} DP:`, error); + } + if (playersToRejoin) { + playersToRejoin.forEach(name => { + const simPlayer = Understudies.create(name); + system.runTimeout(() => { + Understudies.addNametagPrefix(simPlayer); + }, 5); + try { + simPlayer.rejoin(); + } catch (error) { + console.error(`[Canopy] Error rejoining player ${name}:`, error); + } + }); + } + } + + onShutdown() { + if (this.getNativeValue()) + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify(Understudies.understudies.map(player => player.name))); + else + world.setDynamicProperty(this.simplayersToRejoinDP, JSON.stringify([])); + } +} + +export const simplayerRejoining = new SimplayerRejoining(); diff --git a/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js new file mode 100644 index 00000000..fc41561f --- /dev/null +++ b/Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving.js @@ -0,0 +1,12 @@ +import { BooleanRule, GlobalRule } from "../../../lib/canopy/Canopy"; + +class SimplayerSaving extends BooleanRule { + constructor() { + super(GlobalRule.morphOptions({ + identifier: 'simplayerSaving', + defaultValue: true + })); + } +} + +export const simplayerSaving = new SimplayerSaving(); diff --git a/Canopy[RP]/manifest.json b/Canopy[RP]/manifest.json index b13e6b4f..0d0f2d5c 100644 --- a/Canopy[RP]/manifest.json +++ b/Canopy[RP]/manifest.json @@ -1,18 +1,18 @@ { "format_version": 2, "header": { - "name": "Canopy [RP] v1.5.7", + "name": "Canopy [RP] v1.6.0", "description": "Technical informatics & features addon by §aForestOfLight§r.", "uuid": "bcf34368-ed0c-4cf7-938e-582cccf9950d", "version": [ 1, - 5, - 7 + 6, + 0 ], "min_engine_version": [ 1, 26, - 20 + 40 ] }, "modules": [ @@ -31,8 +31,8 @@ "uuid": "7f6b23df-a583-476b-b0e4-87457e65f7c0", "version": [ 1, - 5, - 7 + 6, + 0 ] } ], diff --git a/Canopy[RP]/particles/fortress_hss_marker.particle.json b/Canopy[RP]/particles/fortress_hss_marker.particle.json deleted file mode 100644 index 63ff46ed..00000000 --- a/Canopy[RP]/particles/fortress_hss_marker.particle.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "format_version": "1.10.0", - "particle_effect": { - "description": { - "identifier": "canopy:fortress_hss_marker", - "basic_render_parameters": { - "material": "particles_blend", - "texture": "textures/particle/fortress_hss_marker" - } - }, - "components": { - "minecraft:emitter_rate_manual": { - "max_particles": 1 - }, - "minecraft:emitter_lifetime_expression": { - "activation_expression": 1 - }, - "minecraft:emitter_shape_point": { - "offset": [0, 0.03, 0], - "direction": [0, 1, 0] - }, - "minecraft:particle_lifetime_expression": { - "max_lifetime": 5 - }, - "minecraft:particle_initial_speed": 0, - "minecraft:particle_motion_dynamic": {}, - "minecraft:particle_appearance_billboard": { - "size": [0.4, 0.4], - "facing_camera_mode": "direction_y", - "direction": { - "mode": "custom", - "custom_direction": [0, 0, -1] - }, - "uv": { - "texture_width": 18, - "texture_height": 18, - "uv": [0, 0], - "uv_size": [18, 18] - } - }, - "minecraft:particle_appearance_tinting": { - "color": [1, 1, 1, 1] - } - } - } -} \ No newline at end of file diff --git a/Canopy[RP]/particles/monument_hss_marker.particle.json b/Canopy[RP]/particles/monument_hss_marker.particle.json deleted file mode 100644 index 6db975f7..00000000 --- a/Canopy[RP]/particles/monument_hss_marker.particle.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "format_version": "1.10.0", - "particle_effect": { - "description": { - "identifier": "canopy:monument_hss_marker", - "basic_render_parameters": { - "material": "particles_blend", - "texture": "textures/particle/ocean_monument_hss_marker" - } - }, - "components": { - "minecraft:emitter_rate_manual": { - "max_particles": 1 - }, - "minecraft:emitter_lifetime_expression": { - "activation_expression": 1 - }, - "minecraft:emitter_shape_point": { - "offset": [0, 0.03, 0], - "direction": [0, 1, 0] - }, - "minecraft:particle_lifetime_expression": { - "max_lifetime": 5 - }, - "minecraft:particle_initial_speed": 0, - "minecraft:particle_motion_dynamic": {}, - "minecraft:particle_appearance_billboard": { - "size": [0.4, 0.4], - "facing_camera_mode": "direction_y", - "direction": { - "mode": "custom", - "custom_direction": [0, 0, -1] - }, - "uv": { - "texture_width": 18, - "texture_height": 18, - "uv": [0, 0], - "uv_size": [18, 18] - } - }, - "minecraft:particle_appearance_tinting": { - "color": [1, 1, 1, 1] - } - } - } -} \ No newline at end of file diff --git a/Canopy[RP]/particles/pillager_outpost_hss_marker.particle.json b/Canopy[RP]/particles/pillager_outpost_hss_marker.particle.json deleted file mode 100644 index c2272fe4..00000000 --- a/Canopy[RP]/particles/pillager_outpost_hss_marker.particle.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "format_version": "1.10.0", - "particle_effect": { - "description": { - "identifier": "canopy:pillager_outpost_hss_marker", - "basic_render_parameters": { - "material": "particles_blend", - "texture": "textures/particle/outpost_hss_marker" - } - }, - "components": { - "minecraft:emitter_rate_manual": { - "max_particles": 1 - }, - "minecraft:emitter_lifetime_expression": { - "activation_expression": 1 - }, - "minecraft:emitter_shape_point": { - "offset": [0, 0.03, 0], - "direction": [0, 1, 0] - }, - "minecraft:particle_lifetime_expression": { - "max_lifetime": 5 - }, - "minecraft:particle_initial_speed": 0, - "minecraft:particle_motion_dynamic": {}, - "minecraft:particle_appearance_billboard": { - "size": [0.4, 0.4], - "facing_camera_mode": "direction_y", - "direction": { - "mode": "custom", - "custom_direction": [0, 0, -1] - }, - "uv": { - "texture_width": 18, - "texture_height": 18, - "uv": [0, 0], - "uv_size": [18, 18] - } - }, - "minecraft:particle_appearance_tinting": { - "color": [1, 1, 1, 1] - } - } - } -} \ No newline at end of file diff --git a/Canopy[RP]/particles/swamp_hut_hss_marker.particle.json b/Canopy[RP]/particles/swamp_hut_hss_marker.particle.json deleted file mode 100644 index 3be6f2a0..00000000 --- a/Canopy[RP]/particles/swamp_hut_hss_marker.particle.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "format_version": "1.10.0", - "particle_effect": { - "description": { - "identifier": "canopy:swamp_hut_hss_marker", - "basic_render_parameters": { - "material": "particles_blend", - "texture": "textures/particle/witch_hut_hss_marker" - } - }, - "components": { - "minecraft:emitter_rate_manual": { - "max_particles": 1 - }, - "minecraft:emitter_lifetime_expression": { - "activation_expression": 1 - }, - "minecraft:emitter_shape_point": { - "offset": [0, 0.03, 0], - "direction": [0, 1, 0] - }, - "minecraft:particle_lifetime_expression": { - "max_lifetime": 5 - }, - "minecraft:particle_initial_speed": 0, - "minecraft:particle_motion_dynamic": {}, - "minecraft:particle_appearance_billboard": { - "size": [0.4, 0.4], - "facing_camera_mode": "direction_y", - "direction": { - "mode": "custom", - "custom_direction": [0, 0, -1] - }, - "uv": { - "texture_width": 18, - "texture_height": 18, - "uv": [0, 0], - "uv_size": [18, 18] - } - }, - "minecraft:particle_appearance_tinting": { - "color": [1, 1, 1, 1] - } - } - } -} \ No newline at end of file diff --git a/Canopy[RP]/texts/cy_GB.lang b/Canopy[RP]/texts/cy_GB.lang index 0594a179..dcb0583b 100644 --- a/Canopy[RP]/texts/cy_GB.lang +++ b/Canopy[RP]/texts/cy_GB.lang @@ -67,7 +67,6 @@ commands.canopy.menu.timeout=§8Daeth amser y ffurflen allan ar ôl %s ticiau. commands.canopy.menu.canceled=§8Ffurflen wedi'i chanslo. Ni ddiweddarwyd y rheolau. commands.canopy.menu.submit=§aYmgeisiwch commands.canopy.single=Yn addasu gwerth rheol sengl. -commands.canopy.multiple=Yn addasu gwerth rheolau lluosog. commands.canopy.infodisplayRule=§cMae'r rheol '%1' yn rhan o'r GwybodaethArddangos, a rhaid ei newid gan ddefnyddio %2info. Defnyddiwch %2help am ragor o wybodaeth. commands.changedimension=Yn teleportio endidau i'r dimensiwn penodedig. diff --git a/Canopy[RP]/texts/de_DE.lang b/Canopy[RP]/texts/de_DE.lang index f9ffd83d..e29f8095 100644 --- a/Canopy[RP]/texts/de_DE.lang +++ b/Canopy[RP]/texts/de_DE.lang @@ -66,7 +66,6 @@ commands.canopy.menu.timeout=§8Das Formular ist nach %s Ticks abgelaufen. commands.canopy.menu.canceled=§8Das Formular wurde abgebrochen. Die Regeln wurden nicht aktualisiert. commands.canopy.menu.submit=§aAnwenden commands.canopy.single=Aktiviert oder deaktiviert eine bestimmte Regel. -commands.canopy.multiple=Aktiviert oder deaktiviert alle bestimmten Regeln. commands.canopy.infodisplayRule=§cDie Regel '%1' ist ein Teil des Info Displays, und muss mit %2info umgeschaltet werden. Nutze %2help für mehr Informationen. commands.changedimension=Telepotiert dich zur angegebenen Diemension. diff --git a/Canopy[RP]/texts/en_US.lang b/Canopy[RP]/texts/en_US.lang index a42b34d2..1ce5853d 100644 --- a/Canopy[RP]/texts/en_US.lang +++ b/Canopy[RP]/texts/en_US.lang @@ -20,13 +20,51 @@ commands.generic.invalidaction=§cInvalid action. Use /help for more i commands.help=Displays help pages. commands.help.search.noresult=§cNo results found for '%s'. -commands.help.search.results=§l§aCanopy§r §2Help search results for '§r%1§2':%2 +commands.help.search.results=§l§aCanopy§r §2Help search results for '§r%1§2': commands.help.page.header=§l§aCanopy§r§2 Help Page: §f%1 commands.help.infodisplay=Togglable rules for your InfoDisplay. commands.help.rules=Togglable global rules. commands.help.extension.rules=Togglable rules for §o§a%s§r§8. commands.help.extension.commands=Commands for §o§a%s§r§2. +commands.analyzearea=Analyzes a region of blocks and displays results for a JavaScript expression. +commands.analyzearea.overcapacity=§cToo many blocks in the specified area (>2^32 blocks). +commands.analyzearea.loadcapacity=§cThe region spans too many chunks (ticking area capacity exceeded). +commands.analyzearea.unknownerror=§cAn unknown error occurred. +commands.analyzearea.syntaxerror=§cThe expression could not be parsed. +commands.analyzearea.forbidden=§cThe expression accesses a property or method that is not allowed. +commands.analyzearea.completed=§7Found %1 matching blocks. +commands.analyzearea.removed=§7Removed the analysis for those coordinates. +commands.analyzearea.removenotfound=§cNo analysis found for those coordinates. +commands.analyzearea.create.invalid=§cPlease enter valid coordinates and an expression. +commands.analyzearea.teleport.gamemode=§cTeleporting to a match requires Creative or Spectator mode. +commands.analyzearea.stats.header=§l§aArea Analysis§r +commands.analyzearea.stats=§f%1 §7-> §f%2\n§fExpression: §7%3\n§fMatches: §a%4\n§fSize: §7%5 +commands.analyzearea.stats.capped=§f%1 §7-> §f%2\n§fExpression: §7%3\n§fMatches: §c%4+ (cap reached)\n§fSize: §7%5 +commands.analyzearea.stats.analyzing=§f%1 §7-> §f%2\n§fExpression: §7%3\n§fAnalyzing... §e%4 +commands.analyzearea.ui.selector.title=§2Area Analyses +commands.analyzearea.ui.selector.new=(+) New Analysis +commands.analyzearea.ui.selector.empty=§7No analyses yet. +commands.analyzearea.ui.create.title=§2New Area Analysis +commands.analyzearea.ui.create.fromX=From X +commands.analyzearea.ui.create.fromY=From Y +commands.analyzearea.ui.create.fromZ=From Z +commands.analyzearea.ui.create.toX=To X +commands.analyzearea.ui.create.toY=To Y +commands.analyzearea.ui.create.toZ=To Z +commands.analyzearea.ui.create.dimension=Dimension +commands.analyzearea.ui.create.expression=Expression +commands.analyzearea.ui.create.submit=Create +commands.analyzearea.ui.page.title=§2Area Analysis +commands.analyzearea.ui.page.notrun=§7Not run this session. Press Re-analyze. +commands.analyzearea.ui.page.prev=< Prev < +commands.analyzearea.ui.page.next=> Next > +commands.analyzearea.ui.page.reanalyze=Re-analyze +commands.analyzearea.ui.page.enableboxes=Enable Match Rendering +commands.analyzearea.ui.page.disableboxes=Disable Match Rendering +commands.analyzearea.ui.page.remove=(-) Remove Area +commands.analyzearea.ui.page.back=Back + commands.biomeedges=Finds and displays biome edges in a region. commands.biomeedges.finderadded=§7Analyzing biome edges... commands.biomeedges.finderremoved=§7Removed the most recent biome edges region. @@ -58,17 +96,16 @@ commands.camera.spectate.hardcore=§cPrevented you from spectating. Spectating i commands.camera.spectate.flying=§cYou must be on the ground to spectate. commands.camera.invalidaction=§cInvalid camera action. -commands.canopy=Enable or disable a rule. +commands.canopy=Set a rule value. Wrap numeric values in quotes, e.g. "16". commands.canopy.version=Displays the current version of Canopy and all loaded extensions. commands.canopy.version.message=§7This server is running §l§aCanopy§r commands.canopy.version.extensions=§7Loaded extensions: -commands.canopy.menu=Displays a menu to toggle rules. +commands.canopy.menu=Displays a menu to set rule values. commands.canopy.menu.busy=§8Close your chat window to access the form. commands.canopy.menu.timeout=§8Form timed out after %s ticks. commands.canopy.menu.canceled=§8Form canceled. Rules were not updated. commands.canopy.menu.submit=§aApply commands.canopy.single=Modifies the value of a single rule. -commands.canopy.multiple=Modifies the value of multiple rules. commands.canopy.infodisplayRule=§cThe rule '%1' is part of the InfoDisplay, and must be toggled using %2info. Use %2help for more information. commands.changedimension=Teleports entities to the specified dimension. @@ -118,7 +155,7 @@ commands.data.components=§aComponents:§r %s commands.data.tags=§aTags:§r %s commands.data.dynamicProperties=§aDynamicProperties:§r %1 total byte count: %2 commands.data.effects=§aEffects:§r %s -commands.data.other=§aOther:§r head location: %1, rotation: [%2, %3], velocity: %4, view direction: %5 +commands.data.other=§aOther:§r head location: %1, rotation: [%2, %3], velocity: %4, view direction: %5, AABB: %6 commands.debugentity=Displays debug information about entities. commands.debugentity.invalidProperty=§cInvalid debug property. @@ -183,12 +220,9 @@ commands.hss.stopped=§7Stopped displaying hardcoded spawn spots. commands.hss.alreadyrunning=§cYou are already finding fortress hardcoded spawn spots. commands.hss.notrunning=§cYou are not currently finding fortress hardcoded spawn spots. -commands.info=Toggle InfoDisplay rules. (Alias: i) +commands.info=Toggle InfoDisplay rules. commands.info.menu=Displays a menu to toggle InfoDisplay rules. commands.info.single=Toggle a single InfoDisplay rule. -commands.info.multiple=Toggle multiple InfoDisplay rules. -commands.info.all=Toggle all InfoDisplay rules. -commands.info.allupdated=§7All InfoDisplay rules are now §l commands.info.canopyRule=§cThe rule '%1' is global rule, and must be toggled using %2canopy. Use %2help for more information. commands.jump=Teleport to the block you are targeting. (Alias: j) @@ -239,6 +273,59 @@ commands.peek.fail.noitems=§cNo items found in %1 at %2. commands.peek.query.cleared=§7Peek query cleared. commands.peek.query.set=§7Peek query set to '%s'. +commands.playeraction=Make a simplayer do actions with variable timing. +commands.playeraction.invalidtiming=§cInvalid %1 timing: %2. +commands.playeraction.invalidticks=§cInvalid '%1' tick duration: %2. Expected an integer. + +commands.playerglide=Make a simplayer start or stop gliding with an Elytra. + +commands.playerinventory=Print the inventory of a simplayer. +commands.playerinventory.noinventory=§cNo inventory found +commands.playerinventory.empty=§7%s's inventory is empty. +commands.playerinventory.header=%s's inventory: +commands.playerinventory.item=§7- %1%2§7: %3 x%4 + +commands.playerjoin=Make a new simplayer join at your location. + +commands.playerleave=Make a simplayer leave the game. + +commands.playerlook=Make a simplayer look in specified directions. +commands.playerlook.at.missing=§cMissing coordinates for look at location. +commands.playerlook.rotation.missing=§cMissing yaw or pitch for look rotation. +commands.playerlook.invalidoption=§cInvalid look option: '%s' +commands.playerlook.block.entityonly=§cBlock targeting may only be used by entities. +commands.playerlook.block.noblock=§cNo block in view. +commands.playerlook.entity.entityonly=§cEntity targeting may only be used by entities. +commands.playerlook.entity.noentity=§cNo entity in view. +commands.playerlook.me.noserver=§cSelf-targeting cannot be used by the server. + +commands.playermove=Make a simplayer move in specified directions. +commands.playermove.invalidoption=§cInvalid move option: '%s' +commands.playermove.block.entityonly=§cMoving to a block may only be used by entities. +commands.playermove.block.noblock=§cNo block in view. +commands.playermove.entity.entityonly=§cMoving to an entity may only be used by entities. +commands.playermove.entity.noentity=§cNo entity in view. +commands.playermove.me.noserver=§cMoving to yourself cannot be used by the server. + +commands.playerprefix=Set a prefix for simplayer nametags. Use '-none' to clear. +commands.playerprefix.removed=§7Simplayer prefix removed. +commands.playerprefix.set=§7Simplayer prefix set to "§r%s§r§7". + +commands.playerrejoin=Make a simplayer rejoin at its last location. + +commands.playerselect=Make a simplayer select a hotbar slot. +commands.playerselect.invalidslot=§cInvalid slot number: %s. Expected a number from 0 to 8. + +commands.playersneak=Make a simplayer start or stop sneaking. + +commands.playersprint=Make a simplayer start or stop sprinting. + +commands.playerstop=Make a simplayer stop doing all actions. + +commands.playerswapheld=Swap the held item of a simplayer with your held item. + +commands.playertp=Make a simplayer teleport to you. + commands.pos=Shows your current position, or the positions of other players. commands.pos.self=§aYour position: §f%s commands.pos.other=§a%1's position: §f%2 @@ -401,6 +488,8 @@ rules.renderEndGatewayExits=Renders the exit locations of end gateways after pas rules.renewableElytraDropChance=Gives phantoms a chance to drop elytra when killed by a shulker bullet. rules.renewableSponge=Guardians transform into elder guardians when hurt by lightning. rules.serverSideCollisionBoxes=Renders collision boxes according to the entity's server position instead of its client position. +rules.simplayerSaving=Enables saving playerdata for simplayers. Weakens performance but allows simplayers to keep their inventory and location when they leave and rejoin. +rules.simplayerRejoining=Makes online simplayers rejoin when the world reloads. rules.spawnEggSpawnWithMinecart=When using a spawn egg on a rail, the spawned entity will be placed in a minecart on the rail. rules.tntFuse=The TNT fuse time in ticks. rules.tntPrimeMomentum=Hardcodes the TNT prime momentum. @@ -412,7 +501,7 @@ rules.infoDisplay.cardinalFacing.display=Facing: %s rules.infoDisplay.chunkCoords=Shows the coordinates of the chunk you are in and your location in that chunk. rules.infoDisplay.chunkCoords.display=Chunk: %1§r in %2 rules.infoDisplay.coords=Shows your coordinates truncated at 2 decimal places. -rules.infodisplay.dimension=Shows your current dimension. +rules.infoDisplay.dimension=Shows your current dimension. rules.infoDisplay.entities=Shows the number of entities in front of you. rules.infoDisplay.entities.display=Entities: %s rules.infoDisplay.eventTrackers=Shows the counts of tracked events. @@ -420,6 +509,8 @@ rules.infoDisplay.facing=Shows your facing direction using yaw and pitch. rules.infoDisplay.facing.display=Facing: %1 %2 rules.infoDisplay.heldItemDurability=Shows the durability of the item in your main hand. rules.infoDisplay.heldItemDurability.display=Durability %s +rules.infoDisplay.blockBreakSpeed=Shows your average blocks broken per second over the last %1 ticks (%2 seconds). +rules.infoDisplay.blockBreakSpeed.display=Block Break Speed: %1 b/s rules.infoDisplay.hopperCounterCounts=Shows all active hopper counter counts in their respective colors. Hopper counter mode controls this info. rules.infoDisplay.light=Shows the light level of the block where your foot is. rules.infoDisplay.light.display=Light: %1 §r(%2 sky§r) @@ -439,17 +530,20 @@ rules.infoDisplay.peekInventory=Shows the inventory of the block or entity you a rules.infoDisplay.peekInventory.empty=Empty rules.infoDisplay.ping=Shows your current network latency to the server. rules.infoDisplay.ping.display=Ping: %s +rules.infoDisplay.renderLightLevel=Renders light level values on top of nearby blocks. Warning: This is a very laggy rule. rules.infoDisplay.renderSignalStrength=Renders signal strength values on top of nearby redstone dust. rules.infoDisplay.sessionTime=Shows the time since you joined the world. rules.infoDisplay.sessionTime.display=Session: %s rules.infoDisplay.signalStrength=Shows the signal strength of the block you are targeting. rules.infoDisplay.simulationMap=Shows a map of the loaded chunks around you. The simmap command can be used to configure this. Warning: This is a very laggy rule. +rules.infoDisplay.horse=Shows the speed and max health of the horse you are riding. +rules.infoDisplay.horse.display=Horse Speed: §a%1§r, Max Health: §c%2§r rules.infoDisplay.slimeChunk=Shows whether the chunk you are in is a slime chunk. rules.infoDisplay.slimeChunk.display=Slime Chunk: %s rules.infoDisplay.speed=Shows your current speed in meters per second. -rules.infodisplay.structures=Shows naturally generated structures at your location. -rules.infodisplay.structures.display=Structures: -rules.infodisplay.structures.display.none=None +rules.infoDisplay.structures=Shows naturally generated structures at your location. +rules.infoDisplay.structures.display=Structures: +rules.infoDisplay.structures.display.none=None rules.infoDisplay.target=Shows the identifier of the block or entity you are targeting. rules.infoDisplay.timeOfDay=Shows the Minecraft day-cycle time as a 12-hour digital clock time. rules.infoDisplay.tps=Shows the server's ticks per second. @@ -459,4 +553,10 @@ rules.infoDisplay.velocity=Shows your current x, y, and z velocities in meters p rules.infoDisplay.weather=Shows the weather in your current dimension. rules.infoDisplay.weather.display=Weather: %s rules.infoDisplay.worldDay=Shows the count of Minecraft days since the world began. -rules.infoDisplay.worldDay.display=Day: %s \ No newline at end of file +rules.infoDisplay.worldDay.display=Day: %s + +## simplayer +simplayer.notonline=§cSimplayer '%s' is not online. +simplayer.alreadyonline=§cSimplayer '%s' is already online. +simplayer.leave.broadcast=§e%s left the game +simplayer.swapheld.error=§cError while swapping items: %s \ No newline at end of file diff --git a/Canopy[RP]/texts/id_ID.lang b/Canopy[RP]/texts/id_ID.lang index 92ebd0b4..7a36b21b 100644 --- a/Canopy[RP]/texts/id_ID.lang +++ b/Canopy[RP]/texts/id_ID.lang @@ -20,7 +20,7 @@ commands.generic.invalidaction=§cAksi tidak valid. Gunakan /help unt commands.help=Menampilkan halaman bantuan. commands.help.search.noresult=§cTidak ditemukan hasil untuk '%s'. -commands.help.search.results=§l§aCanopy§r §2Hasil pencarian untuk '§r%1§2':%2 +commands.help.search.results=§l§aCanopy§r §2Hasil pencarian untuk '§r%1§2': commands.help.page.header=§l§aCanopy§r§2 Halaman Bantuan: §f%1 commands.help.infodisplay=Aturan yang bisa diubah² untuk InfoDisplay Anda. commands.help.rules=Aturan global yang bisa diubah². @@ -54,6 +54,8 @@ commands.camera.spectate.viewing=§cAnda tidak bisa masuk ke mode penonton saat commands.camera.spectate.gamemode=§cAnda tidak bisa mengubah mode permainan saat di mode penonton. commands.camera.spectate.started=§aMenonton commands.camera.spectate.ended=§7Menonton berakhir +commands.camera.spectate.hardcore=§cMencegah Anda untuk menonton. Fitur menonton dalam mode hardcore memiliki bug yang menyebabkan layar terkunci sementara. Ini adalah masalah dari Mojang. +commands.camera.spectate.flying=§cAnda harus berada di lokasi untuk menonton. commands.camera.invalidaction=§cAksi kamera tidak valid. commands.canopy=Mengaktifkan atau menonaktifkan aturan. @@ -66,7 +68,6 @@ commands.canopy.menu.timeout=§8Menu habis masa berlakunya setelah %s tick. commands.canopy.menu.canceled=§8Menu dibatalkan. Aturan tidak diperbarui. commands.canopy.menu.submit=§aMenerapkan commands.canopy.single=Mengaktifkan atau menonaktifkan satu aturan. -commands.canopy.multiple=Mengaktifkan atau menonaktifkan beberapa aturan. commands.canopy.infodisplayRule=§cAturan '%1' adalah bagian dari InfoDisplay, dan harus diubah menggunakan %2info. Ketik %2help untuk informasi lebih lanjut. commands.changedimension=Menteleportasi entitas ke dimensi yang ditentukan. @@ -87,6 +88,7 @@ commands.cleanup.success=§7Membersihkan %s entitas (r%2). commands.counter=Mengelola penghitung penampung. (Alias: ct) commands.counter.channel.notfound=§cWarna tidak valid: %s. Silakan gunakan salah satu warna blok wol. commands.counter.query=Menampilkan hitungan dan kecepatan penghitung penampung untuk warna yang ditentukan. +commands.counter.query.all=Menampilkan hitungan dan kecepatan semua penghitung penampung. commands.counter.query.empty=§7Tidak ada penghitung penampung yang digunakan. commands.counter.query.channel=§7Item untuk %1§7 (%2%3 min.), total: §f%4§7, (§f%5§7): commands.counter.realtime=Menampilkan hitungan dan kecepatan, tetapi menggunakan waktu dunia nyata, bukan waktu berbasis tick. @@ -149,6 +151,7 @@ commands.gamemode.sp=Atur mode permainan ke mode penonton. commands.generator=Mengelola generator penampung. (Alias: gt) commands.generator.channel.notfound=§cWarna tidak valid: %s. Silakan gunakan salah satu warna blok wol. +commands.generator.query.all=Menampilkan hitungan dan kecepatan semua generator penampung. commands.generator.query=Menampilkan hitungan dan kecepatan generator penampung untuk warna yang ditentukan. commands.generator.query.empty=§7Tidak ada generator penampung yang digunakan. commands.generator.query.channel=§7Item yang dihasilkan untuk %1§7 (%2%3 min.), total: §f%4§7, (§f%5§7): @@ -187,10 +190,10 @@ commands.info.all=Mengaktifkan atau menonaktifkan semua aturan InfoDisplay. commands.info.allupdated=§7Semua aturan InfoDisplay sekarang §l commands.info.canopyRule=§cAturan '%1' adalah aturan global, dan harus diubah menggunakan %2canopy. Ketik %2help untuk informasi lebih lanjut. -commands.jump=Teleportasi ke blok yang sedang Anda targetkan. (Alias: j) +commands.jump=Teleportasi ke blok yang sedang Anda lihat. (Alias: j) commands.jump.fail.noblock=§cTidak ada blok yang ditemukan untuk melompat ke. -commands.log=Mencatat gerakan tnt, proyektil, dan blok jatuh. +commands.log=Mencatat gerakan tnt, proyektil, dan gerakan blok jatuh. commands.log.precision=§7Presisi mencatat diatur ke %s. commands.log.started=§7Mulai mencatat %s. commands.log.stopped=§7Berhenti mencatat %s. @@ -235,6 +238,57 @@ commands.peek.fail.noitems=§cTidak ada item di dalam %1 di %2. commands.peek.query.cleared=§7Mengintip kueri dihapus. commands.peek.query.set=§7Mengintip kueri diatur ke '%s'. +commands.playeraction=Membuat SimPlayer melakukan tindakan dengan waktu yang bervariasi. +commands.playeraction.invalidtiming=§cTidak valid %1 waktu: %2. +commands.playeraction.invalidticks=§cTidak valid '%1' durasi tick: %2. Diharapkan bilangan bulat. + +commands.playerinventory=Cetak daftar inventaris simplayer. +commands.playerinventory.noinventory=§cTidak ditemukan inventaris. +commands.playerinventory.empty=Inventaris §7%s's kosong. +commands.playerinventory.header=Inventaris %s's: +commands.playerinventory.item=§7- %1%2§7: %3 x%4 + +commands.playerjoin=Buat simplayer baru, bergabung di lokasi Anda. + +commands.playerleave=Buat simplayer keluar dari permainan. + +commands.playerlook=Buat simplayer melihat ke arah yang ditentukan. +commands.playerlook.at.missing=§cKoordinat lokasi yang akan dilihat tidak tersedia. +commands.playerlook.rotation.missing=§cNilai yaw atau pitch tidak tersedia untuk rotasi pandangan. +commands.playerlook.invalidoption=§cOpsi melihat tidak valid: '%s' +commands.playerlook.block.entityonly=§cPenargetan blok hanya dapat digunakan oleh entitas. +commands.playerlook.block.noblock=§cTidak ada blok dalam pandangan. +commands.playerlook.entity.entityonly=§cPenargetan entitas hanya dapat digunakan oleh entitas. +commands.playerlook.entity.noentity=§cTidak ada entitas dalam pandangan. +commands.playerlook.me.noserver=§cFitur "Self-targeting" tidak dapat digunakan oleh server. + +commands.playermove=Buat simplayer bergerak ke arah yang ditentukan. +commands.playermove.invalidoption=§cOpsi gerakan tidak valid: '%s' +commands.playermove.block.entityonly=§cPindah ke sebuah blok hanya dapat digunakan oleh entitas. +commands.playermove.block.noblock=§cTidak ada blok dalam pandangan. +commands.playermove.entity.entityonly=§cPindah ke entitas hanya dapat digunakan oleh entitas. +commands.playermove.entity.noentity=§cTidak ada entitas dalam pandangan. +commands.playermove.me.noserver=§cFitur "Moving to yourself" tidak dapat digunakan oleh server. + +commands.playerprefix=Tentukan awalan untuk nama simplayer. Gunakan ‘-none’ untuk menghapusnya. +commands.playerprefix.removed=§7Awalan “Simplayer” telah dihapus. +commands.playerprefix.set=§7Awalan Simplayer diatur ke "§r%s§r§7". + +commands.playerrejoin=Buat simplayer bergabung kembali di lokasi terakhirnya. + +commands.playerselect=Buat simplayer memilih slot hotbar. +commands.playerselect.invalidslot=§cNomor slot tidak valid: %s. Harusnya angka antara 0 hingga 8. + +commands.playersneak=Buat simplayer mulai atau berhenti menyelinap. + +commands.playersprint=Buat simplayer mulai atau berhenti berlari. + +commands.playerstop=Buat simplayer berhenti melakukan semua tindakan. + +commands.playerswapheld=Tukar item yang dipegang simplayer dengan item yang Anda pegang. + +commands.playertp=Buat simplayer berteleportasi ke lokasi Anda. + commands.pos=Menunjukkan posisi Anda saat ini, atau posisi pemain lain. commands.pos.self=§aPosisi Anda: §f%s commands.pos.other=§aPosisi %1's: §f%2 @@ -253,7 +307,7 @@ commands.simmap.help.display.reset=Mengatur lokasi untuk peta simulasi di InfoDi commands.simmap.header=§7Chunk yang termuat di %1§7 sekitar §2%2§7 commands.simmap.invalidDistance=§cJarak ‘%1’ tidak valid. Silakan gunakan nilai antara 1 dan %2. commands.simmap.config.distance=§7Jarak Peta Simulasi di InfoDisplay diperbarui ke %s. -commands.simmap.config.location=§7Lokasi Peta Simulasi di InfoDisplay diperbarui ke %1 di %2. +commands.simmap.config.location=§7Lokasi Peta Simulasi di InfoDisplay diperbarui ke %1 di %2§7. commands.simmap.config.reset=§7Lokasi Peta Simulasi di InfoDisplay diperbarui untuk mengikuti lokasi Anda saat ini. commands.sit=Membuat karakter Anda duduk. @@ -316,6 +370,12 @@ commands.trackevent.stop=§7Berhenti melacak %s. commands.trackevent.start=§7Mulai melacak %s. commands.trackevent.invalid=§cPeristiwa %1 tidak ditemukan di %2. +commands.velocity=Mempengaruhi kecepatan entitas. +commands.velocity.missingvelocity=§cSilakan masukkan kecepatan x, y, dan z. +commands.velocity.query=§7Kecepatan entitas (m/gt): +commands.velocity.add=§7Menambahkan kecepatan pada entitas (m/gt): +commands.velocity.set=§7Atur kecepatan untuk entitas (m/gt): + commands.warp=Teleportasi dan kelola warps. (Alias: w) commands.warp.edit=Menambahkan atau menghapus warp. commands.warp.tp=Menteleportasi Anda ke sebuah warp. @@ -359,36 +419,43 @@ rules.carefulBreak=Secara otomatis mengambil item saat menghancurkan blok dan me rules.cauldronConcreteConversion=Item serbuk beton di dalam kawah yang berisi air akan berubah menjadi item beton. rules.chunkBorders=Mengaktifkan chunk border saat panah berada di slot inventaris 13 (tengah atas). rules.collisionBoxes=Mengaktifkan collision box entitas saat panah berada di slot inventaris 14 (di sebelah tengah atas). +rules.creativeHotbarSwitching=Memungkinkan peralihan cepat antara beberapa hotbar. Letakkan panah di slot inventaris 17 (kanan atas), lalu menyelinap dan scroll untuk beralih. rules.creativeInstantTame=Menjinakkan hewan secara instan dengan makanannya masing-masing di mode kreatif. rules.creativeNetherWaterPlacement=Memungkinkan untuk menaruh air di Nether di mode kreatif. rules.creativeNoTileDrops=Mencegah item jatuh dari blok yang di hancurkan di mode kreatif. -rules.creativeOneHitKill=Memungkinkan pemain di mode kreatif untuk membunuh entitas apa pun dengan satu hit. Jika menyelinap, itu juga akan membunuh entitas terdekat. +rules.creativeOneHitKill=Memungkinkan pemain di mode kreatif untuk membunuh entitas apa pun dengan satu pukulan. Jika menyelinap, itu juga akan membunuh entitas terdekat. rules.dupeTnt=TNT dapat diduplikasi ketika digerakkan oleh piston dan diberi daya di sebelah blok not. rules.durabilityNotifier=Mengaktifkan suara denting dan tip akan muncul saat alat Anda memiliki %s durability tersisa. rules.durabilityNotifier.alert=§cDurability tersisa: %s rules.durabilitySwap=Menukar alat dengan 0 durability dari tangan Anda. -rules.echoShardsEnableShriekers=Menggunakan Serpihan Gema pada Penjerit Sculk memungkinkannya memanggil wardens. +rules.echoShardsEnableShriekers=Menggunakan Serpihan Gema pada Penjerit Sculk memungkinkannya memanggil warden. +rules.enderPearlChunkLoading=Memungkinkan mutiara ender untuk men-tick radius chunk yang ditentukan (persegi) di sekitarnya. rules.entityInstantDeath=Menghapus animasi kematian 20gt. Entitas juga tidak akan menjatuhkan xp. +rules.entitySeparation=Ketika entitas yang ditumpuk memicu pelat tekanan, satu entitas akan terlepas dari tumpukan ke arah yang dihadapi oleh dropper terdekat. rules.explosionChainReactionOnly=Membuat ledakan hanya mempengaruhi blok TNT. rules.explosionNoBlockDamage=Membuat ledakan tidak mempengaruhi blok. rules.explosionOff=Menonaktifkan ledakan sepenuhnya. rules.flippinArrows=Menggunakan panah pada blok akan membalik, memutar, atau membukanya. Menaruhnya di offhand akan membalik blok saat diletakkan. -rules.hotbarSwitching=Memungkinkan peralihan cepat antara beberapa hotbar. Letakkan panah di slot inventaris 17 (kanan atas), lalu menyelinap dan scroll untuk beralih. rules.instaminableDeepslate=Membuat batu tulis dasar bisa di intsa-mine saat menggunakan netherite, efisiensi 5, dan semangat (haste) 2. rules.instaminableEndstone=Membuat batu end bisa di intsa-mine saat menggunakan netherite, efisiensi 5, dan semangat (haste) 2. +rules.minecartChunkLoading=Memungkinkan kereta tambang untuk men-tick radius chunk yang ditentukan (persegi) di sekitar mereka selama 10 detik setelah mereka dimunculkan. rules.noWelcomeMessage=Menonaktifkan pesan selamat datang §lCanopy§r§8. rules.pistonBedrockBreaking=Memungkinkan piston menghancurkan batuan dasar saat membelakangi blok batuan dasar dan mengembang. rules.playerSit=Memungkinkan pemain untuk duduk setelah %s kali menyelinap dengan cepat. +rules.potionBoostedBreeding=Mengembalikan fitur yang memungkinkan ramuan kecepatan memengaruhi atribut perkawinan. rules.quickFillContainer=Menggunakan item pada kontainer dengan panah di slot inventaris 9 (kiri atas), akan menyimpan semua item tersebut ke dalam kontainer. rules.quickFillContainer.filled=§7Mengisi %1 dengan semua %2 rules.quickFillContainer.taken=§7Mengambil semua %1 dari %2 -rules.refillHand=Mengisi ulang tangan Anda dengan item dari inventaris saat Anda kehabisan. Letakkan panah di slot inventaris 10 (di sebelah kiri atas) untuk digunakan. +rules.refillHand=Mengisi ulang tangan Anda dengan item dari inventaris saat Anda kehabisan. Letakkan panah di slot inventaris 10 (di sebelah dari kiri atas) untuk digunakan. +rules.renderEndGatewayExits=Menampilkan lokasi keluar dari gerbang akhir setelah melewatinya. rules.renewableElytraDropChance=Hantu memiliki peluang untuk menjatuhkan elytra ketika terbunuh oleh peluru shulker. rules.renewableSponge=Guardians berubah menjadi elder guardians saat tersambar oleh petir. +rules.serverSideCollisionBoxes=Menampilkan kotak tabrakan berdasarkan posisi entitas di server, bukan posisi entitas di klien. +rules.simplayerSaving=Mengaktifkan penyimpanan data pemain untuk simplayer. Hal ini mengurangi performa, tetapi memungkinkan simplayer untuk mempertahankan inventaris dan lokasinya saat mereka keluar dan masuk dari permainan. +rules.simplayerRejoining=Membuat simplayer yang online bergabung kembali ketika dunia memuat ulang. rules.spawnEggSpawnWithMinecart=Saat menggunakan telur kemunculan di rel, entitas yang dihasilkan akan ditempatkan di kereta tambang di rel tersebut. rules.tntFuse=Waktu pembakaran sumbu TNT dalam tick. rules.tntPrimeMomentum=Hardcodes momentum prima TNT. -rules.universalChunkLoading=Membuat kereta tambang men-tick 5x5 area chunk di sekitarnya selama 10 detik setelah mereka muncul. rules.infoDisplay.biome=Menampilkan bioma tempat Anda berada. rules.infoDisplay.blockStates=Menampilkan status blok yang Anda targetkan. @@ -403,6 +470,8 @@ rules.infoDisplay.entities.display=§rEntitas: %s rules.infoDisplay.eventTrackers=Menampilkan jumlah peristiwa yang dilacak. rules.infoDisplay.facing=Menampilkan arah menghadap Anda menggunakan yaw dan pitch. rules.infoDisplay.facing.display=Menghadap: %1 %2 +rules.infoDisplay.heldItemDurability=Menampilkan durability item yang ada di tangan utama Anda. +rules.infoDisplay.heldItemDurability.display=Durability %s rules.infoDisplay.hopperCounterCounts=Menampilkan semua penghitung penampung yang aktif dalam warnanya masing-masing. Mode penghitung penampung mengontrol info ini. rules.infoDisplay.light=Menampilkan level cahaya di blok tempat kaki Anda berada. rules.infoDisplay.light.display=Cahaya: %1 §r(%2 langit§r) @@ -417,8 +486,12 @@ rules.infoDisplay.moonPhase.waningCrescent=Bulan Sabit Menurun rules.infoDisplay.moonPhase.waningGibbous=Bulan Sabit Menurun rules.infoDisplay.moonPhase.waxingCrescent=Bulan Sabit Menjamur rules.infoDisplay.moonPhase.waxingGibbous=Bulan Sabit Menjamur +rules.infoDisplay.noFog=Menghilangkan kabut. Air dan lava tidak terpengaruh. rules.infoDisplay.peekInventory=Menampilkan inventaris blok atau entitas yang Anda targetkan. rules.infoDisplay.peekInventory.empty=Kosong +rules.infoDisplay.ping=Menampilkan latensi jaringan Anda saat ini ke server. +rules.infoDisplay.ping.display=Ping: %s +rules.infoDisplay.renderSignalStrength=Menampilkan nilai kekuatan sinyal di atas debu batu merah. rules.infoDisplay.sessionTime=Menunjukkan waktu sejak Anda bergabung dengan dunia. rules.infoDisplay.sessionTime.display=§rSesi: %s rules.infoDisplay.signalStrength=Menampilkan kekuatan sinyal dari blok yang anda targetkan. @@ -439,3 +512,9 @@ rules.infoDisplay.weather=Menampilkan cuaca di dimensi Anda saat ini. rules.infoDisplay.weather.display=Cuaca: %s rules.infoDisplay.worldDay=Menampilkan jumlah hari Minecraft sejak dunia dibuat. rules.infoDisplay.worldDay.display=§rHari ke: %s + +## simplayer +simplayer.notonline=§cSimplayer ‘%s’ sedang tidak online. +simplayer.alreadyonline=§cSimplayer ‘%s’ sudah online. +simplayer.leave.broadcast=§e%s meninggalkan permainan +simplayer.swapheld.error=§cError saat menukar item: %s diff --git a/Canopy[RP]/texts/ja_JP.lang b/Canopy[RP]/texts/ja_JP.lang index 3a523b63..78ac0a70 100644 --- a/Canopy[RP]/texts/ja_JP.lang +++ b/Canopy[RP]/texts/ja_JP.lang @@ -68,7 +68,6 @@ commands.canopy.menu.timeout=§8%s ティック経過したためフォームが commands.canopy.menu.canceled=§8フォームがキャンセルされました。ルールは更新されていません。 commands.canopy.menu.submit=§a適用 commands.canopy.single=単一のルールの値を変更します。 -commands.canopy.multiple=複数のルールの値を変更します。 commands.canopy.infodisplayRule=§cルール '%1' はInfoDisplayのルールであり、切り替えるには %2info を使用する必要があります。詳細は %2help を参照してください。 commands.changedimension=エンティティを指定のディメンションにテレポートします。 diff --git a/Canopy[RP]/texts/zh_CN.lang b/Canopy[RP]/texts/zh_CN.lang index cee7e01c..dc6a1e23 100644 --- a/Canopy[RP]/texts/zh_CN.lang +++ b/Canopy[RP]/texts/zh_CN.lang @@ -1,450 +1,520 @@ -## vanilla -entity.canopy:rideable.name=Canopy Rideable ## 无需翻译 -action.hint.exit.canopy:rideable=潜行以站立 - -## generic -generic.welcome.start=§7当前服务器已加载 §l§aCanopy§r§7. 输入"./help"开始熟悉指令.§r -generic.welcome.extensions=§7已加载扩展: §a%s -generic.player.notfound=§c玩家 '%s' 未找到. -generic.target.notfound=§c目标未找到. -generic.entity.notfound=§c实体未找到. -generic.total=总数 - -## commands -commands.generic.unknown=§c无效的指令: '%1'. 输入"%2help" 获取更多信息. -commands.generic.nopermission=§c你没有足够的权限使用这个指令. -commands.generic.usage=§c使用方法: %s -commands.generic.blocked.survival=§c此指令只能在创造模式或者旁观模式下使用. -commands.generic.invalidsource=§c这个命令不能从这个来源执行. -commands.generic.invalidaction=§c无效的操作.请使用 %shelp 获取更多信息. - -commands.help=显示指令帮助信息. -commands.help.search.noresult=§c没有与此相关的结果: '%s' -commands.help.search.results=§l§aCanopy§r §2指令帮助页面找到此结果 '§r%1§2':%2 -commands.help.page.header=§l§aCanopy§r§2 指令帮助 页数: §f%1 -commands.help.infodisplay=开关游戏中的信息显示. -commands.help.rules=开关全局规则. -commands.help.extension.rules=开关扩展 §a%s§2 的规则. -commands.help.extension.commands=扩展 §a%s§2 的指令. - -commands.biomeedges=在区域内查找并显示生物群系边界. -commands.biomeedges.finderadded=§7正在分析生物群系边界... -commands.biomeedges.finderremoved=§7已移除最近的生物群系边界区域. -commands.biomeedges.findingstopped=§7已移除所有生物群系边界区域. -commands.biomeedges.notfinding=§c当前没有生物群系边界区域. -commands.biomeedges.missinglocations=§c请同时提供起点和终点位置. -commands.biomeedges.overcapacity=§c指定区域内的方块过多. (%1 > %2) - -commands.butcher=立即从世界中移除选定的或你注视着的实体. (译注: 被移除的实体不会在移除时播放死亡动画或掉落物品.) -commands.butcher.fail.player=§c不得移除玩家. -commands.butcher.fail.noneremoved=§c没有实体被移除. -commands.butcher.success=§7移除了 %1. -commands.butcher.success.many=§7移除了 %1 个实体: - -commands.camera=放置一个相机进行监视, 或者使用对生存友好的旁观模式. -commands.camera.spectate=进入和退出生存友好的旁观模式. (快捷指令: cs) -commands.camera.place.viewing=§c不能在监视过程中放置相机. -commands.camera.place.success=§7相机放置在 %s. -commands.camera.view.spectating=§c不能在旁观模式下进行相机监视. -commands.camera.view.fail=§c还没放置过一个相机. -commands.camera.view.dimension=§c请到 %s 去监视相机. -commands.camera.view.started=§a监视中 -commands.camera.view.ended=§7监视结束 -commands.camera.spectate.viewing=§c不能在监视中进入旁观模式. -commands.camera.spectate.gamemode=§c不能在特殊的旁观模式下变更游戏模式. -commands.camera.spectate.started=§a旁观模式 -commands.camera.spectate.ended=§7退出旁观模式 -commands.camera.spectate.hardcore=§c已阻止你进入旁观模式. 在极限模式下进行旁观存在一个软锁定漏洞. 这是Mojang的问题. -commands.camera.spectate.flying=§c你必须在地面上才可以进入旁观. -commands.camera.invalidaction=§c无效相机行为. - -commands.canopy=启用或者关闭规则. -commands.canopy.version=显示Canopy的当前版本和所有载入的扩展. -commands.canopy.version.message=§7当前服务器已加载 §l§aCanopy§r. -commands.canopy.version.extensions=§7已加载的扩展: -commands.canopy.menu=显示菜单来切换规则. -commands.canopy.menu.busy=§8关闭聊天窗口以访问UI窗口. -commands.canopy.menu.timeout=§8窗口在 %s 个刻度后超时. -commands.canopy.menu.canceled=§8更改已放弃(规则不会更新). -commands.canopy.menu.submit=§a确认(提交) -commands.canopy.single=修改单个规则的值. -commands.canopy.multiple=修改多个规则的值. -commands.canopy.infodisplayRule=§c规则 '%1' 是 信息显示 的一部分, 并且必须使用%2信息进行切换. 有关详细信息, 请使用%2帮助. - -commands.changedimension=传送你到指定的维度. -commands.changedimension.notfound=§c无效的维度参数. 请使用其中之一: %s -commands.changedimension.success.coords=§7成功传送到坐标 %1 维度 %2. -commands.changedimension.success=§7传送到维度 %s. -commands.changedimension.fail.coords=§c无效的坐标. 请提供所有 x、y、z 数值或不提供任何数值. - -commands.claimprojectiles=改变半径内所有弹射物的拥有者. -commands.claimprojectiles.fail.sourcenotplayer=§c请指定一个玩家来使用此命令. -commands.claimprojectiles.fail.nonefound=§7半径(%s blocks)内, 没有弹射物. -commands.claimprojectiles.success.self=§7成功将 %1 个弹射物(半径 %2 )的拥有者改为了你. -commands.claimprojectiles.success.other=§7成功将 %1 个弹射物(半径 %2 )的拥有者改为了 %3. - -commands.cleanup=清除半径内所有的掉落物和经验球. (快捷指令: k) -commands.cleanup.success=§7清除了 %s 个实体. - -commands.counter=管理漏斗计数器. (快捷指令: ct) -commands.counter.channel.notfound=§c无效的颜色: %s. 请使用羊毛方块的颜色之一. -commands.counter.query=显示指定的颜色频道的漏斗计数和效率. -commands.counter.query.empty=§7没有漏斗计数器在工作. -commands.counter.query.channel=§7频道 %1§7 (%2%3 min.), 总数: §f%4§7, (§f%5§7): -commands.counter.realtime=显示漏斗计数和效率(现实时间单位). -commands.counter.mode=设置漏斗计数器的模式. -commands.counter.mode.notfound=§c无效的模式: '%1'. 请使用以下的模式之一: %2 -commands.counter.mode.single=§7漏斗计数器 %1§7 模式: %2 -commands.counter.mode.single.actionbar=[%1] 设置 %2 漏斗计数器模式为 %3 -commands.counter.mode.all=§7所有漏斗计数器模式: %s -commands.counter.mode.all.actionbar=[%1] 设置所有漏斗计数器模式为 %2 -commands.counter.reset=重置所有的漏斗计数器和计时器. -commands.counter.reset.single=§7已重置计时器和计数器: %s -commands.counter.reset.single.actionbar=[%1] 重置 %2 漏斗计数器. -commands.counter.reset.all=§7所有的漏斗计数器和计时器已被重置. -commands.counter.reset.all.actionbar=[%s] 重置了所有漏斗计数器. -commands.counter.remove=删除指定频道中的所有漏斗. -commands.counter.remove.single=§7已移除 %s 中的所有漏斗. -commands.counter.remove.single.actionbar=[%1] 已移除 %s 中的所有漏斗 -commands.counter.remove.all=§7已移除所有频道中的所有漏斗. -commands.counter.remove.all.actionbar=[%s] 已移除所有频道中的所有漏斗 - -commands.data=显示你所指方块或者实体的信息. -commands.data.notarget.id=§c没有于此id相配的实体 '%2'. -commands.data.properties=§a性质:§r %s -commands.data.states=§a状态:§r %s -commands.data.components=§a元件:§r %s -commands.data.tags=§a标签:§r %s -commands.data.dynamicProperties=§a动态属性:§r %1 的总字节数: %2 -commands.data.effects=§a效果:§r %s -commands.data.other=§a其他:§r 头部位置: %1 旋转: [%2, %3], 速度: %4, 视角方向: %5 - -commands.debugentity=显示有关实体的调试信息. -commands.debugentity.invalidProperty=§c调试属性无效. -commands.debugentity.invalidAction=§c调试行为无效. -commands.debugentity.added=§7为 %2 个实体添加了 '%1' 调试信息显示: -commands.debugentity.removed=§7为 %2 个实体删除了 '%1' 调试信息显示: - -commands.distance=计算两点的距离. (快捷指令: d) -commands.distance.target=计算你所指的方块或者实体与你的距离. -commands.distance.fromto=计算两点的距离. -commands.distance.from=保存位置用于计算距离. -commands.distance.from.success=§7已保存位置: %s -commands.distance.to=计算已保存的位置与指定位置之间的距离. -commands.distance.to.fail.nosave=§c未保存位置. 保存一个位置用于计算: %s到 [x y z] 的距离 -commands.distance.target.notfound=§c没有找到方块或者实体用于计算距离. -commands.distance.cartesian=§7几何距离: §r§l%s§r -commands.distance.cylindrical=§7几何距离(XZ平面): §r§l%s§r -commands.distance.manhattan=§7§7曼哈顿距离: §r§l%s§r - -commands.entitydensity=在所在维度寻找实体密集区域. -commands.entitydensity.fail.noentities=§7没有找到实体密集区域 %s. 可能没有实体在这个维度? -commands.entitydensity.fail.dimension=§c无效的维度参数. 请使用以下参数之一: %s -commands.entitydensity.fail.gridsize=§c无效的网格大小. 请使用1到2048之间的整数. 建议使用: 100-1024. -commands.entitydensity.success.header=§7实体密集区域在 %1 (网格大小 %2x%3): -commands.entitydensity.success.area=§7-有 %1 个实体在 %2, %3 - -commands.gamemode.s=已将你的游戏模式设为生存. -commands.gamemode.a=已将你的游戏模式设为冒险. -commands.gamemode.c=已将你的游戏模式设为创造. -commands.gamemode.sp=已将你的游戏模式设为旁观. - -commands.generator=管理漏斗生成器. (快捷指令: gt) -commands.generator.channel.notfound=§c无效颜色: %s. 请使用一种羊毛块颜色. -commands.generator.query=显示指定颜色的漏斗生成器的计数和速率. -commands.generator.query.empty=§7这里没有使用中的漏斗生成器. -commands.generator.query.channel=§7已生成 %1 物品 在§7 (%2%3 min.), 共计: §f%4§7, (§f%5§7): -commands.generator.realtime=基于真实世界时间(而非游戏时间)显示计数和速率. -commands.generator.reset=重置所有漏斗生成器并重启计时器. -commands.generator.reset.single=§7重置并重新计时: %s -commands.generator.reset.single.actionbar=[%1] 重置 %2 漏斗生成器. -commands.generator.reset.all=§7所有频道已复位, 漏斗发生器计时器已启动. -commands.generator.reset.all.actionbar=[%s] 重置所有漏斗生成器 -commands.generator.remove=删除指定频道中的所有漏斗生成器. -commands.generator.remove.single=§7已删除所有漏斗生成器在 %s. -commands.generator.remove.single.actionbar=[%1] 已删除所有漏斗生成器在 %2 -commands.generator.remove.all=§7已删除所有频道中的所有漏斗生成器. -commands.generator.remove.all.actionbar=[%s] 已删除所有频道中的所有漏斗生成器. - -commands.health=显示服务器的TPS、MSPT和实体数量. -commands.health.startprofile=§7为游戏刻时间进行性能分析中... -commands.health.fail.mspt=§c不能计算出MSPT. 请反馈问题. - -commands.hss=寻找并显示世界中的HSS(硬编码生成点, Hardcoded Spawn Spots). -commands.hss.invalidaction=§c无效的HSS操作. -commands.hss.started=§7正在计算你所在位置结构的硬编码生成点. -commands.hss.started.fortress=§7已开始寻找下界要塞的硬编码生成点. 将模拟生成过程以加快速度. -commands.hss.started.nostructure=在你所在位置未发现带有硬编码生成点的自然生成结构. -commands.hss.started.unloaded=无法检测结构边界. 请确保包含该结构的所有区块均已加载. -commands.hss.started.worldbounds=无法检测结构边界. 结构边界搜索超出了世界边界. -commands.hss.stopped=§7已停止显示硬编码生成点. -commands.hss.alreadyrunning=§c你已经在寻找下界要塞的硬编码生成点了. -commands.hss.notrunning=§c你当前并未在寻找下界要塞的硬编码生成点. - -commands.info=启用/禁用信息显示规则. (快捷指令: i) -commands.info.menu=显示用于切换信息显示规则的菜单. -commands.info.single=切换单个信息显示规则. -commands.info.multiple=切换多个信息显示规则. -commands.info.all=切换所有信息显示规则. -commands.info.allupdated=§r§7 所有信息显示规则. -commands.info.canopyRule=§c规则 '%1' 是全局规则, 并且必须使用%2信息进行切换. 有关详细信息, 请使用%2帮助. - -commands.jump=传送到所指方块上. (快捷指令: j) -commands.jump.fail.noblock=§c没找到方块传送跳跃. - -commands.log=追踪TNT、弹射物和下落的方块运动. -commands.log.precision=§7追踪精度设置为 %s. -commands.log.started=§7开始追踪 %s. -commands.log.stopped=§7停止追踪 %s. -commands.log.invalidtype=§c日志类型无效. - -commands.lifetime.tracking=开始和停止跟踪实体生命周期及生成/移除原因. -commands.lifetime.tracking.unknownaction=§c未知的跟踪操作. -commands.lifetime.tracking.already=§c已经在跟踪生命周期了. -commands.lifetime.tracking.not=§c当前没有在跟踪生命周期. -commands.lifetime.tracking.start=§a已开始实体生命周期跟踪. -commands.lifetime.tracking.stop=§a已停止实体生命周期跟踪. -commands.lifetime.tracking.restart=§a已重新启动实体生命周期跟踪. -commands.lifetime.query=查询详细的实体生命周期及生成/移除原因统计. -commands.lifetime.query.item=查询详细的物品实体生命周期及生成/移除原因统计. -commands.lifetime.query.invalidaction=§c请输入有效的查询操作. -commands.lifetime.query.invalidentity=§c请输入有效的实体类型. -commands.lifetime.query.header=§l生命周期统计§r (已跟踪 %s 分钟的 -commands.lifetime.query.dimensionheader=§l%1§r§7: §2%2§7 生成 (%3/小时), §4%4§7 移除 (%5/小时) -commands.lifetime.query.body=§7- §f%1 §2生§7/§4移§7: §2%2§7/§4%3§7, §3存§7: §3%4§7/§b%5§7/§5%6§7 -commands.lifetime.query.entity=%s的生命周期结果 -commands.lifetime.query.entity.header=%s的生命周期结果 -commands.lifetime.query.entity.lifetime.header=§3生命周期概览 -commands.lifetime.query.entity.lifetime.min=§7- §f最小生命周期§7: §3%s§7 -commands.lifetime.query.entity.lifetime.max=§7- §f最大生命周期§7: §b%s§7 -commands.lifetime.query.entity.lifetime.average=§7- §f平均生命周期§7: §5%s§7 -commands.lifetime.query.entity.spawns.header=§2生成原因 -commands.lifetime.query.entity.spawns=§7- §f%1§7: §2%2§7, (%3/小时) §f%4% -commands.lifetime.query.entity.removals.header=§4移除原因 -commands.lifetime.query.entity.removals=§7- §f%1§7: §4%2§7, (%3/小时) §f%4% -commands.lifetime.query.entity.unknowntype=未知 -commands.lifetime.query.realtime= 真实时间) -commands.lifetime.query.realtime.unit= 秒 -commands.lifetime.query.ticktime= 游戏时间) -commands.lifetime.query.ticktime.unit= 游戏刻 - -commands.loop=在一个tick中多次运行原版命令. - -commands.peek=窥视目标物品栏并可以高亮列表中的物品. -commands.peek.fail.unloaded=§c在 %s 的目标未加载. -commands.peek.fail.noinventory=§c没找到物品栏: %1 在 %2. -commands.peek.fail.noitems=§c没找到物品: %1 在 %2. -commands.peek.query.cleared=§7窥视列表清除. -commands.peek.query.set=§7窥视列表设置为 '%s'. - -commands.pos=显示你的位置或者其他玩家的位置. -commands.pos.self=§a你的位置: §f%s -commands.pos.other=§a%1的位置: §f%2 -commands.pos.dimension=§7维度: §7%s -commands.pos.relative.overworld=§7地狱映射到主世界的位置: §a%s -commands.pos.relative.nether=§7主世界映射到地狱的位置: §c%s - -commands.retest=重置刷怪追踪、漏斗计数器及漏斗发生器 -commands.retest.success=§7已重置刷怪计数器、漏斗计数器及漏斗发生器 - -commands.simmap=显示您附近或指定位置的已加载区块的地图. -commands.simmap.help.distance=显示block半径等于指定距离的地图. -commands.simmap.help.location=显示指定位置周围的地图. -commands.simmap.help.display.set=在信息显示中设置模拟地图的距离或位置. -commands.simmap.help.display.reset=在信息显示中设置模拟地图的位置,使其跟随当前位置. -commands.simmap.header=§7加载区块 %1§7 在 §2%2§7 附近 -commands.simmap.invalidDistance=§c距离 '%1' 无效. 请使用从 1 到 %2 的距离. -commands.simmap.config.distance=§7信息显示模拟地图距离更新为 %s. -commands.simmap.config.location=§7信息显示模拟地图位置更新为 %1 在 %2§7. -commands.simmap.config.reset=§7信息显示模拟地图位置更新为跟随您的当前位置. - -commands.sit=使您操作的玩家坐下. -commands.sit.busy=§c您过忙以至于无法坐下. - -commands.spawn=模拟生成和检测生成指令. -commands.spawn.entities=展示当前世界所有实体及其位置的列表. -commands.spawn.recent=显示最近30s所有怪物的生成数据. 请指定一种怪物到筛选器中. -commands.spawn.tracking.start=开始跟踪怪物生成. 请指定坐标划定区域用于跟踪. -commands.spawn.tracking.start.success=§7开始检测怪物生成. -commands.spawn.tracking.start.mob=§7正在检测该怪物的生成: %s. -commands.spawn.tracking.start.mob.actionbar=[%1] §a已加入 %2 到怪物跟踪并重置. -commands.spawn.tracking.start.area= 区域: %1 到 %2. -commands.spawn.tracking.start.mocking= 由于怪物模拟生成已开启, 怪物不再会生成但会被跟踪. -commands.spawn.tracking.start.actionbar=[%s] §7开始跟踪怪物生成. -commands.spawn.tracking.mob=开始检测指定的怪物生成. 请指定坐标划定区域. 重新运行命令来添加更多怪物种类. -commands.spawn.tracking.mob.invalid=§c无效的怪物名称: %s -commands.spawn.tracking.query=总结自测试开始的所有的生成. -commands.spawn.tracking.query.dimension=§7维度 %s§r: -commands.spawn.tracking.no=§c怪物生成没有被在被跟踪. -commands.spawn.tracking.already=§c怪物生成正在被跟踪. -commands.spawn.tracking.test=重置所有怪物生成计数器和漏斗计数器. -commands.spawn.tracking.test.success=§7怪物生成计数器和漏斗计数器已被重置. -commands.spawn.tracking.test.success.actionbar=[%s] §7已重置怪物生成和漏斗计数器. -commands.spawn.tracking.stop=停止跟踪怪物生成. -commands.spawn.tracking.stop.success=§7怪物生成不再被跟踪. -commands.spawn.tracking.stop.actionbar=[%s] §7已停止怪物生成跟踪. -commands.spawn.reset=Resets all spawn counters. -commands.spawn.mocking=开启/关闭怪物生成但怪物生成进程仍在进行. -commands.spawn.mocking.enable=§a模拟生成已开启. 怪物不再实际生成, 但生成进程仍在继续. -commands.spawn.mocking.disable=§c模拟生成已关闭. 怪物生成现在回归正常. -commands.spawn.mocking.enable.actionbar=[%s] §a模拟生成已开启. -commands.spawn.mocking.disable.actionbar=[%s] §c模拟生成已关闭. - -commands.summontnt=生成指定数量的点燃的TNT到你的位置. -commands.summontnt.fail.none=§cTNT生成失败. -commands.summontnt.success=§7已生成 §c%s 个TNT§7. - -commands.tick=设置和控制服务器tick速度. -commands.tick.mspt=放慢服务器tick速度到指定mspt. -commands.tick.mspt.fail=§cmspt不能低于50.0. -commands.tick.mspt.success=§7%1 将服务器的tick设置到 %2 mspt. -commands.tick.step=允许服务器已正常的速度步进指定游戏刻数. -commands.tick.step.fail=§c没设置游戏速度不能步进游戏刻. -commands.tick.step.start=§7%1 正在步进 %2 个游戏刻... -commands.tick.step.done=§7游戏刻步进完成. -commands.tick.reset=使游戏速度回到正常. -commands.tick.reset.success=§7%s 成功重置游戏速度. -commands.tick.sleep=暂停服务器指定时间 (单位: 毫秒). -commands.tick.sleep.fail=§c无效的停止时间. -commands.tick.sleep.success=§7%1 正在停止服务器 %2 毫秒. - -commands.tntfuse=以游戏刻为单位设置TNT的引信时间. -commands.tntfuse.reset.success=§7重置所有TNT引信时间到 §a80§7 游戏刻. -commands.tntfuse.set.fail=§c无效的引信时间: %1 ticks. 必须时 0 到 %2 ticks. -commands.tntfuse.set.success=§7TNT的引信时间设置为 §a%s§7 ticks. - -commands.trackevent=计算游戏事件发生数量. 显示数量在信息栏里. -commands.trackevent.stop=§7停止跟踪游戏事件 %s. -commands.trackevent.start=§7开始跟踪游戏事件 %s. -commands.trackevent.invalid=§c游戏事件 %1 未在 %2 中找到. - -commands.velocity=影响实体的速度. -commands.velocity.missingvelocity=§c请输入x、y、z方向的速度. -commands.velocity.query=§7实体速度 (m/gt): -commands.velocity.add=§7已为实体增加速度 (m/gt): -commands.velocity.set=§7已设置实体速度 (m/gt): - -commands.warp=传送和管理路径点. (快捷指令: w) -commands.warp.edit=添加和移除路径点. -commands.warp.tp=将你传送到一个路径点. -commands.warp.list=列出所有可用的路径点. -commands.warp.exists=§c路径点 '%s' 已经存在. 使用 ./warps 列出所有的路径点. -commands.warp.noexist=§c路径点 '%s' 未找到. 使用 ./warps 列出所有的路径点. -commands.warp.add.success=§7路径点 '%s' 已被添加. -commands.warp.remove.success=§7路径点 '%s' 已被移除. -commands.warp.tp.fail.dimension=§c请到 %1 来传送到 '%2'. -commands.warp.tp.success=§7传送到路径点 '%s'. -commands.warp.list.empty=§7当前没有路径点. -commands.warp.list.header=§7可用的路径点: - -## rules -rules.generic.unknown=§c无效的规则: %1. 使用 %2help 来获取更多信息. -rules.generic.invalidtype=§c无效类型: '%1'的值必须是%2类型. -rules.generic.outofrange=§c超出范围: '%1'的值必须在%2和%3之间 -rules.generic.outofrange.withother= 或为以下之一: %s -rules.generic.blocked=§c%s 规则仍关闭. -rules.generic.status=§7%1 现在的状态是 §l -rules.generic.nochange=§7%1 现在的状态已经是 §l -rules.generic.updated=§7%1 现在的状态更新为 §l -rules.generic.enabled=§a已启用 -rules.generic.disabled=§c已禁用 -rules.generic.ability=§7能力 -rules.generic.defaultvalue=(默认值: %s) - -rules.commandCamera=开启camera指令. -rules.commandClaimProjectiles=开启claimprojectiles指令. -rules.hopperCounters=开启counter指令和漏斗计数器功能. -rules.hopperGenerators=启用生成器命令和漏斗生成器功能. -rules.commandJumpSurvival=开启jump指令(在生存模式下). -rules.commandPosOthers=允许在其他玩家上使用pos命令. -rules.commandWarp=开启warp & warps指令. -rules.commandWarpSurvival=开启warp & warps指令(在生存模式下). -rules.allowBubbleColumnPlacement=关闭放置气泡柱限制. -rules.allowPeekInventory=启用peek命令和PeekInventory信息显示规则,并可以使用小望远镜进行窥视. -rules.armorStandRespawning=盔甲架被弹射物击中会掉落所持物品. -rules.autoItemPickup=破坏方块自动拾取物品. -rules.carefulBreak=破坏方块和潜行时自动拾取物品. -rules.cauldronConcreteConversion=混凝土粉末物品丢入装水的炼药锅变成固化的混凝土. -rules.chunkBorders=当箭在物品栏槽位13(顶部中间)时, 启用区块边界可视化. -rules.collisionBoxes=当箭在物品栏槽位14(顶部中间旁边)时, 启用实体碰撞箱可视化. -rules.creativeInstantTame=允许在在创造模式下用对应的食物立即驯服动物. -rules.creativeNetherWaterPlacement=允许在创造模式下于下界放置水 -rules.creativeNoTileDrops=允许在创造模式下防止方块从被破坏的方块中掉落. -rules.creativeOneHitKill=一击必杀. 蹲着时, 玩家周围的实体也会被杀(在创造模式下). -rules.dupeTnt=TNT被活塞推动并且被音符盒充能时复制. -rules.durabilityNotifier=当你的工具还剩%s耐久度时, 发出叮的响声和提示. -rules.durabilityNotifier.alert=§c耐久度还剩: %s -rules.durabilitySwap=从您的手中带走0耐久物品. -rules.echoShardsEnableShriekers=在幽匿感测体上使用回响碎片可使其召唤监守者. -rules.entityInstantDeath=消除20游戏刻死亡动画. 同时实体不再会掉落经验. -rules.explosionChainReactionOnly=使爆炸只影响TNT方块. -rules.explosionNoBlockDamage=关闭爆炸破坏. -rules.explosionOff=完全关闭爆炸. -rules.flippinArrows=对方块使用箭可以翻转、旋转和打开. 放在副手可以使方块放置时翻转. -rules.creativeHotbarSwitching=允许多个快捷栏之间快速切换. 将箭放入物品栏槽位17(右上角), 然后潜行并滚动以切换. -rules.instaminableDeepslate=急迫2下使用效率5的下届合金镐时, 可以秒破深板岩. -rules.instaminableEndstone=急迫2下使用效率5的下届合金镐时, 可以秒破末地石. -rules.noWelcomeMessage=禁用 §lCanopy§r§8 欢迎消息. -rules.pistonBedrockBreaking=允许活塞在背对基岩时, 伸出时破坏基岩. -rules.playerSit=允许玩家通过快速 %s 次蹲起操作来坐下. -rules.potionBoostedBreeding=重新引入允许速度药水影响繁殖属性的行为. -rules.quickFillContainer=对容器使用一个物品时, 放一个箭在物品栏的左上角, 可以将背包里所有的这个物品放进容器里. -rules.quickFillContainer.filled=§7已填充%1通过%2 -rules.quickFillContainer.taken=§7已取走所有%1从%2 -rules.refillHand=当你手上的物品用完时,将用背包里的自动填充. 在清单左上角旁边的插槽中放置一个箭头(弓箭)以供使用. -rules.renewableElytraDropChance=幻翼被潜影贝导弹杀死时有几率掉落鞘翅. -rules.renewableSponge=守卫者被闪电击中会变成远古守卫者. -rules.spawnEggSpawnWithMinecart=在铁轨上使用刷怪蛋时, 生成的实体将被放置在铁轨上的矿车中. -rules.tntFuse=TNT引信时间(游戏刻). -rules.tntPrimeMomentum=硬编码TNT引爆动量. -rules.universalChunkLoading=矿车生成时加载5x5的区块10s. - -rules.infoDisplay.biome=显示群系. -rules.infoDisplay.blockStates=显示你看着的方块状态. -rules.infoDisplay.cardinalFacing=使用N, S, E, W 和坐标系方向 (ex. N (-z))显示你的朝向. -rules.infoDisplay.cardinalFacing.display=§r朝向: §7%s§r -rules.infoDisplay.chunkCoords=显示您所在的方块的坐标以及您在该方块中的位置. -rules.infoDisplay.chunkCoords.display=§r区块: §7%1 in %2§r -rules.infoDisplay.coords=坐标显示精确到两位小数. -rules.infodisplay.dimension=显示你当前的维度. -rules.infoDisplay.entities=显示你面前的实体个数. -rules.infoDisplay.entities.display=§r实体数: §7%s§r -rules.infoDisplay.eventTrackers=显示跟踪的游戏事件发生次数. -rules.infoDisplay.facing=显示你的仰角和俯角. -rules.infoDisplay.facing.display=§r俯角: §7%1§r 仰角: §7%2§r -rules.infoDisplay.hopperCounterCounts=显示所有漏斗计数器和对应颜色. 漏斗计数器模式控制这个信息. -rules.infoDisplay.light=显示脚下亮度. -rules.infoDisplay.light.display=§r亮度: §e%s§r -rules.infoDisplay.liquidStates=显示你目标液体的状态. -rules.infoDisplay.liquidTarget=显示你目标液体的标识符. -rules.infoDisplay.moonPhase=显示月相. -rules.infoDisplay.moonPhase.firstQuarter=上弦月 -rules.infoDisplay.moonPhase.full=满月 -rules.infoDisplay.moonPhase.lastQuarter=下弦月 -rules.infoDisplay.moonPhase.new=新月 -rules.infoDisplay.moonPhase.waningCrescent=残月 -rules.infoDisplay.moonPhase.waningGibbous=亏凸月 -rules.infoDisplay.moonPhase.waxingCrescent=眉月 -rules.infoDisplay.moonPhase.waxingGibbous=盈凸月 -rules.infoDisplay.peekInventory=显示方块或者实体的物品栏. -rules.infoDisplay.peekInventory.empty=空 -rules.infoDisplay.sessionTime=显示的你加入存档的时间. -rules.infoDisplay.sessionTime.display=§r在线时间: §7%s§r -rules.infoDisplay.signalStrength=显示所指方块的信号强度. -rules.infoDisplay.simulationMap=显示您周围已加载的方块的地图. simmap命令可用于对此进行配置. 警告: 这是一个非常滞后的规则. -rules.infoDisplay.slimeChunk=显示当前区块是否是史莱姆区块(只在史莱姆区块时显示). -rules.infoDisplay.slimeChunk.display=§7(§a史莱姆区块§7)§r -rules.infoDisplay.speed=以米每秒为单位显示当前速度. -rules.infodisplay.structures=显示你所在位置的自然生成结构. -rules.infodisplay.structures.display=结构: -rules.infodisplay.structures.display.none=无 -rules.infoDisplay.target=显示你目标的方块或实体的标识符. -rules.infoDisplay.timeOfDay=显示游戏中时间. -rules.infoDisplay.tps=显示TPS. -rules.infoDisplay.tps.display=§rTPS: %s§r -rules.infoDisplay.tpsAndEntities.display=§rTPS: %1§r 实体数: §7%2§r -rules.infoDisplay.velocity=显示你当前的x,y,z速度(米/游戏刻). -rules.infoDisplay.weather=显示你当前维度的天气. -rules.infoDisplay.weather.display=天气: %s -rules.infoDisplay.worldDay=显示存档天数. -rules.infoDisplay.worldDay.display=§r存档天数: §7%s§r +## vanilla +entity.canopy:rideable.name=Canopy Rideable ## 无需翻译 +action.hint.exit.canopy:rideable=潜行以站立 + +## generic +generic.welcome.start=§7当前服务器已加载 §l§aCanopy§r§7. 输入"./help"开始熟悉指令.§r +generic.welcome.extensions=§7已加载扩展: §a%s +generic.player.notfound=§c玩家 '%s' 未找到. +generic.target.notfound=§c目标未找到. +generic.entity.notfound=§c实体未找到. +generic.total=总数 + +## commands +commands.generic.unknown=§c无效的指令: '%1'. 输入"%2help" 获取更多信息. +commands.generic.nopermission=§c你没有足够的权限使用这个指令. +commands.generic.usage=§c使用方法: %s +commands.generic.blocked.survival=§c此指令只能在创造模式或者旁观模式下使用. +commands.generic.invalidsource=§c这个命令不能从这个来源执行. +commands.generic.invalidaction=§c无效的操作.请使用 %shelp 获取更多信息. + +commands.help=显示指令帮助信息. +commands.help.search.noresult=§c没有与此相关的结果: '%s' +commands.help.search.results=§l§aCanopy§r §2指令帮助页面找到此结果 '§r%1§2':%2 +commands.help.page.header=§l§aCanopy§r§2 指令帮助 页数: §f%1 +commands.help.infodisplay=开关游戏中的信息显示. +commands.help.rules=开关全局规则. +commands.help.extension.rules=开关扩展 §a%s§2 的规则. +commands.help.extension.commands=扩展 §a%s§2 的指令. + +commands.biomeedges=在区域内查找并显示生物群系边界. +commands.biomeedges.finderadded=§7正在分析生物群系边界... +commands.biomeedges.finderremoved=§7已移除最近的生物群系边界区域. +commands.biomeedges.findingstopped=§7已移除所有生物群系边界区域. +commands.biomeedges.notfinding=§c当前没有生物群系边界区域. +commands.biomeedges.missinglocations=§c请同时提供起点和终点位置. +commands.biomeedges.overcapacity=§c指定区域内的方块过多. (%1 > %2) + +commands.butcher=立即从世界中移除选定的或你注视着的实体. (译注: 被移除的实体不会在移除时播放死亡动画或掉落物品.) +commands.butcher.fail.player=§c不得移除玩家. +commands.butcher.fail.noneremoved=§c没有实体被移除. +commands.butcher.success=§7移除了 %1. +commands.butcher.success.many=§7移除了 %1 个实体: + +commands.camera=放置一个相机进行监视, 或者使用对生存友好的旁观模式. +commands.camera.spectate=进入和退出生存友好的旁观模式. (快捷指令: cs) +commands.camera.place.viewing=§c不能在监视过程中放置相机. +commands.camera.place.success=§7相机放置在 %s. +commands.camera.view.spectating=§c不能在旁观模式下进行相机监视. +commands.camera.view.fail=§c还没放置过一个相机. +commands.camera.view.dimension=§c请到 %s 去监视相机. +commands.camera.view.started=§a监视中 +commands.camera.view.ended=§7监视结束 +commands.camera.spectate.viewing=§c不能在监视中进入旁观模式. +commands.camera.spectate.gamemode=§c不能在特殊的旁观模式下变更游戏模式. +commands.camera.spectate.started=§a旁观模式 +commands.camera.spectate.ended=§7退出旁观模式 +commands.camera.spectate.hardcore=§c已阻止你进入旁观模式. 在极限模式下进行旁观存在一个软锁定漏洞. 这是Mojang的问题. +commands.camera.spectate.flying=§c你必须在地面上才可以进入旁观. +commands.camera.invalidaction=§c无效相机行为. + +commands.canopy=启用或者关闭规则. +commands.canopy.version=显示Canopy的当前版本和所有载入的扩展. +commands.canopy.version.message=§7当前服务器已加载 §l§aCanopy§r. +commands.canopy.version.extensions=§7已加载的扩展: +commands.canopy.menu=显示菜单来切换规则. +commands.canopy.menu.busy=§8关闭聊天窗口以访问UI窗口. +commands.canopy.menu.timeout=§8窗口在 %s 个刻度后超时. +commands.canopy.menu.canceled=§8更改已放弃(规则不会更新). +commands.canopy.menu.submit=§a确认(提交) +commands.canopy.single=修改单个规则的值. +commands.canopy.infodisplayRule=§c规则 '%1' 是 信息显示 的一部分, 并且必须使用%2信息进行切换. 有关详细信息, 请使用%2帮助. + +commands.changedimension=传送你到指定的维度. +commands.changedimension.notfound=§c无效的维度参数. 请使用其中之一: %s +commands.changedimension.success.coords=§7成功传送到坐标 %1 维度 %2. +commands.changedimension.success=§7传送到维度 %s. +commands.changedimension.fail.coords=§c无效的坐标. 请提供所有 x、y、z 数值或不提供任何数值. + +commands.claimprojectiles=改变半径内所有弹射物的拥有者. +commands.claimprojectiles.fail.sourcenotplayer=§c请指定一个玩家来使用此命令. +commands.claimprojectiles.fail.nonefound=§7半径(%s blocks)内, 没有弹射物. +commands.claimprojectiles.success.self=§7成功将 %1 个弹射物(半径 %2 )的拥有者改为了你. +commands.claimprojectiles.success.other=§7成功将 %1 个弹射物(半径 %2 )的拥有者改为了 %3. + +commands.cleanup=清除半径内所有的掉落物和经验球. (快捷指令: k) +commands.cleanup.success=§7清除了 %s 个实体. + +commands.counter=管理漏斗计数器. (快捷指令: ct) +commands.counter.channel.notfound=§c无效的颜色: %s. 请使用羊毛方块的颜色之一. +commands.counter.query=显示指定的颜色频道的漏斗计数和效率. +commands.counter.query.all=显示所有漏斗计数器的计数和速率. +commands.counter.query.empty=§7没有漏斗计数器在工作. +commands.counter.query.channel=§7频道 %1§7 (%2%3 min.), 总数: §f%4§7, (§f%5§7): +commands.counter.realtime=显示漏斗计数和效率(现实时间单位). +commands.counter.mode=设置漏斗计数器的模式. +commands.counter.mode.notfound=§c无效的模式: '%1'. 请使用以下的模式之一: %2 +commands.counter.mode.single=§7漏斗计数器 %1§7 模式: %2 +commands.counter.mode.single.actionbar=[%1] 设置 %2 漏斗计数器模式为 %3 +commands.counter.mode.all=§7所有漏斗计数器模式: %s +commands.counter.mode.all.actionbar=[%1] 设置所有漏斗计数器模式为 %2 +commands.counter.reset=重置所有的漏斗计数器和计时器. +commands.counter.reset.single=§7已重置计时器和计数器: %s +commands.counter.reset.single.actionbar=[%1] 重置 %2 漏斗计数器. +commands.counter.reset.all=§7所有的漏斗计数器和计时器已被重置. +commands.counter.reset.all.actionbar=[%s] 重置了所有漏斗计数器. +commands.counter.remove=删除指定频道中的所有漏斗. +commands.counter.remove.single=§7已移除 %s 中的所有漏斗. +commands.counter.remove.single.actionbar=[%1] 已移除 %s 中的所有漏斗 +commands.counter.remove.all=§7已移除所有频道中的所有漏斗. +commands.counter.remove.all.actionbar=[%s] 已移除所有频道中的所有漏斗 + +commands.data=显示你所指方块或者实体的信息. +commands.data.notarget.id=§c没有于此id相配的实体 '%2'. +commands.data.properties=§a性质:§r %s +commands.data.states=§a状态:§r %s +commands.data.components=§a元件:§r %s +commands.data.tags=§a标签:§r %s +commands.data.dynamicProperties=§a动态属性:§r %1 的总字节数: %2 +commands.data.effects=§a效果:§r %s +commands.data.other=§a其他:§r 头部位置: %1 旋转: [%2, %3], 速度: %4, 视角方向: %5 + +commands.debugentity=显示有关实体的调试信息. +commands.debugentity.invalidProperty=§c调试属性无效. +commands.debugentity.invalidAction=§c调试行为无效. +commands.debugentity.added=§7为 %2 个实体添加了 '%1' 调试信息显示: +commands.debugentity.removed=§7为 %2 个实体删除了 '%1' 调试信息显示: + +commands.distance=计算两点的距离. (快捷指令: d) +commands.distance.target=计算你所指的方块或者实体与你的距离. +commands.distance.fromto=计算两点的距离. +commands.distance.from=保存位置用于计算距离. +commands.distance.from.success=§7已保存位置: %s +commands.distance.to=计算已保存的位置与指定位置之间的距离. +commands.distance.to.fail.nosave=§c未保存位置. 保存一个位置用于计算: %s到 [x y z] 的距离 +commands.distance.target.notfound=§c没有找到方块或者实体用于计算距离. +commands.distance.cartesian=§7几何距离: §r§l%s§r +commands.distance.cylindrical=§7几何距离(XZ平面): §r§l%s§r +commands.distance.manhattan=§7§7曼哈顿距离: §r§l%s§r + +commands.entitydensity=在所在维度寻找实体密集区域. +commands.entitydensity.fail.noentities=§7没有找到实体密集区域 %s. 可能没有实体在这个维度? +commands.entitydensity.fail.dimension=§c无效的维度参数. 请使用以下参数之一: %s +commands.entitydensity.fail.gridsize=§c无效的网格大小. 请使用1到2048之间的整数. 建议使用: 100-1024. +commands.entitydensity.success.header=§7实体密集区域在 %1 (网格大小 %2x%3): +commands.entitydensity.success.area=§7-有 %1 个实体在 %2, %3 + +commands.gamemode.s=已将你的游戏模式设为生存. +commands.gamemode.a=已将你的游戏模式设为冒险. +commands.gamemode.c=已将你的游戏模式设为创造. +commands.gamemode.sp=已将你的游戏模式设为旁观. + +commands.generator=管理漏斗生成器. (快捷指令: gt) +commands.generator.channel.notfound=§c无效颜色: %s. 请使用一种羊毛块颜色. +commands.generator.query.all=显示所有漏斗生成器的计数和速率. +commands.generator.query=显示指定颜色的漏斗生成器的计数和速率. +commands.generator.query.empty=§7这里没有使用中的漏斗生成器. +commands.generator.query.channel=§7已生成 %1 物品 在§7 (%2%3 min.), 共计: §f%4§7, (§f%5§7): +commands.generator.realtime=基于真实世界时间(而非游戏时间)显示计数和速率. +commands.generator.reset=重置所有漏斗生成器并重启计时器. +commands.generator.reset.single=§7重置并重新计时: %s +commands.generator.reset.single.actionbar=[%1] 重置 %2 漏斗生成器. +commands.generator.reset.all=§7所有频道已复位, 漏斗发生器计时器已启动. +commands.generator.reset.all.actionbar=[%s] 重置所有漏斗生成器 +commands.generator.remove=删除指定频道中的所有漏斗生成器. +commands.generator.remove.single=§7已删除所有漏斗生成器在 %s. +commands.generator.remove.single.actionbar=[%1] 已删除所有漏斗生成器在 %2 +commands.generator.remove.all=§7已删除所有频道中的所有漏斗生成器. +commands.generator.remove.all.actionbar=[%s] 已删除所有频道中的所有漏斗生成器. + +commands.health=显示服务器的TPS、MSPT和实体数量. +commands.health.startprofile=§7为游戏刻时间进行性能分析中... +commands.health.fail.mspt=§c不能计算出MSPT. 请反馈问题. + +commands.hss=寻找并显示世界中的HSS(硬编码生成点, Hardcoded Spawn Spots). +commands.hss.invalidaction=§c无效的HSS操作. +commands.hss.started=§7正在计算你所在位置结构的硬编码生成点. +commands.hss.started.fortress=§7已开始寻找下界要塞的硬编码生成点. 将模拟生成过程以加快速度. +commands.hss.started.nostructure=在你所在位置未发现带有硬编码生成点的自然生成结构. +commands.hss.started.unloaded=无法检测结构边界. 请确保包含该结构的所有区块均已加载. +commands.hss.started.worldbounds=无法检测结构边界. 结构边界搜索超出了世界边界. +commands.hss.stopped=§7已停止显示硬编码生成点. +commands.hss.alreadyrunning=§c你已经在寻找下界要塞的硬编码生成点了. +commands.hss.notrunning=§c你当前并未在寻找下界要塞的硬编码生成点. + +commands.info=启用/禁用信息显示规则. (快捷指令: i) +commands.info.menu=显示用于切换信息显示规则的菜单. +commands.info.single=切换单个信息显示规则. +commands.info.multiple=切换多个信息显示规则. +commands.info.all=切换所有信息显示规则. +commands.info.allupdated=§r§7 所有信息显示规则. +commands.info.canopyRule=§c规则 '%1' 是全局规则, 并且必须使用%2信息进行切换. 有关详细信息, 请使用%2帮助. + +commands.jump=传送到所指方块上. (快捷指令: j) +commands.jump.fail.noblock=§c没找到方块传送跳跃. + +commands.log=追踪TNT、弹射物和下落的方块运动. +commands.log.precision=§7追踪精度设置为 %s. +commands.log.started=§7开始追踪 %s. +commands.log.stopped=§7停止追踪 %s. +commands.log.invalidtype=§c日志类型无效. + +commands.lifetime.tracking=开始和停止跟踪实体生命周期及生成/移除原因. +commands.lifetime.tracking.unknownaction=§c未知的跟踪操作. +commands.lifetime.tracking.already=§c已经在跟踪生命周期了. +commands.lifetime.tracking.not=§c当前没有在跟踪生命周期. +commands.lifetime.tracking.start=§a已开始实体生命周期跟踪. +commands.lifetime.tracking.stop=§a已停止实体生命周期跟踪. +commands.lifetime.tracking.restart=§a已重新启动实体生命周期跟踪. +commands.lifetime.query=查询详细的实体生命周期及生成/移除原因统计. +commands.lifetime.query.item=查询详细的物品实体生命周期及生成/移除原因统计. +commands.lifetime.query.invalidaction=§c请输入有效的查询操作. +commands.lifetime.query.invalidentity=§c请输入有效的实体类型. +commands.lifetime.query.header=§l生命周期统计§r (已跟踪 %s 分钟的 +commands.lifetime.query.dimensionheader=§l%1§r§7: §2%2§7 生成 (%3/小时), §4%4§7 移除 (%5/小时) +commands.lifetime.query.body=§7- §f%1 §2生§7/§4移§7: §2%2§7/§4%3§7, §3存§7: §3%4§7/§b%5§7/§5%6§7 +commands.lifetime.query.entity=%s的生命周期结果 +commands.lifetime.query.entity.header=%s的生命周期结果 +commands.lifetime.query.entity.lifetime.header=§3生命周期概览 +commands.lifetime.query.entity.lifetime.min=§7- §f最小生命周期§7: §3%s§7 +commands.lifetime.query.entity.lifetime.max=§7- §f最大生命周期§7: §b%s§7 +commands.lifetime.query.entity.lifetime.average=§7- §f平均生命周期§7: §5%s§7 +commands.lifetime.query.entity.spawns.header=§2生成原因 +commands.lifetime.query.entity.spawns=§7- §f%1§7: §2%2§7, (%3/小时) §f%4% +commands.lifetime.query.entity.removals.header=§4移除原因 +commands.lifetime.query.entity.removals=§7- §f%1§7: §4%2§7, (%3/小时) §f%4% +commands.lifetime.query.entity.unknowntype=未知 +commands.lifetime.query.realtime= 真实时间) +commands.lifetime.query.realtime.unit= 秒 +commands.lifetime.query.ticktime= 游戏时间) +commands.lifetime.query.ticktime.unit= 游戏刻 + +commands.loop=在一个tick中多次运行原版命令. + +commands.peek=窥视目标物品栏并可以高亮列表中的物品. +commands.peek.fail.unloaded=§c在 %s 的目标未加载. +commands.peek.fail.noinventory=§c没找到物品栏: %1 在 %2. +commands.peek.fail.noitems=§c没找到物品: %1 在 %2. +commands.peek.query.cleared=§7窥视列表清除. +commands.peek.query.set=§7窥视列表设置为 '%s'. + +commands.playeraction=使模拟玩家以可变的时刻执行动作. +commands.playeraction.invalidtiming=§c无效的 %1 时刻: %2. +commands.playeraction.invalidticks=§c无效的 '%1' 刻持续时间: %2. 应为整数. + +commands.playerinventory=显示模拟玩家的物品栏. +commands.playerinventory.noinventory=§c未找到物品栏 +commands.playerinventory.empty=§7%s 的物品栏为空. +commands.playerinventory.header=%s 的物品栏: +commands.playerinventory.item=§7- %1%2§7: %3 x%4 + +commands.playerjoin=使一个新的模拟玩家在你的位置加入. + +commands.playerleave=使模拟玩家离开游戏. + +commands.playerlook=使模拟玩家朝指定方向看. +commands.playerlook.at.missing=§c缺少用于看向位置的坐标. +commands.playerlook.rotation.missing=§c缺少用于视角旋转的偏航角或俯仰角. +commands.playerlook.invalidoption=§c无效的看向选项: '%s' +commands.playerlook.block.entityonly=§c方块目标选取只能由实体使用. +commands.playerlook.block.noblock=§c视野内没有方块. +commands.playerlook.entity.entityonly=§c实体目标选取只能由实体使用. +commands.playerlook.entity.noentity=§c视野内没有实体. +commands.playerlook.me.noserver=§c服务器不能使用以自身为目标. + +commands.playermove=使模拟玩家向指定方向移动. +commands.playermove.invalidoption=§c无效的移动选项: '%s' +commands.playermove.block.entityonly=§c移动到方块只能由实体使用. +commands.playermove.block.noblock=§c视野内没有方块. +commands.playermove.entity.entityonly=§c移动到实体只能由实体使用. +commands.playermove.entity.noentity=§c视野内没有实体. +commands.playermove.me.noserver=§c服务器不能使用移动到自己. + +commands.playerprefix=设置模拟玩家名称标签的前缀. 使用 '-none' 清除. +commands.playerprefix.removed=§7已移除模拟玩家前缀. +commands.playerprefix.set=§7模拟玩家前缀已设置为 "§r%s§r§7". + +commands.playerrejoin=使模拟玩家在其上次位置重新加入. + +commands.playerselect=使模拟玩家选择一个快捷栏槽位. +commands.playerselect.invalidslot=§c无效的槽位编号: %s. 应为 0 到 8 之间的数字. + +commands.playersneak=使模拟玩家开始或停止潜行. + +commands.playersprint=使模拟玩家开始或停止疾跑. + +commands.playerstop=使模拟玩家停止执行所有动作. + +commands.playerswapheld=将模拟玩家手持的物品与你手持的物品交换. + +commands.playertp=使模拟玩家传送到你身边. + +commands.pos=显示你的位置或者其他玩家的位置. +commands.pos.self=§a你的位置: §f%s +commands.pos.other=§a%1的位置: §f%2 +commands.pos.dimension=§7维度: §7%s +commands.pos.relative.overworld=§7地狱映射到主世界的位置: §a%s +commands.pos.relative.nether=§7主世界映射到地狱的位置: §c%s + +commands.retest=重置刷怪追踪、漏斗计数器及漏斗发生器 +commands.retest.success=§7已重置刷怪计数器、漏斗计数器及漏斗发生器 + +commands.simmap=显示您附近或指定位置的已加载区块的地图. +commands.simmap.help.distance=显示block半径等于指定距离的地图. +commands.simmap.help.location=显示指定位置周围的地图. +commands.simmap.help.display.set=在信息显示中设置模拟地图的距离或位置. +commands.simmap.help.display.reset=在信息显示中设置模拟地图的位置,使其跟随当前位置. +commands.simmap.header=§7加载区块 %1§7 在 §2%2§7 附近 +commands.simmap.invalidDistance=§c距离 '%1' 无效. 请使用从 1 到 %2 的距离. +commands.simmap.config.distance=§7信息显示模拟地图距离更新为 %s. +commands.simmap.config.location=§7信息显示模拟地图位置更新为 %1 在 %2§7. +commands.simmap.config.reset=§7信息显示模拟地图位置更新为跟随您的当前位置. + +commands.sit=使您操作的玩家坐下. +commands.sit.busy=§c您过忙以至于无法坐下. + +commands.spawn=模拟生成和检测生成指令. +commands.spawn.entities=展示当前世界所有实体及其位置的列表. +commands.spawn.recent=显示最近30s所有怪物的生成数据. 请指定一种怪物到筛选器中. +commands.spawn.tracking.start=开始跟踪怪物生成. 请指定坐标划定区域用于跟踪. +commands.spawn.tracking.start.success=§7开始检测怪物生成. +commands.spawn.tracking.start.mob=§7正在检测该怪物的生成: %s. +commands.spawn.tracking.start.mob.actionbar=[%1] §a已加入 %2 到怪物跟踪并重置. +commands.spawn.tracking.start.area= 区域: %1 到 %2. +commands.spawn.tracking.start.mocking= 由于怪物模拟生成已开启, 怪物不再会生成但会被跟踪. +commands.spawn.tracking.start.actionbar=[%s] §7开始跟踪怪物生成. +commands.spawn.tracking.mob=开始检测指定的怪物生成. 请指定坐标划定区域. 重新运行命令来添加更多怪物种类. +commands.spawn.tracking.mob.invalid=§c无效的怪物名称: %s +commands.spawn.tracking.query=总结自测试开始的所有的生成. +commands.spawn.tracking.query.dimension=§7维度 %s§r: +commands.spawn.tracking.no=§c怪物生成没有被在被跟踪. +commands.spawn.tracking.already=§c怪物生成正在被跟踪. +commands.spawn.tracking.test=重置所有怪物生成计数器和漏斗计数器. +commands.spawn.tracking.test.success=§7怪物生成计数器和漏斗计数器已被重置. +commands.spawn.tracking.test.success.actionbar=[%s] §7已重置怪物生成和漏斗计数器. +commands.spawn.tracking.stop=停止跟踪怪物生成. +commands.spawn.tracking.stop.success=§7怪物生成不再被跟踪. +commands.spawn.tracking.stop.actionbar=[%s] §7已停止怪物生成跟踪. +commands.spawn.reset=Resets all spawn counters. +commands.spawn.mocking=开启/关闭怪物生成但怪物生成进程仍在进行. +commands.spawn.mocking.enable=§a模拟生成已开启. 怪物不再实际生成, 但生成进程仍在继续. +commands.spawn.mocking.disable=§c模拟生成已关闭. 怪物生成现在回归正常. +commands.spawn.mocking.enable.actionbar=[%s] §a模拟生成已开启. +commands.spawn.mocking.disable.actionbar=[%s] §c模拟生成已关闭. + +commands.summontnt=生成指定数量的点燃的TNT到你的位置. +commands.summontnt.fail.none=§cTNT生成失败. +commands.summontnt.success=§7已生成 §c%s 个TNT§7. + +commands.tick=设置和控制服务器tick速度. +commands.tick.mspt=放慢服务器tick速度到指定mspt. +commands.tick.mspt.fail=§cmspt不能低于50.0. +commands.tick.mspt.success=§7%1 将服务器的tick设置到 %2 mspt. +commands.tick.step=允许服务器已正常的速度步进指定游戏刻数. +commands.tick.step.fail=§c没设置游戏速度不能步进游戏刻. +commands.tick.step.start=§7%1 正在步进 %2 个游戏刻... +commands.tick.step.done=§7游戏刻步进完成. +commands.tick.reset=使游戏速度回到正常. +commands.tick.reset.success=§7%s 成功重置游戏速度. +commands.tick.sleep=暂停服务器指定时间 (单位: 毫秒). +commands.tick.sleep.fail=§c无效的停止时间. +commands.tick.sleep.success=§7%1 正在停止服务器 %2 毫秒. + +commands.tntfuse=以游戏刻为单位设置TNT的引信时间. +commands.tntfuse.reset.success=§7重置所有TNT引信时间到 §a80§7 游戏刻. +commands.tntfuse.set.fail=§c无效的引信时间: %1 ticks. 必须时 0 到 %2 ticks. +commands.tntfuse.set.success=§7TNT的引信时间设置为 §a%s§7 ticks. + +commands.trackevent=计算游戏事件发生数量. 显示数量在信息栏里. +commands.trackevent.stop=§7停止跟踪游戏事件 %s. +commands.trackevent.start=§7开始跟踪游戏事件 %s. +commands.trackevent.invalid=§c游戏事件 %1 未在 %2 中找到. + +commands.velocity=影响实体的速度. +commands.velocity.missingvelocity=§c请输入x、y、z方向的速度. +commands.velocity.query=§7实体速度 (m/gt): +commands.velocity.add=§7已为实体增加速度 (m/gt): +commands.velocity.set=§7已设置实体速度 (m/gt): + +commands.warp=传送和管理路径点. (快捷指令: w) +commands.warp.edit=添加和移除路径点. +commands.warp.tp=将你传送到一个路径点. +commands.warp.list=列出所有可用的路径点. +commands.warp.exists=§c路径点 '%s' 已经存在. 使用 ./warps 列出所有的路径点. +commands.warp.noexist=§c路径点 '%s' 未找到. 使用 ./warps 列出所有的路径点. +commands.warp.add.success=§7路径点 '%s' 已被添加. +commands.warp.remove.success=§7路径点 '%s' 已被移除. +commands.warp.tp.fail.dimension=§c请到 %1 来传送到 '%2'. +commands.warp.tp.success=§7传送到路径点 '%s'. +commands.warp.list.empty=§7当前没有路径点. +commands.warp.list.header=§7可用的路径点: + +## rules +rules.generic.unknown=§c无效的规则: %1. 使用 %2help 来获取更多信息. +rules.generic.invalidtype=§c无效类型: '%1'的值必须是%2类型. +rules.generic.outofrange=§c超出范围: '%1'的值必须在%2和%3之间 +rules.generic.outofrange.withother= 或为以下之一: %s +rules.generic.blocked=§c%s 规则仍关闭. +rules.generic.status=§7%1 现在的状态是 §l +rules.generic.nochange=§7%1 现在的状态已经是 §l +rules.generic.updated=§7%1 现在的状态更新为 §l +rules.generic.enabled=§a已启用 +rules.generic.disabled=§c已禁用 +rules.generic.ability=§7能力 +rules.generic.defaultvalue=(默认值: %s) + +rules.commandCamera=开启camera指令. +rules.commandClaimProjectiles=开启claimprojectiles指令. +rules.hopperCounters=开启counter指令和漏斗计数器功能. +rules.hopperGenerators=启用生成器命令和漏斗生成器功能. +rules.commandJumpSurvival=开启jump指令(在生存模式下). +rules.commandPosOthers=允许在其他玩家上使用pos命令. +rules.commandWarp=开启warp & warps指令. +rules.commandWarpSurvival=开启warp & warps指令(在生存模式下). +rules.allowBubbleColumnPlacement=关闭放置气泡柱限制. +rules.allowPeekInventory=启用peek命令和PeekInventory信息显示规则,并可以使用小望远镜进行窥视. +rules.armorStandRespawning=盔甲架被弹射物击中会掉落所持物品. +rules.autoItemPickup=破坏方块自动拾取物品. +rules.carefulBreak=破坏方块和潜行时自动拾取物品. +rules.cauldronConcreteConversion=混凝土粉末物品丢入装水的炼药锅变成固化的混凝土. +rules.chunkBorders=当箭在物品栏槽位13(顶部中间)时, 启用区块边界可视化. +rules.collisionBoxes=当箭在物品栏槽位14(顶部中间旁边)时, 启用实体碰撞箱可视化. +rules.creativeHotbarSwitching=允许多个快捷栏之间快速切换. 将箭放入物品栏槽位17(右上角), 然后潜行并滚动以切换. +rules.creativeInstantTame=允许在在创造模式下用对应的食物立即驯服动物. +rules.creativeNetherWaterPlacement=允许在创造模式下于下界放置水 +rules.creativeNoTileDrops=允许在创造模式下防止方块从被破坏的方块中掉落. +rules.creativeOneHitKill=一击必杀. 蹲着时, 玩家周围的实体也会被杀(在创造模式下). +rules.dupeTnt=TNT被活塞推动并且被音符盒充能时复制. +rules.durabilityNotifier=当你的工具还剩%s耐久度时, 发出叮的响声和提示. +rules.durabilityNotifier.alert=§c耐久度还剩: %s +rules.durabilitySwap=从您的手中带走0耐久物品. +rules.echoShardsEnableShriekers=在幽匿感测体上使用回响碎片可使其召唤监守者. +rules.enderPearlChunkLoading=允许末影珍珠使其周围指定区块半径(方形范围)内的区块运作. +rules.entityInstantDeath=消除20游戏刻死亡动画. 同时实体不再会掉落经验. +rules.entitySeparation=当堆叠的实体触发压力板时, 会有一个实体沿相邻投掷器朝向的方向从堆叠中分离. +rules.explosionChainReactionOnly=使爆炸只影响TNT方块. +rules.explosionNoBlockDamage=关闭爆炸破坏. +rules.explosionOff=完全关闭爆炸. +rules.flippinArrows=对方块使用箭可以翻转、旋转和打开. 放在副手可以使方块放置时翻转. +rules.instaminableDeepslate=急迫2下使用效率5的下届合金镐时, 可以秒破深板岩. +rules.instaminableEndstone=急迫2下使用效率5的下届合金镐时, 可以秒破末地石. +rules.minecartChunkLoading=允许矿车在生成后的 10 秒内使其周围指定区块半径(方形范围)内的区块运作. +rules.noWelcomeMessage=禁用 §lCanopy§r§8 欢迎消息. +rules.pistonBedrockBreaking=允许活塞在背对基岩时, 伸出时破坏基岩. +rules.playerSit=允许玩家通过快速 %s 次蹲起操作来坐下. +rules.potionBoostedBreeding=重新引入允许速度药水影响繁殖属性的行为. +rules.quickFillContainer=对容器使用一个物品时, 放一个箭在物品栏的左上角, 可以将背包里所有的这个物品放进容器里. +rules.quickFillContainer.filled=§7已填充%1通过%2 +rules.quickFillContainer.taken=§7已取走所有%1从%2 +rules.refillHand=当你手上的物品用完时,将用背包里的自动填充. 在清单左上角旁边的插槽中放置一个箭头(弓箭)以供使用. +rules.renderEndGatewayExits=在通过末地折跃门后显示其出口位置. +rules.renewableElytraDropChance=幻翼被潜影贝导弹杀死时有几率掉落鞘翅. +rules.renewableSponge=守卫者被闪电击中会变成远古守卫者. +rules.serverSideCollisionBoxes=根据实体的服务器端位置而非客户端位置显示碰撞箱. +rules.simplayerSaving=启用对模拟玩家的 playerdata 保存. 会降低性能, 但可让模拟玩家在离开并重新加入时保留其物品栏和位置. +rules.simplayerRejoining=使在线模拟玩家在世界重新加载时重新加入. +rules.spawnEggSpawnWithMinecart=在铁轨上使用刷怪蛋时, 生成的实体将被放置在铁轨上的矿车中. +rules.tntFuse=TNT引信时间(游戏刻). +rules.tntPrimeMomentum=硬编码TNT引爆动量. + +rules.infoDisplay.biome=显示群系. +rules.infoDisplay.blockStates=显示你看着的方块状态. +rules.infoDisplay.cardinalFacing=使用N, S, E, W 和坐标系方向 (ex. N (-z))显示你的朝向. +rules.infoDisplay.cardinalFacing.display=§r朝向: §7%s§r +rules.infoDisplay.chunkCoords=显示您所在的方块的坐标以及您在该方块中的位置. +rules.infoDisplay.chunkCoords.display=§r区块: §7%1 in %2§r +rules.infoDisplay.coords=坐标显示精确到两位小数. +rules.infodisplay.dimension=显示你当前的维度. +rules.infoDisplay.entities=显示你面前的实体个数. +rules.infoDisplay.entities.display=§r实体数: §7%s§r +rules.infoDisplay.eventTrackers=显示跟踪的游戏事件发生次数. +rules.infoDisplay.facing=显示你的仰角和俯角. +rules.infoDisplay.facing.display=§r俯角: §7%1§r 仰角: §7%2§r +rules.infoDisplay.heldItemDurability=显示你主手中物品的耐久度. +rules.infoDisplay.heldItemDurability.display=耐久度 %s +rules.infoDisplay.hopperCounterCounts=显示所有漏斗计数器和对应颜色. 漏斗计数器模式控制这个信息. +rules.infoDisplay.light=显示脚下亮度. +rules.infoDisplay.light.display=§r亮度: §e%s§r +rules.infoDisplay.liquidStates=显示你目标液体的状态. +rules.infoDisplay.liquidTarget=显示你目标液体的标识符. +rules.infoDisplay.moonPhase=显示月相. +rules.infoDisplay.moonPhase.firstQuarter=上弦月 +rules.infoDisplay.moonPhase.full=满月 +rules.infoDisplay.moonPhase.lastQuarter=下弦月 +rules.infoDisplay.moonPhase.new=新月 +rules.infoDisplay.moonPhase.waningCrescent=残月 +rules.infoDisplay.moonPhase.waningGibbous=亏凸月 +rules.infoDisplay.moonPhase.waxingCrescent=眉月 +rules.infoDisplay.moonPhase.waxingGibbous=盈凸月 +rules.infoDisplay.noFog=禁用迷雾. 水和熔岩不受影响. +rules.infoDisplay.peekInventory=显示方块或者实体的物品栏. +rules.infoDisplay.peekInventory.empty=空 +rules.infoDisplay.ping=显示你当前到服务器的网络延迟. +rules.infoDisplay.ping.display=延迟: %s +rules.infoDisplay.renderSignalStrength=在附近红石粉上方显示信号强度值. +rules.infoDisplay.sessionTime=显示的你加入存档的时间. +rules.infoDisplay.sessionTime.display=§r在线时间: §7%s§r +rules.infoDisplay.signalStrength=显示所指方块的信号强度. +rules.infoDisplay.simulationMap=显示您周围已加载的方块的地图. simmap命令可用于对此进行配置. 警告: 这是一个非常滞后的规则. +rules.infoDisplay.slimeChunk=显示当前区块是否是史莱姆区块(只在史莱姆区块时显示). +rules.infoDisplay.slimeChunk.display=§7(§a史莱姆区块§7)§r +rules.infoDisplay.speed=以米每秒为单位显示当前速度. +rules.infodisplay.structures=显示你所在位置的自然生成结构. +rules.infodisplay.structures.display=结构: +rules.infodisplay.structures.display.none=无 +rules.infoDisplay.target=显示你目标的方块或实体的标识符. +rules.infoDisplay.timeOfDay=显示游戏中时间. +rules.infoDisplay.tps=显示TPS. +rules.infoDisplay.tps.display=§rTPS: %s§r +rules.infoDisplay.tpsAndEntities.display=§rTPS: %1§r 实体数: §7%2§r +rules.infoDisplay.velocity=显示你当前的x,y,z速度(米/游戏刻). +rules.infoDisplay.weather=显示你当前维度的天气. +rules.infoDisplay.weather.display=天气: %s +rules.infoDisplay.worldDay=显示存档天数. +rules.infoDisplay.worldDay.display=§r存档天数: §7%s§r + +## simplayer +simplayer.notonline=§c模拟玩家 '%s' 不在线. +simplayer.alreadyonline=§c模拟玩家 '%s' 已在线. +simplayer.leave.broadcast=§e%s 离开了游戏 +simplayer.swapheld.error=§c交换物品时出错: %s \ No newline at end of file diff --git a/Canopy[RP]/textures/particle/fortress_hss_marker.png b/Canopy[RP]/textures/particle/fortress_hss_marker.png deleted file mode 100644 index f71efeeb..00000000 Binary files a/Canopy[RP]/textures/particle/fortress_hss_marker.png and /dev/null differ diff --git a/Canopy[RP]/textures/particle/ocean_monument_hss_marker.png b/Canopy[RP]/textures/particle/ocean_monument_hss_marker.png deleted file mode 100644 index a5127368..00000000 Binary files a/Canopy[RP]/textures/particle/ocean_monument_hss_marker.png and /dev/null differ diff --git a/Canopy[RP]/textures/particle/outpost_hss_marker.png b/Canopy[RP]/textures/particle/outpost_hss_marker.png deleted file mode 100644 index 7b78b14f..00000000 Binary files a/Canopy[RP]/textures/particle/outpost_hss_marker.png and /dev/null differ diff --git a/Canopy[RP]/textures/particle/witch_hut_hss_marker.png b/Canopy[RP]/textures/particle/witch_hut_hss_marker.png deleted file mode 100644 index 7e56c07f..00000000 Binary files a/Canopy[RP]/textures/particle/witch_hut_hss_marker.png and /dev/null differ diff --git a/README.md b/README.md index b1007fab..be893e83 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![GitHub Downloads](https://img.shields.io/github/downloads/ForestOfLight/Canopy/total?label=Github%20downloads&logo=github)](https://github.com/ForestOfLight/Canopy/releases/latest) [![Curseforge Downloads](https://cf.way2muchnoise.eu/full_1062078_downloads.svg)](https://www.curseforge.com/minecraft-bedrock/addons/canopy) - [![Minecraft - Version](https://img.shields.io/badge/Minecraft-v26.30_(Bedrock)-brightgreen)](https://feedback.minecraft.net/hc/en-us/sections/360001186971-Release-Changelogs) + [![Minecraft - Version](https://img.shields.io/badge/Minecraft-v26.40_(Bedrock)-brightgreen)](https://feedback.minecraft.net/hc/en-us/sections/360001186971-Release-Changelogs) [![CI](https://github.com/ForestOfLight/Canopy/actions/workflows/ci.yml/badge.svg)](https://github.com/ForestOfLight/Canopy/actions/workflows/ci.yml) [![Discord](https://badgen.net/discord/members/9KGche8fxm?icon=discord&label=Discord&list=what)](https://discord.gg/9KGche8fxm) [![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-donate-yellow.svg)](https://buymeacoffee.com/forestoflight) @@ -19,16 +19,13 @@ Whether you're optimizing farm rates, digging into game mechanics, or building u - **Live InfoDisplay** – Get real-time stats on TPS, light levels, biome info, block/entity details, and more. - **Farm & Spawn Tracking** – Optimize efficiency with hopper counters and precise spawn monitoring. -- **Tick Speed Control** – Slow down ticks for in-depth technical analysis. -- **Modular & Expandable** – Add functionality with extensions and community add-ons. +- **Tick Speed Control** – Slow down ticks to see what's going on in your builds. +- **Modular & Expandable** – Add functionality with community-built extensions. +- **Simulated Players** - Spawn and control simulated players. - **Built for Power and Performance** – Designed to run effectively and efficiently. **Explore all the features in the [Canopy Wiki](https://github.com/ForestOfLight/Canopy/wiki)!** -## Looking for Simulated Players? - -Check out **[Understudy](https://github.com/ForestOfLight/Understudy)** – a **Canopy** extension that gives you complete control over simulated players in your world. - ## Getting Started Installing **Canopy** is fast and easy: diff --git a/__tests__/BP/scripts/lib/canopy/commands/VanillaCommands.test.js b/__tests__/BP/scripts/lib/canopy/commands/VanillaCommands.test.js index 3567885b..ad0f181f 100644 --- a/__tests__/BP/scripts/lib/canopy/commands/VanillaCommands.test.js +++ b/__tests__/BP/scripts/lib/canopy/commands/VanillaCommands.test.js @@ -48,3 +48,29 @@ describe('VanillaCommand.getSubCommandWikiDescription', () => { expect(cmd.getSubCommandWikiDescription()).toEqual({}); }); }); + +describe('VanillaCommand.registerEnums', () => { + beforeEach(() => { VanillaCommands.clear(); }); + + it('registers array-valued enums as-is', () => { + const registry = { registerEnum: vi.fn() }; + const cmd = new VanillaCommand({ + name: 'canopy:arr', description: 'x', callback: vi.fn(), + enums: [{ name: 'canopy:arr', values: ['a', 'b'] }] + }); + cmd.registerEnums(registry); + expect(registry.registerEnum).toHaveBeenCalledWith('canopy:arr', ['a', 'b']); + }); + + it('invokes function-valued enums at registration time', () => { + const registry = { registerEnum: vi.fn() }; + const valuesFn = vi.fn(() => ['x', 'y', 'z']); + const cmd = new VanillaCommand({ + name: 'canopy:fn', description: 'x', callback: vi.fn(), + enums: [{ name: 'canopy:fn', values: valuesFn }] + }); + cmd.registerEnums(registry); + expect(valuesFn).toHaveBeenCalledTimes(1); + expect(registry.registerEnum).toHaveBeenCalledWith('canopy:fn', ['x', 'y', 'z']); + }); +}); diff --git a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js index 56f15b86..9f04e52f 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/AbilityRule.test.js @@ -67,8 +67,8 @@ describe('AbilityRule', () => { }); it('should use a custom action item if provided', () => { - const arrowAbilityTestRuleData = { ...testRuleData, identifier: 'arrowAbilityTestRule' }; - const customArrowAbility = new AbilityRule(arrowAbilityTestRuleData, { slotNumber: 1, actionItem: 'minecraft:other_item' }); + const customArrowRuleData = { ...testRuleData, identifier: 'customArrowRule' }; + const customArrowAbility = new AbilityRule(customArrowRuleData, { slotNumber: 1, actionItem: 'minecraft:other_item' }); expect(customArrowAbility.getActionItemId()).toBe('minecraft:other_item'); }); diff --git a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js index 5641dbe6..7d1e9e8f 100644 --- a/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js +++ b/__tests__/BP/scripts/lib/canopy/rules/Rules.test.js @@ -246,6 +246,23 @@ describe('Rules', () => { expect(Rules.get('queued_rule')).toBe(mockRule); expect(Rules.rulesToRegister).toHaveLength(0); }); + + it('should not queue duplicate rule identifiers before world load', async () => { + Rules.clear(); + Rules.worldLoaded = false; + const firstRule = { getID: () => 'queued_rule', getCategory: () => 'test' }; + const duplicateRule = { getID: () => 'queued_rule', getCategory: () => 'test' }; + + await Rules.register(firstRule); + await Rules.register(duplicateRule); + + expect(Rules.rulesToRegister).toHaveLength(1); + expect(Rules.rulesToRegister[0]).toBe(firstRule); + + Rules.worldLoaded = true; + await Rules.registerQueuedRules(); + expect(Rules.get('queued_rule')).toBe(firstRule); + }); }); describe('register with worldLoaded and Rules category', () => { @@ -315,4 +332,65 @@ describe('Rules', () => { expect(testRules.length).toBe(0); }); }); + + describe('Rules.getSettableRuleIDs', () => { + beforeEach(() => { + Rules.clear(); + Rules.rulesToRegister = []; + Rules.worldLoaded = false; + }); + + it('returns IDs of queued "Rules"-category rules', () => { + new BooleanRule({ category: 'Rules', identifier: 'queuedRuleA', defaultValue: false }); + new BooleanRule({ category: 'Rules', identifier: 'queuedRuleB', defaultValue: false }); + expect(Rules.getSettableRuleIDs().sort()).toEqual(['queuedRuleA', 'queuedRuleB']); + }); + + it('excludes rules outside the "Rules" category', () => { + new BooleanRule({ category: 'Rules', identifier: 'settable', defaultValue: false }); + new BooleanRule({ category: 'InfoDisplay', identifier: 'displayOnly', defaultValue: false }); + expect(Rules.getSettableRuleIDs()).toEqual(['settable']); + }); + }); + + describe('Rules.getRuleIDsByCategory', () => { + beforeEach(() => { + Rules.clear(); + Rules.rulesToRegister = []; + Rules.worldLoaded = false; + }); + + it('returns IDs of queued rules in the given category', () => { + new BooleanRule({ category: 'InfoDisplay', identifier: 'showCoords', defaultValue: false }); + new BooleanRule({ category: 'InfoDisplay', identifier: 'showBiome', defaultValue: false }); + expect(Rules.getRuleIDsByCategory('InfoDisplay').sort()).toEqual(['showBiome', 'showCoords']); + }); + + it('excludes rules outside the requested category', () => { + new BooleanRule({ category: 'InfoDisplay', identifier: 'showCoords', defaultValue: false }); + new BooleanRule({ category: 'Rules', identifier: 'settable', defaultValue: false }); + expect(Rules.getRuleIDsByCategory('InfoDisplay')).toEqual(['showCoords']); + }); + + it('deduplicates IDs that appear in both registered and queued sources', () => { + Rules.worldLoaded = true; + new BooleanRule({ category: 'InfoDisplay', identifier: 'showCoords', defaultValue: false }); + Rules.rulesToRegister = [{ getID: () => 'showCoords', getCategory: () => 'InfoDisplay' }]; + expect(Rules.getRuleIDsByCategory('InfoDisplay')).toEqual(['showCoords']); + }); + }); + + describe('Rules.getSettableRuleIDs delegation', () => { + beforeEach(() => { + Rules.clear(); + Rules.rulesToRegister = []; + Rules.worldLoaded = false; + }); + + it('returns only "Rules"-category IDs via getRuleIDsByCategory', () => { + new BooleanRule({ category: 'Rules', identifier: 'settable', defaultValue: false }); + new BooleanRule({ category: 'InfoDisplay', identifier: 'showCoords', defaultValue: false }); + expect(Rules.getSettableRuleIDs()).toEqual(['settable']); + }); + }); }); diff --git a/__tests__/BP/scripts/lib/jsep/jsep.test.js b/__tests__/BP/scripts/lib/jsep/jsep.test.js new file mode 100644 index 00000000..b1504dc6 --- /dev/null +++ b/__tests__/BP/scripts/lib/jsep/jsep.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import jsep from '../../../../../Canopy[BP]/scripts/lib/jsep/jsep.js'; + +describe('vendored jsep', () => { + it('parses a logical-and expression into an AST', () => { + const ast = jsep("typeId === 'minecraft:stone' && x > 0"); + expect(ast.type).toBe('BinaryExpression'); + expect(ast.operator).toBe('&&'); + }); + + it('throws on a syntax error', () => { + expect(() => jsep('a &&')).toThrow(); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/RegionLoader.test.js b/__tests__/BP/scripts/src/classes/RegionLoader.test.js new file mode 100644 index 00000000..1ab8f81f --- /dev/null +++ b/__tests__/BP/scripts/src/classes/RegionLoader.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { world } from '@minecraft/server'; +import { RegionLoader } from '../../../../../Canopy[BP]/scripts/src/classes/RegionLoader.js'; + +describe('RegionLoader', () => { + const dimension = { id: 'minecraft:overworld' }; + const min = { x: 0, y: 0, z: 0 }; + const max = { x: 4, y: 4, z: 4 }; + let manager; + + beforeEach(() => { + manager = { + hasCapacity: vi.fn(() => true), + createTickingArea: vi.fn(() => Promise.resolve()), + removeTickingArea: vi.fn() + }; + world.tickingAreaManager = manager; + }); + + afterEach(() => { + delete world.tickingAreaManager; + }); + + it('checks capacity with dimension/from/to', () => { + const loader = new RegionLoader(dimension, min, max, 'canopy_analyzearea_1'); + expect(loader.hasCapacity()).toBe(true); + expect(manager.hasCapacity).toHaveBeenCalledWith({ dimension, from: min, to: max }); + }); + + it('creates a ticking area with the id on load', async () => { + const loader = new RegionLoader(dimension, min, max, 'canopy_analyzearea_1'); + await loader.load(); + expect(manager.createTickingArea).toHaveBeenCalledWith('canopy_analyzearea_1', { dimension, from: min, to: max }); + }); + + it('removes the ticking area on unload and swallows errors', () => { + manager.removeTickingArea.mockImplementation(() => { throw new Error('gone'); }); + const loader = new RegionLoader(dimension, min, max, 'canopy_analyzearea_1'); + expect(() => loader.unload()).not.toThrow(); + expect(manager.removeTickingArea).toHaveBeenCalledWith('canopy_analyzearea_1'); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js new file mode 100644 index 00000000..b51f804c --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/Analysis.test.js @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { Analysis } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js'; +import { ExpressionForbiddenError } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/ExpressionForbiddenError.js'; +import { LoadCapacityError } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/LoadCapacityError.js'; +import { stringifyLocation } from '../../../../../../Canopy[BP]/scripts/include/utils.js'; + +const identity = { + id: 'a1', + from: { x: 5, y: 1, z: 5 }, + to: { x: 0, y: 3, z: 0 }, + dimensionId: 'minecraft:overworld', + expression: "typeId === 'minecraft:stone'", + createdAt: 1234 +}; + +describe('Analysis', () => { + it('normalizes corners into min/max on construction', () => { + const analysis = new Analysis(identity); + expect(analysis.min).toEqual({ x: 0, y: 1, z: 0 }); + expect(analysis.max).toEqual({ x: 5, y: 3, z: 5 }); + }); + + it('floors and orders swapped/negative corners on construction', () => { + const analysis = new Analysis({ ...identity, from: { x: 5.9, y: 2, z: -3 }, to: { x: -1, y: 10.2, z: 4 } }); + expect(analysis.min).toEqual({ x: -1, y: 2, z: -3 }); + expect(analysis.max).toEqual({ x: 5, y: 10, z: 4 }); + }); + + it('serializes to identity only (no results) with normalized corners', () => { + const analysis = new Analysis(identity); + analysis.matches = [{ x: 1, y: 1, z: 1 }]; + expect(analysis.serialize()).toEqual({ + id: 'a1', + from: { x: 0, y: 1, z: 0 }, + to: { x: 5, y: 3, z: 5 }, + dimensionId: 'minecraft:overworld', + expression: "typeId === 'minecraft:stone'", + createdAt: 1234 + }); + }); + + it('round-trips through serialize/deserialize', () => { + const analysis = new Analysis(identity); + const clone = Analysis.deserialize(analysis.serialize()); + expect(clone.serialize()).toEqual(analysis.serialize()); + expect(clone.matches).toEqual([]); + }); + + it('matchesCoords regardless of corner order, respecting dimension', () => { + const analysis = new Analysis(identity); + expect(analysis.matchesCoords({ x: 0, y: 3, z: 5 }, { x: 5, y: 1, z: 0 }, 'minecraft:overworld')).toBe(true); + expect(analysis.matchesCoords({ x: 0, y: 1, z: 0 }, { x: 5, y: 3, z: 5 }, 'minecraft:nether')).toBe(false); + expect(analysis.matchesCoords({ x: 0, y: 1, z: 0 }, { x: 4, y: 3, z: 5 }, 'minecraft:overworld')).toBe(false); + }); + + it('computes capacity and a namespaced ticking id', () => { + const analysis = new Analysis(identity); + expect(analysis.capacity()).toBe(6 * 3 * 6); + expect(analysis.tickingId()).toBe('canopy_analyzearea_a1'); + }); + + it('starts idle at zero progress and (un)subscribes progress handlers', () => { + const analysis = new Analysis(identity); + expect(analysis.running).toBe(false); + expect(analysis.progress).toBe(0); + const unsubscribe = analysis.subscribe({ onProgress: () => {} }); + expect(typeof unsubscribe).toBe('function'); + expect(analysis.subscribers.size).toBe(1); + unsubscribe(); + expect(analysis.subscribers.size).toBe(0); + }); + + it('errorMessage maps known errors by type and anything else to unknown', () => { + expect(Analysis.errorMessage(new LoadCapacityError())).toEqual({ translate: 'commands.analyzearea.loadcapacity' }); + expect(Analysis.errorMessage(new ExpressionForbiddenError('nope'))).toEqual({ translate: 'commands.analyzearea.forbidden' }); + expect(Analysis.errorMessage(new Error('boom'))).toEqual({ translate: 'commands.analyzearea.unknownerror' }); + expect(Analysis.errorMessage(undefined)).toEqual({ translate: 'commands.analyzearea.unknownerror' }); + }); + + it('statusMessage is a single source of truth across states', () => { + const analysis = new Analysis(identity); + expect(analysis.statusMessage()).toEqual({ translate: 'commands.analyzearea.ui.page.notrun' }); + + analysis.hasRun = true; + analysis.matches = [{ x: 0, y: 1, z: 0 }]; + const results = analysis.statusMessage(); + expect(results.translate).toBe('commands.analyzearea.stats'); + expect(results.with[2]).toBe(identity.expression); + expect(results.with[3]).toBe('1'); + expect(results.with[4]).toBe('6x3x6'); + + analysis.capped = true; + expect(analysis.statusMessage().translate).toBe('commands.analyzearea.stats.capped'); + + analysis.running = true; + analysis.progress = 0.5; + const from = stringifyLocation(analysis.min, 0); + const to = stringifyLocation(analysis.max, 0); + expect(analysis.statusMessage()).toEqual({ translate: 'commands.analyzearea.stats.analyzing', with: [from, to, identity.expression, '50%'] }); + }); + + it('create generates an id and createdAt', () => { + const analysis = Analysis.create({ x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }, 'minecraft:overworld', 'x === 0'); + expect(typeof analysis.id).toBe('string'); + expect(analysis.id.length).toBeGreaterThan(0); + expect(typeof analysis.createdAt).toBe('number'); + }); + + describe('tryCreate', () => { + const from = { x: 0, y: 0, z: 0 }; + + it('returns the analysis for a valid expression', () => { + const result = Analysis.tryCreate(from, { x: 1, y: 1, z: 1 }, 'minecraft:overworld', "typeId === 'minecraft:stone'"); + expect(result.ok).toBe(true); + expect(result.analysis).toBeInstanceOf(Analysis); + }); + + it('rejects an over-capacity region', () => { + const result = Analysis.tryCreate(from, { x: 2 ** 32, y: 0, z: 0 }, 'minecraft:overworld', 'true'); + expect(result).toEqual({ ok: false, reason: 'overcapacity' }); + }); + + it('rejects a syntactically invalid expression', () => { + const result = Analysis.tryCreate(from, { x: 1, y: 1, z: 1 }, 'minecraft:overworld', 'a &&'); + expect(result).toEqual({ ok: false, reason: 'syntaxerror' }); + }); + + it('rejects a forbidden expression', () => { + const result = Analysis.tryCreate(from, { x: 1, y: 1, z: 1 }, 'minecraft:overworld', 'block.constructor'); + expect(result).toEqual({ ok: false, reason: 'forbidden' }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js new file mode 100644 index 00000000..7df24630 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { debugDrawer, DebugText } from '@minecraft/debug-utilities'; +import { AnalyzeAreaRenderer } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaRenderer.js'; + +describe('AnalyzeAreaRenderer', () => { + const dimension = { id: 'minecraft:overworld' }; + const min = { x: 0, y: 0, z: 0 }; + const max = { x: 2, y: 2, z: 2 }; + const locations = [{ x: 0, y: 0, z: 0 }, { x: 1, y: 2, z: 3 }]; + const statsText = 'Area Analysis stats'; + let renderer; + + beforeEach(() => { + debugDrawer.addShape.mockClear(); + renderer = new AnalyzeAreaRenderer(dimension, min, max, locations, statsText); + }); + + describe('outline layer (box + stats text)', () => { + it('draws a white region box and a stats text shape', () => { + renderer.showOutline(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + expect(renderer.outlineShape).toBeDefined(); + expect(renderer.outlineShape.bound).toEqual({ x: 3, y: 3, z: 3 }); + expect(renderer.outlineShape.color).toEqual({ red: 1, green: 1, blue: 1, alpha: 1 }); + expect(renderer.textShape).toBeInstanceOf(DebugText); + }); + + it('showOutline is idempotent', () => { + renderer.showOutline(); + renderer.showOutline(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + }); + + it('setText wraps the body with the stats header and forwards it to the live text shape', () => { + renderer.showOutline(); + renderer.textShape.setText = vi.fn(); + renderer.setText('Analyzing... 42%'); + const expected = { rawtext: [{ translate: 'commands.analyzearea.stats.header' }, { text: '\n' }, 'Analyzing... 42%'] }; + expect(renderer.statsText).toEqual(expected); + expect(renderer.textShape.setText).toHaveBeenCalledWith(expected); + }); + + it('hideOutline removes both the box and the text', () => { + renderer.showOutline(); + const box = renderer.outlineShape; + const text = renderer.textShape; + renderer.hideOutline(); + expect(box.remove).toHaveBeenCalled(); + expect(text.remove).toHaveBeenCalled(); + expect(renderer.outlineShape).toBeUndefined(); + expect(renderer.textShape).toBeUndefined(); + }); + }); + + describe('match boxes', () => { + it('draws one box per matching location on showMatches', () => { + renderer.showMatches(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + expect(renderer.matchesVisible).toBe(true); + }); + + it('showMatches is idempotent', () => { + renderer.showMatches(); + renderer.showMatches(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(2); + }); + + it('hideMatches removes all match boxes and can be re-shown', () => { + renderer.showMatches(); + const shapes = [...renderer.matchShapes]; + renderer.hideMatches(); + shapes.forEach((s) => expect(s.remove).toHaveBeenCalled()); + expect(renderer.matchShapes).toHaveLength(0); + expect(renderer.matchesVisible).toBe(false); + renderer.showMatches(); + expect(renderer.matchesVisible).toBe(true); + }); + }); + + it('outline (box + text) is independent of the match-box toggle', () => { + renderer.showOutline(); + renderer.showMatches(); + expect(debugDrawer.addShape).toHaveBeenCalledTimes(4); // box + text + 2 match boxes + renderer.hideMatches(); + expect(renderer.outlineShape).toBeDefined(); + expect(renderer.textShape).toBeInstanceOf(DebugText); + expect(renderer.matchShapes).toHaveLength(0); + }); + + it('destroy removes the outline, text, and match boxes', () => { + renderer.showOutline(); + renderer.showMatches(); + const box = renderer.outlineShape; + const text = renderer.textShape; + const matches = [...renderer.matchShapes]; + renderer.destroy(); + expect(box.remove).toHaveBeenCalled(); + expect(text.remove).toHaveBeenCalled(); + matches.forEach((s) => expect(s.remove).toHaveBeenCalled()); + expect(renderer.outlineShape).toBeUndefined(); + expect(renderer.textShape).toBeUndefined(); + expect(renderer.matchShapes).toHaveLength(0); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js new file mode 100644 index 00000000..aa939a6d --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AnalyzeAreaUI.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { AnalyzeAreaUI, LIST_PAGE_SIZE } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI.js'; + +describe('AnalyzeAreaUI', () => { + it('exposes the page builders as methods', () => { + expect(typeof AnalyzeAreaUI).toBe('function'); + expect(typeof AnalyzeAreaUI.prototype.showSelector).toBe('function'); + expect(typeof AnalyzeAreaUI.prototype.showCreateForm).toBe('function'); + expect(typeof AnalyzeAreaUI.prototype.showAnalysisPage).toBe('function'); + }); + + it('uses a 50-item page size', () => { + expect(LIST_PAGE_SIZE).toBe(50); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalysisManager.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalysisManager.test.js new file mode 100644 index 00000000..68388871 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalysisManager.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { world } from '@minecraft/server'; +import { Analysis } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/Analysis.js'; +import { AreaAnalysisManager, PROPERTY_KEY } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager.js'; + +function fixture(id, from, to) { + return new Analysis({ id, from, to, dimensionId: 'minecraft:overworld', expression: 'x === 0', createdAt: 1 }); +} + +describe('AreaAnalysisManager', () => { + beforeEach(() => { + world.setDynamicProperty(PROPERTY_KEY, undefined); + }); + + it('starts empty when no property is stored', () => { + expect(new AreaAnalysisManager().list()).toEqual([]); + }); + + it('persists added analyses to the world property', () => { + const manager = new AreaAnalysisManager(); + manager.add(fixture('a1', { x: 0, y: 0, z: 0 }, { x: 2, y: 2, z: 2 })); + const reloaded = new AreaAnalysisManager(); + expect(reloaded.list()).toHaveLength(1); + expect(reloaded.list()[0].id).toBe('a1'); + }); + + it('removes analyses and updates the property', () => { + const manager = new AreaAnalysisManager(); + const a = fixture('a1', { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }); + manager.add(a); + manager.remove(a); + expect(new AreaAnalysisManager().list()).toEqual([]); + }); + + it('finds an analysis by normalized coords + dimension', () => { + const manager = new AreaAnalysisManager(); + manager.add(fixture('a1', { x: 0, y: 0, z: 0 }, { x: 4, y: 4, z: 4 })); + const found = manager.findByCoords({ x: 4, y: 4, z: 4 }, { x: 0, y: 0, z: 0 }, 'minecraft:overworld'); + expect(found?.id).toBe('a1'); + expect(manager.findByCoords({ x: 0, y: 0, z: 0 }, { x: 4, y: 4, z: 4 }, 'minecraft:nether')).toBeUndefined(); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js new file mode 100644 index 00000000..3d7fdf59 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/AreaAnalyzer.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { AreaAnalyzer, MATCH_CAP, SCAN_CAP } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalyzer.js'; + +// A dimension whose getBlock returns a block with typeId based on coordinates. +function makeDimension(typeIdAt) { + return { + getBlock: (loc) => { + const typeId = typeIdAt(loc); + if (typeId === undefined) return undefined; + if (typeId === 'THROW') throw new Error('unloaded'); + return { typeId, ...loc }; + } + }; +} + +const isStone = { evaluate: (block) => block.typeId === 'minecraft:stone' }; + +describe('AreaAnalyzer', () => { + it('collects locations whose block matches the evaluator', () => { + const dimension = makeDimension((loc) => (loc.x === 1 ? 'minecraft:stone' : 'minecraft:air')); + const analyzer = new AreaAnalyzer(dimension, { x: 0, y: 0, z: 0 }, { x: 2, y: 0, z: 0 }, isStone); + analyzer.runToCompletion(); + expect(analyzer.matches).toEqual([{ x: 1, y: 0, z: 0 }]); + expect(analyzer.scanned).toBe(3); + expect(analyzer.capped).toBe(false); + }); + + it('counts undefined blocks and thrown evaluations as errors, not matches', () => { + const dimension = makeDimension((loc) => (loc.x === 0 ? undefined : 'THROW')); + const analyzer = new AreaAnalyzer(dimension, { x: 0, y: 0, z: 0 }, { x: 1, y: 0, z: 0 }, isStone); + analyzer.runToCompletion(); + expect(analyzer.matches).toEqual([]); + expect(analyzer.errorCount).toBe(2); + }); + + it('stops at the match cap and sets capped', () => { + const dimension = makeDimension(() => 'minecraft:stone'); + const analyzer = new AreaAnalyzer(dimension, { x: 0, y: 0, z: 0 }, { x: 4, y: 0, z: 0 }, isStone, { matchCap: 3 }); + analyzer.runToCompletion(); + expect(analyzer.matches).toHaveLength(3); + expect(analyzer.capped).toBe(true); + }); + + it('exposes the default match cap', () => { + expect(MATCH_CAP).toBe(10000); + }); + + it('exposes the scan cap', () => { + expect(SCAN_CAP).toBe(2 ** 32); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js new file mode 100644 index 00000000..1c1a2b4c --- /dev/null +++ b/__tests__/BP/scripts/src/classes/analyzearea/ExpressionEvaluator.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@minecraft/server', () => ({ + ItemStack: class { + constructor(typeId, amount = 1) { + this.typeId = typeId; + this.amount = amount; + } + } +})); + +import { ExpressionEvaluator } from '../../../../../../Canopy[BP]/scripts/src/classes/analyzearea/ExpressionEvaluator.js'; + +function makeBlock() { + return { + typeId: 'minecraft:redstone_wire', + x: 4, + permutation: { + getState: (name) => (name === 'redstone_signal' ? 7 : undefined) + }, + getTags: () => ['powered', 'redstone'] + }; +} + +describe('ExpressionEvaluator', () => { + it('resolves bare identifiers against the block', () => { + const evaluator = new ExpressionEvaluator("typeId === 'minecraft:redstone_wire'"); + expect(evaluator.evaluate(makeBlock())).toBe(true); + }); + + it('resolves the `block` identifier to the block itself', () => { + const evaluator = new ExpressionEvaluator("block.typeId === 'minecraft:redstone_wire'"); + expect(evaluator.evaluate(makeBlock())).toBe(true); + }); + + it('evaluates member calls with correct this binding', () => { + const evaluator = new ExpressionEvaluator("permutation.getState('redstone_signal') > 0"); + expect(evaluator.evaluate(makeBlock())).toBe(true); + }); + + it('short-circuits && and || (does not evaluate the dead operand)', () => { + // Right side would throw if evaluated; && must not evaluate it when the left is false. + const andCase = new ExpressionEvaluator("typeId === 'minecraft:air' && missing.getState('x')"); + expect(() => andCase.evaluate(makeBlock())).not.toThrow(); + expect(andCase.evaluate(makeBlock())).toBe(false); + + // Right side would throw if evaluated; || must not evaluate it when the left is true. + const orCase = new ExpressionEvaluator("typeId === 'minecraft:redstone_wire' || missing.getState('x')"); + expect(() => orCase.evaluate(makeBlock())).not.toThrow(); + expect(orCase.evaluate(makeBlock())).toBe(true); + + // Sanity: an eagerly-evaluated bare `missing.getState('x')` really does throw. + expect(() => new ExpressionEvaluator("missing.getState('x')").evaluate(makeBlock())).toThrow(); + }); + + it('applies arithmetic, comparison, and unary operators', () => { + expect(new ExpressionEvaluator('x + 1 === 5').evaluate(makeBlock())).toBe(true); + expect(new ExpressionEvaluator('!(x < 0)').evaluate(makeBlock())).toBe(true); + expect(new ExpressionEvaluator('-x === -4').evaluate(makeBlock())).toBe(true); + }); + + it('throws on a syntax error at construction', () => { + expect(() => new ExpressionEvaluator('a &&')).toThrow(); + }); + + it('surfaces runtime errors from the block to the caller', () => { + const evaluator = new ExpressionEvaluator('hasTag("x")'); + const block = { hasTag: () => { throw new Error('restricted'); } }; + expect(() => evaluator.evaluate(block)).toThrow('restricted'); + }); + + describe('sandbox', () => { + it('rejects the constructor/prototype escape at construction', () => { + expect(() => new ExpressionEvaluator('block.constructor.constructor("return 1")()')).toThrow(/Forbidden property access: constructor/); + expect(() => new ExpressionEvaluator('block.__proto__')).toThrow(/Forbidden property access: __proto__/); + expect(() => new ExpressionEvaluator('hasTag.prototype')).toThrow(/Forbidden property access: prototype/); + }); + + it('rejects computed access to a forbidden key at runtime', () => { + const evaluator = new ExpressionEvaluator("block['constr' + 'uctor']"); + expect(() => evaluator.evaluate(makeBlock())).toThrow(/Forbidden property access: constructor/); + }); + + it('rejects calls to methods that are not read-only-safe', () => { + expect(() => new ExpressionEvaluator("setPermutation('x')")).toThrow(/Forbidden method call: setPermutation/); + expect(() => new ExpressionEvaluator("block.setType('minecraft:tnt')")).toThrow(/Forbidden method call: setType/); + expect(() => new ExpressionEvaluator("runCommand('kill @a')")).toThrow(/Forbidden method call: runCommand/); + }); + + it('allows read-only method calls', () => { + expect(new ExpressionEvaluator("permutation.getState('redstone_signal') === 7").evaluate(makeBlock())).toBe(true); + expect(new ExpressionEvaluator("hasTag('wood')").evaluate({ hasTag: (t) => t === 'wood' })).toBe(true); + }); + + it('allows safe built-in JS methods on values', () => { + expect(new ExpressionEvaluator("typeId.includes('redstone')").evaluate(makeBlock())).toBe(true); + expect(new ExpressionEvaluator("typeId.startsWith('minecraft:')").evaluate(makeBlock())).toBe(true); + expect(new ExpressionEvaluator('typeId.toUpperCase()').evaluate(makeBlock())).toBe('MINECRAFT:REDSTONE_WIRE'); + }); + + it('still rejects invocation-redirection escapes (call/apply/bind)', () => { + expect(() => new ExpressionEvaluator("block.setType.call(block, 'minecraft:tnt')")).toThrow(/Forbidden method call: call/); + expect(() => new ExpressionEvaluator('block.setType.apply(block)')).toThrow(/Forbidden method call: apply/); + expect(() => new ExpressionEvaluator("hasTag.bind(block)")).toThrow(/Forbidden method call: bind/); + }); + }); + + describe('new ItemStack', () => { + it('constructs an ItemStack with its arguments', () => { + expect(new ExpressionEvaluator("new ItemStack('minecraft:diamond')").evaluate({})).toEqual({ typeId: 'minecraft:diamond', amount: 1 }); + expect(new ExpressionEvaluator("new ItemStack('minecraft:arrow', 16)").evaluate({})).toEqual({ typeId: 'minecraft:arrow', amount: 16 }); + }); + + it('evaluates constructor arguments against the block', () => { + expect(new ExpressionEvaluator('new ItemStack(typeId)').evaluate(makeBlock())).toEqual({ typeId: 'minecraft:redstone_wire', amount: 1 }); + }); + + it('rejects constructing any class other than ItemStack', () => { + expect(() => new ExpressionEvaluator("new Player('x')")).toThrow(/Only 'new ItemStack/); + expect(() => new ExpressionEvaluator('new Date()')).toThrow(/Only 'new ItemStack/); + }); + + it('rejects new with a non-identifier callee', () => { + expect(() => new ExpressionEvaluator("new block.constructor('x')")).toThrow(); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js b/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js new file mode 100644 index 00000000..8dd69b0f --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Actions.test.js @@ -0,0 +1,167 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { scheduler } from '@forestoflight/minecraft-vitest-mocks'; +import { Actions } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Actions'; +import { RepeatableAction, REPEATABLE_ACTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; + +describe('Actions', () => { + let mockUnderstudy; + let actions; + let performSpy; + let repeatableActionOnTickSpy; + + beforeEach(() => { + mockUnderstudy = { name: 'TestBot' }; + actions = new Actions(mockUnderstudy); + performSpy = vi.spyOn(RepeatableAction.prototype, 'perform').mockImplementation(() => {}); + repeatableActionOnTickSpy = vi.spyOn(RepeatableAction.prototype, 'onTick').mockImplementation(() => {}); + }); + + describe('constructor', () => { + it('stores the understudy', () => { + expect(actions.understudy).toBe(mockUnderstudy); + }); + + it('starts with no actions queued', () => { + expect(actions.isEmpty()).toBe(true); + }); + }); + + describe('once', () => { + it('queues a single action', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + + it('performs the action on the next onTick call', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + + it('performs the action only once', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + + it('does not perform the action before the tick delay elapses', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK, 5); + actions.onTick(); + expect(performSpy).not.toHaveBeenCalled(); + }); + + it('performs the action after the tick delay elapses', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK, 5); + scheduler.advanceTicks(5); + actions.onTick(); + expect(performSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('repeat', () => { + it('adds a repeating action', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(true); + }); + + it('replaces an existing action of the same type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 5); + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 10); + expect(actions.get(REPEATABLE_ACTIONS.ATTACK).intervalTicks).toBe(10); + }); + + it('does not affect other action types when replacing', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.repeat(REPEATABLE_ACTIONS.ATTACK, 5); + expect(actions.has(REPEATABLE_ACTIONS.JUMP)).toBe(true); + }); + }); + + describe('get', () => { + it('returns the repeating action with the given type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + const action = actions.get(REPEATABLE_ACTIONS.ATTACK); + expect(action).toBeInstanceOf(RepeatableAction); + expect(action.type).toBe(REPEATABLE_ACTIONS.ATTACK); + }); + + it('returns undefined when no action of that type exists', () => { + expect(actions.get(REPEATABLE_ACTIONS.ATTACK)).toBeUndefined(); + }); + }); + + describe('has', () => { + it('returns true when a repeating action with the given type exists', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(true); + }); + + it('returns false when no repeating action with the given type exists', () => { + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(false); + }); + }); + + describe('isEmpty', () => { + it('returns true when no actions are queued', () => { + expect(actions.isEmpty()).toBe(true); + }); + + it('returns false when a single action is queued', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + + it('returns false when a repeating action is queued', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + expect(actions.isEmpty()).toBe(false); + }); + }); + + describe('remove', () => { + it('removes the repeating action with the given type', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.remove(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.ATTACK)).toBe(false); + }); + + it('does not remove other action types', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.remove(REPEATABLE_ACTIONS.ATTACK); + expect(actions.has(REPEATABLE_ACTIONS.JUMP)).toBe(true); + }); + }); + + describe('clear', () => { + it('removes all repeating and single actions', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.clear(); + expect(actions.isEmpty()).toBe(true); + }); + }); + + describe('onTick', () => { + it('calls perform on each pending single action', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.once(REPEATABLE_ACTIONS.JUMP); + actions.onTick(); + expect(performSpy).toHaveBeenCalledTimes(2); + }); + + it('clears single actions after performing them', () => { + actions.once(REPEATABLE_ACTIONS.ATTACK); + actions.onTick(); + expect(actions.isEmpty()).toBe(true); + }); + + it('calls onTick on each repeating action', () => { + actions.repeat(REPEATABLE_ACTIONS.ATTACK); + actions.repeat(REPEATABLE_ACTIONS.JUMP); + actions.onTick(); + expect(repeatableActionOnTickSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js new file mode 100644 index 00000000..8b00494f --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/PlayerInfoSaver.test.js @@ -0,0 +1,180 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, system, EntityComponentTypes, TicksPerSecond } from '@minecraft/server'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import { PlayerInfoSaver } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/PlayerInfoSaver'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { simplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving'; +import { UnderstudySaveInfoError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudySaveInfoError'; +import { UnderstudyNotConnectedError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('PlayerInfoSaver', () => { + let understudy; + let infoSaver; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension('minecraft:overworld') }); + infoSaver = new PlayerInfoSaver(understudy); + worldDynamicPropertyStore.set('simplayerSaving', true); + }); + + describe('get', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('throws when no player info has been saved', () => { + worldDynamicPropertyStore.set('TestBot:playerinfo', undefined); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('throws when player info is corrupted', () => { + worldDynamicPropertyStore.set('TestBot:playerinfo', 'this is not valid json'); + expect(() => infoSaver.get()).toThrow(UnderstudySaveInfoError); + }); + + it('returns parsed player info when data exists', () => { + const playerInfo = { + location: { x: 0, y: 64, z: 0 }, rotation: { x: 0, y: 0 }, + dimensionId: 'minecraft:overworld', gameMode: 'Survival', projectileIds: [] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + expect(infoSaver.get()).toEqual(playerInfo); + }); + + it('throws an error when something undefined happens', () => { + const original = world.getDynamicProperty; + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key, value) => { + if (key === 'TestBot:playerinfo') + throw new Error('Unexpected error'); + else + return original.call(world, key, value); + }); + expect(() => infoSaver.get()).toThrow(Error); + }); + }); + + describe('save', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws an error when the understudy is not connected', () => { + understudy.leave(); + expect(() => infoSaver.save()).toThrow(UnderstudyNotConnectedError); + }); + + it('does not save when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + infoSaver.save(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('writes player info to the dynamic property when connected', () => { + infoSaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('saves player info data', () => { + infoSaver.save(); + const call = world.setDynamicProperty.mock.calls.find(c => c[0] === 'TestBot:playerinfo'); + const saved = JSON.parse(call[1]); + expect(saved).toBeDefined(); + }); + + it('saves projectile ids owned by the understudy', () => { + const projectile = { id: 'proj1', getComponent: vi.fn().mockReturnValue({ owner: understudy.simulatedPlayer }) }; + world.getDimension.mockReturnValueOnce({ + getEntities: vi.fn(() => [projectile]) + }); + infoSaver.save(); + const call = world.setDynamicProperty.mock.calls.find(c => c[0] === 'TestBot:playerinfo'); + const saved = JSON.parse(call[1]); + expect(saved.projectileIds).toContain('proj1'); + }); + }); + + describe('loadInventoryAndProjectileOwnership', () => { + it('throws when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + expect(() => infoSaver.loadInventoryAndProjectileOwnership()).toThrow(UnderstudySaveInfoError); + }); + + it('loads player inventory when data exists', () => { + const playerInfo = { + location: { x: 1, y: 64, z: 2 }, rotation: { x: 0, y: 90 }, + dimensionId: 'minecraft:overworld', gameMode: 'Creative', projectileIds: [] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + worldDynamicPropertyStore.set('bot_TestBot_inventory', JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } })); + infoSaver.loadInventoryAndProjectileOwnership(); + expect(understudy.getInventory().setItem).toHaveBeenCalled(); + }); + + it('loads claimed projectiles when data exists', () => { + const projectile = { id: 'proj1', getComponent: vi.fn().mockReturnValue({ owner: void 0 }) }; + world.getEntity.mockImplementation(id => id === 'proj1' ? projectile : void 0); + const playerInfo = { + location: { x: 1, y: 64, z: 2 }, rotation: { x: 0, y: 90 }, + dimensionId: 'minecraft:overworld', gameMode: 'Creative', projectileIds: ['proj1'] + }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(playerInfo)); + infoSaver.loadInventoryAndProjectileOwnership(); + expect(projectile.getComponent(EntityComponentTypes.Projectile).owner).toBe(understudy.simulatedPlayer); + }); + }); + + describe('onConnectedTick', () => { + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + }); + + it('does nothing when simplayerSaving is disabled', () => { + vi.mocked(simplayerSaving.getNativeValue).mockReturnValue(false); + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('saves when elapsed ticks is a multiple of saveInterval', () => { + system.currentTick = infoSaver.saveInterval; + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('TestBot:playerinfo', expect.any(String)); + }); + + it('does not save playerinfo when elapsed ticks is not a multiple of saveInterval', () => { + system.currentTick = infoSaver.saveInterval - 1; + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).not.toHaveBeenCalledWith('TestBot:playerinfo', expect.anything()); + }); + + it('saves inventory with NBT every 5 seconds when the player has repeating actions', () => { + system.currentTick = TicksPerSecond * 5; + understudy.actions.repeat('attack'); + const spy = vi.spyOn(infoSaver, 'save'); + infoSaver.onConnectedTick(); + expect(spy).toHaveBeenCalled(); + }); + + it('saves inventory without NBT on off-ticks when player has repeating actions', () => { + system.currentTick = TicksPerSecond * 5 - 1; + understudy.actions.repeat('attack'); + infoSaver.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js new file mode 100644 index 00000000..d9d3f3a8 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/RepeatableAction.test.js @@ -0,0 +1,175 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { RepeatableAction, REPEATABLE_ACTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { UnknownRepeatingActionError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnknownRepeatingActionError'; +import { UnderstudyNotConnectedError } from '../../../../../../Canopy[BP]/scripts/src/classes/errors/UnderstudyNotConnectedError'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('RepeatableAction', () => { + let understudy; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + }); + + describe('constructor', () => { + it('stores understudy, type, intervalTicks, and startTick', () => { + system.currentTick = 10; + const action = new RepeatableAction(understudy, 'attack', 5); + expect(action.understudy).toBe(understudy); + expect(action.type).toBe('attack'); + expect(action.intervalTicks).toBe(5); + expect(action.startTick).toBe(10); + }); + + it('defaults intervalTicks to 0', () => { + const action = new RepeatableAction(understudy, 'attack'); + expect(action.intervalTicks).toBe(0); + }); + }); + + describe('setInterval', () => { + it('updates intervalTicks', () => { + const action = new RepeatableAction(understudy, 'attack', 5); + action.setInterval(20); + expect(action.intervalTicks).toBe(20); + }); + }); + + describe('isActionTick', () => { + it('always returns true when intervalTicks is 0', () => { + const action = new RepeatableAction(understudy, 'attack', 0); + system.currentTick = 7; + expect(action.isActionTick()).toBe(true); + }); + + it('returns true when elapsed ticks is a multiple of interval', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, 'attack', 5); + system.currentTick = 5; + expect(action.isActionTick()).toBe(true); + }); + + it('returns false when elapsed ticks is not a multiple of interval', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, 'attack', 5); + system.currentTick = 3; + expect(action.isActionTick()).toBe(false); + }); + }); + + describe('while connected', () => { + beforeEach(() => { + understudy.join({ location: { x: 0, y: 0, z: 0 }, dimension: world.getDimension('overworld') }); + }); + + describe('onTick', () => { + it('calls perform when isActionTick returns true', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK, 0); + action.onTick(); + expect(action.understudy.simulatedPlayer.attack).toHaveBeenCalled(); + }); + + it('does not call perform when isActionTick returns false', () => { + system.currentTick = 0; + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK, 5); + system.currentTick = 3; + action.onTick(); + expect(action.understudy.simulatedPlayer.attack).not.toHaveBeenCalled(); + }); + }); + + describe('perform', () => { + it('throws when understudy is not connected', () => { + const offlineUnderstudy = new Understudy('OfflineBot'); + const action = new RepeatableAction(offlineUnderstudy, REPEATABLE_ACTIONS.ATTACK); + expect(() => action.perform()).toThrow(UnderstudyNotConnectedError); + }); + + it('calls simulatedPlayer.attack() for ATTACK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.ATTACK); + action.perform(); + expect(action.understudy.simulatedPlayer.attack).toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.interact() for INTERACT', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.INTERACT); + action.perform(); + expect(action.understudy.simulatedPlayer.interact).toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.useItemInSlot() for USE', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.USE); + action.perform(); + expect(action.understudy.simulatedPlayer.useItemInSlot).toHaveBeenCalled(); + }); + + it('makes the simulated player build for BUILD', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BUILD); + action.perform(); + expect(action.understudy.simulatedPlayer.startBuild).toHaveBeenCalled(); + }); + + it('makes the simulated player break when looking at a block for BREAK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BREAK); + understudy.simulatedPlayer.getBlockFromViewDirection.mockReturnValue({ block: { location: { x: 1, y: 64, z: 1 } } }); + action.perform(); + expect(action.understudy.simulatedPlayer.breakBlock).toHaveBeenCalled(); + }); + + it('does not attempt to break when not looking at a block for BREAK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.BREAK); + understudy.simulatedPlayer.getBlockFromViewDirection.mockReturnValue(undefined); + action.perform(); + expect(action.understudy.simulatedPlayer.breakBlock).not.toHaveBeenCalled(); + }); + + it('makes the simulated player drop a single item for DROP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP); + understudy.getInventory().setItem(0, { typeId: 'minecraft:stone', amount: 1 }); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).toHaveBeenCalled(); + }); + + it('does not attempt to drop when not holding an item for DROP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP); + understudy.getInventory().setItem(0, undefined); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).not.toHaveBeenCalled(); + }); + + it('calls simulatedPlayer.dropSelectedItem() for DROP_STACK', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP_STACK); + action.perform(); + expect(action.understudy.simulatedPlayer.dropSelectedItem).toHaveBeenCalled(); + }); + + it('makes the simulated player drop all items for DROP_ALL', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.DROP_ALL); + action.perform(); + const inventory = understudy.getInventory(); + expect(inventory.setItem).toHaveBeenCalledWith(0, undefined); + }); + + it('calls simulatedPlayer.jump() for JUMP', () => { + const action = new RepeatableAction(understudy, REPEATABLE_ACTIONS.JUMP); + action.perform(); + expect(action.understudy.simulatedPlayer.jump).toHaveBeenCalled(); + }); + + it('throws an error for an unknown action type', () => { + const action = new RepeatableAction(understudy, 'unknownType'); + expect(() => action.perform()).toThrow(UnknownRepeatingActionError); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js new file mode 100644 index 00000000..fe19f6c5 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudies.test.js @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { scheduler } from '@forestoflight/minecraft-vitest-mocks'; + +vi.mock('@minecraft/server', async () => await import('@forestoflight/minecraft-vitest-mocks/server')); +vi.mock('@minecraft/server-gametest', async () => await import('@forestoflight/minecraft-vitest-mocks/server-gametest')); +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); + +let Understudies; + +beforeEach(async () => { + vi.resetModules(); + system.runInterval.mockImplementation((cb, interval) => scheduler.scheduleInterval(cb, interval ?? 1)); + system.clearRun.mockImplementation(id => scheduler.delete(id)); + system.run.mockImplementation(cb => scheduler.scheduleDelay(cb, 1)); + system.runTimeout.mockImplementation((cb, d) => scheduler.scheduleDelay(cb, d)); + ({ default: Understudies } = await import('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies')); +}); + +afterEach(() => { + scheduler.reset(); +}); + +describe('isUnderstudy', () => { + it('returns false when no understudies exist', () => { + expect(Understudies.isUnderstudy({ name: 'Bob' })).toBe(false); + }); + + it('returns false for a player whose name matches a disconnected understudy', () => { + Understudies.create('Bob'); + expect(Understudies.isUnderstudy({ name: 'Bob' })).toBe(false); + }); + + it('returns false for null', () => { + expect(Understudies.isUnderstudy(null)).toBe(false); + }); +}); + +describe('lazy interval management', () => { + it('does not start the interval before any understudy connects', () => { + expect(scheduler.scheduled.size).toBe(0); + }); + + it('starts the interval when onConnect is called for the first time', () => { + Understudies.create('Alice'); + Understudies.onConnect(); + expect(scheduler.scheduled.size).toBeGreaterThan(0); + }); + + it('does not start a second interval when onConnect is called again', () => { + Understudies.create('Alice'); + Understudies.onConnect(); + const countAfterFirst = scheduler.scheduled.size; + Understudies.onConnect(); + expect(scheduler.scheduled.size).toBe(countAfterFirst); + }); +}); + +describe('create and get', () => { + it('creates and retrieves an understudy by name', () => { + const u = Understudies.create('Charlie'); + expect(Understudies.get('Charlie')).toBe(u); + }); + + it('throws when creating a duplicate name that is already online', () => { + Understudies.create('Dave'); + expect(() => Understudies.create('Dave')).toThrow(); + }); +}); + +describe('onEntityDie', () => { + it('does nothing when the dead entity is not a player', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.onEntityDie({ deadEntity: { typeId: 'minecraft:zombie', name: 'Alice' } }); + expect(u.isConnected()).toBe(true); + }); + + it('disconnects and removes an understudy when their player entity dies', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.onEntityDie({ deadEntity: { typeId: 'minecraft:player', name: 'Alice' } }); + expect(u.isConnected()).toBe(false); + }); +}); + +describe('onPlayerGameModeChange', () => { + it('saves player info when an understudy changes game mode', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + const saveSpy = vi.spyOn(u, 'savePlayerInfo'); + Understudies.onPlayerGameModeChange({ player: { name: 'Alice' } }); + expect(saveSpy).toHaveBeenCalled(); + }); +}); + +describe('remove', () => { + it('disconnects the understudy immediately', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + Understudies.remove(u); + expect(u.isConnected()).toBe(false); + }); + + it('removes the understudy from the list after the disconnect is processed', () => { + const u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); // drain join's system.run before disconnecting + Understudies.remove(u); + scheduler.advanceTicks(1); // let remove's runInterval fire + expect(Understudies.length()).toBe(0); + }); +}); + +describe('removeAll', () => { + it('removes all online understudies', () => { + const a = Understudies.create('Alice'); + const b = Understudies.create('Bob'); + Understudies.onConnect(); + a.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + b.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); // drain join callbacks before disconnecting + Understudies.removeAll(); + scheduler.advanceTicks(1); // let remove intervals fire + expect(Understudies.length()).toBe(0); + }); +}); + +describe('setNametagPrefix', () => { + let u; + + beforeEach(() => { + u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + it('sets nameTag to [prefix] name format when prefix is non-empty', () => { + Understudies.setNametagPrefix('Bot'); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); + }); + + it('resets nameTag to just the name when prefix is empty string', () => { + Understudies.setNametagPrefix('Bot'); + Understudies.setNametagPrefix(''); + expect(u.simulatedPlayer.nameTag).toBe('Alice'); + }); + + it('stores the prefix in world dynamic property', () => { + Understudies.setNametagPrefix('Bot'); + expect(world.setDynamicProperty).toHaveBeenCalledWith('nametagPrefix', 'Bot'); + }); +}); + +describe('addNametagPrefix', () => { + let u; + + beforeEach(() => { + u = Understudies.create('Alice'); + Understudies.onConnect(); + u.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + it('sets nameTag when a prefix is stored in world properties', () => { + world.getDynamicProperty.mockReturnValueOnce('Bot'); + Understudies.addNametagPrefix(u); + expect(u.simulatedPlayer.nameTag).toBe('§r[Bot§r] Alice'); + }); + + it('does not change nameTag when no prefix is stored', () => { + world.getDynamicProperty.mockReturnValueOnce(undefined); + const before = u.simulatedPlayer.nameTag; + Understudies.addNametagPrefix(u); + expect(u.simulatedPlayer.nameTag).toBe(before); + }); +}); + +describe('message helpers', () => { + it('returns the correct not-online message', () => { + expect(Understudies.getNotOnlineMessage('TestBot')).toEqual({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns the correct already-online message', () => { + expect(Understudies.getAlreadyOnlineMessage('TestBot')).toEqual({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js new file mode 100644 index 00000000..15db2655 --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/Understudy.test.js @@ -0,0 +1,668 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, system, Block, Entity, Player } from '@minecraft/server'; +import { scheduler, worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; +import { MOVE_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playermove'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('Understudy', () => { + let understudy; + + beforeEach(() => { + vi.clearAllMocks(); + system.currentTick = 0; + understudy = new Understudy('TestBot'); + }); + + describe('constructor', () => { + it('stores the given name', () => { + expect(understudy.name).toBe('TestBot'); + }); + + it('captures system.currentTick as createdTick', () => { + system.currentTick = 42; + expect(new Understudy('Alice').createdTick).toBe(42); + }); + + it('starts disconnected', () => { + expect(understudy.isConnected()).toBe(false); + }); + }); + + describe('isConnected', () => { + it('returns false before join is called', () => { + expect(understudy.isConnected()).toBe(false); + }); + + it('returns true after join is called', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.isConnected()).toBe(true); + }); + + it('returns false after leave is called', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + understudy.leave(); + expect(understudy.isConnected()).toBe(false); + }); + }); + + describe('createdTick', () => { + it('returns the tick when the Understudy was created', () => { + system.currentTick = 100; + const u = new Understudy('Alice'); + expect(u.createdTick).toBe(100); + }); + + it('cannot be set', () => { + expect(() => { understudy.createdTick = 50; }).toThrow(); + }); + }); + + describe('join', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a new simulated player', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.simulatedPlayer).toBeDefined(); + }); + + it('sets isConnected to true', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(understudy.isConnected()).toBe(true); + }); + + it('throws if already connected', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(() => understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() })).toThrow(); + }); + + it('warns if loading player info hits a known error', () => { + system.run.mockImplementation(cb => { scheduler.scheduleDelay(cb, 1); }); + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key) => { + if (key === 'TestBot:playerinfo') + return 'invalid json'; + return void 0; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + scheduler.advanceTicks(1); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('throws if loading player info hits an unknown error', () => { + system.run.mockImplementation(cb => { cb(); }); + const original = world.getDynamicProperty; + vi.spyOn(world, 'getDynamicProperty').mockImplementation((key, value) => { + if (key === 'TestBot:playerinfo') + throw new Error('Unexpected error'); + else + return original.call(world, key, value); + }); + expect(() => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }).toThrow(); + }); + }); + + describe('rejoin', () => { + it('calls join with saved player info', () => { + const savedInfo = { location: { x: 0, y: 64, z: 0 }, dimensionId: 'minecraft:overworld', rotation: { x: 0, y: 0 }, gameMode: 'Survival' }; + worldDynamicPropertyStore.set('TestBot:playerinfo', JSON.stringify(savedInfo)); + vi.spyOn(understudy, 'join'); + understudy.rejoin(); + expect(understudy.join).toHaveBeenCalledWith( + expect.objectContaining({ location: savedInfo.location, rotation: savedInfo.rotation, gameMode: savedInfo.gameMode }) + ); + }); + + it('throws if already connected', () => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + expect(() => understudy.rejoin()).toThrow(); + }); + }); + + describe('while connected', () => { + beforeEach(() => { + understudy.join({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() }); + }); + + describe('onConnectedTick', () => { + it('updates the player info saver', () => { + understudy.onConnectedTick(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + expect.stringContaining('TestBot:playerinfo'), expect.any(String) + ); + }); + + it('runs the actions', () => { + understudy.actions.once('attack'); + understudy.onConnectedTick(); + expect(understudy.actions.isEmpty()).toBe(true); + }); + + it('clears the look target if it is no longer valid', () => { + const target = new Entity(); + target.isValid = true; + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + target.isValid = false; + understudy.onConnectedTick(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('keeps the look target if it is still valid', () => { + const target = new Entity(); + target.isValid = true; + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + understudy.onConnectedTick(); + expect(understudy.lookTarget).toBe(target); + }); + + it('refreshes the held item of the simulated player', () => { + const spy = vi.spyOn(understudy, 'refreshHeldItem'); + understudy.onConnectedTick(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('simulatedPlayer', () => { + it('returns the simulated player object', () => { + expect(understudy.simulatedPlayer).toBeDefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.simulatedPlayer = {}; }).toThrow(); + }); + + it('throws when accessed while not connected', () => { + understudy.leave(); + expect(() => understudy.simulatedPlayer).toThrow(); + }); + }); + + describe('actions', () => { + it('returns the actions object', () => { + expect(understudy.actions).toBeDefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.actions = {}; }).toThrow(); + }); + + it('throws when accessed while not connected', () => { + understudy.leave(); + expect(() => understudy.actions).toThrow(); + }); + }); + + describe('lookTarget / clearLookTarget', () => { + it('lookTarget returns undefined initially', () => { + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('returns a target after instructed to look', () => { + const mockBlock = new Block(); + mockBlock.location = { x: 0, y: 64, z: 0 }; + understudy.look(mockBlock); + expect(understudy.lookTarget).toBeDefined(); + }); + + it('clearLookTarget sets lookTarget to undefined', () => { + understudy.clearLookTarget(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.lookTarget = {}; }).toThrow(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.lookTarget).toThrow(); + }); + }); + + describe('headRotation', () => { + it('returns simulatedPlayer.headRotation when there is no look target', () => { + understudy.simulatedPlayer.headRotation = { x: 10, y: 20 }; + expect(understudy.headRotation).toEqual({ x: 10, y: 20 }); + }); + + it('returns simulatedPlayer.headRotation and clears target when target.isValid is false', () => { + const invalidEntity = new Entity(); + invalidEntity.isValid = false; + invalidEntity.location = { x: 1, y: 64, z: 1 }; + understudy.look(invalidEntity); + expect(understudy.headRotation).toEqual({ x: 0, y: 0 }); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('returns a computed rotation toward a non-Entity target with isValid true', () => { + const blockTarget = new Block(); + blockTarget.isValid = true; + blockTarget.location = { x: 10, y: 64, z: 0 }; + understudy.look(blockTarget); + const result = understudy.headRotation; + expect(result).not.toEqual({ x: 0, y: 0 }); + }); + + it('returns a computed rotation toward an Entity target', () => { + const entityTarget = new Entity(); + entityTarget.isValid = true; + entityTarget.getHeadLocation = vi.fn(() => ({ x: 10, y: 65, z: 0 })); + understudy.look(entityTarget); + const result = understudy.headRotation; + expect(result).not.toEqual({ x: 0, y: 0 }); + }); + + it('returns headRotation when Entity.getHeadLocation throws', () => { + const entityTarget = new Entity(); + entityTarget.isValid = true; + entityTarget.getHeadLocation = vi.fn(() => { throw new Error('invalid'); }); + understudy.look(entityTarget); + expect(understudy.headRotation).toEqual({ x: 0, y: 0 }); + }); + + it('cannot be set directly', () => { + expect(() => { understudy.headRotation = {}; }).toThrow(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.headRotation).toThrow(); + }); + }); + + describe('savePlayerInfo', () => { + it('delegates to playerInfoSaver.save()', () => { + understudy.savePlayerInfo(); + expect(world.setDynamicProperty).toHaveBeenCalled(); + }); + + it('throws an error if the understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.savePlayerInfo()).toThrow(); + }); + }); + + describe('leave', () => { + it('removes the simulated player', () => { + understudy.leave(); + expect(() => understudy.simulatedPlayer).toThrow(); + }); + + it('sets isConnected to false', () => { + understudy.leave(); + expect(understudy.isConnected()).toBe(false); + }); + + it('broadcasts a leave message', () => { + understudy.leave(); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.leave.broadcast', with: ['TestBot'] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.leave()).toThrow(); + }); + }); + + describe('teleport', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('teleports the simulatedPlayer to the given location', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension(), rotation: { x: 0, y: 0 } }; + understudy.teleport(teleportOptions); + expect(understudy.simulatedPlayer.teleport).toHaveBeenCalledWith( + teleportOptions.location, expect.any(Object) + ); + }); + + it('passes rotation and dimension in teleport options', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension(), rotation: { x: 10, y: 45 } }; + understudy.teleport(teleportOptions); + const options = understudy.simulatedPlayer.teleport.mock.calls[0][1]; + expect(options.dimension).toBeDefined(); + expect(options.rotation).toEqual(teleportOptions.rotation); + }); + + it('uses 0, 0 as default rotation', () => { + const teleportOptions = { location: { x: 5, y: 70, z: 5 }, dimension: world.getDimension() }; + understudy.teleport(teleportOptions); + const options = understudy.simulatedPlayer.teleport.mock.calls[0][1]; + expect(options.rotation).toEqual({ x: 0, y: 0 }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.teleport({ location: { x: 0, y: 64, z: 0 }, dimension: world.getDimension() })).toThrow(); + }); + }); + + describe('look', () => { + it('calls lookAt and stores block location as look target', () => { + const target = new Block(); + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + expect(understudy.simulatedPlayer.lookAt).toHaveBeenCalledWith(target); + expect(understudy.lookTarget).toBe(target); + }); + + it('calls lookAtEntity and stores entity as look target', () => { + const target = new Entity(); + understudy.look(target); + expect(understudy.simulatedPlayer.lookAtEntity).toHaveBeenCalledWith(target); + expect(understudy.lookTarget).toBe(target); + }); + + it('calls lookAtLocation for a rotation object without storing a look target', () => { + understudy.look({ x: 15, y: 90 }); + expect(understudy.simulatedPlayer.lookAtLocation).toHaveBeenCalled(); + expect(understudy.simulatedPlayer.setRotation).toHaveBeenCalledWith({ x: 15, y: 90 }); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.look({ x: 0, y: 0 })).toThrow(); + }); + }); + + describe('stopLooking', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns early when there is no look target', () => { + understudy.stopLooking(); + expect(understudy.simulatedPlayer.lookAtLocation).not.toHaveBeenCalled(); + }); + + it('looks at the location for a Block target', () => { + const target = new Block(); + target.location = { x: 5, y: 64, z: 5 }; + understudy.look(target); + understudy.stopLooking(); + expect(understudy.lookTarget).toBeUndefined(); + expect(understudy.simulatedPlayer.lookAtLocation).toHaveBeenCalledWith(target.location); + }); + + it('looks at the head location for a Player target', () => { + const target = new Player(); + target.getHeadLocation = vi.fn(() => ({ x: 5, y: 66, z: 5 })); + understudy.look(target); + understudy.stopLooking(); + expect(understudy.lookTarget).toBeUndefined(); + expect(understudy.simulatedPlayer.lookAtLocation).toHaveBeenCalledWith(target.getHeadLocation()); + }); + + it('looks at the location for other types of targets', () => { + const target = new Entity(); + target.x = 20; + target.y = 45; + target.z = 20; + target.location = { x: 20, y: 45, z: 20 }; + understudy.look(target); + understudy.stopLooking(); + expect(understudy.lookTarget).toBeUndefined(); + expect(understudy.simulatedPlayer.lookAtLocation).toHaveBeenCalledWith( + expect.objectContaining({ x: 20, y: 45, z: 20 }) + ); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.stopLooking()).toThrow(); + }); + }); + + describe('moveLocation', () => { + it('navigates to a Block target', () => { + const target = new Block(); + understudy.moveLocation(target); + expect(understudy.simulatedPlayer.navigateToBlock).toHaveBeenCalledWith(target); + }); + + it('navigates to an Entity target', () => { + const target = new Entity(); + understudy.moveLocation(target); + expect(understudy.simulatedPlayer.navigateToEntity).toHaveBeenCalledWith(target); + }); + + it('navigates to a location', () => { + const location = { x: 10, y: 64, z: 10 }; + understudy.moveLocation(location); + expect(understudy.simulatedPlayer.navigateToLocation).toHaveBeenCalledWith(location); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.moveLocation({ x: 0, y: 64, z: 0 })).toThrow(); + }); + }); + + describe('moveRelative', () => { + it('passes [0, 1] to simulatedPlayer.moveRelative for FORWARD', () => { + understudy.moveRelative(MOVE_OPTIONS.FORWARD); + expect(understudy.simulatedPlayer.moveRelative).toHaveBeenCalledWith(0, 1); + }); + + it('passes [0, -1] for BACKWARD', () => { + understudy.moveRelative(MOVE_OPTIONS.BACKWARD); + expect(understudy.simulatedPlayer.moveRelative).toHaveBeenCalledWith(0, -1); + }); + + it('passes [1, 0] for LEFT', () => { + understudy.moveRelative(MOVE_OPTIONS.LEFT); + expect(understudy.simulatedPlayer.moveRelative).toHaveBeenCalledWith(1, 0); + }); + + it('passes [-1, 0] for RIGHT', () => { + understudy.moveRelative(MOVE_OPTIONS.RIGHT); + expect(understudy.simulatedPlayer.moveRelative).toHaveBeenCalledWith(-1, 0); + }); + + it('throws on an invalid direction', () => { + expect(() => understudy.moveRelative('diagonal')).toThrow(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.moveRelative(MOVE_OPTIONS.FORWARD)).toThrow(); + }); + }); + + describe('stopMoving', () => { + it('stops the simulated player from moving', () => { + understudy.stopMoving(); + expect(understudy.simulatedPlayer.stopMoving).toHaveBeenCalled(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.stopMoving()).toThrow(); + }); + }); + + describe('selectSlot', () => { + it('sets the selected slot for the simulated player', () => { + understudy.selectSlot(5); + expect(understudy.simulatedPlayer.selectedSlotIndex).toBe(5); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.selectSlot(0)).toThrow(); + }); + }); + + describe('sprint', () => { + it('sets the sprinting state for the simulated player', () => { + understudy.sprint(true); + expect(understudy.simulatedPlayer.isSprinting).toBe(true); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.sprint(true)).toThrow(); + }); + }); + + describe('sneak', () => { + it('sets the sneaking state for the simulated player', () => { + understudy.sneak(true); + expect(understudy.simulatedPlayer.isSneaking).toBe(true); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.sneak(true)).toThrow(); + }); + }); + + describe('claimProjectiles', () => { + it('claims projectiles within the given radius', () => { + const mockComponent = { owner: null, isValid: true }; + const mockEntity = { getComponent: vi.fn(() => mockComponent) }; + const mockDimension = { getEntities: vi.fn(() => [mockEntity]) }; + understudy.simulatedPlayer.dimension = mockDimension; + understudy.simulatedPlayer.name = 'TestBot'; + understudy.claimProjectiles(10); + expect(mockComponent.owner).toBe(understudy.simulatedPlayer); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.success', with: ['TestBot', String(1)] }); + }); + + it('sends a message when no projectiles are found', () => { + understudy.simulatedPlayer.dimension = { getEntities: vi.fn(() => []) }; + understudy.simulatedPlayer.name = 'TestBot'; + understudy.claimProjectiles(10); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); + }); + + it('ignores invalid projectile components', () => { + const mockComponent = { isValid: false }; + const mockEntity = { getComponent: vi.fn(() => mockComponent) }; + const mockDimension = { getEntities: vi.fn(() => [mockEntity]) }; + understudy.simulatedPlayer.dimension = mockDimension; + understudy.simulatedPlayer.name = 'TestBot'; + understudy.claimProjectiles(10); + expect(world.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.claimprojectiles.none', with: ['TestBot', String(10)] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.claimProjectiles(10)).toThrow(); + }); + }); + + describe('stopAll', () => { + it('clears all actions', () => { + understudy.actions.once('attack'); + understudy.stopAll(); + expect(understudy.actions.isEmpty()).toBe(true); + }); + + it('calls all stop methods on simulatedPlayer', () => { + understudy.stopAll(); + const simulatedPlayer = understudy.simulatedPlayer; + expect(simulatedPlayer.stopMoving).toHaveBeenCalled(); + expect(simulatedPlayer.stopBuild).toHaveBeenCalled(); + expect(simulatedPlayer.stopInteracting).toHaveBeenCalled(); + expect(simulatedPlayer.stopBreakingBlock).toHaveBeenCalled(); + expect(simulatedPlayer.stopUsingItem).toHaveBeenCalled(); + expect(simulatedPlayer.stopSwimming).toHaveBeenCalled(); + expect(simulatedPlayer.stopGliding).toHaveBeenCalled(); + }); + + it('resets sprint and sneak', () => { + understudy.sprint(true); + understudy.sneak(true); + understudy.stopAll(); + expect(understudy.simulatedPlayer.isSprinting).toBe(false); + expect(understudy.simulatedPlayer.isSneaking).toBe(false); + }); + + it('clears the look target', () => { + const target = new Entity(); + target.location = { x: 0, y: 64, z: 0 }; + understudy.look(target); + understudy.stopAll(); + expect(understudy.lookTarget).toBeUndefined(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.stopAll()).toThrow(); + }); + }); + + describe('getInventory', () => { + it('returns the inventory container from simulatedPlayer', () => { + expect(understudy.getInventory()).toBeDefined(); + }); + + it('returns undefined when simulatedPlayer has no inventory component', () => { + understudy.simulatedPlayer.getComponent.mockReturnValue(undefined); + expect(understudy.getInventory()).toBeUndefined(); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.getInventory()).toThrow(); + }); + }); + + describe('swapHeldItemWithPlayer', () => { + let targetContainer; + let targetPlayer; + + beforeEach(() => { + targetContainer = { swapItems: vi.fn() }; + targetPlayer = { + getComponent: vi.fn(() => ({ container: targetContainer })), + selectedSlotIndex: 1, + sendMessage: vi.fn() + }; + }); + + it('swaps items between the understudy and the target player', () => { + understudy.swapHeldItemWithPlayer(targetPlayer); + expect(understudy.getInventory().swapItems).toHaveBeenCalledWith(0, 1, targetContainer); + }); + + it('sends an error message when swapItems throws', () => { + vi.spyOn(understudy.getInventory(), 'swapItems').mockImplementation(() => { throw new Error('swap failed'); }); + understudy.swapHeldItemWithPlayer(targetPlayer); + expect(targetPlayer.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.swapheld.error', with: ['Error'] }); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.swapHeldItemWithPlayer(targetPlayer)).toThrow(); + }); + }); + + describe('refreshHeldItem', () => { + it('re-assigns the selected slot index to visually refresh the held item', () => { + understudy.refreshHeldItem(); + expect(understudy.simulatedPlayer.selectedSlotIndex).toBe(0); + }); + + it('throws when understudy is not connected', () => { + understudy.leave(); + expect(() => understudy.refreshHeldItem()).toThrow(); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js new file mode 100644 index 00000000..451410ff --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/UnderstudyInventorySaver.test.js @@ -0,0 +1,183 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { world, Container, EquipmentSlot, EntityComponentTypes } from '@minecraft/server'; +import { makeEquippable } from '@minecraft/server-gametest'; +import { UnderstudyInventorySaver } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/UnderstudyInventorySaver'; +import Understudy from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudy'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving', () => ({ + simplayerSaving: { getNativeValue: vi.fn(() => true), getID: vi.fn(() => 'simplayerSaving') } +})); +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { onConnect: vi.fn() } +})); + +describe('UnderstudyInventorySaver', () => { + let understudy; + let inventorySaver; + + beforeEach(() => { + vi.clearAllMocks(); + understudy = new Understudy('TestBot'); + inventorySaver = new UnderstudyInventorySaver(understudy); + understudy.join({ location: { x: 0, y: 0, z: 0 }, dimension: world.getDimension('overworld') }); + }); + + describe('constructor', () => { + it('sets inventory dynamic property key based on player name', () => { + expect(inventorySaver.inventoryDP).toBe('bot_TestBot_inventory'); + }); + + it('sets equippable dynamic property key based on player name', () => { + expect(inventorySaver.equippableDP).toBe('bot_TestBot_equippable'); + }); + + it('truncates player name to 8 characters in the table name', () => { + const inv = new UnderstudyInventorySaver(new Understudy('LongNamedPlayer')); + expect(inv.inventoryDP).toBe('bot_LongName_inventory'); + }); + }); + + describe('save', () => { + it('writes inventory items to world dynamic property', () => { + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + + it('writes equippable items to world dynamic property', () => { + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_equippable', expect.any(String)); + }); + + it('includes equipped items in the serialized equippable output', () => { + const sword = { typeId: 'minecraft:iron_sword', amount: 1 }; + const equippable = makeEquippable({ [EquipmentSlot.Head]: sword }); + const container = understudy.getInventory(); + understudy.simulatedPlayer.getComponent.mockImplementation(type => { + if (type === EntityComponentTypes.Equippable) return equippable; + if (type === EntityComponentTypes.Inventory) return { container }; + }); + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + 'bot_TestBot_equippable', + expect.stringContaining('"Head":{"typeId":"minecraft:iron_sword","amount":1}') + ); + }); + + it('excludes undefined items from the serialized output', () => { + const inventory = understudy.getInventory(); + const itemStack = { typeId: 'minecraft:stone', amount: 1 }; + inventory.setItem(0, itemStack); + inventorySaver.save(); + expect(world.setDynamicProperty).toHaveBeenCalledWith( + 'bot_TestBot_inventory', JSON.stringify({ 0: itemStack }) + ); + }); + + it('saves items to the item database', () => { + const spy = vi.spyOn(inventorySaver.itemDatabase, 'setItems'); + inventorySaver.save(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('saveWithoutNBT', () => { + it('writes inventory items to world dynamic property', () => { + inventorySaver.saveWithoutNBT(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_inventory', expect.any(String)); + }); + + it('writes equippable items to world dynamic property', () => { + inventorySaver.saveWithoutNBT(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('bot_TestBot_equippable', expect.any(String)); + }); + + it('does not save items to the item database', () => { + const spy = vi.spyOn(inventorySaver.itemDatabase, 'setItems'); + inventorySaver.saveWithoutNBT(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('load', () => { + describe('inventory', () => { + it('returns early when inventory component is absent', () => { + understudy.simulatedPlayer.getComponent.mockImplementation( + component => component === EntityComponentTypes.Equippable ? makeEquippable() : undefined + ); + inventorySaver.load(); + understudy.simulatedPlayer.getComponent.mockRestore(); + expect(understudy.getInventory().setItem).not.toHaveBeenCalled(); + }); + + it('returns early when no saved data exists', () => { + inventorySaver.load(); + expect(understudy.getInventory().setItem).not.toHaveBeenCalled(); + }); + + it('sets items in the inventory container from saved data', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([{ typeId: 'minecraft:stone', amount: 1 }]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalled(); + }); + + it('falls back to non-NBT item data when absent from the NBT database', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalledWith( + 0, + expect.objectContaining({ typeId: 'minecraft:stone', amount: 1 }) + ); + }); + + it('sets undefined for slots with no saved item data', () => { + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_inventory' + ? JSON.stringify({ 0: { typeId: 'minecraft:stone', amount: 1 } }) + : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + expect(understudy.getInventory().setItem).toHaveBeenCalledWith(1, undefined); + }); + }); + + describe('equippable', () => { + it('returns early when equippable component is absent', () => { + understudy.simulatedPlayer.getComponent.mockImplementation( + component => component === EntityComponentTypes.Inventory ? new Container() : undefined + ); + inventorySaver.load(); + understudy.simulatedPlayer.getComponent.mockRestore(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).not.toHaveBeenCalled(); + }); + + it('returns early when no saved data exists', () => { + inventorySaver.load(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).not.toHaveBeenCalled(); + }); + + it('calls setEquipment for each slot from saved data', () => { + const savedData = Object.fromEntries(Object.keys(EquipmentSlot).map(slot => [slot, null])); + world.getDynamicProperty.mockImplementation(key => + key === 'bot_TestBot_equippable' ? JSON.stringify(savedData) : undefined + ); + vi.spyOn(inventorySaver.itemDatabase, 'getItems').mockReturnValue([]); + inventorySaver.load(); + const equippable = understudy.simulatedPlayer.getComponent(EntityComponentTypes.Equippable); + expect(equippable.setEquipment).toHaveBeenCalledTimes(Object.keys(EquipmentSlot).length); + }); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/classes/simplayer/utils.test.js b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js new file mode 100644 index 00000000..10fa75fa --- /dev/null +++ b/__tests__/BP/scripts/src/classes/simplayer/utils.test.js @@ -0,0 +1,233 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { Block, Entity, Player, world } from '@minecraft/server'; +import { getLookAtLocation, getLookAtRotation, swapSlots, portOldGameModeToNewUpdate, getLocationInfoFromSource } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/utils.js'; + +vi.mock('@minecraft/server', async () => await import('@forestoflight/minecraft-vitest-mocks/server')); + +const PLAYER_EYE_HEIGHT = 1.62001002; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getLookAtLocation', () => { + it('returns a location offset from base using rotation', () => { + const base = { x: 0, y: 0, z: 0 }; + const rotation = { x: 0, y: 0 }; + const result = getLookAtLocation(base, rotation); + expect(result).toHaveProperty('x'); + expect(result).toHaveProperty('y'); + expect(result).toHaveProperty('z'); + }); + + it('adds PLAYER_EYE_HEIGHT to y when pitch is 0', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.y).toBeCloseTo(PLAYER_EYE_HEIGHT); + }); + + it('looks south (positive z) when yaw is 0', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.z).toBeCloseTo(1000); + expect(result.x).toBeCloseTo(0); + }); + + it('looks west (negative x) when yaw is 90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 90 }); + expect(result.x).toBeCloseTo(-1000); + expect(result.z).toBeCloseTo(0); + }); + + it('looks north (negative z) when yaw is 180', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: 180 }); + expect(result.z).toBeCloseTo(-1000); + expect(result.x).toBeCloseTo(0); + }); + + it('looks east (positive x) when yaw is -90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 0, y: -90 }); + expect(result.x).toBeCloseTo(1000); + expect(result.z).toBeCloseTo(0); + }); + + it('points straight up when pitch is -90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: -90, y: 0 }); + expect(result.y).toBeCloseTo(1000 + PLAYER_EYE_HEIGHT); + expect(result.x).toBeCloseTo(0); + expect(result.z).toBeCloseTo(0); + }); + + it('points straight down when pitch is 90', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtLocation(base, { x: 90, y: 0 }); + expect(result.y).toBeCloseTo(-1000 + PLAYER_EYE_HEIGHT); + }); + + it('offsets from base location', () => { + const base = { x: 10, y: 5, z: 20 }; + const result = getLookAtLocation(base, { x: 0, y: 0 }); + expect(result.x).toBeCloseTo(10); + expect(result.y).toBeCloseTo(5 + PLAYER_EYE_HEIGHT); + expect(result.z).toBeCloseTo(1020); + }); +}); + +describe('getLookAtRotation', () => { + it('returns pitch and yaw from base to target', () => { + const base = { x: 0, y: 0, z: 0 }; + const target = { x: 0, y: PLAYER_EYE_HEIGHT, z: 1 }; + const result = getLookAtRotation(base, target); + expect(result).toHaveProperty('x'); + expect(result).toHaveProperty('y'); + expect(typeof result.x).toBe('number'); + expect(typeof result.y).toBe('number'); + }); + + it('returns pitch ~0 when target is at eye height directly south', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT, z: 1 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly north', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT, z: -1 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly east', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 1, y: PLAYER_EYE_HEIGHT, z: 0 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch ~0 when target is at eye height directly west', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: -1, y: PLAYER_EYE_HEIGHT, z: 0 }); + expect(result.x).toBeCloseTo(0); + }); + + it('returns pitch -90 when looking straight up', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT + 1000, z: 0 }); + expect(result.x).toBeCloseTo(-90); + }); + + it('returns pitch 90 when looking straight down', () => { + const base = { x: 0, y: 0, z: 0 }; + const result = getLookAtRotation(base, { x: 0, y: PLAYER_EYE_HEIGHT - 1000, z: 0 }); + expect(result.x).toBeCloseTo(90); + }); +}); + +describe('swapSlots', () => { + it('swaps items between two slots', () => { + const item0 = { typeId: 'minecraft:apple' }; + const item1 = { typeId: 'minecraft:stone' }; + const container = { + getItem: vi.fn(i => i === 0 ? item0 : item1), + setItem: vi.fn() + }; + swapSlots(container, 0, 1); + expect(container.setItem).toHaveBeenCalledWith(0, item1); + expect(container.setItem).toHaveBeenCalledWith(1, item0); + }); + + it('swaps when one slot is empty', () => { + const item0 = { typeId: 'minecraft:apple' }; + const container = { + getItem: vi.fn(i => i === 0 ? item0 : undefined), + setItem: vi.fn() + }; + swapSlots(container, 0, 1); + expect(container.setItem).toHaveBeenCalledWith(0, undefined); + expect(container.setItem).toHaveBeenCalledWith(1, item0); + }); + + it('throws when container is null', () => { + expect(() => swapSlots(null, 0, 1)).toThrow(); + }); + + it('throws when container is undefined', () => { + expect(() => swapSlots(undefined, 0, 1)).toThrow(); + }); +}); + +describe('portOldGameModeToNewUpdate', () => { + it('converts string game modes to GameMode enum values', () => { + expect(portOldGameModeToNewUpdate('survival')).toBe('Survival'); + expect(portOldGameModeToNewUpdate('creative')).toBe('Creative'); + expect(portOldGameModeToNewUpdate('adventure')).toBe('Adventure'); + expect(portOldGameModeToNewUpdate('spectator')).toBe('Spectator'); + }); + + it('handles uppercase game mode strings', () => { + expect(portOldGameModeToNewUpdate('Survival')).toBe('Survival'); + expect(portOldGameModeToNewUpdate('Creative')).toBe('Creative'); + expect(portOldGameModeToNewUpdate('Adventure')).toBe('Adventure'); + expect(portOldGameModeToNewUpdate('Spectator')).toBe('Spectator'); + }); + + it('throws on unknown game mode string', () => { + expect(() => portOldGameModeToNewUpdate('unknown')).toThrow(); + }); + + it('throws when gameMode is not a string', () => { + expect(() => portOldGameModeToNewUpdate(0)).toThrow(); + }); + + it('throws when gameMode is null', () => { + expect(() => portOldGameModeToNewUpdate(null)).toThrow(); + }); +}); + +describe('getLocationInfoFromSource', () => { + it('throws for invalid source', () => { + expect(() => getLocationInfoFromSource({})).toThrow(); + }); + + it('throws for null source', () => { + expect(() => getLocationInfoFromSource(null)).toThrow(); + }); + + it('returns location, dimension, rotation, and gameMode for a Player source', () => { + const player = new Player(); + player.location = { x: 1, y: 64, z: 1 }; + player.dimension = world.getDimension(); + player.getRotation.mockReturnValue({ x: 0, y: 90 }); + player.getGameMode.mockReturnValue('Survival'); + const result = getLocationInfoFromSource(player); + expect(result.location).toEqual(player.location); + expect(result.dimension).toBe(player.dimension); + expect(result.rotation).toEqual({ x: 0, y: 90 }); + expect(result.gameMode).toBe('Survival'); + }); + + it('returns location, dimension, and rotation for an Entity source', () => { + const entity = new Entity(); + entity.location = { x: 5, y: 70, z: 5 }; + entity.dimension = world.getDimension(); + entity.getRotation.mockReturnValue({ x: 10, y: 45 }); + const result = getLocationInfoFromSource(entity); + expect(result.location).toEqual(entity.location); + expect(result.dimension).toBe(entity.dimension); + expect(result.rotation).toEqual({ x: 10, y: 45 }); + expect(result.gameMode).toBeUndefined(); + }); + + it('returns offset location and dimension for a Block source', () => { + const block = new Block(); + block.x = 5; + block.y = 63; + block.z = 5; + block.dimension = world.getDimension(); + const result = getLocationInfoFromSource(block); + expect(result.location).toEqual({ x: 5.5, y: 64, z: 5.5 }); + expect(result.dimension).toBe(block.dimension); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/analyzearea.test.js b/__tests__/BP/scripts/src/commands/analyzearea.test.js new file mode 100644 index 00000000..2fcf5d5f --- /dev/null +++ b/__tests__/BP/scripts/src/commands/analyzearea.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { world, Player } from '@minecraft/server'; +import { scheduler } from '@forestoflight/minecraft-vitest-mocks'; +import { PlayerCommandOrigin } from '../../../../../Canopy[BP]/scripts/lib/canopy/Canopy'; + +const uiConstructor = vi.fn(); +const showSelector = vi.fn(); +const showCreateForm = vi.fn(); +const showAnalysisPage = vi.fn(); +vi.mock('../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AnalyzeAreaUI', () => ({ + LIST_PAGE_SIZE: 50, + AnalyzeAreaUI: class { + constructor(player, manager) { uiConstructor(player, manager); } + showSelector(...a) { return showSelector(...a); } + showCreateForm(...a) { return showCreateForm(...a); } + showAnalysisPage(...a) { return showAnalysisPage(...a); } + } +})); + +const managerApi = { findByCoords: vi.fn(), remove: vi.fn(), add: vi.fn(), list: vi.fn(() => []) }; +vi.mock('../../../../../Canopy[BP]/scripts/src/classes/analyzearea/AreaAnalysisManager', () => ({ + PROPERTY_KEY: 'areaanalyses', + AreaAnalysisManager: { getInstance: () => managerApi } +})); + +import { analyzeAreaCommand } from '../../../../../Canopy[BP]/scripts/src/commands/analyzearea'; + +describe('analyzeAreaCommand', () => { + let player; + let origin; + + beforeEach(() => { + vi.clearAllMocks(); + scheduler.reset(); + player = new Player(); + player.name = 'Tester'; + player.dimension = { id: 'minecraft:overworld' }; + origin = new PlayerCommandOrigin({ sourceType: 'Entity', sourceEntity: player }); + }); + + it('opens the selector with no args for a player', () => { + analyzeAreaCommand.analyzeAreaCommand(origin); + scheduler.advanceTicks(1); + world.getDimension('minecraft:overworld'); // noop to keep world imported + expect(uiConstructor).toHaveBeenCalledWith(player, managerApi); + expect(showSelector).toHaveBeenCalledWith(); + }); + + it('opens a matching analysis page for ', () => { + const analysis = { id: 'a1' }; + managerApi.findByCoords.mockReturnValue(analysis); + analyzeAreaCommand.analyzeAreaCommand(origin, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }); + scheduler.advanceTicks(1); + expect(uiConstructor).toHaveBeenCalledWith(player, managerApi); + expect(showAnalysisPage).toHaveBeenCalledWith(analysis); + }); + + it('opens a prefilled create form when no analysis matches', () => { + managerApi.findByCoords.mockReturnValue(undefined); + analyzeAreaCommand.analyzeAreaCommand(origin, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }); + scheduler.advanceTicks(1); + expect(showCreateForm).toHaveBeenCalledWith({ from: { x: 0, y: 0, z: 0 }, to: { x: 1, y: 1, z: 1 } }); + }); + + it('removes a matching analysis for the reserved `remove` token', () => { + const analysis = { id: 'a1' }; + managerApi.findByCoords.mockReturnValue(analysis); + analyzeAreaCommand.analyzeAreaCommand(origin, { x: 0, y: 0, z: 0 }, { x: 1, y: 1, z: 1 }, 'remove'); + scheduler.advanceTicks(1); + expect(managerApi.remove).toHaveBeenCalledWith(analysis); + }); + + it('rejects UI-only forms for non-player origins', () => { + const blockOrigin = { getType: () => 'Block', sendMessage: vi.fn() }; + const result = analyzeAreaCommand.analyzeAreaCommand(blockOrigin); + expect(result).toEqual({ status: 'Failure', message: 'commands.generic.invalidsource' }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/canopy.test.js b/__tests__/BP/scripts/src/commands/canopy.test.js new file mode 100644 index 00000000..1c9b9c60 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/canopy.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Player } from '@minecraft/server'; +import { PlayerCommandOrigin, ServerCommandOrigin, Rules, Extensions } from '../../../../../Canopy[BP]/scripts/lib/canopy/Canopy'; + +vi.mock('../../../../../Canopy[BP]/scripts/constants', () => ({ PACK_VERSION: '1.2.3' })); + +import { CanopyCommand, canopyCommand } from '../../../../../Canopy[BP]/scripts/src/commands/canopy'; + +describe('CanopyCommand.parseValue', () => { + it('parses booleans from strings', () => { + expect(CanopyCommand.parseValue('true', 'boolean')).toBe(true); + expect(CanopyCommand.parseValue('false', 'boolean')).toBe(false); + }); + it('returns NaN for non-boolean strings on boolean rules', () => { + expect(CanopyCommand.parseValue('yes', 'boolean')).toBeNaN(); + }); + it('parses integers and floats', () => { + expect(CanopyCommand.parseValue('64', 'integer')).toBe(64); + expect(CanopyCommand.parseValue('1.5', 'float')).toBe(1.5); + }); + it('returns NaN for unparseable numbers', () => { + expect(CanopyCommand.parseValue('abc', 'integer')).toBeNaN(); + expect(CanopyCommand.parseValue('abc', 'float')).toBeNaN(); + }); + it('returns null when no value is given', () => { + expect(CanopyCommand.parseValue(null, 'boolean')).toBeNull(); + }); +}); + +describe('CanopyCommand.getRuleEnumValues', () => { + it('appends menu and version to the settable rule IDs', () => { + vi.spyOn(Rules, 'getSettableRuleIDs').mockReturnValue(['foo', 'bar']); + expect(CanopyCommand.getRuleEnumValues()).toEqual(['foo', 'bar', 'menu', 'version']); + }); +}); + +describe('canopyCommand dispatch', () => { + let mockPlayer; + let playerOrigin; + let serverOrigin; + + beforeEach(() => { + mockPlayer = new Player(); + mockPlayer.name = 'TestPlayer'; + playerOrigin = new PlayerCommandOrigin({ sourceEntity: mockPlayer }); + serverOrigin = new ServerCommandOrigin({}); + }); + + it('rejects menu from a non-player origin', () => { + expect(canopyCommand.canopyCommand(serverOrigin, 'menu')).toEqual({ + status: 'Failure', + message: 'commands.generic.invalidsource' + }); + }); + + it('returns Success for menu from a player origin', () => { + expect(canopyCommand.canopyCommand(playerOrigin, 'menu')).toEqual({ status: 'Success' }); + }); + + it('sends the version message and returns Success for version', () => { + vi.spyOn(Extensions, 'getVersionedNames').mockReturnValue([]); + const result = canopyCommand.canopyCommand(playerOrigin, 'version'); + expect(result).toEqual({ status: 'Success' }); + expect(mockPlayer.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'commands.canopy.version.message' }, + { text: ' §av1.2.3§r§7.\n' } + ] + }); + }); + + it('returns Success for a rule change', () => { + vi.spyOn(Rules, 'get').mockReturnValue({ getType: () => 'boolean' }); + expect(canopyCommand.canopyCommand(playerOrigin, 'commandTick', 'true')).toEqual({ status: 'Success' }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/info.test.js b/__tests__/BP/scripts/src/commands/info.test.js new file mode 100644 index 00000000..c8374045 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/info.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Player } from '@minecraft/server'; +import { PlayerCommandOrigin, Rules, InfoDisplayRule } from '../../../../../Canopy[BP]/scripts/lib/canopy/Canopy'; +import { InfoDisplayCommand, infoCommand } from '../../../../../Canopy[BP]/scripts/src/commands/info'; +import { InfoDisplay } from '../../../../../Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay'; + +describe('InfoDisplayCommand.getRuleEnumValues', () => { + it('returns every InfoDisplay rule identifier followed by menu', () => { + expect(InfoDisplayCommand.getRuleEnumValues()).toEqual([...InfoDisplay.getRuleIdentifiers(), 'menu']); + }); +}); + +describe('infoCommand dispatch', () => { + let mockPlayer; + let playerOrigin; + + beforeEach(() => { + mockPlayer = new Player(); + mockPlayer.name = 'TestPlayer'; + playerOrigin = new PlayerCommandOrigin({ sourceEntity: mockPlayer }); + }); + + it('returns Success for menu', () => { + expect(infoCommand.infoCommand(playerOrigin, 'menu')).toEqual({ status: 'Success' }); + }); + + it('returns Success for a rule toggle', () => { + expect(infoCommand.infoCommand(playerOrigin, 'showCoords', true)).toEqual({ status: 'Success' }); + }); +}); + +describe('infoCommand.handleRuleChange', () => { + let player; + + beforeEach(() => { + player = new Player(); + player.name = 'TestPlayer'; + vi.restoreAllMocks(); + }); + + it('reports the current value when no value is given', async () => { + vi.spyOn(InfoDisplayRule, 'exists').mockReturnValue(true); + vi.spyOn(InfoDisplayRule, 'getValue').mockReturnValue(true); + await infoCommand.handleRuleChange(player, 'showCoords', null); + expect(player.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'rules.generic.status', with: ['showCoords'] }, + { translate: 'rules.generic.enabled' }, + { text: '§r§7.' } + ] + }); + }); + + it('sends the unknown message for a rule that does not exist at all', async () => { + vi.spyOn(InfoDisplayRule, 'exists').mockReturnValue(false); + vi.spyOn(Rules, 'exists').mockReturnValue(false); + await infoCommand.handleRuleChange(player, 'nonExistent', true); + expect(player.sendMessage).toHaveBeenCalledWith({ + rawtext: [{ translate: 'rules.generic.unknown', with: ['nonExistent', './'] }] + }); + }); + + it('sends the canopyRule message for a non-InfoDisplay rule that exists', async () => { + vi.spyOn(InfoDisplayRule, 'exists').mockReturnValue(false); + vi.spyOn(Rules, 'exists').mockReturnValue(true); + await infoCommand.handleRuleChange(player, 'commandTick', true); + expect(player.sendMessage).toHaveBeenCalledWith({ + translate: 'commands.info.canopyRule', with: ['commandTick', './'] + }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js new file mode 100644 index 00000000..b5841964 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playeraction.test.js @@ -0,0 +1,103 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playeractionCommand, TIMING_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playeraction'; +import { REPEATABLE_ACTIONS } from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/RepeatableAction'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playeractionCommand', () => { + let mockActions; + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockActions = { + once: vi.fn(), + repeat: vi.fn(), + remove: vi.fn(), + }; + mockUnderstudy = { name: 'TestBot', actions: mockActions }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('queues a once action with ONCE timing (default)', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.ONCE); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for AFTER timing without ticks', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, undefined); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.AFTER, 'undefined'] }); + }); + + it('queues a delayed once action with AFTER timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.AFTER, 10); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK, 10); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('queues a repeating action with CONTINUOUS timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.CONTINUOUS); + expect(mockActions.repeat).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for INTERVAL timing without ticks', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, undefined); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidticks', with: [TIMING_OPTIONS.INTERVAL, 'undefined'] }); + }); + + it('queues an interval repeating action with INTERVAL timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.INTERVAL, 20); + expect(mockActions.repeat).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK, 20); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('removes a repeating action with STOP timing', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, TIMING_OPTIONS.STOP); + expect(mockActions.remove).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid timing option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK, 'invalid'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playeraction.invalidtiming', with: [REPEATABLE_ACTIONS.ATTACK, 'invalid'] }); + }); + + it('defaults to ONCE timing when no timing option is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playeractionCommand.playeractionCommand(mockOrigin, 'TestBot', REPEATABLE_ACTIONS.ATTACK); + expect(mockActions.once).toHaveBeenCalledWith(REPEATABLE_ACTIONS.ATTACK); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js new file mode 100644 index 00000000..8d80c2b2 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerinventory.test.js @@ -0,0 +1,87 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerinventoryCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerinventory'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerinventoryCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { name: 'TestBot', getInventory: vi.fn() }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success with no-inventory message when inventory is absent', () => { + mockUnderstudy.getInventory.mockReturnValue(undefined); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toBe('commands.playerinventory.noinventory'); + }); + + it('returns success with empty message when all slots are empty', () => { + mockUnderstudy.getInventory.mockReturnValue({ size: 36, emptySlotsCount: 36, getItem: vi.fn(() => undefined) }); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerinventory.empty', with: ['TestBot'] }); + }); + + it('lists items when inventory has contents', () => { + const mockInventory = { + size: 36, + emptySlotsCount: 35, + getItem: vi.fn(i => i === 0 ? { typeId: 'minecraft:stone', amount: 64 } : undefined) + }; + mockUnderstudy.getInventory.mockReturnValue(mockInventory); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'commands.playerinventory.header', with: ['TestBot'] }, + { text: '\n' }, + { translate: 'commands.playerinventory.item', with: ['§a', '0', 'minecraft:stone', '64'] } + ] + }); + }); + + it('uses hotbar color code for slots 0-9', () => { + const mockInventory = { + size: 36, + emptySlotsCount: 35, + getItem: vi.fn(i => i === 0 ? { typeId: 'minecraft:stone', amount: 1 } : undefined) + }; + mockUnderstudy.getInventory.mockReturnValue(mockInventory); + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playerinventoryCommand.playerinventoryCommand(mockOrigin, 'TestBot'); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { translate: 'commands.playerinventory.header', with: ['TestBot'] }, + { text: '\n' }, + { translate: 'commands.playerinventory.item', with: ['§a', '0', 'minecraft:stone', '1'] } + ] + }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js new file mode 100644 index 00000000..754f1cc6 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerjoin.test.js @@ -0,0 +1,51 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerjoinCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerjoin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + create: vi.fn(), + remove: vi.fn(), + addNametagPrefix: vi.fn(), + getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerjoinCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { join: vi.fn(), name: 'TestBot' }; + vi.mocked(Understudies.create).mockReturnValue(mockUnderstudy); + vi.mocked(Understudies.isOnline).mockReturnValue(false); + mockOrigin = { getSource: vi.fn(() => ({ location: { x: 0, y: 64, z: 0 }, dimension: {}, getRotation: vi.fn(() => ({ x: 0, y: 0 })), getGameMode: vi.fn(() => 'Survival') })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); + + it('queues a system.run when the simplayer is not online', () => { + playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns undefined (no explicit return) when the simplayer is not online', () => { + const result = playerjoinCommand.playerjoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js new file mode 100644 index 00000000..c92d46fd --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerleave.test.js @@ -0,0 +1,49 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerleaveCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerleave'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + remove: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + getAlreadyOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is already online.`), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerleaveCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { leave: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('queues a system.run when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns undefined (no explicit return) when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerleaveCommand.playerleaveCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js new file mode 100644 index 00000000..2e6cd68d --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerlook.test.js @@ -0,0 +1,139 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, Entity, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerlookCommand, LOOK_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerlook'; +import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerlookCommand', () => { + let mockUnderstudy; + let mockEntityOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { look: vi.fn(), stopLooking: vi.fn(), name: 'TestBot' }; + const mockEntity = new Entity(); + mockEntity.getBlockFromViewDirection = vi.fn(() => ({ block: { location: { x: 0, y: 64, z: 0 } } })); + mockEntity.getEntitiesFromViewDirection = vi.fn(() => [{ entity: new Entity() }]); + mockEntityOrigin = { getSource: vi.fn(() => mockEntity), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.UP); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it.each([LOOK_OPTIONS.UP, LOOK_OPTIONS.DOWN, LOOK_OPTIONS.NORTH, LOOK_OPTIONS.SOUTH, LOOK_OPTIONS.EAST, LOOK_OPTIONS.WEST])( + 'returns success for cardinal direction: %s', + (direction) => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', direction); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + } + ); + + it('returns failure for BLOCK option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + expect(result.message).toBe('commands.playerlook.block.entityonly'); + }); + + it('returns failure for BLOCK option when no block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getBlockFromViewDirection.mockReturnValue(undefined); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for BLOCK option when a block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ENTITY option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for ENTITY option when no entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getEntitiesFromViewDirection.mockReturnValue([]); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ENTITY option when an entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ME option from server origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playerlookCommand.playerlookCommand(serverOrigin, 'TestBot', LOOK_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ME option from entity origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for AT option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT, 0, 64, 0); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for AT option when no position is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.AT); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ROTATION option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION, 0, 0); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ROTATION option when no rotation is provided', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.ROTATION); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for STOP option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', LOOK_OPTIONS.STOP); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid look option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerlookCommand.playerlookCommand(mockEntityOrigin, 'TestBot', 'invalid'); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerlook.invalidoption', with: ['invalid'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js new file mode 100644 index 00000000..0050727b --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playermove.test.js @@ -0,0 +1,120 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, Entity, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playermoveCommand, MOVE_OPTIONS } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playermove'; +import { ServerCommandOrigin } from '../../../../../../Canopy[BP]/scripts/lib/canopy/commands/ServerCommandOrigin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playermoveCommand', () => { + let mockUnderstudy; + let mockEntityOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { moveRelative: vi.fn(), moveLocation: vi.fn(), stopMoving: vi.fn(), name: 'TestBot' }; + const mockEntity = new Entity(); + mockEntity.getBlockFromViewDirection = vi.fn(() => ({ block: { location: { x: 0, y: 64, z: 0 } } })); + mockEntity.getEntitiesFromViewDirection = vi.fn(() => [{ entity: new Entity() }]); + mockEntityOrigin = { getSource: vi.fn(() => mockEntity), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.FORWARD); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it.each([MOVE_OPTIONS.FORWARD, MOVE_OPTIONS.BACKWARD, MOVE_OPTIONS.LEFT, MOVE_OPTIONS.RIGHT])( + 'returns success for relative direction: %s', + (direction) => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', direction); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + } + ); + + it('returns failure for BLOCK option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for BLOCK option when no block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getBlockFromViewDirection.mockReturnValue(undefined); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for BLOCK option when a block is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.BLOCK); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ENTITY option from a non-entity source', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns failure for ENTITY option when no entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + mockEntityOrigin.getSource().getEntitiesFromViewDirection.mockReturnValue([]); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ENTITY option when an entity is in view', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ENTITY); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for ME option from server origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const serverOrigin = new ServerCommandOrigin({ sourceType: 'Server' }); + const result = playermoveCommand.playermoveCommand(serverOrigin, 'TestBot', MOVE_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Failure); + }); + + it('returns success for ME option from entity origin', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.ME); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for TO option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.TO, { x: 0, y: 64, z: 0 }); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns success for STOP option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', MOVE_OPTIONS.STOP); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('returns failure for invalid move option', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playermoveCommand.playermoveCommand(mockEntityOrigin, 'TestBot', 'invalid'); + expect(result).toBeUndefined(); + expect(mockEntityOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playermove.invalidoption', with: ['invalid'] }); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js new file mode 100644 index 00000000..828cb2f8 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerprefix.test.js @@ -0,0 +1,37 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import { playerprefixCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerprefix'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + setNametagPrefix: vi.fn(), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerprefixCommand', () => { + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('clears the prefix and returns success message when "-none" is passed', () => { + const result = playerprefixCommand.playerprefixCommand(mockOrigin, '-none'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(result.message).toBe('commands.playerprefix.removed'); + expect(system.run).toHaveBeenCalled(); + }); + + it('sets the prefix and returns success message with the new prefix', () => { + const result = playerprefixCommand.playerprefixCommand(mockOrigin, 'Bot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerprefix.set', with: ['Bot'] }); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js new file mode 100644 index 00000000..a259a69c --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerrejoin.test.js @@ -0,0 +1,54 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerrejoinCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerrejoin'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + isOnline: vi.fn(() => false), + create: vi.fn(), + addNametagPrefix: vi.fn(), + getNotOnlineMessage: vi.fn(name => `§cSimplayer '${name}' is not online.`), + getAlreadyOnlineMessage: vi.fn(name => ({ translate: 'simplayer.alreadyonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerrejoinCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { rejoin: vi.fn(), join: vi.fn(), name: 'TestBot' }; + vi.mocked(Understudies.create).mockReturnValue(mockUnderstudy); + vi.mocked(Understudies.isOnline).mockReturnValue(false); + mockOrigin = { + getSource: vi.fn(() => ({ + location: { x: 0, y: 64, z: 0 }, + dimension: {}, + getRotation: vi.fn(() => ({ x: 0, y: 0 })), + getGameMode: vi.fn(() => 'Survival') + })), + sendMessage: vi.fn() + }; + }); + + it('returns failure when the simplayer is already online', () => { + vi.mocked(Understudies.isOnline).mockReturnValue(true); + const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.alreadyonline', with: ['TestBot'] }); + }); + + it('returns success and queues rejoin when the simplayer is offline', () => { + const result = playerrejoinCommand.playerrejoinCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js new file mode 100644 index 00000000..a8d81218 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerselect.test.js @@ -0,0 +1,62 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerselectCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerselect'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerselectCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { selectSlot: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns failure when slot number is less than 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', -1); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerselect.invalidslot', with: ['-1'] }); + }); + + it('returns failure when slot number is greater than 8', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 9); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'commands.playerselect.invalidslot', with: ['9'] }); + }); + + it('returns success and queues selectSlot for valid slot 0', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 0); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues selectSlot for valid slot 8', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerselectCommand.playerselectCommand(mockOrigin, 'TestBot', 8); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js new file mode 100644 index 00000000..bda3d8c9 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersneak.test.js @@ -0,0 +1,48 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playersneakCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playersneak'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersneakCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sneak: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersneakCommand.playersneakCommand(mockOrigin, 'TestBot', true); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues sneak(true) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersneakCommand.playersneakCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues sneak(false) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersneakCommand.playersneakCommand(undefined, 'TestBot', false); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js new file mode 100644 index 00000000..c1b30611 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playersprint.test.js @@ -0,0 +1,48 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playersprintCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playersprint'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playersprintCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { sprint: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playersprintCommand.playersprintCommand(mockOrigin, 'TestBot', true); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues sprint(true) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersprintCommand.playersprintCommand(undefined, 'TestBot', true); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); + + it('returns success and queues sprint(false) when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playersprintCommand.playersprintCommand(undefined, 'TestBot', false); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js new file mode 100644 index 00000000..a18b30c0 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerstop.test.js @@ -0,0 +1,41 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerstopCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerstop'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerstopCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { stopAll: vi.fn(), name: 'TestBot' }; + mockOrigin = { sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerstopCommand.playerstopCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues stopAll when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerstopCommand.playerstopCommand(undefined, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js new file mode 100644 index 00000000..9df76ce3 --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playerswapheld.test.js @@ -0,0 +1,41 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playerswapheldCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playerswapheld'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playerswapheldCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { swapHeldItemWithPlayer: vi.fn(), name: 'TestBot' }; + mockOrigin = { getSource: vi.fn(() => ({ name: 'Player1', selectedSlotIndex: 0 })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success and queues swap when online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playerswapheldCommand.playerswapheldCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js new file mode 100644 index 00000000..12af4c8c --- /dev/null +++ b/__tests__/BP/scripts/src/commands/simplayer/playertp.test.js @@ -0,0 +1,46 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { system, CustomCommandStatus } from '@minecraft/server'; +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { playertpCommand } from '../../../../../../Canopy[BP]/scripts/src/commands/simplayer/playertp'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + get: vi.fn(), + getNotOnlineMessage: vi.fn(name => ({ translate: 'simplayer.notonline', with: [name] })), + } +})); + +vi.mock('../../../../../../Canopy[BP]/scripts/lib/canopy/Canopy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, VanillaCommand: vi.fn() }; +}); + +describe('playertpCommand', () => { + let mockUnderstudy; + let mockOrigin; + + beforeEach(() => { + vi.clearAllMocks(); + mockUnderstudy = { teleport: vi.fn(), name: 'TestBot' }; + mockOrigin = { getSource: vi.fn(() => ({ location: { x: 0, y: 64, z: 0 }, dimension: {}, getRotation: vi.fn(() => ({ x: 0, y: 0 })), getGameMode: vi.fn(() => 'Survival') })), sendMessage: vi.fn() }; + }); + + it('returns failure when the simplayer is not online', () => { + vi.mocked(Understudies.get).mockReturnValue(undefined); + const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(result).toBeUndefined(); + expect(mockOrigin.sendMessage).toHaveBeenCalledWith({ translate: 'simplayer.notonline', with: ['TestBot'] }); + }); + + it('returns success when the simplayer is online', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + const result = playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(result.status).toBe(CustomCommandStatus.Success); + }); + + it('queues a system.run for the teleport', () => { + vi.mocked(Understudies.get).mockReturnValue(mockUnderstudy); + playertpCommand.playertpCommand(mockOrigin, 'TestBot'); + expect(system.run).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js b/__tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js new file mode 100644 index 00000000..a12c3cf3 --- /dev/null +++ b/__tests__/BP/scripts/src/rules/infodisplay/InfoDisplay.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Rules } from '../../../../../../Canopy[BP]/scripts/lib/canopy/rules/Rules'; +import { InfoDisplay } from '../../../../../../Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplay'; +import { InfoDisplayElement } from '../../../../../../Canopy[BP]/scripts/src/rules/infodisplay/InfoDisplayElement'; + +vi.mock('@minecraft/server', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + world: { + ...original.world, + afterEvents: { + ...original.world.afterEvents, + worldLoad: { subscribe: (callback) => callback() } + } + } + }; +}); + +function createMockPlayer() { + return { + id: 'info-display-test-player', + getComponent: vi.fn(() => ({ push: vi.fn(), remove: vi.fn() })), + getDynamicProperty: vi.fn(() => undefined), + setDynamicProperty: vi.fn() + }; +} + +describe('InfoDisplayElement.getRuleIdentifier enforcement', () => { + it('throws when a subclass does not implement getRuleIdentifier', () => { + class Unidentified extends InfoDisplayElement {} + expect(() => Unidentified.getRuleIdentifier()).toThrow(/getRuleIdentifier/); + expect(() => new Unidentified({ description: { text: '' } })).toThrow(/getRuleIdentifier/); + }); +}); + +describe('InfoDisplay.getRuleIdentifiers', () => { + beforeEach(() => { + Rules.clear(); + Rules.rulesToRegister = []; + }); + + it('returns the identifiers with no duplicates', () => { + const identifiers = InfoDisplay.getRuleIdentifiers(); + expect(new Set(identifiers).size).toBe(identifiers.length); + }); + + it('matches the InfoDisplay rules actually registered when an InfoDisplay is built', () => { + new InfoDisplay(createMockPlayer()); + const registered = Rules.getByCategory('InfoDisplay').map(rule => rule.getID()); + + // Single source (InfoDisplay.elementSpecs) drives both, so these can never diverge. + expect(new Set(registered)).toEqual(new Set(InfoDisplay.getRuleIdentifiers())); + expect(registered).toHaveLength(InfoDisplay.getRuleIdentifiers().length); + }); +}); diff --git a/__tests__/BP/scripts/src/rules/infodisplay/Ping.test.js b/__tests__/BP/scripts/src/rules/infodisplay/Ping.test.js index b4f42ce9..cc5763e3 100644 --- a/__tests__/BP/scripts/src/rules/infodisplay/Ping.test.js +++ b/__tests__/BP/scripts/src/rules/infodisplay/Ping.test.js @@ -22,4 +22,23 @@ describe('Ping', () => { it('should have a method to return formatted ping', () => { expect(ping.getFormattedDataOwnLine()).toEqual({ translate: 'rules.infoDisplay.ping.display', with: ['§a' + mockPlayer.getPing()] }); }); + + it('should color the ping value green when it is below 100ms', () => { + expect(ping.getFormattedDataOwnLine().with[0]).toContain('§a'); + }); + + it('should color the ping value yellow when it is between 100ms and 300ms', () => { + mockPlayer.getPing.mockReturnValue(150); + expect(ping.getFormattedDataOwnLine().with[0]).toContain('§e'); + }); + + it('should color the ping value red when it is above 300ms', () => { + mockPlayer.getPing.mockReturnValue(350); + expect(ping.getFormattedDataOwnLine().with[0]).toContain('§c'); + }); + + it('should color the ping purple when it is above 1000ms', () => { + mockPlayer.getPing.mockReturnValue(1500); + expect(ping.getFormattedDataOwnLine().with[0]).toContain('§5'); + }); }); diff --git a/__tests__/BP/scripts/src/rules/infodisplay/Structures.test.js b/__tests__/BP/scripts/src/rules/infodisplay/Structures.test.js index 9f72ed0f..993ae8b6 100644 --- a/__tests__/BP/scripts/src/rules/infodisplay/Structures.test.js +++ b/__tests__/BP/scripts/src/rules/infodisplay/Structures.test.js @@ -39,7 +39,7 @@ describe('Structures', () => { it('should have a method to return any generated structures at the player\'s location', () => { expect(structures.getFormattedDataOwnLine()).toEqual({ rawtext: [ - { "translate": "rules.infodisplay.structures.display" }, + { "translate": "rules.infoDisplay.structures.display" }, { text: "§dminecraft:monument, minecraft:pillager_outpost" } ] }); }); @@ -47,8 +47,8 @@ describe('Structures', () => { it('should return an empty string when there are no structures found', () => { mockPlayer.dimension.getGeneratedStructures.mockReturnValue([]); expect(structures.getFormattedDataOwnLine()).toEqual({ "rawtext": [ - { "translate": "rules.infodisplay.structures.display" }, - { "translate": "rules.infodisplay.structures.display.none" }, + { "translate": "rules.infoDisplay.structures.display" }, + { "translate": "rules.infoDisplay.structures.display.none" }, ] }); }); }); diff --git a/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js new file mode 100644 index 00000000..34007054 --- /dev/null +++ b/__tests__/BP/scripts/src/rules/simplayer/noSimplayerSaving.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; +import { simplayerSaving } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerSaving'; + +describe('simplayerSaving', () => { + beforeEach(() => { + vi.clearAllMocks(); + worldDynamicPropertyStore.set('simplayerSaving', void 0); + }); + + describe('getID', () => { + it('returns the correct identifier', () => { + expect(simplayerSaving.getID()).toBe('simplayerSaving'); + }); + }); + + describe('getNativeValue', () => { + it('returns true by default when no value is stored', () => { + expect(simplayerSaving.getNativeValue()).toBe(true); + }); + + it('returns true when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerSaving', true); + expect(simplayerSaving.getNativeValue()).toBe(true); + }); + + it('returns false when the rule is explicitly disabled', () => { + worldDynamicPropertyStore.set('simplayerSaving', false); + expect(simplayerSaving.getNativeValue()).toBe(false); + }); + }); +}); diff --git a/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js b/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js new file mode 100644 index 00000000..36aab6a7 --- /dev/null +++ b/__tests__/BP/scripts/src/rules/simplayer/simplayerRejoining.test.js @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { system, world } from '@minecraft/server'; +import { worldDynamicPropertyStore } from '@forestoflight/minecraft-vitest-mocks'; + +vi.mock('../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies', () => ({ + default: { + create: vi.fn(), + addNametagPrefix: vi.fn(), + understudies: [] + } +})); + +import Understudies from '../../../../../../Canopy[BP]/scripts/src/classes/simplayer/Understudies'; +import { simplayerRejoining } from '../../../../../../Canopy[BP]/scripts/src/rules/simplayer/simplayerRejoining'; + +describe('simplayerRejoining', () => { + beforeEach(() => { + vi.clearAllMocks(); + worldDynamicPropertyStore.set('simplayerRejoining', undefined); + worldDynamicPropertyStore.set('simplayersToRejoin', undefined); + Understudies.understudies = []; + }); + + describe('getID', () => { + it('returns the correct identifier', () => { + expect(simplayerRejoining.getID()).toBe('simplayerRejoining'); + }); + }); + + describe('getNativeValue', () => { + it('returns false by default', () => { + expect(simplayerRejoining.getNativeValue()).toBe(false); + }); + + it('returns true when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + expect(simplayerRejoining.getNativeValue()).toBe(true); + }); + }); + + describe('subscribeToEvent', () => { + it('subscribes to the shutdown event', () => { + simplayerRejoining.subscribeToEvent(); + expect(system.beforeEvents.shutdown.subscribe).toHaveBeenCalledWith(simplayerRejoining.onShutdownBound); + }); + }); + + describe('unsubscribeFromEvent', () => { + it('unsubscribes from the shutdown event', () => { + simplayerRejoining.unsubscribeFromEvent(); + expect(system.beforeEvents.shutdown.unsubscribe).toHaveBeenCalledWith(simplayerRejoining.onShutdownBound); + }); + }); + + describe('onShutdown', () => { + it('saves the names of online simplayers when the rule is enabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + Understudies.understudies = [{ name: 'Alice' }, { name: 'Bob' }]; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + }); + + it('saves an empty array when no simplayers are online', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + Understudies.understudies = []; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify([])); + }); + + it('saves an empty array when the rule is disabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', false); + Understudies.understudies = [{ name: 'Alice' }]; + simplayerRejoining.onShutdown(); + expect(world.setDynamicProperty).toHaveBeenCalledWith('simplayersToRejoin', JSON.stringify([])); + }); + }); + + describe('onStartup', () => { + it('does nothing when the rule is disabled', () => { + worldDynamicPropertyStore.set('simplayerRejoining', false); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + }); + + it('does nothing when no player list is stored', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + }); + + it('does nothing when stored player list is invalid JSON', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', 'not valid json'); + const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + simplayerRejoining.onStartup(); + expect(Understudies.create).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('creates and rejoins simplayers listed in the stored data', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + const mockPlayer = { rejoin: vi.fn() }; + vi.mocked(Understudies.create).mockReturnValue(mockPlayer); + simplayerRejoining.onStartup(); + expect(Understudies.create).toHaveBeenCalledWith('Alice'); + expect(Understudies.create).toHaveBeenCalledWith('Bob'); + expect(mockPlayer.rejoin).toHaveBeenCalledTimes(2); + }); + + it('logs an error and continues when a player fails to rejoin', () => { + worldDynamicPropertyStore.set('simplayerRejoining', true); + worldDynamicPropertyStore.set('simplayersToRejoin', JSON.stringify(['Alice', 'Bob'])); + const alicePlayer = { rejoin: vi.fn(() => { throw new Error('rejoin failed'); }) }; + const bobPlayer = { rejoin: vi.fn() }; + vi.mocked(Understudies.create) + .mockReturnValueOnce(alicePlayer) + .mockReturnValueOnce(bobPlayer); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + simplayerRejoining.onStartup(); + expect(bobPlayer.rejoin).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/config.json b/config.json index d4c82014..c7e8e248 100644 --- a/config.json +++ b/config.json @@ -17,6 +17,10 @@ "runWith": "nodejs", "script": "filters/update_mob_data/main.js" }, + "generate_readonly_methods": { + "runWith": "nodejs", + "script": "filters/generate_readonly_methods/main.js" + }, "package_mcaddon": { "runWith": "nodejs", "script": "filters/package_mcaddon/main.js" @@ -50,6 +54,7 @@ }, "filters": [ { "filter": "update_mob_data" }, + { "filter": "generate_readonly_methods" }, { "filter": "package_mcaddon" } ] }, diff --git a/docs/scripts/generate-wiki.js b/docs/scripts/generate-wiki.js index 42e59ca3..f85b6bd7 100644 --- a/docs/scripts/generate-wiki.js +++ b/docs/scripts/generate-wiki.js @@ -27,30 +27,19 @@ function resolveDescription(desc, lang) { return ''; } -const PARAM_TYPE_DISPLAY = { - Boolean: 'bool', - Enum: null, // replaced by enum values inline - Float: 'float', - Integer: 'int', - Location: 'x y z', - String: 'string', - EntitySelector: 'entity', - EntityType: 'entityType', - BlockType: 'blockType', -}; - function buildParamDisplay(param, enums) { if (param.type === 'Enum') { const enumDef = enums?.find(e => e.name === param.name); - return enumDef ? enumDef.values.join('/') : param.name; + return enumDef ? enumDef.values.join('/') : 'enum'; } - return PARAM_TYPE_DISPLAY[param.type] ?? param.name; + return String(param.type ?? 'value'); } function buildVanillaCommandBlock(cmd, lang) { const cc = cmd.customCommand; const cmdName = cmd.getName(); const sub = cmd.getSubCommandWikiDescription(); + const commandDesc = cc.wikiDescription ?? resolveDescription(cc.description, lang); const isOp = cc.permissionLevel && cc.permissionLevel !== 'Any'; const opSuffix = isOp ? ' Requires OP.' : ''; @@ -67,8 +56,7 @@ function buildVanillaCommandBlock(cmd, lang) { const label = p.name.replace(/^[^:]+:/, ''); parts.push(`[${label}: ${display}]`); } - const desc = cc.wikiDescription ?? resolveDescription(cc.description, lang); - return `**Usage: \`${parts.join(' ')}\`** \n${desc}${opSuffix}`; + return `**Usage: \`${parts.join(' ')}\`** \n${commandDesc}${opSuffix}`; } // Sub-command style — one block per enum value @@ -93,7 +81,8 @@ function buildVanillaCommandBlock(cmd, lang) { } blocks.push(`**Usage: \`${usageParts.join(' ')}\`** \n${info.description}${opSuffix}`); } - return blocks.join('\n\n'); + const prefix = commandDesc ? `${commandDesc}\n\n` : ''; + return `${prefix}${blocks.join('\n\n')}`; } function buildCommandBlock(cmd, lang) { diff --git a/eslint.config.js b/eslint.config.js index 236cf7b7..71d2a310 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,8 @@ export default [ '**/scripts/lib/mt.js', '**/scripts/lib/MCBE-IPC/', '**/scripts/lib/SRCItemDatabase/', - '**/scripts/lib/chestui/' + '**/scripts/lib/chestui/', + '**/scripts/lib/jsep/', ] }, js.configs.recommended, diff --git a/filters/generate_readonly_methods/main.js b/filters/generate_readonly_methods/main.js new file mode 100644 index 00000000..a1deb9c1 --- /dev/null +++ b/filters/generate_readonly_methods/main.js @@ -0,0 +1,62 @@ +import { createRequire } from 'module'; +import fs from 'fs'; +import path from 'path'; + +const RESTRICTED_MARKER = "can't be called in restricted-execution mode"; +const DANGEROUS_NAMES = new Set(['constructor', '__proto__', 'prototype']); +const METHOD_RE = /^\s+(?:static\s+|readonly\s+|get\s+|set\s+)*([A-Za-z_]\w*)\s*(?:<.+>)?\s*\(/; +const OUTPUT_RELATIVE = 'scripts/src/classes/analyzearea/readOnlyMethods.js'; + +function loadServerDts() { + const require = createRequire(import.meta.url); + const pkgPath = require.resolve('@minecraft/server/package.json'); + const version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; + const dts = fs.readFileSync(path.join(path.dirname(pkgPath), 'index.d.ts'), 'utf8'); + return { dts, version }; +} + +function parseReadOnlyMethods(dts) { + const allNames = new Set(); + const forbidden = new Set(); + let inComment = false; + let doc = ''; + for (const line of dts.split('\n')) { + const trimmed = line.trim(); + if (inComment) { + doc += `${line}\n`; + if (trimmed.includes('*/')) inComment = false; + continue; + } + if (trimmed.startsWith('/*')) { + doc = `${line}\n`; + inComment = !trimmed.includes('*/'); + continue; + } + if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue; + const match = METHOD_RE.exec(line); + if (match) { + allNames.add(match[1]); + if (doc.includes(RESTRICTED_MARKER)) forbidden.add(match[1]); + } + if (trimmed.length > 0) doc = ''; + } + return [...allNames].filter((name) => !forbidden.has(name) && !DANGEROUS_NAMES.has(name)).sort(); +} + +function formatFile(methods, version) { + const list = methods.map((name) => ` '${name}'`).join(',\n'); + return `// AUTO-GENERATED by filters/generate_readonly_methods. Do not edit by hand.\n` + + `// Source: @minecraft/server@${version}\n` + + `export const readOnlyMethods = new Set([\n${list}\n]);\n`; +} + +function main() { + const { dts, version } = loadServerDts(); + const methods = parseReadOnlyMethods(dts); + const bpRoot = fs.existsSync('BP') ? 'BP' : 'Canopy[BP]'; + const target = path.join(bpRoot, OUTPUT_RELATIVE); + fs.writeFileSync(target, formatFile(methods, version), 'utf8'); + console.log(`[generate-readonly-methods] Wrote ${methods.length} read-only method names from @minecraft/server@${version} to ${target}`); +} + +main(); diff --git a/package-lock.json b/package-lock.json index 7aaf0ade..bddde019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,15 @@ "name": "canopy", "license": "MIT", "dependencies": { - "@minecraft/debug-utilities": "^1.0.0-beta.1.26.30-stable", - "@minecraft/server": "^2.9.0-beta.1.26.30-stable", - "@minecraft/server-ui": "^2.2.0-beta.1.26.30-stable" + "@minecraft/debug-utilities": "1.0.0-beta.1.26.40-stable", + "@minecraft/server": "2.10.0-beta.1.26.40-stable", + "@minecraft/server-gametest": "1.0.0-beta.1.26.40-stable", + "@minecraft/server-ui": "2.2.0-beta.1.26.40-stable" }, "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.19.0", - "@forestoflight/minecraft-vitest-mocks": "^1.0.7", + "@forestoflight/minecraft-vitest-mocks": "^1.0.8", "@vitest/coverage-v8": "^3.0.4", "axios": "^1.7.9", "eslint": "^9.19.0", @@ -726,9 +727,9 @@ } }, "node_modules/@forestoflight/minecraft-vitest-mocks": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@forestoflight/minecraft-vitest-mocks/-/minecraft-vitest-mocks-1.0.7.tgz", - "integrity": "sha512-xLDviI3S4ejrwMiMPNS1nZraMhqg2SxB6juS346nnYpnrUJ9RBZRaNn27fckOHOhQxBxBrC9WwWwfzkRu8/f0Q==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@forestoflight/minecraft-vitest-mocks/-/minecraft-vitest-mocks-1.0.8.tgz", + "integrity": "sha512-TiMDbY+shLAfXg4lq986nUagLfFL9cFDzMSjQmstuo/pKa2+2NDjLi8vVOpBSIS8D1epF6WXFrgdU4YF7bWVew==", "dev": true, "peerDependencies": { "vitest": "*" @@ -877,33 +878,43 @@ "peer": true }, "node_modules/@minecraft/debug-utilities": { - "version": "1.0.0-beta.1.26.30-stable", - "resolved": "https://registry.npmjs.org/@minecraft/debug-utilities/-/debug-utilities-1.0.0-beta.1.26.30-stable.tgz", - "integrity": "sha512-Iqo+3Ci5KH3g5Kb/Yy51dqiu2WpntmdNjGK6MRrdNN+cjYXA1kcFTrt998K3os15Nx1J4rbkKkFgonXRKL98rQ==", + "version": "1.0.0-beta.1.26.40-stable", + "resolved": "https://registry.npmjs.org/@minecraft/debug-utilities/-/debug-utilities-1.0.0-beta.1.26.40-stable.tgz", + "integrity": "sha512-4JvxODw+8w0U3srQ23vZzmzt8HTginiOApszde9baW4NvS/nEz4KAlwlLRXqNcLTCCS2JNcg6vbgpjjlkHz4gg==", "license": "MIT", "peerDependencies": { "@minecraft/common": "^1.0.0", - "@minecraft/server": "^1.17.0 || ^2.0.0 || ^2.9.0-beta.1.26.30-stable" + "@minecraft/server": "^1.17.0 || ^2.0.0 || ^2.10.0-beta.1.26.40-stable" } }, "node_modules/@minecraft/server": { - "version": "2.9.0-beta.1.26.30-stable", - "resolved": "https://registry.npmjs.org/@minecraft/server/-/server-2.9.0-beta.1.26.30-stable.tgz", - "integrity": "sha512-KhYSda6eAyFCVC5mcnVE6F0lyXmZJQGpF1D07t7b0rNztFVp7Eh0/nvPRfBNUs90G4O1reAS7jZBHl9Kp9+cZA==", + "version": "2.10.0-beta.1.26.40-stable", + "resolved": "https://registry.npmjs.org/@minecraft/server/-/server-2.10.0-beta.1.26.40-stable.tgz", + "integrity": "sha512-PygRGiom/LRXV/fxcZ0ygCzCzF8pRp+PoqnVX+saazdOMXUvu940fZZjXCaTdjvasLp8tN4i3EgJV1aTxidKAQ==", "license": "MIT", "peerDependencies": { "@minecraft/common": "^1.2.0", "@minecraft/vanilla-data": ">=1.20.70" } }, + "node_modules/@minecraft/server-gametest": { + "version": "1.0.0-beta.1.26.40-stable", + "resolved": "https://registry.npmjs.org/@minecraft/server-gametest/-/server-gametest-1.0.0-beta.1.26.40-stable.tgz", + "integrity": "sha512-xr3WTrEZ8BV37Qvf/UGsQJ/7keJWV1FVrkNu+IJ8Qs/tUXbQqqyZQ6wjGaGwzxPz9lr11VE6EH4UYp+8Mll0Dg==", + "license": "MIT", + "peerDependencies": { + "@minecraft/common": "^1.0.0", + "@minecraft/server": "^1.17.0 || ^2.0.0 || ^2.10.0-beta.1.26.40-stable" + } + }, "node_modules/@minecraft/server-ui": { - "version": "2.2.0-beta.1.26.30-stable", - "resolved": "https://registry.npmjs.org/@minecraft/server-ui/-/server-ui-2.2.0-beta.1.26.30-stable.tgz", - "integrity": "sha512-OMkGdrU5w/g/oIHR6ltpxbTNzEDYcDoHI56jW5FOBK+U6UwBO5wQteAPVvKKcBqD8KsY0R72xw1cKNfVwwJFIg==", + "version": "2.2.0-beta.1.26.40-stable", + "resolved": "https://registry.npmjs.org/@minecraft/server-ui/-/server-ui-2.2.0-beta.1.26.40-stable.tgz", + "integrity": "sha512-Ab/k5lYMktAjVttv3u//SCCc/AUmKnzJYA+ZvvAoKxQ1hEDwLC2OhGF+YF/S/mkSX6oPK8hD6I8bJBEq2CyCOw==", "license": "MIT", "peerDependencies": { "@minecraft/common": "^1.0.0", - "@minecraft/server": "^2.0.0 || ^2.9.0-beta.1.26.30-stable" + "@minecraft/server": "^2.0.0 || ^2.10.0-beta.1.26.40-stable" } }, "node_modules/@minecraft/vanilla-data": { diff --git a/package.json b/package.json index b5ff4e21..e43843b2 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ }, "license": "MIT", "dependencies": { - "@minecraft/debug-utilities": "^1.0.0-beta.1.26.30-stable", - "@minecraft/server": "^2.9.0-beta.1.26.30-stable", - "@minecraft/server-ui": "^2.2.0-beta.1.26.30-stable" + "@minecraft/debug-utilities": "1.0.0-beta.1.26.40-stable", + "@minecraft/server": "2.10.0-beta.1.26.40-stable", + "@minecraft/server-gametest": "1.0.0-beta.1.26.40-stable", + "@minecraft/server-ui": "2.2.0-beta.1.26.40-stable" }, "scripts": { "test": "vitest run --config vitest.config.js --coverage", @@ -22,7 +23,7 @@ "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.19.0", - "@forestoflight/minecraft-vitest-mocks": "^1.0.7", + "@forestoflight/minecraft-vitest-mocks": "^1.0.8", "@vitest/coverage-v8": "^3.0.4", "axios": "^1.7.9", "eslint": "^9.19.0", diff --git a/vite.config.js b/vite.config.js index 99f91d1f..b4c311ea 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,7 @@ export default defineConfig({ alias: { '@minecraft/server': `${__dirname}/__mocks__/@minecraft/server`, '@minecraft/server-ui': `${__dirname}/__mocks__/@minecraft/server-ui`, + '@minecraft/server-gametest': `${__dirname}/__mocks__/@minecraft/server-gametest`, } }, test: { diff --git a/vitest.config.js b/vitest.config.js index 47eeb81f..31c5e42a 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -10,7 +10,8 @@ export default defineConfig({ alias: { '@minecraft/server': `@forestoflight/minecraft-vitest-mocks/server`, '@minecraft/server-ui': `@forestoflight/minecraft-vitest-mocks/server-ui`, - '@minecraft/debug-utilities': `@forestoflight/minecraft-vitest-mocks/debug-utilities` + '@minecraft/debug-utilities': `@forestoflight/minecraft-vitest-mocks/debug-utilities`, + '@minecraft/server-gametest': `@forestoflight/minecraft-vitest-mocks/server-gametest` } }, test: { diff --git a/vitest.wiki.config.js b/vitest.wiki.config.js index 9c5388c8..0b42c6a2 100644 --- a/vitest.wiki.config.js +++ b/vitest.wiki.config.js @@ -11,6 +11,7 @@ export default defineConfig({ '@minecraft/server': `@forestoflight/minecraft-vitest-mocks/server`, '@minecraft/server-ui': `@forestoflight/minecraft-vitest-mocks/server-ui`, '@minecraft/debug-utilities': `@forestoflight/minecraft-vitest-mocks/debug-utilities`, + '@minecraft/server-gametest': `@forestoflight/minecraft-vitest-mocks/server-gametest`, 'lib/canopy/Canopy': `${__dirname}/Canopy[BP]/scripts/lib/canopy/Canopy.js`, 'src/commands/trackevent': `${__dirname}/Canopy[BP]/scripts/src/commands/trackevent.js`, 'src/classes/Instaminable': `${__dirname}/Canopy[BP]/scripts/src/classes/Instaminable.js`,